Compare commits

...

39 Commits

Author SHA1 Message Date
leo 543ac20d66 harmonize dev prod 2026-02-24 16:36:49 +01:00
leo 1d42dd26ed harmonize docker commands 2026-02-24 16:16:56 +01:00
lebaudantoine ce9f812a7e 🔖(minor) bump release to 1.7.0 2026-02-19 12:37:26 +01:00
lebaudantoine b02591170f 🐛(frontend) configure missing participants shortcut
Configure missing shortcut in the frontend for the participant
side panel.

It was accidentally omitted while merging Cyril's changes.
2026-02-19 12:12:23 +01:00
lebaudantoine e58181f846 🧑‍💻(backend) configure the external application API
Configure the external application API across different Kubernetes setups
to enable seamless usage without repeated configuration
when iterating on endpoints.
2026-02-19 11:16:10 +01:00
lebaudantoine d37f47e82c (frontend) expose Windows app web link
Expose a Windows application web link requested by a partner who wraps Visio
inside a containerized Chrome application due to security concerns and limited
trust in video codecs.

This commit introduces a proof of concept implementation.
We plan to iterate on this approach and likely generalize it under a more
neutral lasuite meet naming in future revisions.
2026-02-19 10:17:06 +01:00
lebaudantoine db80c09c10 ⬆️(frontend) update prettier 2026-02-18 22:10:24 +01:00
lebaudantoine fd9f2a81ca ⬆️(dependencies) update js dependencies 2026-02-18 22:10:24 +01:00
unteem d865db5f1b 📝(doc) fix variable name 2026-02-18 21:45:26 +01:00
unteem 7cc5b2b961 📝(doc) fix env files for docker compose
remove unused env file
mount .env
2026-02-18 21:45:26 +01:00
Cyril c85977cb68 (frontend) add clickable settings general link in idle modal
helps users quickly disable idle warning from the right settings tab.
2026-02-18 15:17:37 +01:00
Ovgodd 3c3b4a32e3 (frontend) support additional shortcuts to broaden accessibility
Add support for additional shortcuts to broaden accessibility and
integration capabilities. Some of these are required to ensure full
functionality with the RENATER SIP media gateway, allowing shortcut
mapping to DTMF signals. Others improve usability for keyboard-only
users; a lightweight helper will be introduced to surface available
shortcuts and make them easier to discover and use.
2026-02-12 18:56:48 +01:00
Ovgodd 9b033c55b2 (frontend) support Shift and Alt key when building shortcuts
Add support for Shift and Alt modifiers when building shortcuts,
expanding the range of possible combinations and allowing more expressive
and flexible shortcut definitions.
2026-02-12 18:56:48 +01:00
Ovgodd a2c7becaf4 ♻️(frontend) centralize shortcuts in a catalog
Centralize shortcuts into a single source of truth, making them easier to
discover and manage, and laying the groundwork for future override support
and the ability to revert to default definitions if needed.

Shortcuts are now retrieved by identifier, while leaving each component
responsible for declaring when a shortcut should be enabled and which
handler should be called;
2026-02-12 18:56:48 +01:00
lebaudantoine 89031abb63 🔖(minor) bump release to 1.6.0 2026-02-10 15:31:29 +01:00
Bastien Ogier fc92fa4eb4 🚀(docs) document Scalingo deployment
(docs) document Scalingo deployment
2026-02-10 10:44:13 +01:00
Bastien Ogier 2c65cc061e 🚀(settings) standardize DATABASE_URL environment retrieval
(settings) standardize DATABASE_URL environment retrieval
2026-02-10 10:44:13 +01:00
Bastien Ogier bfadeae6ee 🚀(scalingo) custom logo override
(scalingo) custom logo override
2026-02-10 10:44:13 +01:00
Sylvain Zimmer 117677bd14 🚀(paas) add PaaS deployment scripts, tested on Scalingo
add PaaS deployment scripts, tested on Scalingo
2026-02-10 10:44:13 +01:00
lebaudantoine 69c6e58017 🔒️(backend) add application validation when consuming external JWT
Token generation already verifies that the application is active, but this
guarantee was not enforced when the token was used. This change adds a
runtime check to ensure the client_id claim matches an existing and active
application when evaluating permissions.

This also introduces an emergency revocation mechanism, allowing all previously
issued tokens for a given application to be invalidated if the application is
disabled.
2026-02-09 22:18:09 +01:00
lebaudantoine 6742f5d19d (backend) monitor throttling rate failure through sentry
Use a mixin, introduced by @lunika in the shared
backend library to monitor throttling behavior.

The mixin tracks when throttling limits are reached, sending errors to Sentry
to trigger alerts when configured. This helps detect misconfigurations,
fine-tune throttling settings, and identify suspicious operations.

This enables safely increasing API throttling limits while ensuring stability,
providing confidence that higher limits won’t break the system.
2026-02-09 15:50:53 +01:00
lebaudantoine 23de7e52bc ♻️(backend) extract throttling classes into a module
Extract throttling classes into a dedicated Python module, following the
structure of suitenumerique/docs.

This is a preparatory refactor to ease upcoming changes to the throttling
implementation. No functional behavior change is introduced in this commit.
2026-02-09 15:50:53 +01:00
lebaudantoine 3887255e9c ♻️(backend) rework permission to better align with DRF responsibilities
If a viewset action is not implemented, the permission layer no longer returns
a 403. Instead, it lets DRF handle the request and return the appropriate 405
Method Not Allowed response, ensuring cleaner and more standard API error
handling.
2026-02-09 12:16:12 +01:00
lebaudantoine 5d6ad3f3f6 🔒️(backend) enhance scope manipulation
Enhance scope manipulation by normalizing and sanitizing
scope values before processing.

Scopes are now converted to lowercase to ensure consistent behavior,
deduplicated while preserving their original order, and handled in a
deterministic way aligned with the intended authorization model.
2026-02-09 12:16:12 +01:00
lebaudantoine 44d68a9c80 (backend) strengthen external API viewset test coverage
Reinforce the test suite around the external API viewset to better
prevent regressions, permission leaks, and unexpected failures.

Adds additional scenarios covering permission enforcement, edge cases,
and error handling to ensure the external API behavior remains stable
and secure as it evolves.
2026-02-09 12:16:12 +01:00
lebaudantoine ed5c1bbd84 ♻️(backend) improve scope prefix removal logic
The previous replace usage was too broad and could remove multiple
occurrences, which was not the original intention.

Replace the replace call with removeprefix, which more accurately
matches the expected behavior by only removing the prefix when present
at the start of the string.
2026-02-09 12:16:12 +01:00
lebaudantoine f8c6da8021 🔐(backend) enforce object-level permission checks on room endpoint
Apply strict permission validation on the external API room endpoint to
enforce the principle of least privilege. Unlike the default API (which allows
unauthenticated room retrieval and filters access in the serializer), the
external API now only exposes rooms to users with explicit permissions.

This change fixes a security issue. Slug-based room retrieval, as supported
by the default API, is not introduced here but could be added later if needed.
Retrieving rooms by UUID is retained, as guessing a UUID is significantly harder
than a slug.

A dedicated permission class was created to avoid coupling permissions between
the default and external APIs. The external API enforces stricter access rules.

Access policies may be revisited based on user and integrator feedback. The
external API currently has no production usage.
2026-02-09 12:16:12 +01:00
lebaudantoine 5ba1657e00 🧪(backend) add test exposing rooms permission flaw in external API
Add a failing test demonstrating that a user can retrieve a room they
do not have access to when the room UUID is known.

This highlights an improper object-level permission verification in the
external API. While exploitation requires obtaining the target room
UUID, this still represents a security issue (BOLA / IDOR class
vulnerability) and must be fixed.

The test documents the expected behavior and will pass once proper
access filtering or permission checks are enforced.
2026-02-09 12:16:12 +01:00
René Fischer c28b8ba902 🌐(frontend) add missing DE translation for accessibility settings 2026-02-08 23:57:51 +01:00
lebaudantoine 6962367e18 🐛(backend) fix notification tests broken by renaming env var
SCREEN_RECORDING_BASE_URL was renamed to RECORDING_DOWNLOAD_BASE_URL.

The new variable supersedes the old one, which is temporarily kept for backward
compatibility. This test failure was missed because the local common file was
out of sync with common.dist.

Add the new variable with a default value of None to ensure a smooth
deprecation path when the old variable is removed.
2026-02-07 00:14:49 +01:00
Cyril 0bd57e8623 💄(frontend) clean up spinner styles
remove inline styles for better maintainability
2026-02-06 23:29:23 +01:00
Cyril 27f2023104 ️(frontend) add reduced-motion spinner fallback
show an hourglass when animations are reduced
2026-02-06 23:29:23 +01:00
lebaudantoine 44362eca23 📝(changelog) update changelog
Update changelog with PR's purpose
2026-02-05 19:16:02 +01:00
lebaudantoine c34a85699b ⬆️(backend) upgrade Django to address multiple high-severity CVEs
This update fixes several SQL injection vulnerabilities, including issues in
RasterField band index handling and crafted column aliases (notably in
QuerySet.order_by()), as reported in CVE-2026-1207, CVE-2026-1287, and
CVE-2026-1312.
2026-02-05 19:16:02 +01:00
lebaudantoine 12d8c4a9db ️(admin) improve recording access select component performance
Replace the basic select component that loaded thousands of options into the
DOM with a smarter component supporting dynamic loading and search.

With large user bases, linking users to recording access caused massive option
lists to render, severely impacting performance. This change dramatically
improves page loading speed.
2026-02-05 19:16:02 +01:00
lebaudantoine 42a05da5c0 🔒️(admin) make recording fields read-only for security and performance
These values should not be updated from the admin interface. Allowing changes
to a recording’s associated room could lead to data leaks (e.g., notifications
being resent to the wrong users after a malicious modification).

Also remove the room select field, which rendered a dropdown with ~150k options,
flooding the DOM and severely degrading page performance.
2026-02-05 19:16:02 +01:00
lebaudantoine 4344dd6e35 ️(admin) optimize room view queries by prefetching user access
Use prefetch_related for the room–user access relationship to avoid N+1
queries. select_related cannot be used here since this is a many-to-many
relation. This significantly improves performance.
2026-02-05 19:16:02 +01:00
lebaudantoine fe28902b2e ️(admin) optimize recording view by selecting room at the SQL level
Use select_related on the room foreign key to avoid N+1 queries. This makes
Django perform a join between tables instead of triggering additional queries
per row, reducing complexity from O(n²) patterns to O(n) and significantly
improving performance.
2026-02-05 19:16:02 +01:00
lebaudantoine 1e1e1a2657 ️(admin) remove list filters based on room in recording view
This was a mistake: the filter was never used in production and caused
performance issues. It generated a list of unique room slugs, bloating the DOM
with thousands of values and slowing down view rendering. Remove this
regression.
2026-02-05 19:16:02 +01:00
73 changed files with 1960 additions and 350 deletions
+4 -4
View File
@@ -152,7 +152,7 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-build-args: '-f src/summary/Dockerfile --target summary-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
@@ -161,7 +161,7 @@ jobs:
with:
context: ./src/summary
file: ./src/summary/Dockerfile
target: production
target: summary-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -191,7 +191,7 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-build-args: '-f src/agents/Dockerfile --target agents-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
@@ -200,7 +200,7 @@ jobs:
with:
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
target: agents-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+26
View File
@@ -8,11 +8,37 @@ and this project adheres to
## [Unreleased]
## [1.7.0] - 2026-02-19
### Added
- ✨(frontend) expose Windows app web link #976
- ✨(frontend) support additional shortcuts to broaden accessibility
### Changed
- ✨(frontend) add clickable settings general link in idle modal #974
## [1.6.0] - 2026-02-10
### Added
- ✨(backend) monitor throttling rate failure through sentry #964
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
### Changed
- ♿️(frontend) improve spinner reducedmotion fallback #931
- ♿️(frontend) fix form labels and autocomplete wiring #932
- 🥅(summary) catch file-related exceptions when handling recording #944
- 📝(frontend) update legal terms #956
- ⚡️(backend) enhance django admin's loading performance #954
- 🌐(frontend) add missing DE translation for accessibility settings
### Fixed
- 🔐(backend) enforce object-level permission checks on room endpoint #959
- 🔒️(backend) add application validation when consuming external JWT #963
## [1.5.0] - 2026-01-28
+14 -14
View File
@@ -40,9 +40,9 @@ DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-development
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-development
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
@@ -94,8 +94,8 @@ build: ## build the project containers
@$(MAKE) build-frontend
.PHONY: build
build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev
build-backend: ## build the app-development container
@$(COMPOSE) build app-development
.PHONY: build-backend
@@ -107,12 +107,12 @@ down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
logs: ## display app-development logs (follow mode)
@$(COMPOSE) logs -f app-development
.PHONY: logs
run-backend: ## start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d celery-development
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run-backend
@@ -227,14 +227,14 @@ back-i18n-generate: ## create the .pot files used for i18n
@$(MANAGE) makemessages -a --keep-pot
.PHONY: back-i18n-generate
shell: ## connect to database shell
shell: ## open a Django Python shell
@$(MANAGE) shell #_plus
.PHONY: dbshell
.PHONY: shell
# -- Database
dbshell: ## connect to database shell
docker compose exec app-dev python manage.py dbshell
docker compose exec app-development python manage.py dbshell
.PHONY: dbshell
resetdb: FLUSH_ARGS ?=
@@ -348,16 +348,16 @@ build-k8s-cluster: ## build the kubernetes cluster using kind
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
./bin/install-external-secrets.sh
.PHONY: build-k8s-cluster
.PHONY: install-external-secrets
start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
.PHONY: start-tilt
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
.PHONY: start-tilt-keycloak
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
.PHONY: start-tilt-dinum
+2
View File
@@ -0,0 +1,2 @@
web: bin/buildpack_start.sh
postdeploy: python manage.py migrate
+2 -2
View File
@@ -59,7 +59,7 @@ docker_build(
context='../src/summary',
dockerfile='../src/summary/Dockerfile',
only=['.'],
target = 'production',
target = 'summary-production',
live_update=[
sync('../src/summary', '/app'),
]
@@ -71,7 +71,7 @@ docker_build(
context='../src/agents',
dockerfile='../src/agents/Dockerfile',
only=['.'],
target = 'production',
target = 'agents-production',
live_update=[
sync('../src/agents', '/app'),
]
+1 -1
View File
@@ -89,5 +89,5 @@ function _dc_exec() {
#
# ARGS : django's manage.py command arguments
function _django_manage() {
_dc_run "app-dev" python manage.py "$@"
_dc_run "app-development" python manage.py "$@"
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-compile script"
# Cleanup
rm -rf docker docs env.d gitlint
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-frontend script"
# Move the frontend build to the nginx root and clean up
mkdir -p build/
mv src/frontend/dist build/frontend-out
ASSETS_DIR=build/frontend-out/assets
if [ -n "$CUSTOM_LOGO_URL" ]; then
# Ensure https
[[ ! "$CUSTOM_LOGO_URL" =~ ^https:// ]] && echo "[custom-logo] ERROR: URL must use HTTPS" >&2 && exit 1
# Prevent SSRF
HOSTNAME=$(echo "$CUSTOM_LOGO_URL" | sed -E 's|^https://([^/:]+).*|\1|')
[[ "$HOSTNAME" =~ ^(localhost|127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|0\.0\.0\.0|\[::1\]) ]] && echo "[custom-logo] ERROR: SSRF blocked: $HOSTNAME" >&2 && exit 1
LOGO_FILE="${ASSETS_DIR}/logo.svg"
TMP_FILE=$(mktemp "${LOGO_FILE}.XXXXXX.tmp")
# Actual download
echo "[custom-logo] INFO: Downloading custom logo from: $CUSTOM_LOGO_URL"
curl -fsSL --tlsv1.2 -o "$TMP_FILE" "$CUSTOM_LOGO_URL"
# Validate filesize
FILESIZE=$(stat -c%s "$TMP_FILE" 2>/dev/null || stat -f%z "$TMP_FILE")
[[ "$FILESIZE" -eq 0 ]] && echo "[custom-logo] ERROR: empty file" >&2 && exit 1
[[ "$FILESIZE" -gt 5242880 ]] && echo "[custom-logo] ERROR: file too large (${FILESIZE}B > 5MB)" >&2 && exit 1
# Validate file type
IS_SVG=false
HEADER=$(head -c 100 "$TMP_FILE" | tr -d '\0' | tr '[:upper:]' '[:lower:]')
[[ "$HEADER" =~ ^.*"<svg".*$ ]] && IS_SVG=true
[[ "$HEADER" =~ ^.*"<?xml".*"<svg".*$ ]] && IS_SVG=true
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
mv -f "$TMP_FILE" "$LOGO_FILE"
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
fi
mv src/backend/* ./
mv deploy/paas/* ./
echo "3.13" > .python-version
echo "." > requirements.txt
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Start the Django backend server
gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
# Start the Nginx server
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n
# then kill all the other tasks
pkill -P $$
+1 -1
View File
@@ -4,5 +4,5 @@ source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
-e DJANGO_CONFIGURATION=Test \
app-dev \
app-development \
pytest "$@"
+1 -1
View File
@@ -4,7 +4,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
-e DJANGO_CONFIGURATION=Test \
app-dev \
app-development \
python manage.py spectacular \
--api-version 'v1.0' \
--urlconf 'meet.api_urls' \
+14 -14
View File
@@ -55,13 +55,13 @@ services:
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password &&
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-development:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json &&
sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
exit 0;"
app-dev:
app-development:
build:
context: .
target: backend-development
@@ -94,7 +94,7 @@ services:
- resource-server
- default
celery-dev:
celery-development:
user: ${DOCKER_USER:-1000}
image: meet:backend-development
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG"]
@@ -107,9 +107,9 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
- app-dev
- app-development
app:
app-production:
build:
context: .
target: backend-production
@@ -128,7 +128,7 @@ services:
- livekit
- minio
celery:
celery-production:
user: ${DOCKER_USER:-1000}
image: meet:backend-production
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO"]
@@ -138,7 +138,7 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
- app
- app-production
nginx:
image: nginx:1.25
@@ -161,7 +161,7 @@ services:
args:
VITE_API_BASE_URL: "http://localhost:8071"
VITE_APP_TITLE: "LaSuite Meet"
image: meet:frontend-development
image: meet:frontend-production
ports:
- "3000:8080"
@@ -249,10 +249,10 @@ services:
ports:
- "6379:6379"
app-summary-dev:
app-summary-development:
build:
context: src/summary
target: development
target: summary-development
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
@@ -270,7 +270,7 @@ services:
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
target: summary-production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
env_file:
- env.d/development/summary
@@ -278,7 +278,7 @@ services:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- app-summary-development
- minio
develop:
watch:
@@ -290,7 +290,7 @@ services:
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
target: summary-production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
env_file:
- env.d/development/summary
@@ -298,7 +298,7 @@ services:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- app-summary-development
- minio
develop:
watch:
+52
View File
@@ -0,0 +1,52 @@
# ERB templated nginx configuration
# see https://doc.scalingo.com/platform/deployment/buildpacks/nginx
upstream backend_server {
server localhost:8000 fail_timeout=0;
}
server {
listen <%= ENV["PORT"] %>;
server_name _;
server_tokens off;
root /app/build/frontend-out;
# Django rest framework
location ^~ /api/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Django admin
location ^~ /admin/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
@@ -1,9 +1,9 @@
upstream meet_backend {
server ${BACKEND_HOST}:8000 fail_timeout=0;
server ${BACKEND_INTERNAL_HOST}:8000 fail_timeout=0;
}
upstream meet_frontend {
server ${FRONTEND_HOST}:8080 fail_timeout=0;
server ${FRONTEND_INTERNAL_HOST}:8080 fail_timeout=0;
}
server {
+1 -1
View File
@@ -7,4 +7,4 @@ keys:
webhook:
api_key: devkey
urls:
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
- http://app-development:8000/api/v1.0/rooms/webhooks-livekit/
+2 -1
View File
@@ -20,8 +20,8 @@ services:
user: ${DOCKER_USER:-1000}
restart: always
env_file:
- .env
- env.d/common
- env.d/backend
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
@@ -45,6 +45,7 @@ services:
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- .env
- env.d/common
# Uncomment and set your values if using our nginx proxy example
# environment:
+4
View File
@@ -9,6 +9,10 @@ La Suite Meet maintainers use only the Kubernetes deployment method in productio
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
## Scalingo
La Suite Meet can be deployed on Scalingo PaaS using the Suite Numérique buildpack. See the [Scalingo deployment guide](./scalingo.md) for detailed instructions.
## Other ways to install La Suite Meet
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
+185
View File
@@ -0,0 +1,185 @@
# Deployment on Scalingo
This guide explains how to deploy La Suite Meet on [Scalingo](https://scalingo.com/) using the [Suite Numérique buildpack](https://github.com/suitenumerique/buildpack).
## Overview
Scalingo is a Platform-as-a-Service (PaaS) that simplifies application deployment. This setup uses a custom buildpack to handle both the frontend (Vite) and backend (Django) builds, serving them through Nginx.
## Prerequisites
- A Scalingo account
- Scalingo CLI installed (optional but recommended)
- A PostgreSQL database addon
- A Redis addon (for caching and sessions)
## Step 1: Create Your App
Create a new app on Scalingo using `scalingo` cli or using the [Scalingo dashboard](https://dashboard.scalingo.com/).
## Step 2: Provision Addons
Add the required PostgreSQL and Redis services.
This will set the following environment variables automatically:
- `SCALINGO_POSTGRESQL_URL` - Database connection string
- `SCALINGO_REDIS_URL` - Redis connection string
## Step 3: Configure Environment Variables
Set the following environment variables in your Scalingo app:
### Buildpack Configuration
```bash
scalingo env-set BUILDBACK_URL="https://github.com/suitenumerique/buildpack#main"
scalingo env-set LASUITE_APP_NAME="meet"
scalingo env-set LASUITE_BACKEND_DIR="."
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
scalingo env-set LASUITE_NGINX_DIR="."
scalingo env-set LASUITE_SCRIPT_POSTCOMPILE="bin/buildpack_postcompile.sh"
scalingo env-set LASUITE_SCRIPT_POSTFRONTEND="bin/buildpack_postfrontend.sh"
```
### Database and Cache
```bash
scalingo env-set DATABASE_URL="\$SCALINGO_POSTGRESQL_URL"
scalingo env-set REDIS_URL="\$SCALINGO_REDIS_URL"
```
### Django Settings
```bash
scalingo env-set DJANGO_SETTINGS_MODULE="meet.settings"
scalingo env-set DJANGO_CONFIGURATION="Production"
scalingo env-set DJANGO_SECRET_KEY="<generate-a-secure-secret-key>"
scalingo env-set DJANGO_ALLOWED_HOSTS="my-meet-app.osc-fr1.scalingo.io"
```
### OIDC Authentication
Configure your OIDC provider (e.g., Keycloak, Authentik):
```bash
scalingo env-set OIDC_OP_BASE_URL="https://auth.yourdomain.com/realms/meet"
scalingo env-set OIDC_RP_CLIENT_ID="meet-client-id"
scalingo env-set OIDC_RP_CLIENT_SECRET="<your-client-secret>"
scalingo env-set OIDC_RP_SIGN_ALGO="RS256"
```
### LiveKit Configuration
Meet requires a LiveKit server for video conferencing:
```bash
scalingo env-set LIVEKIT_API_URL="wss://livekit.yourdomain.com"
scalingo env-set LIVEKIT_API_KEY="<your-livekit-api-key>"
scalingo env-set LIVEKIT_API_SECRET="<your-livekit-api-secret>"
```
### Email Configuration (Optional)
For email notifications see https://doc.scalingo.com/platform/app/sending-emails:
```bash
scalingo env-set DJANGO_EMAIL_HOST="smtp.example.org"
scalingo env-set DJANGO_EMAIL_PORT="587"
scalingo env-set DJANGO_EMAIL_HOST_USER="<smtp-user>"
scalingo env-set DJANGO_EMAIL_HOST_PASSWORD="<smtp-password>"
scalingo env-set DJANGO_EMAIL_USE_TLS="True"
scalingo env-set DJANGO_EMAIL_FROM="meet@yourdomain.com"
```
## Step 4: Deploy
Deploy your application:
```bash
git push scalingo main
```
The Procfile will automatically:
1. Build the frontend (Vite)
2. Build the backend (Django)
3. Run the post-compile script (cleanup)
4. Run the post-frontend script (move assets and prepare for deployment)
5. Start Nginx and Gunicorn
6. Run django migrations
## Step 5: Create superuser
After the first deployment, create an admin user:
```bash
scalingo run python manage.py createsuperuser
```
## Custom Domain (Optional)
To use a custom domain:
1. Add the domain in Scalingo dashboard
2. Update `DJANGO_ALLOWED_HOSTS` with your custom domain
3. Configure your DNS to point to Scalingo
```bash
scalingo domains-add meet.yourdomain.com
scalingo env-set DJANGO_ALLOWED_HOSTS="meet.yourdomain.com,my-meet-app.osc-fr1.scalingo.io"
```
## Custom Logo (Optional)
To use a custom logo, set the `CUSTOM_LOGO_URL` environment variable with an HTTPS URL pointing to an SVG item (max 5MB):
```bash
scalingo env-set CUSTOM_LOGO_URL="https://cdn.yourdomain.com/logo.svg"
```
## Troubleshooting
### Check Logs
```bash
scalingo logs --tail
```
### Common Issues
1. **Build fails**: Check that all required environment variables are set
2. **Database connection error**: Verify `DATABASE_URL` is correctly set to `$SCALINGO_POSTGRESQL_URL`
3. **Static files not served**: Ensure the buildpack post-frontend script ran successfully
4. **OIDC errors**: Verify your OIDC provider configuration and callback URLs
### Useful Commands
```bash
# Open a console
scalingo run bash
# Restart the app
scalingo restart
# Scale containers
scalingo scale web:2
# One-off command
scalingo run python manage.py shell
```
## Architecture
On Scalingo, the application runs as follows:
1. **Build Phase**: The buildpack compiles both frontend and backend
2. **Runtime**:
- Nginx serves static files and proxies to the backend
- Gunicorn runs the Django WSGI application
- Both processes are managed by the `bin/buildpack_start.sh` script
## Additional Resources
- [Scalingo Documentation](https://doc.scalingo.com/)
- [Suite Numérique Buildpack](https://github.com/suitenumerique/buildpack)
- [Meet Environment Variables](../../src/helm/meet/README.md)
- [Django Configurations Documentation](https://django-configurations.readthedocs.io/)
+1 -1
View File
@@ -61,7 +61,7 @@ ALLOW_UNREGISTERED_ROOMS=False
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_ENDPOINT=http://app-summary-development:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
+1 -1
View File
@@ -1,4 +1,4 @@
APP_NAME="meet-app-summary-dev"
APP_NAME="meet-app-summary-development"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
+1 -1
View File
@@ -15,7 +15,7 @@ COPY pyproject.toml .
RUN mkdir /install && \
pip install --prefix=/install .
FROM base AS production
FROM base AS agents-production
WORKDIR /app
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.5.0"
version = "1.7.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+17 -2
View File
@@ -115,6 +115,10 @@ class RoomAdmin(admin.ModelAdmin):
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
return super().get_queryset(request).prefetch_related("accesses__user")
def get_owner(self, obj):
"""Return the owner of the room for display in the admin list."""
@@ -138,6 +142,7 @@ class RecordingAccessInline(admin.TabularInline):
model = models.RecordingAccess
extra = 0
autocomplete_fields = ["user"]
@admin.action(description=_("Resend notification to external service"))
@@ -207,8 +212,18 @@ class RecordingAdmin(admin.ModelAdmin):
"created_at",
"worker_id",
)
list_filter = ["status", "room", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
list_filter = ["created_at"]
list_select_related = ("room",)
readonly_fields = (
"id",
"created_at",
"options",
"mode",
"room",
"status",
"updated_at",
"worker_id",
)
actions = [resend_notification]
def get_queryset(self, request):
+26
View File
@@ -0,0 +1,26 @@
"""Throttling modules for the API."""
from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle
from sentry_sdk import capture_message
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
capture_message(message, "warning")
class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
+4 -16
View File
@@ -10,7 +10,7 @@ from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.text import slugify
from rest_framework import decorators, mixins, pagination, throttling, viewsets
from rest_framework import decorators, mixins, pagination, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
@@ -58,7 +58,7 @@ from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService
from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers
from . import permissions, serializers, throttling
from .feature_flag import FeatureFlag
# pylint: disable=too-many-ancestors
@@ -191,18 +191,6 @@ class UserViewSet(
)
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
@@ -379,7 +367,7 @@ class RoomViewSet(
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[RequestEntryAnonRateThrottle],
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
)
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room"""
@@ -489,7 +477,7 @@ class RoomViewSet(
methods=["post"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[CreationCallbackAnonRateThrottle],
throttle_classes=[throttling.CreationCallbackAnonRateThrottle],
)
def creation_callback(self, request):
"""Retrieve cached room data via an unauthenticated request with a unique ID.
@@ -10,6 +10,8 @@ import jwt as pyJwt
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from rest_framework import authentication, exceptions
from core.models import Application
User = get_user_model()
logger = logging.getLogger(__name__)
@@ -94,6 +96,18 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
application = Application.objects.get(client_id=client_id)
except Application.DoesNotExist as e:
logger.warning("Application not found: %s", client_id)
raise exceptions.AuthenticationFailed("Application not found.") from e
if not application.active:
logger.warning(
"Inactive application attempted authentication: %s", client_id
)
raise exceptions.AuthenticationFailed("Application is disabled.")
if not is_delegated:
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
+27 -5
View File
@@ -33,12 +33,11 @@ class BaseScopePermission(permissions.BasePermission):
Raises:
PermissionDenied: If required scope is missing from token
"""
# Get the current action (e.g., 'list', 'create')
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
action = getattr(view, "action", None)
if not action:
raise exceptions.PermissionDenied(
"Insufficient permissions. Unknown action."
)
# DRF routers return a 405 for unsupported methods
return True
required_scope = self.scope_map.get(action)
if not required_scope:
@@ -57,9 +56,12 @@ class BaseScopePermission(permissions.BasePermission):
if isinstance(token_scopes, str):
token_scopes = token_scopes.split()
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
if settings.OIDC_RS_SCOPES_PREFIX:
token_scopes = [
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
for scope in token_scopes
]
@@ -82,3 +84,23 @@ class HasRequiredRoomScope(BaseScopePermission):
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
"destroy": models.ApplicationScope.ROOMS_DELETE,
}
class RoomPermissions(permissions.BasePermission):
"""Permissions applying to the room API endpoint."""
def has_permission(self, request, view):
"""Allow access only to authenticated users."""
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Enforce role-based access: read=any role, delete=owner, write=admin or owner."""
user = request.user
if request.method in permissions.SAFE_METHODS:
return obj.has_any_role(user)
if request.method == "DELETE":
return obj.is_owner(user)
return obj.is_administrator_or_owner(user)
+3 -1
View File
@@ -182,7 +182,9 @@ class RoomViewSet(
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
api.permissions.IsAuthenticated
& permissions.HasRequiredRoomScope
& permissions.RoomPermissions
]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
+4
View File
@@ -292,6 +292,10 @@ class Resource(BaseModel):
role = RoleChoices.MEMBER
return role
def has_any_role(self, user):
"""Check if a user has any role on the resource."""
return self.get_role(user) is not None
def is_administrator_or_owner(self, user):
"""
Check if a user is administrator or owner of the resource."""
@@ -102,6 +102,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
settings.RECORDING_DOWNLOAD_BASE_URL = None
settings.EMAIL_FROM = "notifications@acme.com"
recording = factories.RecordingFactory(room__name="Conference Room A")
+635 -17
View File
@@ -2,21 +2,21 @@
Tests for external API /room endpoint
"""
# pylint: disable=W0621
# pylint: disable=W0621,C0302
import uuid
from datetime import datetime, timedelta, timezone
from unittest import mock
from django.conf import settings
import jwt
import pytest
import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import (
RoomFactory,
UserFactory,
)
from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
pytestmark = pytest.mark.django_db
@@ -27,12 +27,14 @@ def generate_test_token(user, scopes):
now = datetime.now(timezone.utc)
scope_string = " ".join(scopes)
application = ApplicationFactory()
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": "test-client-id",
"client_id": str(application.client_id),
"scope": scope_string,
"user_id": str(user.id),
"delegated": True,
@@ -53,6 +55,23 @@ def test_api_rooms_list_requires_authentication():
assert response.status_code == 401
def test_api_rooms_list_inactive_user(settings):
"""List should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False)
RoomFactory(users=[(user1, RoleChoices.OWNER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_list_with_valid_token(settings):
"""Listing rooms with valid token should succeed."""
@@ -73,6 +92,24 @@ def test_api_rooms_list_with_valid_token(settings):
assert response.data["results"][0]["id"] == str(room.id)
def test_api_rooms_list_with_no_rooms(settings):
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Generate valid token
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 0
assert response.data["results"] == []
def test_api_rooms_list_with_expired_token(settings):
"""Listing rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
@@ -92,8 +129,8 @@ def test_api_rooms_list_with_expired_token(settings):
@responses.activate
def test_api_rooms_list_with_invalid_token(settings):
"""Listing rooms with invalid token should return 400."""
def test_api_rooms_list_with_invalid_rs_token(settings):
"""Listing rooms with invalid resource server token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
@@ -130,7 +167,27 @@ def test_api_rooms_list_missing_scope(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
assert (
"insufficient permissions. required scope: rooms:list"
in str(response.data).lower()
)
def test_api_rooms_list_no_scope(settings):
"""Listing rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_list_filters_by_user(settings):
@@ -144,7 +201,9 @@ def test_api_rooms_list_filters_by_user(settings):
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
token = generate_test_token(
user1, [ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
@@ -158,6 +217,84 @@ def test_api_rooms_list_filters_by_user(settings):
assert str(room2.id) not in returned_ids
def test_api_rooms_retrieve_requires_authentication(settings):
"""Retrieving rooms without authentication should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
client = APIClient()
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 401
def test_api_rooms_retrieve_inactive_user(settings):
"""Retrieve should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False)
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_retrieve_with_expired_token(settings):
"""Retrieving rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_retrieve_with_invalid_rs_token(settings):
"""Retrieving rooms with invalid resource server token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": False,
},
)
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
def test_api_rooms_retrieve_requires_scope(settings):
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
@@ -178,6 +315,24 @@ def test_api_rooms_retrieve_requires_scope(settings):
)
def test_api_rooms_retrieve_no_scope(settings):
"""Retrieving rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_retrieve_success(settings):
"""Retrieving a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
@@ -212,6 +367,132 @@ def test_api_rooms_retrieve_success(settings):
}
def test_api_rooms_retrieve_success_by_user(settings):
"""Retrieve should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
user2 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
room4 = RoomFactory(users=[(user1, RoleChoices.ADMIN)])
token = generate_test_token(
user1, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_LIST]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room2.id}/")
assert response.status_code == 403
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 200
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room3.id}/")
assert response.status_code == 200
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room4.id}/")
assert response.status_code == 200
def test_api_rooms_retrieve_not_found(settings):
"""Retrieving a non-existing room with correct scope should return a 404."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
assert response.status_code == 404
assert "no room matches the given query." in str(response.data).lower()
def test_api_rooms_create_requires_authentication(settings):
"""Creating rooms without authentication should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
client = APIClient()
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
def test_api_rooms_create_with_expired_token(settings):
"""Creating rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_create_with_invalid_rs_token(settings):
"""Creating rooms with invalid resource server token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": False,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.post("/external-api/v1.0/rooms/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
def test_api_rooms_create_inactive_user(settings):
"""Create should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False)
token = generate_test_token(user1, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_create_requires_scope(settings):
"""Creating a room requires ROOMS_CREATE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
@@ -225,18 +506,38 @@ def test_api_rooms_create_requires_scope(settings):
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:create" in str(
response.data
assert (
"insufficient permissions. required scope: rooms:create"
in str(response.data).lower()
)
def test_api_rooms_create_no_scope(settings):
"""Creating rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_create_success(settings):
"""Creating a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
token = generate_test_token(
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
@@ -245,6 +546,8 @@ def test_api_rooms_create_success(settings):
assert response.status_code == 201
assert "id" in response.data
assert "slug" in response.data
assert "name" in response.data
assert response.data["name"] == response.data["slug"]
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
@@ -252,6 +555,72 @@ def test_api_rooms_create_success(settings):
assert room.access_level == "trusted"
def test_api_rooms_create_readonly_enforcement(settings):
"""Creating a room succeeds and any provided read-only fields are ignored."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{
"id": "fake-id",
"slug": "fake-slug",
"name": "fake-name",
"access_level": "public",
},
format="json",
)
assert response.status_code == 201
assert "slug" in response.data
assert response.data["id"] != "fake-id"
assert "name" in response.data
assert response.data["slug"] != "fake-slug"
assert "id" in response.data
assert response.data["name"] != "fake-name"
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert room.access_level == "trusted"
def test_api_rooms_unknown_actions(settings):
"""Updating or deleting a room are not supported yet."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(
user,
[
ApplicationScope.ROOMS_RETRIEVE,
ApplicationScope.ROOMS_DELETE,
ApplicationScope.ROOMS_UPDATE,
],
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 405
assert 'method "delete" not allowed.' in str(response.data).lower()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 405
assert 'method "patch" not allowed.' in str(response.data).lower()
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
@@ -290,10 +659,43 @@ def test_api_rooms_response_no_telephony(settings):
assert response.data["id"] == str(room.id)
def test_api_rooms_token_scope_case_insensitive(settings):
"""Token's scope should be case-insensitive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
# Generate token with mixed-case scope "Rooms:List" to verify that scope
# validation is case-insensitive (should match "rooms:list")
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "Rooms:List", # Mixed case - should be accepted as "rooms:list"
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
def test_api_rooms_token_without_delegated_flag(settings):
"""Token without delegated flag should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
@@ -302,7 +704,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "test-client",
"client_id": str(application.client_id),
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": False, # Not delegated
@@ -318,7 +720,73 @@ def test_api_rooms_token_without_delegated_flag(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token type." in str(response.data)
assert "invalid token type." in str(response.data).lower()
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
"""Token signed with an invalid key should defer to the next authentication."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
"invalid-private-key",
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
mock_rs_authenticate.assert_called()
assert response.status_code == 401
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
"""Token signed with an invalid alg should defer to the next authentication."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_ALG = "RS256"
user = UserFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "test-client",
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm="HS256", # different value
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
mock_rs_authenticate.assert_called()
assert response.status_code == 401
def test_api_rooms_token_missing_client_id(settings):
@@ -348,7 +816,157 @@ def test_api_rooms_token_missing_client_id(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token claims." in str(response.data)
assert "invalid token claims." in str(response.data).lower()
def test_api_rooms_token_missing_user_id(settings):
"""Token without user_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "rooms:list",
"delegated": True,
# Missing user_id
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
def test_api_rooms_token_invalid_audience(settings):
"""Token with an invalid audience should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": "invalid-audience",
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(user.id),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token." in str(response.data).lower()
def test_api_rooms_token_unknown_user(settings):
"""Token for unknown user should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user not found." in str(response.data).lower()
def test_api_rooms_token_unknown_application(settings):
"""Token for unknown application should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "unknown-client-id",
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "application not found." in str(response.data).lower()
def test_api_rooms_token_inactive_application(settings):
"""Token for inactive application should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory(active=False)
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "application is disabled." in str(response.data).lower()
@responses.activate
@@ -507,7 +1125,7 @@ def test_resource_server_authentication_successful(settings):
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve",
"active": True,
},
)
+9 -1
View File
@@ -18,6 +18,7 @@ from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
@@ -92,7 +93,11 @@ class Base(Configuration):
# Database
DATABASES = {
"default": {
"default": dj_database_url.config()
if values.DatabaseURLValue(
None, environ_name="DATABASE_URL", environ_prefix=None
)
else {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
@@ -292,6 +297,9 @@ class Base(Configuration):
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
"core.api.throttling.sentry_monitoring_throttle_failure"
)
SPECTACULAR_SETTINGS = {
"TITLE": "Meet API",
+3 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.5.0"
version = "1.7.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -29,6 +29,7 @@ dependencies = [
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.5.3",
"dj-database-url==3.1.0",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.0.0",
@@ -38,7 +39,7 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.9",
"django==5.2.11",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
+6
View File
@@ -5,6 +5,12 @@ server {
root /usr/share/nginx/html;
location = /.wellknown/windows-app-web-link {
default_type application/json;
alias /usr/share/nginx/html/.wellknown/windows-app-web-link;
add_header Content-Disposition "attachment; filename=windows-app-web-link";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
+515 -154
View File
@@ -1,42 +1,42 @@
{
"name": "meet",
"version": "1.5.0",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.5.0",
"version": "1.7.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource/material-icons-outlined": "5.2.6",
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.9.3",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"crisp-sdk-web": "1.0.27",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"humanize-duration": "3.33.2",
"i18next": "25.8.4",
"i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.7",
"posthog-js": "1.256.2",
"posthog-js": "1.342.1",
"react": "18.3.1",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.1.5",
"wouter": "3.7.1"
"valtio": "2.3.0",
"wouter": "3.9.0"
},
"devDependencies": {
"@pandacss/dev": "0.54.0",
@@ -55,7 +55,7 @@
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier": "3.8.1",
"typescript": "5.8.3",
"vite": "7.0.8",
"vite-tsconfig-paths": "5.1.4"
@@ -363,9 +363,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1021,9 +1021,9 @@
"license": "MIT"
},
"node_modules/@fontsource-variable/material-symbols-outlined": {
"version": "5.2.30",
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.30.tgz",
"integrity": "sha512-BjSx7nqvISJs2Pjd8sBH583AnD4k6dD4Em7AVISoLXzbX3PIFWAE2GPm13LlCys8u8idkyUd62L8yn6ts6DdbA==",
"version": "5.2.34",
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.34.tgz",
"integrity": "sha512-FfJVoKdYlxvIHflVZlrevf6e1WmbJGeAdbcLmOmkftoP2Du0qrRD/SmNchIXijsKXF7j3z1m++e92Qi6/U+jRA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
@@ -1158,9 +1158,9 @@
"dev": true
},
"node_modules/@internationalized/date": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.11.0.tgz",
"integrity": "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -1177,9 +1177,9 @@
}
},
"node_modules/@internationalized/number": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.3.tgz",
"integrity": "sha512-p+Zh1sb6EfrfVaS86jlHGQ9HA66fJhV9x5LiE5vCbZtXEHAuhcmUZUdZ4WrFpUBfNalr2OkAJI5AcKEQF+Lebw==",
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz",
"integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -1354,6 +1354,252 @@
"node": ">= 8"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
"integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/core": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
"integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/exporter-logs-otlp-http": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz",
"integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/otlp-exporter-base": "0.208.0",
"@opentelemetry/otlp-transformer": "0.208.0",
"@opentelemetry/sdk-logs": "0.208.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-exporter-base": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz",
"integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/otlp-transformer": "0.208.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz",
"integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
"@opentelemetry/sdk-logs": "0.208.0",
"@opentelemetry/sdk-metrics": "2.2.0",
"@opentelemetry/sdk-trace-base": "2.2.0",
"protobufjs": "^7.3.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz",
"integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.5.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz",
"integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz",
"integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz",
"integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
"integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.39.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz",
"integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@pandacss/config": {
"version": "0.54.0",
"resolved": "https://registry.npmjs.org/@pandacss/config/-/config-0.54.0.tgz",
@@ -1746,6 +1992,85 @@
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-0.54.0.tgz",
"integrity": "sha512-5kspg2UOgFWrawbHeoleZvbZ6id/kIBLHoDeD1CnLbl9fEbZAZ3Avi5Hi1mIFe9E8PFzp9V+N9IfQL7pYhavfA=="
},
"node_modules/@posthog/core": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.20.1.tgz",
"integrity": "sha512-uoTmWkYCtLYFpiK37/JCq+BuCA/OZn1qQZn5cPv1EEKt3ni3Zgg48xWCnSEyGFl5KKSXlfCruiRTwnbAtCgrBA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.6"
}
},
"node_modules/@posthog/types": {
"version": "1.342.1",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.342.1.tgz",
"integrity": "sha512-bcyBdO88FWTkd5AVTa4Nu8T7RfY0WJrG7WMCXum/rcvNjYhS3DmOfKf8o/Bt56vA3J3yeU0vbgrmltYVoTAfaA==",
"license": "MIT"
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@react-aria/autocomplete": {
"version": "3.0.0-beta.5",
"resolved": "https://registry.npmjs.org/@react-aria/autocomplete/-/autocomplete-3.0.0-beta.5.tgz",
@@ -2096,18 +2421,18 @@
}
},
"node_modules/@react-aria/i18n": {
"version": "3.12.10",
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.10.tgz",
"integrity": "sha512-1j00soQ2W0nTgzaaIsGFdMF/5aN60AEdCJPhmXGZiuWdWzMxObN9LQ9vdzYPTjTqyqMdSaSp9DZKs5I26Xovpw==",
"version": "3.12.15",
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.15.tgz",
"integrity": "sha512-3CrAN7ORVHrckvTmbPq76jFZabqq+rScosGT5+ElircJ5rF5+JcdT99Hp5Xg6R10jk74e8G3xiqdYsUd+7iJMA==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.8.2",
"@internationalized/date": "^3.11.0",
"@internationalized/message": "^3.1.8",
"@internationalized/number": "^3.6.3",
"@internationalized/number": "^3.6.5",
"@internationalized/string": "^3.2.7",
"@react-aria/ssr": "^3.9.9",
"@react-aria/utils": "^3.29.1",
"@react-types/shared": "^3.30.0",
"@react-aria/ssr": "^3.9.10",
"@react-aria/utils": "^3.33.0",
"@react-types/shared": "^3.33.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
@@ -2116,15 +2441,15 @@
}
},
"node_modules/@react-aria/interactions": {
"version": "3.25.3",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.3.tgz",
"integrity": "sha512-J1bhlrNtjPS/fe5uJQ+0c7/jiXniwa4RQlP+Emjfc/iuqpW2RhbF9ou5vROcLzWIyaW8tVMZ468J68rAs/aZ5A==",
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.0.tgz",
"integrity": "sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.9",
"@react-aria/utils": "^3.29.1",
"@react-aria/ssr": "^3.9.10",
"@react-aria/utils": "^3.33.0",
"@react-stately/flags": "^3.1.2",
"@react-types/shared": "^3.30.0",
"@react-types/shared": "^3.33.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
@@ -2148,15 +2473,15 @@
}
},
"node_modules/@react-aria/landmark": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.4.tgz",
"integrity": "sha512-1U5ce6cqg1qGbK4M4R6vwrhUrKXuUzReZwHaTrXxEY22IMxKDXIZL8G7pFpcKix2XKqjLZWf+g8ngGuNhtQ2QQ==",
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.9.tgz",
"integrity": "sha512-YYyluDBCXupnMh91ccE5g27fczjYmzPebHqTkVYjH4B6k45pOoqsMmWBCMnOTl0qOCeioI+daT8W0MamAZzoSw==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/utils": "^3.29.1",
"@react-types/shared": "^3.30.0",
"@react-aria/utils": "^3.33.0",
"@react-types/shared": "^3.33.0",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.4.0"
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
@@ -2457,9 +2782,9 @@
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.9",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.9.tgz",
"integrity": "sha512-2P5thfjfPy/np18e5wD4WPt8ydNXhij1jwA8oehxZTFqlgVMGXzcWKxTb4RtJrLFsqPO7RUQTiY8QJk0M4Vy2g==",
"version": "3.9.10",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -2579,18 +2904,18 @@
}
},
"node_modules/@react-aria/toast": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.5.tgz",
"integrity": "sha512-uhwiZqPy6hqucBUL7z6uUZjAJ/ou3bNdTjZlXS+zbcm+T0dsjKDfzNkaebyZY7AX3cYkFCaRjc3N6omXwoAviw==",
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.10.tgz",
"integrity": "sha512-irW5Cr4msbPo4A4ysjT70MDJbpGCe1h9SkFgdYXBPA4Xbi4jRT7TiEZeIS1I7Hsvp6shAK1Ld/m6NBS0b/gyzg==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/i18n": "^3.12.10",
"@react-aria/interactions": "^3.25.3",
"@react-aria/landmark": "^3.0.4",
"@react-aria/utils": "^3.29.1",
"@react-stately/toast": "^3.1.1",
"@react-types/button": "^3.12.2",
"@react-types/shared": "^3.30.0",
"@react-aria/i18n": "^3.12.15",
"@react-aria/interactions": "^3.27.0",
"@react-aria/landmark": "^3.0.9",
"@react-aria/utils": "^3.33.0",
"@react-stately/toast": "^3.1.3",
"@react-types/button": "^3.15.0",
"@react-types/shared": "^3.33.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
@@ -2672,15 +2997,15 @@
}
},
"node_modules/@react-aria/utils": {
"version": "3.29.1",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.29.1.tgz",
"integrity": "sha512-yXMFVJ73rbQ/yYE/49n5Uidjw7kh192WNN9PNQGV0Xoc7EJUlSOxqhnpHmYTyO0EotJ8fdM1fMH8durHjUSI8g==",
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.0.tgz",
"integrity": "sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.9",
"@react-aria/ssr": "^3.9.10",
"@react-stately/flags": "^3.1.2",
"@react-stately/utils": "^3.10.7",
"@react-types/shared": "^3.30.0",
"@react-stately/utils": "^3.11.0",
"@react-types/shared": "^3.33.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
@@ -3112,13 +3437,13 @@
}
},
"node_modules/@react-stately/toast": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.1.tgz",
"integrity": "sha512-W4a6xcsFt/E+aHmR2eZK+/p7Y5rdyXSCQ5gKSnbck+S3lijEWAyV45Mv8v95CQqu0bQijj6sy2Js1szq10HVwg==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.3.tgz",
"integrity": "sha512-mT9QJKmD523lqFpOp0VWZ6QHZENFK7HrodnNJDVc7g616s5GNmemdlkITV43fSY3tHeThCVvPu+Uzh7RvQ9mpQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.4.0"
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -3170,9 +3495,9 @@
}
},
"node_modules/@react-stately/utils": {
"version": "3.10.7",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.7.tgz",
"integrity": "sha512-cWvjGAocvy4abO9zbr6PW6taHgF24Mwy/LbQ4TC4Aq3tKdKDntxyD+sh7AkSRfJRT2ccMVaHVv2+FfHThd3PKQ==",
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz",
"integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -3224,12 +3549,12 @@
}
},
"node_modules/@react-types/button": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.12.2.tgz",
"integrity": "sha512-QLoSCX8E7NFIdkVMa65TPieve0rKeltfcIxiMtrphjfNn+83L0IHMcbhjf4r4W19c/zqGbw3E53Hx8mNukoTUw==",
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.15.0.tgz",
"integrity": "sha512-X/K2/Oeuq7Hi8nMIzx4/YlZuvWFiSOHZt27p4HmThCnNO/9IDFPmvPrpkYjWN5eN9Nuk+P5vZUb4A7QJgYpvGA==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.30.0"
"@react-types/shared": "^3.33.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -3399,12 +3724,12 @@
}
},
"node_modules/@react-types/overlays": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.0.tgz",
"integrity": "sha512-T2DqMcDN5p8vb4vu2igoLrAtuewaNImLS8jsK7th7OjwQZfIWJn5Y45jSxHtXJUddEg1LkUjXYPSXCMerMcULw==",
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.3.tgz",
"integrity": "sha512-LzetThNNk8T26pQRbs1I7+isuFhdFYREy7wJCsZmbB0FnZgCukGTfOtThZWv+ry11veyVJiX68jfl4SV6ACTWA==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.31.0"
"@react-types/shared": "^3.33.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -3460,9 +3785,9 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.0.tgz",
"integrity": "sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -4023,7 +4348,6 @@
"version": "22.16.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.0.tgz",
"integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -4062,6 +4386,13 @@
"resolved": "https://registry.npmjs.org/@types/symlink-or-copy/-/symlink-or-copy-1.2.2.tgz",
"integrity": "sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA=="
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz",
@@ -5089,15 +5420,16 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/crisp-sdk-web": {
"version": "1.0.25",
"resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.0.25.tgz",
"integrity": "sha512-CWTHFFeHRV0oqiXoPh/aIAKhFs6xcIM4NenGPnClAMCZUDQgQsF1OWmZWmnVNjJriXUmWRgDfeUxcxygS0dCRA=="
"version": "1.0.27",
"resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.0.27.tgz",
"integrity": "sha512-aNWR3te65YiaVFu/iwdqOo3cyUBZHUheE4d6EtgQu/T18jh/9SpoYXjXF/OzUD3Cqy0pGryoqtuy5gxD8tqX9Q==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -5376,6 +5708,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
@@ -6565,15 +6906,18 @@
}
},
"node_modules/humanize-duration": {
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.0.tgz",
"integrity": "sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==",
"license": "Unlicense"
"version": "3.33.2",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz",
"integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==",
"license": "Unlicense",
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/i18next": {
"version": "25.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",
"integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==",
"version": "25.8.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.4.tgz",
"integrity": "sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==",
"funding": [
{
"type": "individual",
@@ -6590,7 +6934,7 @@
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6"
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
@@ -7084,8 +7428,7 @@
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/javascript-stringify": {
"version": "2.1.0",
@@ -7562,6 +7905,12 @@
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/look-it-up": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/look-it-up/-/look-it-up-2.1.0.tgz",
@@ -7739,7 +8088,7 @@
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "github",
@@ -8043,7 +8392,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -8069,7 +8417,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"devOptional": true,
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -8116,7 +8464,7 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -8261,33 +8609,31 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.256.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.256.2.tgz",
"integrity": "sha512-ypepnUHr33i5a1Uk39mozZXXTENRPC17HCG3WHKK6aRcpNwNs8uEqXaIKICGNM+qre+totKeTgl0WoaUFYmyoQ==",
"version": "1.342.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.342.1.tgz",
"integrity": "sha512-mMnQhWuKj4ejFicLtFzr52InmqploOyW1eInqXBkaVqE1DPhczBDmwsd9MSggY8kv0EXm8zgK+2tzBJUKcX5yg==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.208.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
"@opentelemetry/resources": "^2.2.0",
"@opentelemetry/sdk-logs": "^0.208.0",
"@posthog/core": "1.20.1",
"@posthog/types": "1.342.1",
"core-js": "^3.38.1",
"dompurify": "^3.3.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
"web-vitals": "^4.2.4"
},
"peerDependencies": {
"@rrweb/types": "2.0.0-alpha.17",
"rrweb-snapshot": "2.0.0-alpha.17"
},
"peerDependenciesMeta": {
"@rrweb/types": {
"optional": true
},
"rrweb-snapshot": {
"optional": true
}
"preact": "^10.28.2",
"query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.1.0"
}
},
"node_modules/preact": {
"version": "10.24.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.0.tgz",
"integrity": "sha512-aK8Cf+jkfyuZ0ZZRG9FbYqwmEiGQ4y/PUO4SuTWoyWL244nZZh7bd5h2APd4rSNDYTBNghg1L+5iJN3Skxtbsw==",
"version": "10.28.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -8303,9 +8649,9 @@
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -8331,6 +8677,30 @@
"node": "10.* || >= 12.*"
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-compare": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz",
@@ -8346,6 +8716,12 @@
"node": ">=6"
}
},
"node_modules/query-selector-shadow-dom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
"integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -8747,17 +9123,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-snapshot": {
"version": "2.0.0-alpha.17",
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.17.tgz",
"integrity": "sha512-GBg5pV8LHOTbeVmH2VHLEFR0mc2QpQMzAvcoxEGfPNWgWHc8UvKCyq7pqN1vA+fDZ+yXXbixeO0kB2pzVvFCBw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/rsvp": {
"version": "4.8.5",
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
@@ -8915,7 +9280,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -8927,7 +9291,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -8993,7 +9356,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9540,7 +9903,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
@@ -9614,9 +9976,9 @@
}
},
"node_modules/use-sync-external-store": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
"integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -9643,9 +10005,9 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/valtio": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz",
"integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.3.0.tgz",
"integrity": "sha512-1MfKNcmOIdBSatiJsYgw420n6jnD+jeoI0V+RkOQbCB0ElLh6GKUfPr0hc9uq/KBGeghivDEarRsKFFdSQQnKw==",
"license": "MIT",
"dependencies": {
"proxy-compare": "^3.0.1"
@@ -9931,9 +10293,9 @@
}
},
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
"license": "Apache-2.0"
},
"node_modules/webrtc-adapter": {
@@ -9971,7 +10333,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},
@@ -10037,9 +10398,9 @@
}
},
"node_modules/wouter": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.7.1.tgz",
"integrity": "sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==",
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.9.0.tgz",
"integrity": "sha512-sF/od/PIgqEQBQcrN7a2x3MX6MQE6nW0ygCfy9hQuUkuB28wEZuu/6M5GyqkrrEu9M6jxdkgE12yDFsQMKos4Q==",
"license": "Unlicense",
"dependencies": {
"mitt": "^3.0.1",
+11 -11
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.5.0",
"version": "1.7.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,35 +13,35 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource/material-icons-outlined": "5.2.6",
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.9.3",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"crisp-sdk-web": "1.0.27",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"humanize-duration": "3.33.2",
"i18next": "25.8.4",
"i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.7",
"posthog-js": "1.256.2",
"posthog-js": "1.342.1",
"react": "18.3.1",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.1.5",
"wouter": "3.7.1"
"valtio": "2.3.0",
"wouter": "3.9.0"
},
"devDependencies": {
"@pandacss/dev": "0.54.0",
@@ -60,7 +60,7 @@
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier": "3.8.1",
"typescript": "5.8.3",
"vite": "7.0.8",
"vite-tsconfig-paths": "5.1.4"
@@ -0,0 +1,6 @@
[
{
"packageFamilyName" : "Visio_g3z6ba6vek6vg",
"paths" : [ "*" ]
}
]
@@ -1,4 +1,4 @@
import { Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
import { A, Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
@@ -9,6 +9,8 @@ import { navigateTo } from '@/navigation/navigateTo'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
@@ -18,6 +20,7 @@ export const IsIdleDisconnectModal = () => {
const connectionObserverSnap = useSnapshot(connectionObserverStore)
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
const lastAnnouncementRef = useRef<number | null>(null)
const { openSettingsDialog } = useSettingsDialog()
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
const announce = useScreenReaderAnnounce()
@@ -117,7 +120,19 @@ export const IsIdleDisconnectModal = () => {
}),
})}
</P>
<P>{t('settings')}</P>
<P>
{t('settingsPrefix')}{' '}
<A
color="primary"
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
openSettingsDialog(SettingsDialogExtendedKey.GENERAL)
}}
>
{t('settingsLink')}
</A>
{t('settingsSuffix')}
</P>
<HStack marginTop="2rem">
<Button
onPress={() => {
@@ -11,9 +11,7 @@ import {
ProcessorType,
} from '.'
export class UnifiedBackgroundTrackProcessor
implements BackgroundProcessorInterface
{
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
processorType: ProcessorType
@@ -13,8 +13,7 @@ export interface ProcessorSerialized {
options: BackgroundOptions
}
export interface BackgroundProcessorInterface
extends TrackProcessor<Track.Kind> {
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): Promise<void>
options: BackgroundOptions
clone(): BackgroundProcessorInterface
@@ -6,6 +6,7 @@ import { ToggleButton } from '@/primitives'
import { chatStore } from '@/stores/chat'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ChatToggle = ({
onPress,
@@ -18,6 +19,11 @@ export const ChatToggle = ({
const { isChatOpen, toggleChat } = useSidePanel()
const tooltipLabel = isChatOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'toggle-chat',
handler: toggleChat,
})
return (
<div
className={css({
@@ -18,6 +18,7 @@ import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
type ToggleDeviceStyleProps = {
variant?: NonNullable<ButtonRecipeProps>['variant']
@@ -88,12 +89,14 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceShortcut = useDeviceShortcut(kind)
useRegisterKeyboardShortcut({
shortcut: deviceShortcut,
id: deviceShortcut?.id,
handler: async () => await toggle(),
isDisabled: cannotUseDevice,
})
const pushToTalkShortcut = getShortcutDescriptorById('push-to-talk')
useLongPress({
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
keyCode: kind === 'audioinput' ? pushToTalkShortcut?.code : undefined,
onKeyDown,
onKeyUp,
isDisabled: cannotUseDevice,
@@ -103,7 +106,9 @@ export const ToggleDevice = <T extends ToggleSource>({
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `selectDevice.${kind}`,
})
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
return deviceShortcut?.shortcut
? appendShortcutLabel(label, deviceShortcut.shortcut)
: label
}, [enabled, kind, deviceShortcut, t])
const Icon =
@@ -9,6 +9,7 @@ import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
const SPEAKING_DETECTION_DELAY = 3000
@@ -33,6 +34,16 @@ export const HandToggle = () => {
closeLowerHandToasts()
}, [isHandRaised])
const handleToggle = () => {
toggleRaisedHand()
resetToastState()
}
useRegisterKeyboardShortcut({
id: 'raise-hand',
handler: handleToggle,
})
useEffect(() => {
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
@@ -68,10 +79,7 @@ export const HandToggle = () => {
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={() => {
toggleRaisedHand()
resetToastState()
}}
onPress={handleToggle}
data-attr={`controls-hand-${tooltipLabel}`}
>
<RiHand />
@@ -6,6 +6,7 @@ import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ParticipantsToggle = ({
onPress,
@@ -27,6 +28,11 @@ export const ParticipantsToggle = ({
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'toggle-participants',
handler: toggleParticipants,
})
return (
<div
className={css({
@@ -11,6 +11,7 @@ import {
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
@@ -41,6 +42,11 @@ export const ReactionsToggle = () => {
const [isVisible, setIsVisible] = useState(false)
useRegisterKeyboardShortcut({
id: 'reaction',
handler: () => setIsVisible((prev) => !prev),
})
const sendReaction = async (emoji: string) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
@@ -6,8 +6,7 @@ import {
import React from 'react'
/** @public */
export interface AllowMediaPlaybackProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
export interface AllowMediaPlaybackProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label?: string
}
@@ -11,8 +11,7 @@ const ASPECT_RATIO = 16 / 10
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
/** @public */
export interface CarouselLayoutProps
extends React.HTMLAttributes<HTMLMediaElement> {
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
tracks: TrackReferenceOrPlaceholder[]
children: React.ReactNode
/** Place the tiles vertically or horizontally next to each other.
@@ -13,7 +13,8 @@ import { PaginationControl } from '../controls/PaginationControl'
/** @public */
export interface GridLayoutProps
extends React.HTMLAttributes<HTMLDivElement>,
extends
React.HTMLAttributes<HTMLDivElement>,
Pick<UseParticipantsOptions, 'updateOnlyOn'> {
children: React.ReactNode
tracks: TrackReferenceOrPlaceholder[]
@@ -12,6 +12,6 @@ export function useCanPublishTrack(trackSource: TrackSource): boolean {
return Boolean(
permissions?.canPublish &&
permissions?.canPublishSources?.includes(trackSource)
permissions?.canPublishSources?.includes(trackSource)
)
}
@@ -1,19 +1,16 @@
import { useMemo } from 'react'
import { Shortcut } from '@/features/shortcuts/types'
import {
getShortcutDescriptorById,
ShortcutDescriptor,
} from '@/features/shortcuts/catalog'
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
return useMemo<Shortcut | undefined>(() => {
return useMemo<ShortcutDescriptor | undefined>(() => {
switch (kind) {
case 'audioinput':
return {
key: 'd',
ctrlKey: true,
}
return getShortcutDescriptorById('toggle-microphone')
case 'videoinput':
return {
key: 'e',
ctrlKey: true,
}
return getShortcutDescriptorById('toggle-camera')
default:
return undefined
}
@@ -18,8 +18,7 @@ import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>,
ChatOptions {}
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
/**
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
@@ -22,7 +22,7 @@ export function DesktopControlBar({
const desktopControlBarEl = useRef<HTMLDivElement>(null)
useRegisterKeyboardShortcut({
shortcut: { key: 'F2' },
id: 'focus-toolbar',
handler: () => {
const root = desktopControlBarEl.current
if (!root) return
@@ -66,8 +66,7 @@ const LayoutWrapper = styled(
/**
* @public
*/
export interface VideoConferenceProps
extends React.HTMLAttributes<HTMLDivElement> {
export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
/** @alpha */
SettingsComponent?: React.ComponentType
}
@@ -9,8 +9,7 @@ import NoiseSuppressorWorklet from '@timephy/rnnoise-wasm/NoiseSuppressorWorklet
// and suspend/resume it as needed to manage audio state
let audioContext: AudioContext
export interface AudioProcessorInterface
extends TrackProcessor<Track.Kind.Audio> {
export interface AudioProcessorInterface extends TrackProcessor<Track.Kind.Audio> {
name: string
}
@@ -0,0 +1,83 @@
import { Shortcut } from './types'
// Central list of current keyboard shortcuts.
// Keep a single source of truth for display and, later, customization
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId =
| 'focus-toolbar'
| 'toggle-microphone'
| 'toggle-camera'
| 'push-to-talk'
| 'toggle-chat'
| 'toggle-participants'
| 'raise-hand'
| 'recording'
| 'reaction'
| 'fullscreen'
export const getShortcutDescriptorById = (id: ShortcutId) =>
shortcutCatalog.find((item) => item.id === id)
export type ShortcutDescriptor = {
id: ShortcutId
category: ShortcutCategory
shortcut?: Shortcut
kind?: 'press' | 'longPress'
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
description?: string
}
export const shortcutCatalog: ShortcutDescriptor[] = [
{
id: 'focus-toolbar',
category: 'navigation',
shortcut: { key: 'F2' },
},
{
id: 'toggle-microphone',
category: 'media',
shortcut: { key: 'd', ctrlKey: true },
},
{
id: 'toggle-camera',
category: 'media',
shortcut: { key: 'e', ctrlKey: true },
},
{
id: 'push-to-talk',
category: 'media',
kind: 'longPress',
code: 'KeyV',
},
{
id: 'reaction',
category: 'interaction',
shortcut: { key: 'E', ctrlKey: true, shiftKey: true },
},
{
id: 'fullscreen',
category: 'interaction',
shortcut: { key: 'F', ctrlKey: true, shiftKey: true },
},
{
id: 'recording',
category: 'interaction',
shortcut: { key: 'L', ctrlKey: true, shiftKey: true },
},
{
id: 'raise-hand',
category: 'interaction',
shortcut: { key: 'H', ctrlKey: true, shiftKey: true },
},
{
id: 'toggle-chat',
category: 'interaction',
shortcut: { key: 'M', ctrlKey: true, shiftKey: true },
},
{
id: 'toggle-participants',
category: 'interaction',
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
},
]
@@ -1,4 +1,6 @@
export type Shortcut = {
key: string
ctrlKey?: boolean
shiftKey?: boolean
altKey?: boolean
}
@@ -11,11 +11,13 @@ export const useKeyboardShortcuts = () => {
// This approach handles basic shortcuts but isn't comprehensive.
// Issues might occur. First draft.
const onKeyDown = async (e: KeyboardEvent) => {
const { key, metaKey, ctrlKey } = e
const { key, metaKey, ctrlKey, shiftKey, altKey } = e
if (!key) return
const shortcutKey = formatShortcutKey({
key,
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
shiftKey,
altKey,
})
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut) return
@@ -1,26 +1,28 @@
import { useEffect } from 'react'
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
import { formatShortcutKey } from '@/features/shortcuts/utils'
import { Shortcut } from '@/features/shortcuts/types'
import { ShortcutId, getShortcutDescriptorById } from './catalog'
export type useRegisterKeyboardShortcutProps = {
shortcut?: Shortcut
id?: ShortcutId
handler: () => Promise<void | boolean | undefined> | void
isDisabled?: boolean
}
export const useRegisterKeyboardShortcut = ({
shortcut,
id,
handler,
isDisabled = false,
}: useRegisterKeyboardShortcutProps) => {
useEffect(() => {
if (!shortcut) return
const formattedKey = formatShortcutKey(shortcut)
if (!id) return
const descriptor = getShortcutDescriptorById(id)
if (!descriptor?.shortcut) return
const formattedKey = formatShortcutKey(descriptor.shortcut)
if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
} else {
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
}
}, [handler, shortcut, isDisabled])
}, [handler, id, isDisabled])
}
+16 -4
View File
@@ -4,15 +4,27 @@ import { Shortcut } from '@/features/shortcuts/types'
export const CTRL = 'ctrl'
export const formatShortcutKey = (shortcut: Shortcut) => {
if (shortcut.ctrlKey) return `${CTRL}+${shortcut.key.toUpperCase()}`
return shortcut.key.toUpperCase()
const parts = []
if (shortcut.ctrlKey) parts.push(CTRL)
if (shortcut.altKey) parts.push('alt')
if (shortcut.shiftKey) parts.push('shift')
parts.push(shortcut.key.toUpperCase())
return parts.join('+')
}
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
if (!shortcut.key) return
let formattedKeyLabel = shortcut.key.toLowerCase()
const parts: string[] = []
if (shortcut.ctrlKey) {
formattedKeyLabel = `${isMacintosh() ? '⌘' : 'Ctrl'}+${formattedKeyLabel}`
parts.push(isMacintosh() ? '⌘' : 'Ctrl')
}
if (shortcut.altKey) {
parts.push(isMacintosh() ? '⌥' : 'Alt')
}
if (shortcut.shiftKey) {
parts.push('Shift')
}
parts.push(shortcut.key.toLowerCase())
const formattedKeyLabel = parts.join('+')
return `${label} (${formattedKeyLabel})`
}
@@ -20,8 +20,7 @@ export interface TranscriptionSegment {
lastReceivedTime: number
}
export interface TranscriptionSegmentWithParticipant
extends TranscriptionSegment {
export interface TranscriptionSegmentWithParticipant extends TranscriptionSegment {
participant: Participant
}
+3 -1
View File
@@ -154,7 +154,9 @@
"isIdleDisconnectModal": {
"title": "Bist du noch da?",
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
"settingsPrefix": "Um diese Nachricht nicht mehr zu sehen, gehe zu",
"settingsLink": "Einstellungen > Allgemein",
"settingsSuffix": ".",
"stayButton": "Gespräch fortsetzen",
"leaveButton": "Jetzt verlassen",
"countdownAnnouncement": "Das Gespräch endet in {{duration}}."
@@ -108,12 +108,18 @@
"label": "Sprache"
},
"settingsButtonLabel": "Einstellungen",
"accessibility": {
"announceReactions": {
"label": "Reaktionen vorlesen"
}
},
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Video",
"general": "Allgemein",
"notifications": "Benachrichtigungen",
"accessibility": "Barrierefreiheit",
"transcription": "Transkription"
}
}
+3 -1
View File
@@ -154,7 +154,9 @@
"isIdleDisconnectModal": {
"title": "Are you still there?",
"body": "You are the only participant. This call will end in {{duration}}. Would you like to continue the call?",
"settings": "To stop seeing this message, go to Settings > General.",
"settingsPrefix": "To stop seeing this message, go to",
"settingsLink": "Settings > General",
"settingsSuffix": ".",
"stayButton": "Continue the call",
"leaveButton": "Leave now",
"countdownAnnouncement": "Call ends in {{duration}}."
+3 -1
View File
@@ -154,7 +154,9 @@
"isIdleDisconnectModal": {
"title": "Êtes-vous toujours là ?",
"body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?",
"settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.",
"settingsPrefix": "Pour ne plus voir ce message, allez dans",
"settingsLink": "Paramètres > Général",
"settingsSuffix": ".",
"stayButton": "Poursuivre l'appel",
"leaveButton": "Quitter maintenant",
"countdownAnnouncement": "L'appel se termine dans {{duration}}."
+3 -1
View File
@@ -154,7 +154,9 @@
"isIdleDisconnectModal": {
"title": "Ben je er nog?",
"body": "Je bent de enige deelnemer. Dit gesprek wordt over {{duration}} beëindigd. Wil je het gesprek voortzetten?",
"settings": "Om dit bericht niet meer te zien, ga naar Instellingen > Algemeen.",
"settingsPrefix": "Om dit bericht niet meer te zien, ga naar",
"settingsLink": "Instellingen > Algemeen",
"settingsSuffix": ".",
"stayButton": "Gesprek voortzetten",
"leaveButton": "Nu verlaten",
"countdownAnnouncement": "Het gesprek eindigt over {{duration}}."
+73 -33
View File
@@ -1,5 +1,12 @@
import { ProgressBar } from 'react-aria-components'
import { css } from '@/styled-system/css'
import { RiHourglassFill } from '@remixicon/react'
import { css, cx } from '@/styled-system/css'
const rotatingArcClassName = css({
animation: 'rotate 1s ease-in-out infinite',
transformOrigin: 'center',
transition: 'transform 16ms linear',
})
export const Spinner = ({
size = 56,
@@ -13,44 +20,77 @@ export const Spinner = ({
const r = 14 - strokeWidth
const c = 2 * r * Math.PI
return (
<ProgressBar aria-label="Loading" value={30}>
<ProgressBar aria-label="Loading..." value={30}>
{({ percentage }) => (
<svg
width={size}
height={size}
viewBox="0 0 28 28"
fill="none"
strokeWidth={strokeWidth}
<div
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<circle
cx={center}
cy={center}
r={r}
strokeDasharray={0}
strokeDashoffset={0}
strokeLinecap="round"
<svg
width={size}
height={size}
viewBox="0 0 28 28"
fill="none"
strokeWidth={strokeWidth}
className={css({
stroke: variant == 'light' ? 'primary.100' : 'transparent',
'@media (prefers-reduced-motion: reduce)': {
display: 'none',
},
})}
style={{}}
/>
<circle
cx={center}
cy={center}
r={r}
strokeDasharray={`${c} ${c}`}
strokeDashoffset={percentage && c - (percentage / 100) * c}
strokeLinecap="round"
>
<circle
cx={center}
cy={center}
r={r}
strokeDasharray={0}
strokeDashoffset={0}
strokeLinecap="round"
className={css({
stroke: variant == 'light' ? 'primary.100' : 'transparent',
})}
/>
<circle
cx={center}
cy={center}
r={r}
strokeDasharray={`${c} ${c}`}
strokeDashoffset={
typeof percentage === 'number'
? c - (percentage / 100) * c
: undefined
}
strokeLinecap="round"
className={cx(
rotatingArcClassName,
css({
stroke: variant == 'light' ? 'primary.800' : 'white',
})
)}
/>
</svg>
<span
aria-hidden="true"
className={css({
stroke: variant == 'light' ? 'primary.800' : 'white',
display: 'none',
color: 'black',
'@media (prefers-reduced-motion: reduce)': {
display: 'inline-flex',
},
})}
style={{
animation: `rotate 1s ease-in-out infinite`,
transformOrigin: 'center',
transition: 'transform 16ms linear',
}}
/>
</svg>
>
<RiHourglassFill
size={Math.max(16, Math.round(size * 0.4))}
style={{
display: 'block',
transform: 'translateY(1px)',
color: variant == 'light' ? 'primary.800' : 'white',
}}
/>
</span>
</div>
)}
</ProgressBar>
)
@@ -75,6 +75,10 @@ backend:
ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304'
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
ROOM_SUBTITLE_ENABLED: True
EXTERNAL_API_ENABLED: True
APPLICATION_JWT_AUDIENCE: https://meet.127.0.0.1.nip.io/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY: devKeyApplication
APPLICATION_BASE_URL: https://meet.127.0.0.1.nip.io
migrate:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.5.0",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.5.0",
"version": "1.7.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.5.0",
"version": "1.7.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.5.0",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.5.0",
"version": "1.7.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.5.0",
"version": "1.7.0",
"author": "",
"license": "ISC",
"description": "",
+2 -2
View File
@@ -16,7 +16,7 @@ COPY pyproject.toml .
RUN pip3 install --no-cache-dir .
FROM base AS development
FROM base AS summary-development
WORKDIR /app
COPY . .
@@ -24,7 +24,7 @@ RUN pip3 install --no-cache-dir -e ".[dev]" || pip3 install --no-cache-dir -e .
CMD ["uvicorn", "summary.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
FROM base AS production
FROM base AS summary-production
WORKDIR /app
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.5.0"
version = "1.7.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",