Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 37d54b5670 🔒️(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 13:56:15 +01:00
142 changed files with 3014 additions and 7903 deletions
+5 -7
View File
@@ -24,15 +24,13 @@ data
.cache
.circleci
.git
.vscode
.iml
.idea
db.sqlite3
.mypy_cache
.pylint.d
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
.pytest_cache
# Frontend
**/node_modules
node_modules
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Download Crowdin files
uses: crowdin/github-action@v2
+38 -73
View File
@@ -23,13 +23,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -43,19 +37,18 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -66,13 +59,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -86,12 +73,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -99,7 +86,6 @@ jobs:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -110,13 +96,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -130,12 +110,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -143,7 +123,6 @@ jobs:
context: .
file: ./docker/dinum-frontend/Dockerfile
target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -154,13 +133,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -174,13 +147,13 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/summary/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
@@ -189,7 +162,6 @@ jobs:
context: ./src/summary
file: ./src/summary/Dockerfile
target: production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -200,13 +172,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -220,14 +186,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/agents/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
# docker-context: './src/agents'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -235,7 +201,6 @@ jobs:
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+20 -43
View File
@@ -7,18 +7,14 @@ on:
pull_request:
branches:
- "*"
permissions:
contents: read
jobs:
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: show
@@ -43,11 +39,9 @@ jobs:
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -55,11 +49,9 @@ jobs:
lint-changelog:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -70,22 +62,20 @@ jobs:
build-mails:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -105,23 +95,21 @@ jobs:
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
@@ -136,16 +124,14 @@ jobs:
lint-agents:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
@@ -158,16 +144,14 @@ jobs:
lint-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
@@ -181,8 +165,7 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: build-mails
permissions:
contents: read
defaults:
run:
working-directory: src/backend
@@ -233,7 +216,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Create writable /data
run: |
@@ -241,7 +224,7 @@ jobs:
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -275,7 +258,7 @@ jobs:
mc mb meet/meet-media-storage"
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
@@ -296,11 +279,9 @@ jobs:
lint-front:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install dependencies
run: cd src/frontend/ && npm ci
@@ -313,14 +294,12 @@ jobs:
lint-sdk:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
@@ -333,15 +312,13 @@ jobs:
build-sdk:
runs-on: ubuntu-latest
permissions:
contents: read
needs: lint-sdk
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
-29
View File
@@ -1,29 +0,0 @@
# /!\
# Security Note: This action is not hardened against prompt injection attacks and should only be used
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
# to ensure workflows only run after a maintainer has reviewed the PR.
name: Security Review
permissions:
pull-requests: write # Needed for leaving PR comments
contents: read
on:
pull_request:
branches:
- 'main'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 2
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
with:
comment-pr: true
exclude-directories: docs,gitlint,LICENSES,bin
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
-81
View File
@@ -8,87 +8,6 @@ and this project adheres to
## [Unreleased]
## [1.10.0] - 2026-03-05
### Fixed
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
- ♿(frontend) announce selected state to screen readers #1081
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
### Changed
- 🔒️(backend) enhance API input validation to strengthen security #1053
- 🦺(backend) strengthen API validation for recording options #1063
- ⚡️(frontend) optimize few performance caveats #1073
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
### Fixed
- 🐛(migrations) use settings in migrations #1058
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
## [1.9.0] - 2026-03-02
### Added
- 👷(docker) add arm64 platform support for image builds
- ✨(summary) add localization support for transcription context text
### Changed
- ♻️(frontend) replace custom reactions toolbar with react aria popover #985
- 🔒️(frontend) uninstall curl from the frontend production image #987
- 💄(frontend) add focus ring to reaction emoji buttons
- ✨(frontend) introduce a shortcut settings tab #975
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
- 🌐(frontend) localize SR modifier labels #1010
- ⬆️(backend) update python dependencies #1011
- ♿️(frontend) fix focus ring on tab container components #1012
- ♿️(frontend) upgrade join meeting modal accessibility #1027
- ⬆️(python) bump minimal required python version to 3.13 #1033
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
- ♿️(frontend) add skip link component for keyboard navigation #1019
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
### Fixed
- 🩹(frontend) fix German language preference update #1021
## [1.8.0] - 2026-02-20
### Changed
- 🔒️(agents) uninstall pip from the agents image
- 🔒️(summary) switch to Alpine base image
- 🔒️(backend) uninstall pip in the production image
### Fixed
- 🔒️(agents) upgrade OpenSSL to address CVE-2025-15467
- 📌(agents) pin protobuf to 6.33.5 to fix CVE-2026-0994
## [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
- ♻️(backend) refactor external API token-related items #1006
## [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
-3
View File
@@ -127,9 +127,6 @@ ARG MEET_STATIC_ROOT=/data/static
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/meet.py /usr/local/etc/gunicorn/meet.py
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
+4 -6
View File
@@ -23,10 +23,9 @@
# ==============================================================================
# VARIABLES
ESC := $(shell printf '\033')
BOLD := $(ESC)[1m
RESET := $(ESC)[0m
GREEN := $(ESC)[1;32m
BOLD := \033[1m
RESET := \033[0m
GREEN := \033[1;32m
# -- Database
@@ -86,8 +85,7 @@ bootstrap: \
demo \
back-i18n-compile \
mails-install \
mails-build \
run
mails-build
.PHONY: bootstrap
# -- Docker/compose
-2
View File
@@ -1,2 +0,0 @@
web: bin/buildpack_start.sh
postdeploy: python manage.py migrate
+1 -5
View File
@@ -18,7 +18,6 @@ docker_build(
'localhost:5001/meet-backend:latest',
context='..',
dockerfile='../Dockerfile',
build_args={'DOCKER_USER': '1001:127'},
only=['./src/backend', './src/mail', './docker'],
target = 'backend-production',
live_update=[
@@ -34,7 +33,6 @@ clean_old_images('localhost:5001/meet-backend')
docker_build(
'localhost:5001/meet-frontend-dinum:latest',
context='..',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target = 'frontend-production',
@@ -59,7 +57,6 @@ clean_old_images('localhost:5001/meet-frontend-generic')
docker_build(
'localhost:5001/meet-summary:latest',
context='../src/summary',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/summary/Dockerfile',
only=['.'],
target = 'production',
@@ -72,9 +69,8 @@ clean_old_images('localhost:5001/meet-summary')
docker_build(
'localhost:5001/meet-agents:latest',
context='../src/agents',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/agents/Dockerfile',
only=['.'],
only=['.'],
target = 'production',
live_update=[
sync('../src/agents', '/app'),
-9
View File
@@ -1,9 +0,0 @@
#!/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
@@ -1,50 +0,0 @@
#!/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
@@ -1,15 +0,0 @@
#!/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 $$
-52
View File
@@ -1,52 +0,0 @@
# 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_INTERNAL_HOST}:8000 fail_timeout=0;
server ${BACKEND_HOST}:8000 fail_timeout=0;
}
upstream meet_frontend {
server ${FRONTEND_INTERNAL_HOST}:8080 fail_timeout=0;
server ${FRONTEND_HOST}:8080 fail_timeout=0;
}
server {
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.9.4
FROM livekit/livekit-server:v1.9.0
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
+1 -2
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,7 +45,6 @@ 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,10 +9,6 @@ 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
@@ -1,185 +0,0 @@
# 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 BUILDPACK_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
View File
@@ -2,7 +2,6 @@
Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>"
"""
from __future__ import unicode_literals
import re
-21
View File
@@ -3,21 +3,6 @@
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
{
"groupName": "js dependencies",
"matchManagers": ["npm"],
"schedule": ["on the first day of the month"],
"matchPackagePatterns": ["*"],
"minimumReleaseAge": "7 days",
"internalChecksFilter": "strict"
},
{
"groupName": "python dependencies",
"matchManagers": ["setup-cfg", "pep621"],
"schedule": ["on the first day of the month"],
"matchPackagePatterns": ["*"],
"minimumReleaseAge": "7 days"
},
{
"enabled": false,
"groupName": "ignored python dependencies",
@@ -30,12 +15,6 @@
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
-5
View File
@@ -4,8 +4,6 @@ FROM python:3.13-slim AS base
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
@@ -21,9 +19,6 @@ FROM base AS production
WORKDIR /app
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
ARG DOCKER_USER
USER ${DOCKER_USER}
+2 -3
View File
@@ -1,15 +1,14 @@
[project]
name = "agents"
version = "1.10.0"
version = "1.5.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.1",
"protobuf==6.33.5"
"python-dotenv==1.2.1"
]
[project.optional-dependencies]
+13 -81
View File
@@ -1,14 +1,10 @@
"""Client serializers for the Meet core app."""
# pylint: disable=abstract-method,no-name-in-module
from typing import Literal
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field
from livekit.api import ParticipantPermission
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -205,27 +201,6 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class RecordingOptions(BaseModel):
"""Configuration options for recording.
Attributes:
language: ISO 639-1 language code compatible with whisperX.
When `None`, the transcription engine will attempt to
auto-detect the spoken language.
transcribe: Whether to transcribe the recorded audio.
When `None`, falls back to the application default.
original_mode: The original recording mode before any override.
Must be one of the valid RecordingModeChoices values when provided.
"""
language: str | None = None
transcribe: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None
model_config = {"extra": "forbid"}
class StartRecordingSerializer(BaseValidationOnlySerializer):
"""Validate start recording requests."""
@@ -238,11 +213,10 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
"screen_recording or transcript.",
},
)
options = SchemaField(
schema=RecordingOptions | None,
options = serializers.JSONField(
required=False,
allow_null=True,
help_text="Recording options",
default=dict,
)
@@ -287,28 +261,6 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf.
Control what a participant is allowed to publish, subscribe, and do within a room.
Unknown fields are rejected.
"""
can_subscribe: bool | None = None
can_publish: bool | None = None
can_publish_data: bool | None = None
can_publish_sources: list[int] = Field(
default_factory=list
) # TrackSource enum values
hidden: bool | None = None
recorder: bool | None = None
can_update_metadata: bool | None = None
agent: bool | None = None
can_subscribe_metrics: bool | None = None
model_config = {"extra": "forbid"}
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
@@ -320,11 +272,10 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
allow_null=True,
help_text="Participant attributes as JSON object",
)
permission = SchemaField(
schema=ParticipantPermission | None,
permission = serializers.DictField(
required=False,
allow_null=True,
help_text="Participant permissions",
help_text="Participant permission as JSON object",
)
name = serializers.CharField(
max_length=255,
@@ -334,33 +285,6 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
help_text="Display name for the participant",
)
def validate_permission(self, permission):
"""Validate that the given permission does not include forbidden or unimplemented fields."""
if permission is None:
return None
suspicious_fields = [
field
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
if getattr(permission, field) is not None
]
if suspicious_fields:
raise SuspiciousOperation(
f"Setting the following participant permissions is not allowed: "
f"{', '.join(suspicious_fields)}."
)
if permission.can_subscribe_metrics is not None:
raise serializers.ValidationError(
{
"permission": {
"can_subscribe_metrics": "This permission is not implemented."
}
}
)
return permission
def validate(self, attrs):
"""Ensure at least one update field is provided."""
update_fields = ["metadata", "attributes", "permission", "name"]
@@ -376,4 +300,12 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
f"{', '.join(update_fields)}."
)
if "permission" in attrs:
try:
ParticipantPermission(**attrs["permission"])
except ValueError as e:
raise serializers.ValidationError(
{"permission": f"Invalid permission: {str(e)}"}
) from e
return attrs
-26
View File
@@ -1,26 +0,0 @@
"""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"
+19 -11
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, viewsets
from rest_framework import decorators, mixins, pagination, throttling, 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, throttling
from . import permissions, serializers
from .feature_flag import FeatureFlag
# pylint: disable=too-many-ancestors
@@ -191,6 +191,18 @@ 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,
@@ -296,14 +308,12 @@ class RoomViewSet(
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data.get("options")
options = serializer.validated_data["options"]
room = self.get_object()
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
room=room, mode=mode, options=options
)
models.RecordingAccess.objects.create(
@@ -369,7 +379,7 @@ class RoomViewSet(
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
throttle_classes=[RequestEntryAnonRateThrottle],
)
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room"""
@@ -479,7 +489,7 @@ class RoomViewSet(
methods=["post"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[throttling.CreationCallbackAnonRateThrottle],
throttle_classes=[CreationCallbackAnonRateThrottle],
)
def creation_callback(self, request):
"""Retrieve cached room data via an unauthenticated request with a unique ID.
@@ -609,15 +619,13 @@ class RoomViewSet(
serializer = serializers.UpdateParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
permission = serializer.validated_data.get("permission")
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
metadata=serializer.validated_data.get("metadata"),
attributes=serializer.validated_data.get("attributes"),
permission=permission.model_dump() if permission else None,
permission=serializer.validated_data.get("permission"),
name=serializer.validated_data.get("name"),
)
except ParticipantsManagementException:
+47 -132
View File
@@ -1,51 +1,27 @@
"""Authentication Backends for external application to the Meet core app."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
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
from core.services import jwt_token
User = get_user_model()
logger = logging.getLogger(__name__)
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
"""JWT authentication for application-delegated API access.
def __init__(
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
):
"""Initialize the JWT authentication backend with the given token service configuration.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm (e.g. HS256)
issuer: Expected token issuer identifier
audience: Expected token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type (e.g. Bearer)
"""
super().__init__()
self._token_service = jwt_token.JwtTokenService(
secret_key=secret_key,
algorithm=algorithm,
issuer=issuer,
audience=audience,
expiration_seconds=expiration_seconds,
token_type=token_type,
)
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def authenticate(self, request):
"""Extract and validate JWT from Authorization header.
@@ -72,78 +48,6 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
return self.authenticate_credentials(token)
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict, or None if token is invalid
Raises:
AuthenticationFailed: If token is expired or has invalid issuer/audience
"""
try:
payload = self._token_service.decode_jwt(token)
return payload
except jwt_token.TokenExpiredError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except jwt_token.TokenInvalidError as e:
logger.warning("Invalid JWT issuer or audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except jwt_token.TokenDecodeError:
# Invalid JWT token - defer to next authentication backend
return None
def validate_payload(self, payload):
"""Validate JWT payload claims.
Override in subclasses to add custom validation.
Args:
payload: Decoded JWT payload
Raises:
AuthenticationFailed: If required claims are missing or invalid
"""
def get_user(self, payload):
"""Retrieve and validate user from payload.
Args:
payload: Decoded JWT payload
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
user_id = payload.get("user_id")
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
@@ -158,41 +62,36 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
Raises:
AuthenticationFailed: If token is expired, or user not found
"""
payload = self.decode_jwt(token)
if payload is None:
# Decode and validate JWT
try:
payload = pyJwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
return None
self.validate_payload(payload)
user = self.get_user(payload)
return (user, payload)
class ApplicationJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for application-delegated API access.
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def __init__(self):
"""Initialize authentication backend with application JWT settings from Django settings."""
super().__init__(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
def validate_payload(self, payload):
"""Validate application-specific claims."""
user_id = payload.get("user_id")
client_id = payload.get("client_id")
is_delegated = payload.get("delegated", False)
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not client_id:
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
@@ -213,6 +112,22 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return (user, payload)
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
+23 -17
View File
@@ -1,5 +1,6 @@
"""External API endpoints"""
from datetime import datetime, timedelta, timezone
from logging import getLogger
from django.conf import settings
@@ -7,6 +8,7 @@ from django.contrib.auth.hashers import check_password
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email
import jwt
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import decorators, mixins, viewsets
from rest_framework import (
@@ -20,7 +22,6 @@ from rest_framework import (
)
from core import api, models
from core.services.jwt_token import JwtTokenService
from . import authentication, permissions, serializers
@@ -127,28 +128,33 @@ class ApplicationViewSet(viewsets.ViewSet):
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
token_service = JwtTokenService(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": client_id,
"scope": scope,
"user_id": str(user.id),
"delegated": True,
}
data = token_service.generate_jwt(
user,
scope,
{
"client_id": client_id,
"delegated": True,
},
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
return drf_response.Response(
data,
{
"access_token": token,
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": scope,
},
status=drf_status.HTTP_200_OK,
)
+2 -2
View File
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='resource',
name='users',
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='resourceaccess',
@@ -1,5 +1,5 @@
# Generated by Django 5.0.7 on 2024-08-07 14:39
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -1,5 +1,5 @@
# Generated by Django 5.1.8 on 2025-04-22 14:52
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -167,7 +167,6 @@ class NotificationService:
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
}
headers = {
-153
View File
@@ -1,153 +0,0 @@
"""JWT token service."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
from datetime import datetime, timedelta, timezone
from typing import Optional
from django.core.exceptions import ImproperlyConfigured
import jwt
class JWTError(Exception):
"""Base exception for all JWT token errors."""
class TokenExpiredError(JWTError):
"""Raised when the JWT token has expired."""
class TokenInvalidError(JWTError):
"""Raised when the JWT token has an invalid issuer or audience."""
class TokenDecodeError(JWTError):
"""Raised for any other unrecoverable JWT decode failure."""
class JwtTokenService:
"""Generic JWT token service with configurable settings."""
def __init__(
self,
secret_key: str,
algorithm: str,
issuer: str,
audience: str,
expiration_seconds: int,
token_type: str,
):
"""
Initialize the token service with custom settings.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm
issuer: Token issuer identifier
audience: Token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type
Raises:
ImproperlyConfigured: If secret_key is None or empty
"""
if not secret_key:
raise ImproperlyConfigured("Secret key is required.")
if not algorithm:
raise ImproperlyConfigured("Algorithm is required.")
if not token_type:
raise ImproperlyConfigured("Token's type is required.")
if expiration_seconds is None:
raise ImproperlyConfigured("Expiration's seconds is required.")
self._key = secret_key
self._algorithm = algorithm
self._issuer = issuer
self._audience = audience
self._expiration_seconds = expiration_seconds
self._token_type = token_type
def generate_jwt(
self, user, scope: str, extra_payload: Optional[dict] = None
) -> dict:
"""
Generate an access token for the given user.
Note: any extra_payload variables named iat, exp, or user_id will
be overwritten by this service
Args:
user: User instance for whom to generate the token
scope: Space-separated scope string
Returns:
Dictionary containing access_token, token_type, expires_in, and scope optionally
"""
now = datetime.now(timezone.utc)
payload = extra_payload.copy() if extra_payload else {}
payload.update(
{
"iat": now,
"exp": now + timedelta(seconds=self._expiration_seconds),
"user_id": str(user.id),
}
)
if self._issuer:
payload["iss"] = self._issuer
if self._audience:
payload["aud"] = self._audience
if scope:
payload["scope"] = scope
token = jwt.encode(
payload,
self._key,
algorithm=self._algorithm,
)
response = {
"access_token": token,
"token_type": self._token_type,
"expires_in": self._expiration_seconds,
}
if scope:
response["scope"] = scope
return response
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict.
Raises:
TokenExpiredError: If the token has expired.
TokenInvalidError: If the token has an invalid issuer or audience.
TokenDecodeError: If the token is malformed or cannot be decoded.
"""
try:
payload = jwt.decode(
token,
self._key,
algorithms=[self._algorithm],
issuer=self._issuer,
audience=self._audience,
)
return payload
except jwt.ExpiredSignatureError as e:
raise TokenExpiredError("Token expired.") from e
except (jwt.InvalidIssuerError, jwt.InvalidAudienceError) as e:
raise TokenInvalidError("Invalid token.") from e
except jwt.InvalidTokenError as e:
raise TokenDecodeError("Token decode error.") from e
@@ -8,7 +8,6 @@ import random
from unittest import mock
from uuid import uuid4
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse
import pytest
@@ -133,7 +132,11 @@ def test_update_participant_success(mock_livekit_client):
1,
2,
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"hidden": False,
"recorder": False,
"can_update_metadata": True,
"agent": False,
"can_subscribe_metrics": False,
},
"name": "John Doe",
}
@@ -148,151 +151,6 @@ def test_update_participant_success(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"permission_payload",
[
{}, # empty dict is valid
{"can_subscribe": True},
{"can_publish": True},
{"can_publish_data": True},
{"can_publish_sources": [1, 2]},
{"can_update_metadata": True},
],
)
def test_update_participant_permission_fields_are_optional(
mock_livekit_client, permission_payload
):
"""Test that each required permission field can be passed individually."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": permission_payload,
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"value,permission_key",
[
(False, "hidden"),
(True, "hidden"),
(False, "recorder"),
(True, "recorder"),
(False, "agent"),
(True, "agent"),
],
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission(
mock_suspicious, value, permission_key
):
"""Test update participant raises 400 when a restricted permission is set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_update_metadata": False,
permission_key: value,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
f"Setting the following participant permissions is not allowed: {permission_key}."
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission_multiple(mock_suspicious):
"""Test update participant raises 400 when multiple suspicious permissions are set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"hidden": True,
"recorder": False,
"can_update_metadata": False,
"agent": True,
"can_subscribe_metrics": False,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
"Setting the following participant permissions is not allowed: hidden, recorder, agent."
)
@pytest.mark.parametrize("value", (False, True))
def test_update_participant_unimplemented_can_subscribe_metrics(value):
"""Test update participant raises 400 when can_subscribe_metrics is set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_update_metadata": False,
"can_subscribe_metrics": value,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "can_subscribe_metrics" in str(response.data)
def test_update_participant_forbidden_without_access():
"""Test update participant returns 403 when user lacks room privileges."""
client = APIClient()
@@ -368,17 +226,7 @@ def test_update_participant_invalid_permission():
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"permission": [
{
"type": "extra_forbidden",
"loc": ["invalid-attributes"],
"msg": "Extra inputs are not permitted",
"input": "True",
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
},
]
}
assert "Invalid permission" in str(response.data)
def test_update_participant_wrong_metadata_attributes():
@@ -199,308 +199,3 @@ def test_start_recording_success(
access = recording.accesses.first()
assert access.user == user
assert access.role == "owner"
@pytest.mark.parametrize("value", ["fr", "en", "nl", "de"])
def test_start_recording_options_language_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept a valid ISO 639-1 language code."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"language": value}
@pytest.mark.parametrize("value", ["invalid-value", "francais", "123"])
def test_start_recording_options_language_not_validated(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Invalid language codes are currently accepted — no format validation yet.
TODO: tighten this once language validation is introduced.
"""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": value}},
format="json",
)
assert response.status_code == 201
def test_start_recording_options_language_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept null language (triggers auto-detection)."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", [True, 1, "y", "on", "true", "yes", "t"])
def test_start_recording_options_transcribe_valid_true(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic true values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": True}
@pytest.mark.parametrize("value", [False, 0, "n", "off", "false", "no", "f"])
def test_start_recording_options_transcribe_valid_false(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic false values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": False}
def test_start_recording_options_transcribe_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept transcribe=null (falls back to application default)."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept options=null."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": None},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with no options field at all."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_unknown_field_rejected(settings):
"""Should reject unknown fields in options (extra='forbid')."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"unknown_field": "value"}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["foo", 12])
def test_start_recording_options_invalid_transcribe_type(settings, value):
"""Should reject non-boolean transcribe values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["screen_recording", "transcript"])
def test_start_recording_options_original_mode_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept valid recording mode choices for original_mode."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"original_mode": value}
def test_start_recording_options_original_mode_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept original_mode=null."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_original_mode_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with original_mode omitted."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
def test_start_recording_options_original_mode_invalid(settings, value):
"""Should reject invalid recording mode values for original_mode."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": value}},
format="json",
)
assert response.status_code == 400
@@ -55,8 +55,9 @@ def test_api_rooms_list_requires_authentication():
assert response.status_code == 401
def test_api_rooms_list_inactive_user():
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)])
@@ -71,9 +72,11 @@ def test_api_rooms_list_inactive_user():
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_list_with_valid_token():
def test_api_rooms_list_with_valid_token(settings):
"""Listing rooms with valid token should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -89,8 +92,9 @@ def test_api_rooms_list_with_valid_token():
assert response.data["results"][0]["id"] == str(room.id)
def test_api_rooms_list_with_no_rooms():
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()
@@ -108,6 +112,7 @@ def test_api_rooms_list_with_no_rooms():
def test_api_rooms_list_with_expired_token(settings):
"""Listing rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
@@ -148,8 +153,9 @@ def test_api_rooms_list_with_invalid_rs_token(settings):
assert response.status_code == 400
def test_api_rooms_list_missing_scope():
def test_api_rooms_list_missing_scope(settings):
"""Listing rooms without required scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -167,8 +173,9 @@ def test_api_rooms_list_missing_scope():
)
def test_api_rooms_list_no_scope():
def test_api_rooms_list_no_scope(settings):
"""Listing rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -183,8 +190,9 @@ def test_api_rooms_list_no_scope():
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_list_filters_by_user():
def test_api_rooms_list_filters_by_user(settings):
"""List should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
user2 = UserFactory()
@@ -209,9 +217,11 @@ def test_api_rooms_list_filters_by_user():
assert str(room2.id) not in returned_ids
def test_api_rooms_retrieve_requires_authentication():
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)])
@@ -221,8 +231,9 @@ def test_api_rooms_retrieve_requires_authentication():
assert response.status_code == 401
def test_api_rooms_retrieve_inactive_user():
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)])
@@ -239,6 +250,7 @@ def test_api_rooms_retrieve_inactive_user():
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()
@@ -283,8 +295,9 @@ def test_api_rooms_retrieve_with_invalid_rs_token(settings):
assert response.status_code == 400
def test_api_rooms_retrieve_requires_scope():
def test_api_rooms_retrieve_requires_scope(settings):
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -302,8 +315,9 @@ def test_api_rooms_retrieve_requires_scope():
)
def test_api_rooms_retrieve_no_scope():
def test_api_rooms_retrieve_no_scope(settings):
"""Retrieving rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -321,6 +335,7 @@ def test_api_rooms_retrieve_no_scope():
def test_api_rooms_retrieve_success(settings):
"""Retrieving a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = "http://your-application.com"
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
@@ -352,8 +367,9 @@ def test_api_rooms_retrieve_success(settings):
}
def test_api_rooms_retrieve_success_by_user():
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()
@@ -392,8 +408,9 @@ def test_api_rooms_retrieve_success_by_user():
assert response.status_code == 200
def test_api_rooms_retrieve_not_found():
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])
@@ -406,9 +423,11 @@ def test_api_rooms_retrieve_not_found():
assert "no room matches the given query." in str(response.data).lower()
def test_api_rooms_create_requires_authentication():
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/")
@@ -417,6 +436,7 @@ def test_api_rooms_create_requires_authentication():
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()
@@ -457,8 +477,9 @@ def test_api_rooms_create_with_invalid_rs_token(settings):
assert response.status_code == 400
def test_api_rooms_create_inactive_user():
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)
@@ -472,8 +493,9 @@ def test_api_rooms_create_inactive_user():
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_create_requires_scope():
def test_api_rooms_create_requires_scope(settings):
"""Creating a room requires ROOMS_CREATE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without ROOMS_CREATE scope
@@ -490,8 +512,9 @@ def test_api_rooms_create_requires_scope():
)
def test_api_rooms_create_no_scope():
def test_api_rooms_create_no_scope(settings):
"""Creating rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -506,8 +529,9 @@ def test_api_rooms_create_no_scope():
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_create_success():
def test_api_rooms_create_success(settings):
"""Creating a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -531,8 +555,9 @@ def test_api_rooms_create_success():
assert room.access_level == "trusted"
def test_api_rooms_create_readonly_enforcement():
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()
@@ -565,8 +590,9 @@ def test_api_rooms_create_readonly_enforcement():
assert room.access_level == "trusted"
def test_api_rooms_unknown_actions():
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)])
@@ -597,6 +623,7 @@ def test_api_rooms_unknown_actions():
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"
settings.APPLICATION_BASE_URL = None
user = UserFactory()
@@ -615,6 +642,7 @@ def test_api_rooms_response_no_url(settings):
def test_api_rooms_response_no_telephony(settings):
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.ROOM_TELEPHONY_ENABLED = False
user = UserFactory()
@@ -633,6 +661,7 @@ def test_api_rooms_response_no_telephony(settings):
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()
@@ -664,6 +693,7 @@ def test_api_rooms_token_scope_case_insensitive(settings):
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()
@@ -696,6 +726,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
@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()
@@ -728,6 +759,7 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
@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()
@@ -759,6 +791,7 @@ def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
def test_api_rooms_token_missing_client_id(settings):
"""Token without client_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
now = datetime.now(timezone.utc)
@@ -788,6 +821,7 @@ def test_api_rooms_token_missing_client_id(settings):
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)
@@ -817,6 +851,7 @@ def test_api_rooms_token_missing_user_id(settings):
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()
@@ -847,6 +882,7 @@ def test_api_rooms_token_invalid_audience(settings):
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)
@@ -876,6 +912,7 @@ def test_api_rooms_token_unknown_user(settings):
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 = {
@@ -904,6 +941,7 @@ def test_api_rooms_token_unknown_application(settings):
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)
@@ -21,6 +21,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
UserFactory(email="User.Family@example.com")
application = ApplicationFactory(
active=True,
@@ -172,8 +173,9 @@ def test_api_applications_generate_token_domain_not_authorized():
assert "not authorized for this email domain" in str(response.data)
def test_api_applications_generate_token_domain_authorized():
def test_api_applications_generate_token_domain_authorized(settings):
"""Application with domain authorization should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@allowed.com")
application = ApplicationFactory(
active=True,
@@ -228,6 +230,7 @@ def test_api_applications_generate_token_user_not_found():
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
@@ -277,6 +280,7 @@ def test_api_applications_token_payload_structure(settings):
def test_api_applications_token_new_user(settings):
"""Should create a new pending user when creation is allowed and user doesn't exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
@@ -333,6 +337,7 @@ def test_api_applications_token_new_user(settings):
def test_api_applications_token_existing_user(settings):
"""Application should not create a new user when user exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
settings.APPLICATION_ALLOW_USER_CREATION = True
+2 -19
View File
@@ -18,7 +18,6 @@ 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
@@ -93,11 +92,7 @@ class Base(Configuration):
# Database
DATABASES = {
"default": dj_database_url.config()
if values.DatabaseURLValue(
None, environ_name="DATABASE_URL", environ_prefix=None
)
else {
"default": {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
@@ -297,9 +292,6 @@ class Base(Configuration):
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
"core.api.throttling.sentry_monitoring_throttle_failure"
)
SPECTACULAR_SETTINGS = {
"TITLE": "Meet API",
@@ -596,12 +588,6 @@ class Base(Configuration):
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
)
# if provided, treat as suspicious (possible privilege escalation attempt).
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
["hidden", "recorder", "agent"],
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
environ_prefix=None,
)
# Recording settings
RECORDING_ENABLE = values.BooleanValue(
@@ -673,7 +659,7 @@ class Base(Configuration):
[],
environ_name="BREVO_API_CONTACT_LIST_IDS",
environ_prefix=None,
converter=int,
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
@@ -923,9 +909,6 @@ class Test(Base):
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
def __init__(self):
+25 -28
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.10.0"
version = "1.5.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,48 +17,45 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.10",
]
description = "A simple video and phone conferencing tool, powered by LiveKit"
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.13"
requires-python = ">=3.10"
dependencies = [
"boto3==1.42.49",
"boto3==1.40.69",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.6.2",
"dj-database-url==3.1.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-lasuite[all]==0.0.24",
"django-countries==8.0.0",
"django-lasuite[all]==0.0.19",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.12",
"django==5.2.11",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2026.1.26",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==25.1.0",
"jsonschema==4.26.0",
"markdown==3.10.2",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"markdown==3.10",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2",
"pydantic==2.12.4",
"PyJWT==2.11.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
"requests==2.32.5",
"sentry-sdk==2.53.0",
"sentry-sdk==2.43.0",
"whitenoise==6.11.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7",
"aiohttp==3.13.3",
]
@@ -71,21 +68,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.1.1",
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.10.0",
"pyfakefs==6.1.1",
"pylint-django==2.7.0",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.12.0",
"pytest==9.0.2",
"pytest-django==4.11.1",
"pytest==9.0.0",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.15.1",
"types-requests==2.32.4.20260107",
"ruff==0.14.4",
"types-requests==2.32.4.20250913",
]
[tool.setuptools]
+1 -2
View File
@@ -43,8 +43,7 @@ RUN apk update && apk upgrade libssl3 \
libxml2>=2.12.7-r2 \
libxslt>=1.1.39-r2 \
libexpat>=2.7.2-r0 \
libpng>=1.6.53-r0 \
&& apk del curl
libpng>=1.6.53-r0
USER nginx
-6
View File
@@ -5,12 +5,6 @@ server {
root /usr/share/nginx/html;
location = /.well-known/windows-app-web-link {
default_type application/json;
alias /usr/share/nginx/html/.well-known/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;
+1823 -3797
View File
File diff suppressed because it is too large Load Diff
+25 -25
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.10.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,56 +13,56 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6",
"@livekit/components-react": "2.9.19",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.0",
"@pandacss/preset-panda": "1.8.2",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.9.3",
"@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",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.27",
"crisp-sdk-web": "1.0.25",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "25.8.8",
"i18next-browser-languagedetector": "8.2.1",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"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.17.1",
"posthog-js": "1.342.1",
"livekit-client": "2.15.7",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.14.0",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.3.0",
"wouter": "3.9.0"
"valtio": "2.1.5",
"wouter": "3.7.1"
},
"devDependencies": {
"@pandacss/dev": "1.8.2",
"@tanstack/eslint-plugin-query": "5.91.4",
"@tanstack/react-query-devtools": "5.91.3",
"@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5",
"@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"@vitejs/plugin-react": "5.1.4",
"@vitejs/plugin-react": "4.6.0",
"eslint": "8.57.0",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.6",
"prettier": "3.8.1",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.3.1",
"vite-tsconfig-paths": "6.1.1"
"vite": "7.0.8",
"vite-tsconfig-paths": "5.1.4"
}
}
@@ -1,6 +0,0 @@
[
{
"packageFamilyName" : "Visio_g3z6ba6vek6vg",
"paths" : [ "*" ]
}
]
@@ -4,7 +4,6 @@ import { Button } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const Heading = styled('h2', {
base: {
@@ -145,21 +144,6 @@ type Slide = {
isAvailableInBeta?: boolean
}
const carouselNavButton = css({
_focusVisible: {
outline: '2px solid var(--colors-focus-ring) !important',
outlineOffset: '1px',
},
_disabled: {
color: 'greyscale.400',
cursor: 'default',
pointerEvents: 'none',
_pressed: {
backgroundColor: 'transparent',
},
},
})
// todo - optimize how images are imported
const SLIDES: Slide[] = [
{
@@ -179,45 +163,11 @@ const SLIDES: Slide[] = [
export const IntroSlider = () => {
const [slideIndex, setSlideIndex] = useState(0)
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
const announce = useScreenReaderAnnounce()
const NUMBER_SLIDES = SLIDES.length
const goPrev = () => {
if (slideIndex === 0) return
const newIndex = slideIndex - 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const goNext = () => {
if (slideIndex === NUMBER_SLIDES - 1) return
const newIndex = slideIndex + 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const ariaLabelParams = {
current: slideIndex + 1,
total: NUMBER_SLIDES,
}
const previousAriaLabel = t('previous.labelWithPosition', ariaLabelParams)
const nextAriaLabel = t('next.labelWithPosition', ariaLabelParams)
return (
<Container
role="region"
aria-roledescription="carousel"
aria-label={t('carouselLabel')}
>
<Container>
<div
className={css({
display: 'flex',
@@ -230,10 +180,10 @@ export const IntroSlider = () => {
<Button
variant="secondaryText"
square
className={carouselNavButton}
aria-label={previousAriaLabel}
aria-disabled={slideIndex === 0}
onPress={goPrev}
aria-label={t('previous.label')}
tooltip={t('previous.tooltip')}
onPress={() => setSlideIndex(slideIndex - 1)}
isDisabled={slideIndex == 0}
>
<RiArrowLeftSLine />
</Button>
@@ -241,11 +191,7 @@ export const IntroSlider = () => {
</ButtonContainer>
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide
aria-hidden={index !== slideIndex}
visible={index === slideIndex}
key={index}
>
<Slide visible={index == slideIndex} key={index}>
<Image src={slide.src} alt="" role="presentation" />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
@@ -259,10 +205,10 @@ export const IntroSlider = () => {
<Button
variant="secondaryText"
square
className={carouselNavButton}
aria-label={nextAriaLabel}
aria-disabled={slideIndex === NUMBER_SLIDES - 1}
onPress={goNext}
aria-label={t('next.label')}
tooltip={t('next.tooltip')}
onPress={() => setSlideIndex(slideIndex + 1)}
isDisabled={slideIndex == NUMBER_SLIDES - 1}
>
<RiArrowRightSLine />
</Button>
@@ -5,42 +5,37 @@ import { isRoomValid } from '@/features/rooms'
export const JoinMeetingDialog = () => {
const { t } = useTranslation('home')
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
const roomId = (data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
navigateTo('room', roomId)
}
const validateRoomId = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return null
return !isRoomValid(trimmed) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}
return (
<Dialog title={t('joinMeeting')}>
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
<Form
onSubmit={(data) => {
navigateTo(
'room',
(data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
)
}}
submitLabel={t('joinInputSubmit')}
>
<Field
type="text"
autoFocus
isRequired
name="roomId"
label={t('joinInputLabel')}
description={t('joinInputExample', {
example: window.origin + '/azer-tyu-qsdf',
})}
validate={validateRoomId}
validate={(value) => {
return !isRoomValid(value.trim()) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}}
/>
</Form>
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
@@ -65,7 +65,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
alignItems="left"
justify="start"
gap={0}
style={{ maxWidth: '100%', overflow: 'visible' }}
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{t('heading')}
@@ -93,7 +93,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'visible',
overflow: 'hidden',
})}
>
<div
@@ -753,7 +753,7 @@ export const Join = ({
try {
saveVideoInputDeviceId(id)
if (videoTrack) {
await videoTrack.setDeviceId({ exact: id })
await await videoTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch camera device', err)
@@ -4,7 +4,7 @@ import { cva } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { styled, VStack } from '@/styled-system/jsx'
import { usePostHog } from 'posthog-js/react'
import type { PostHog } from 'posthog-js'
import { PostHog } from 'posthog-js'
import { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
@@ -74,17 +74,13 @@ export const useWaitingParticipants = () => {
): Promise<void> => {
try {
setListEnabled(false)
await Promise.all(
waitingParticipants.map((participant) =>
enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
})
)
)
for (const participant of waitingParticipants) {
await enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
})
}
await refetchWaiting()
} catch (e) {
console.error(e)
@@ -1,4 +1,4 @@
import { A, Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
import { Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
@@ -9,18 +9,15 @@ 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 = new Set([90, 60, 30])
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
const FINAL_COUNTDOWN_SECONDS = 10
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()
@@ -58,7 +55,7 @@ export const IsIdleDisconnectModal = () => {
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
const shouldAnnounce =
COUNTDOWN_ANNOUNCEMENT_SECONDS.has(remainingSeconds) ||
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
@@ -120,19 +117,7 @@ export const IsIdleDisconnectModal = () => {
}),
})}
</P>
<P>
{t('settingsPrefix')}{' '}
<A
color="primary"
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
openSettingsDialog(SettingsDialogExtendedKey.GENERAL)
}}
>
{t('settingsLink')}
</A>
{t('settingsSuffix')}
</P>
<P>{t('settings')}</P>
<HStack marginTop="2rem">
<Button
onPress={() => {
@@ -1,28 +1,5 @@
import React, { ReactNode } from 'react'
import { styled } from '@/styled-system/jsx'
const Hint = styled('div', {
base: {
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
paddingInline: '0.5rem',
paddingBlock: '0.1rem',
fontSize: '0.875rem',
opacity: 0,
visibility: 'hidden',
pointerEvents: 'none',
transition: 'opacity 150ms ease',
'.lk-grid-layout > *:first-child:focus-within &': {
opacity: 1,
visibility: 'visible',
pointerEvents: 'auto',
},
},
})
import { css } from '@/styled-system/css'
export interface KeyboardShortcutHintProps {
children: ReactNode
@@ -35,5 +12,21 @@ export interface KeyboardShortcutHintProps {
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
children,
}) => {
return <Hint>{children}</Hint>
return (
<div
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
paddingInline: '0.5rem',
paddingBlock: '0.1rem',
fontSize: '0.875rem',
})}
>
{children}
</div>
)
}
@@ -1,21 +1,8 @@
import type { CSSProperties } from 'react'
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useParticipantInfo } from '@livekit/components-react'
import { Participant } from 'livekit-client'
const participantNameStyles: CSSProperties = {
paddingBottom: '0.1rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
const participantNameScreenShareStyles: CSSProperties = {
...participantNameStyles,
marginLeft: '0.4rem',
}
export const ParticipantName = ({
participant,
isScreenShare = false,
@@ -30,14 +17,26 @@ export const ParticipantName = ({
if (isScreenShare) {
return (
<Text variant="sm" style={participantNameScreenShareStyles}>
<Text
variant="sm"
style={{
paddingBottom: '0.1rem',
marginLeft: '0.4rem',
}}
>
{t('screenShare', { name: displayedName })}
</Text>
)
}
return (
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
<Text
variant="sm"
style={{
paddingBottom: '0.1rem',
}}
aria-hidden="true"
>
{displayedName}
</Text>
)
@@ -183,7 +183,7 @@ export const ParticipantTile: (
}}
>
{isHandRaised && !isScreenShare && (
<span>
<>
<span>{positionInQueue}</span>
<RiHand
color="black"
@@ -197,7 +197,7 @@ export const ParticipantTile: (
animationIterationCount: '2',
}}
/>
</span>
</>
)}
{isScreenShare && (
<ScreenShareIcon
@@ -210,12 +210,10 @@ export const ParticipantTile: (
{isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
@@ -231,7 +229,9 @@ export const ParticipantTile: (
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
{hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
</div>
)
})
@@ -102,11 +102,6 @@ export function FloatingReaction({
paddingTop: '0.15rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
lineHeight: '16px',
maxWidth: '12rem',
display: 'inline-block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
>
{name}
@@ -11,7 +11,9 @@ import {
ProcessorType,
} from '.'
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
export class UnifiedBackgroundTrackProcessor
implements BackgroundProcessorInterface
{
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
processorType: ProcessorType
@@ -13,7 +13,8 @@ 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,7 +6,6 @@ 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,
@@ -19,11 +18,6 @@ export const ChatToggle = ({
const { isChatOpen, toggleChat } = useSidePanel()
const tooltipLabel = isChatOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'toggle-chat',
handler: toggleChat,
})
return (
<div
className={css({
@@ -1,6 +1,5 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
@@ -19,7 +18,6 @@ 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,30 +86,14 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce()
useRegisterKeyboardShortcut({
id: deviceShortcut?.id,
handler: async () => {
const nextState = !enabled
try {
const didChange = await toggle(nextState)
if (didChange === false) return
const message = t(nextState ? 'turnedOn' : 'turnedOff', {
keyPrefix: `selectDevice.${kind}`,
})
announce(message, 'assertive')
} catch {
// no announce
}
},
shortcut: deviceShortcut,
handler: async () => await toggle(),
isDisabled: cannotUseDevice,
})
const pushToTalkShortcut = getShortcutDescriptorById('push-to-talk')
useLongPress({
keyCode: kind === 'audioinput' ? pushToTalkShortcut?.code : undefined,
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
onKeyDown,
onKeyUp,
isDisabled: cannotUseDevice,
@@ -121,9 +103,7 @@ export const ToggleDevice = <T extends ToggleSource>({
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `selectDevice.${kind}`,
})
return deviceShortcut?.shortcut
? appendShortcutLabel(label, deviceShortcut.shortcut)
: label
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
}, [enabled, kind, deviceShortcut, t])
const Icon =
@@ -9,7 +9,6 @@ import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
const SPEAKING_DETECTION_DELAY = 3000
@@ -34,16 +33,6 @@ export const HandToggle = () => {
closeLowerHandToasts()
}, [isHandRaised])
const handleToggle = () => {
toggleRaisedHand()
resetToastState()
}
useRegisterKeyboardShortcut({
id: 'raise-hand',
handler: handleToggle,
})
useEffect(() => {
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
@@ -79,7 +68,10 @@ export const HandToggle = () => {
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={handleToggle}
onPress={() => {
toggleRaisedHand()
resetToastState()
}}
data-attr={`controls-hand-${tooltipLabel}`}
>
<RiHand />
@@ -6,7 +6,6 @@ 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,
@@ -28,11 +27,6 @@ export const ParticipantsToggle = ({
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'toggle-participants',
handler: toggleParticipants,
})
return (
<div
className={css({
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { ToggleButton, Button } from '@/primitives'
@@ -11,13 +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 {
Popover as RACPopover,
Dialog,
DialogTrigger,
} from 'react-aria-components'
import { FocusScope } from '@react-aria/focus'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
@@ -45,12 +39,7 @@ export const ReactionsToggle = () => {
const instanceIdRef = useRef(0)
const room = useRoomContext()
const [isOpen, setIsOpen] = useState(false)
useRegisterKeyboardShortcut({
id: 'reaction',
handler: () => setIsOpen((prev) => !prev),
})
const [isVisible, setIsVisible] = useState(false)
const sendReaction = async (emoji: string) => {
const encoder = new TextEncoder()
@@ -84,76 +73,100 @@ export const ReactionsToggle = () => {
windowMs: 1000,
})
// Custom animation implementation for the emoji toolbar
// Could not use a menu and its animation, because a menu would make the toolbar inaccessible by keyboard
// animation isn't perfect
const [isRendered, setIsRendered] = useState(isVisible)
const [opacity, setOpacity] = useState(isVisible ? 1 : 0)
useEffect(() => {
if (isVisible) {
// Show: first render, then animate in
setIsRendered(true)
// Need to delay setting opacity to ensure CSS transition works
// (using requestAnimationFrame to ensure DOM has updated)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setOpacity(1)
})
})
} else if (isRendered) {
// Hide: first animate out, then unrender
setOpacity(0)
// Wait for animation to complete before removing from DOM
const timer = setTimeout(() => {
setIsRendered(false)
}, 200) // Match this to your animation duration
return () => clearTimeout(timer)
}
}, [isVisible, isRendered])
return (
<>
<div className={css({ position: 'relative' })}>
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<ToggleButton
square
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
isSelected={isOpen}
onChange={setIsOpen}
>
<RiEmotionLine />
</ToggleButton>
<RACPopover
placement="top"
offset={8}
isNonModal
shouldCloseOnInteractOutside={() => false}
<div
className={css({
position: 'relative',
})}
>
<ToggleButton
square
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
onPress={() => setIsVisible(!isVisible)}
>
<RiEmotionLine />
</ToggleButton>
{isRendered && (
<div
className={css({
position: 'absolute',
top: -63,
left: -162,
borderRadius: '8px',
padding: '0.35rem',
backgroundColor: 'primaryDark.50',
'&[data-entering]': {
animation: 'fade 200ms ease',
},
'&[data-exiting]': {
animation: 'fade 200ms ease-in reverse',
},
opacity: opacity,
transition: 'opacity 0.2s ease',
})}
onTransitionEnd={() => {
if (!isVisible) {
setIsRendered(false)
}
}}
>
<Dialog className={css({ outline: 'none' })}>
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
<FocusScope contain autoFocus restoreFocus>
<div
role="toolbar"
aria-orientation="horizontal"
aria-label={t('button')}
className={css({
display: 'flex',
gap: '0.5rem',
})}
<RACToolbar
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
{Object.values(Emoji).map((emoji, index) => (
<Button
key={index}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
variant="primaryTextDark"
size="sm"
square
data-attr={`send-reaction-${emoji}`}
>
{Object.values(Emoji).map((emoji, index) => (
<Button
key={index}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
variant="primaryTextDark"
size="sm"
square
data-attr={`send-reaction-${emoji}`}
>
<img
src={`/assets/reactions/${emoji}.png`}
alt=""
className={css({
width: '28px',
height: '28px',
pointerEvents: 'none',
userSelect: 'none',
})}
/>
</Button>
))}
</div>
</FocusScope>
</Dialog>
</RACPopover>
</DialogTrigger>
<img
src={`/assets/reactions/${emoji}.png`}
alt=""
className={css({
minHeight: '28px',
minWidth: '28px',
pointerEvents: 'none',
userSelect: 'none',
})}
/>
</Button>
))}
</RACToolbar>
</div>
)}
</div>
<ReactionPortals reactions={reactions} />
</>
@@ -6,7 +6,8 @@ import {
import React from 'react'
/** @public */
export interface AllowMediaPlaybackProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
export interface AllowMediaPlaybackProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label?: string
}
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ToolsToggle = ({
variant = 'primaryTextDark',
@@ -16,11 +15,6 @@ export const ToolsToggle = ({
const { isToolsOpen, toggleTools } = useSidePanel()
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'recording',
handler: toggleTools,
})
return (
<div
className={css({
@@ -11,7 +11,8 @@ 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,8 +13,7 @@ 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,16 +1,19 @@
import { useMemo } from 'react'
import {
getShortcutDescriptorById,
ShortcutDescriptor,
} from '@/features/shortcuts/catalog'
import { Shortcut } from '@/features/shortcuts/types'
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
return useMemo<ShortcutDescriptor | undefined>(() => {
return useMemo<Shortcut | undefined>(() => {
switch (kind) {
case 'audioinput':
return getShortcutDescriptorById('toggle-microphone')
return {
key: 'd',
ctrlKey: true,
}
case 'videoinput':
return getShortcutDescriptorById('toggle-camera')
return {
key: 'e',
ctrlKey: true,
}
default:
return undefined
}
@@ -18,7 +18,8 @@ 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
@@ -12,7 +12,6 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
@@ -22,10 +21,8 @@ export function DesktopControlBar({
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
useRegisterKeyboardShortcut({
id: 'focus-toolbar',
shortcut: { key: 'F2' },
handler: () => {
const root = desktopControlBarEl.current
if (!root) return
@@ -35,13 +32,6 @@ export function DesktopControlBar({
firstButton?.focus()
},
})
useRegisterKeyboardShortcut({
id: 'fullscreen',
handler: toggleFullScreen,
isDisabled: !isFullscreenAvailable,
})
return (
<div
ref={desktopControlBarEl}
@@ -31,9 +31,6 @@ import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useSettingsDialog } from '@/features/settings'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
@@ -69,7 +66,8 @@ const LayoutWrapper = styled(
/**
* @public
*/
export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
export interface VideoConferenceProps
extends React.HTMLAttributes<HTMLDivElement> {
/** @alpha */
SettingsComponent?: React.ComponentType
}
@@ -100,7 +98,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const { t: tRooms } = useTranslation('rooms')
const room = useRoomContext()
const announce = useScreenReaderAnnounce()
const { toggleSettingsDialog } = useSettingsDialog()
const getAnnouncementName = useCallback(
(participant?: Participant | null) => {
@@ -115,13 +112,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useConnectionObserver()
useVideoResolutionSubscription()
useRegisterKeyboardShortcut({
id: 'open-shortcuts',
handler: useCallback(() => {
toggleSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS)
}, [toggleSettingsDialog]),
})
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
@@ -9,7 +9,8 @@ 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
}
@@ -4,10 +4,8 @@ export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
export const flexibleRoomIdPattern =
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
const roomRegex = new RegExp(`^${roomIdPattern}$`)
export const isRoomValid = (roomIdOrUrl: string) =>
roomRegex.test(roomIdOrUrl) ||
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
export const normalizeRoomId = (roomId: string) => {
@@ -12,7 +12,6 @@ import {
RiSpeakerLine,
RiVideoOnLine,
RiEyeLine,
RiKeyboardBoxLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
@@ -20,12 +19,11 @@ import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab'
import { VideoTab } from './tabs/VideoTab'
import { TranscriptionTab } from './tabs/TranscriptionTab'
import { ShortcutTab } from './tabs/ShortcutTab'
import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { AccessibilityTab } from './tabs/AccessibilityTab'
import AccessibilityTab from './tabs/AccessibilityTab'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -41,7 +39,6 @@ const tabListContainerStyle = css({
flexDirection: 'column',
borderRight: '1px solid lightGray', // fixme poor color management
paddingY: '1rem',
paddingLeft: '0.2rem',
paddingRight: '1.5rem',
})
@@ -110,10 +107,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS}>
<RiKeyboardBoxLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Icon type="symbols" name="speech_to_text" />
@@ -137,7 +130,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
@@ -36,3 +36,5 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
</TabPanel>
)
}
export default AccessibilityTab
@@ -1,55 +0,0 @@
import { shortcutCatalog } from '@/features/shortcuts/catalog'
import { ShortcutRow } from '@/features/shortcuts/components/ShortcutRow'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
const tableStyle = css({
width: '100%',
borderCollapse: 'collapse',
overflowY: 'auto',
'& caption': {
fontWeight: 'bold',
marginBottom: '0.75rem',
textAlign: 'left',
},
'& th, & td': {
padding: '0.65rem 0',
textAlign: 'left',
fontWeight: 'normal',
},
'& tbody tr': {
borderBottom: '1px solid rgba(255,255,255,0.08)',
},
})
export const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
const { t } = useTranslation(['settings', 'rooms'])
return (
<TabPanel
id={id}
padding="md"
flex
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<table className={tableStyle}>
<caption>{t('shortcuts.listLabel')}</caption>
<thead>
<tr>
<th scope="col">{t('shortcuts.columnAction')}</th>
<th scope="col">{t('shortcuts.columnShortcut')}</th>
</tr>
</thead>
<tbody>
{shortcutCatalog.map((item) => (
<ShortcutRow key={item?.id} descriptor={item} />
))}
</tbody>
</table>
</TabPanel>
)
}
@@ -4,7 +4,7 @@ import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import {
createLocalVideoTrack,
@@ -22,8 +22,6 @@ export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
type DeviceItems = Array<{ value: string; label: string }>
const EMPTY_PROPS = {}
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant, remoteParticipants } = useRoomContext()
@@ -61,7 +59,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
const isCamEnabled = devicesIn?.length > 0
const disabledProps = isCamEnabled
? EMPTY_PROPS
? {}
: {
placeholder: t('permissionsRequired'),
isDisabled: true,
@@ -119,40 +117,6 @@ export const VideoTab = ({ id }: VideoTabProps) => {
}
}, [videoDeviceId, videoElement])
const resolutionItems = useMemo(() => {
return [
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]
}, [t])
const videoQualityItems = useMemo(() => {
return [
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]
}, [t])
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('camera.heading')}>
@@ -214,7 +178,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
<Field
type="select"
label={t('resolution.publish.label')}
items={resolutionItems}
items={[
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]}
selectedKey={videoPublishResolution}
onSelectionChange={async (key) => {
await handleVideoResolutionChange(key as VideoResolution)
@@ -229,7 +206,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
<Field
type="select"
label={t('resolution.subscribe.label')}
items={videoQualityItems}
items={[
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]}
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
@@ -14,25 +14,7 @@ export const useSettingsDialog = () => {
settingsStore.areSettingsOpen = true
}
const closeSettingsDialog = () => {
settingsStore.areSettingsOpen = false
}
const toggleSettingsDialog = (
defaultSelectedTab?: SettingsDialogExtendedKey
) => {
if (areSettingsOpen) {
closeSettingsDialog()
} else {
if (defaultSelectedTab)
settingsStore.defaultSelectedTab = defaultSelectedTab
settingsStore.areSettingsOpen = true
}
}
return {
openSettingsDialog,
closeSettingsDialog,
toggleSettingsDialog,
}
}
@@ -5,6 +5,5 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription',
SHORTCUTS = 'shortcuts',
ACCESSIBILITY = 'accessibility',
}
@@ -1,89 +0,0 @@
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 =
| 'open-shortcuts'
| '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: 'open-shortcuts',
category: 'navigation',
shortcut: { key: '/', ctrlKey: true, shiftKey: true },
},
{
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,34 +0,0 @@
import React from 'react'
import { css, cx } from '@/styled-system/css'
type ShortcutBadgeProps = {
visualLabel: string
srLabel?: string
className?: string
}
const badgeStyle = css({
fontFamily: 'monospace',
backgroundColor: 'rgba(255,255,255,0.12)',
paddingInline: '0.4rem',
paddingBlock: '0.2rem',
borderRadius: '6px',
whiteSpace: 'nowrap',
minWidth: '5.5rem',
textAlign: 'center',
})
export const ShortcutBadge: React.FC<ShortcutBadgeProps> = ({
visualLabel,
srLabel,
className,
}) => {
return (
<>
<kbd className={cx(badgeStyle, className)} aria-hidden="true">
{visualLabel}
</kbd>
{srLabel && <span className="sr-only">{srLabel}</span>}
</>
)
}
@@ -1,42 +0,0 @@
import React from 'react'
import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text'
import { ShortcutDescriptor } from '../catalog'
import { ShortcutBadge } from './ShortcutBadge'
import { useShortcutFormatting } from '../hooks/useShortcutFormatting'
import { useTranslation } from 'react-i18next'
type ShortcutRowProps = {
descriptor: ShortcutDescriptor
}
const shortcutCellStyle = css({
textAlign: 'right',
})
export const ShortcutRow: React.FC<ShortcutRowProps> = ({ descriptor }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shortcutsPanel' })
const { formatVisual, formatForSR } = useShortcutFormatting()
const visualShortcut = formatVisual(
descriptor.shortcut,
descriptor.code,
descriptor.kind
)
const srShortcut = formatForSR(
descriptor.shortcut,
descriptor.code,
descriptor.kind
)
return (
<tr>
<th scope="row" className={text({ variant: 'body' })}>
{t(`actions.${descriptor.id}`)}
</th>
<td className={shortcutCellStyle}>
<ShortcutBadge visualLabel={visualShortcut} srLabel={srShortcut} />
</td>
</tr>
)
}
@@ -1,59 +0,0 @@
import { Shortcut } from './types'
import { isMacintosh } from '@/utils/livekit'
// Visible label for a shortcut (uses ⌘/Ctrl prefix when needed).
export const formatShortcutLabel = (shortcut?: Shortcut) => {
if (!shortcut) return '—'
const key = shortcut.key?.toUpperCase()
if (!key) return '—'
const parts: string[] = []
if (shortcut.ctrlKey) parts.push(isMacintosh() ? '⌘' : 'Ctrl')
if (shortcut.altKey) parts.push(isMacintosh() ? '⌥' : 'Alt')
if (shortcut.shiftKey) parts.push('Shift')
parts.push(key)
return parts.join('+')
}
// SR-friendly label for a shortcut (reads "Control plus D").
export const formatShortcutLabelForSR = (
shortcut: Shortcut | undefined,
{
controlLabel,
commandLabel,
altLabel,
optionLabel,
shiftLabel,
plusLabel,
noShortcutLabel,
}: {
controlLabel: string
commandLabel: string
altLabel: string
optionLabel: string
shiftLabel: string
plusLabel: string
noShortcutLabel: string
}
) => {
if (!shortcut) return noShortcutLabel
const key = shortcut.key?.toUpperCase()
if (!key) return noShortcutLabel
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
const altWord = isMacintosh() ? optionLabel : altLabel
const parts: string[] = []
if (shortcut.ctrlKey) parts.push(ctrlWord)
if (shortcut.altKey) parts.push(altWord)
if (shortcut.shiftKey) parts.push(shiftLabel)
parts.push(key)
return parts.join(` ${plusLabel} `)
}
// Extract displayable key name from KeyboardEvent.code (ex: KeyV -> V).
export const getKeyLabelFromCode = (code?: string) => {
if (!code) return ''
if (code.startsWith('Key') && code.length === 4) return code.slice(3)
if (code.startsWith('Digit') && code.length === 6) return code.slice(5)
if (code === 'Space') return '␣'
if (code.startsWith('Arrow')) return code.slice(5) // Up, Down, Left, Right
return code
}
@@ -1,47 +0,0 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Shortcut } from '../types'
import {
formatShortcutLabel,
formatShortcutLabelForSR,
getKeyLabelFromCode,
} from '../formatLabels'
export const useShortcutFormatting = () => {
const { t } = useTranslation('rooms')
const formatVisual = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => {
if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.visual.hold', { key: label || '?' })
}
return formatShortcutLabel(shortcut)
},
[t]
)
const formatForSR = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => {
if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.sr.hold', { key: label || '?' })
}
return formatShortcutLabelForSR(shortcut, {
controlLabel: t('shortcutsPanel.sr.control'),
commandLabel: t('shortcutsPanel.sr.command'),
altLabel: t('shortcutsPanel.sr.alt'),
optionLabel: t('shortcutsPanel.sr.option'),
shiftLabel: t('shortcutsPanel.sr.shift'),
plusLabel: t('shortcutsPanel.sr.plus'),
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
})
},
[t]
)
return {
formatVisual,
formatForSR,
}
}
@@ -1,6 +1,4 @@
export type Shortcut = {
key: string
ctrlKey?: boolean
shiftKey?: boolean
altKey?: boolean
}
@@ -11,18 +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, shiftKey, altKey } = e
const { key, metaKey, ctrlKey } = e
if (!key) return
const shortcutKey = formatShortcutKey({
key,
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
shiftKey,
altKey,
})
let shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut && shortcutKey === 'ctrl+shift+?') {
shortcut = shortcutsSnap.shortcuts.get('ctrl+shift+/')
}
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut) return
e.preventDefault()
await shortcut()
@@ -1,28 +1,26 @@
import { useEffect } from 'react'
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
import { formatShortcutKey } from '@/features/shortcuts/utils'
import { ShortcutId, getShortcutDescriptorById } from './catalog'
import { Shortcut } from '@/features/shortcuts/types'
export type useRegisterKeyboardShortcutProps = {
id?: ShortcutId
shortcut?: Shortcut
handler: () => Promise<void | boolean | undefined> | void
isDisabled?: boolean
}
export const useRegisterKeyboardShortcut = ({
id,
shortcut,
handler,
isDisabled = false,
}: useRegisterKeyboardShortcutProps) => {
useEffect(() => {
if (!id) return
const descriptor = getShortcutDescriptorById(id)
if (!descriptor?.shortcut) return
const formattedKey = formatShortcutKey(descriptor.shortcut)
if (!shortcut) return
const formattedKey = formatShortcutKey(shortcut)
if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
} else {
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
}
}, [handler, id, isDisabled])
}, [handler, shortcut, isDisabled])
}
+4 -16
View File
@@ -4,27 +4,15 @@ import { Shortcut } from '@/features/shortcuts/types'
export const CTRL = 'ctrl'
export const formatShortcutKey = (shortcut: Shortcut) => {
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('+')
if (shortcut.ctrlKey) return `${CTRL}+${shortcut.key.toUpperCase()}`
return shortcut.key.toUpperCase()
}
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
if (!shortcut.key) return
const parts: string[] = []
let formattedKeyLabel = shortcut.key.toLowerCase()
if (shortcut.ctrlKey) {
parts.push(isMacintosh() ? '⌘' : 'Ctrl')
formattedKeyLabel = `${isMacintosh() ? '⌘' : 'Ctrl'}+${formattedKeyLabel}`
}
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,7 +20,8 @@ export interface TranscriptionSegment {
lastReceivedTime: number
}
export interface TranscriptionSegmentWithParticipant extends TranscriptionSegment {
export interface TranscriptionSegmentWithParticipant
extends TranscriptionSegment {
participant: Participant
}
-3
View File
@@ -5,7 +5,6 @@ import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives'
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
export type Layout = 'fullpage' | 'centered'
@@ -22,7 +21,6 @@ export const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
{showHeader && <SkipLink />}
<div
className={css({
display: 'flex',
@@ -37,7 +35,6 @@ export const Layout = ({ children }: { children: ReactNode }) => {
>
{showHeader && <Header />}
<main
id={MAIN_CONTENT_ID}
className={css({
flexGrow: 1,
overflow: 'auto',
-69
View File
@@ -1,69 +0,0 @@
import { type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
export const MAIN_CONTENT_ID = 'main-content'
// Visually hidden until focus (not sr-only). Must become visible on focus for keyboard users.
const StyledSkipLink = styled('a', {
base: {
position: 'absolute',
width: '1px',
height: '1px',
margin: '-1px',
padding: 0,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
textDecoration: 'none',
_focusVisible: {
position: 'fixed',
top: '0.5rem',
left: '50%',
transform: 'translateX(-50%)',
width: 'auto',
height: 'auto',
margin: 0,
padding: '0.625rem 1rem',
overflow: 'visible',
clip: 'auto',
whiteSpace: 'normal',
zIndex: 9999,
backgroundColor: 'white',
color: 'primary.800',
fontWeight: 500,
fontSize: '0.875rem',
border: '1px solid',
borderColor: 'primary.800',
borderRadius: 4,
outline: '2px solid',
outlineColor: 'focusRing',
outlineOffset: 2,
},
},
})
export const SkipLink = () => {
const { t } = useTranslation()
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
const main = document.getElementById(MAIN_CONTENT_ID)
if (!main) return
const heading = main.querySelector('h1, h2, h3') as HTMLElement | null
const target = heading ?? main
if (!target.hasAttribute('tabindex')) {
target.setAttribute('tabindex', '-1')
}
target.focus()
}
return (
<StyledSkipLink href={`#${MAIN_CONTENT_ID}`} onClick={handleClick}>
{t('skipLink')}
</StyledSkipLink>
)
}
+1 -3
View File
@@ -25,7 +25,6 @@
"heading": "Überprüfen Sie Ihren Meeting-Code",
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
},
"selected": "ausgewählt",
"submit": "OK",
"footer": {
"links": {
@@ -46,14 +45,13 @@
"license": "Etalab 2.0 Lizenz"
},
"loginHint": {
"title": "Melden Sie sich mit Ihrem Konto an",
"title": "Melden Sie sich mit Ihrem ProConnect-Konto an",
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
"button": {
"ariaLabel": "Hinweis schließen",
"label": "OK"
}
},
"skipLink": "Zum Hauptinhalt springen",
"clipboardContent": {
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
+5 -9
View File
@@ -31,14 +31,12 @@
},
"introSlider": {
"previous": {
"label": "Vorherige Folie",
"labelWithPosition": "Vorherige Folie ({{current}} von {{total}})",
"tooltip": "Vorherige Folie"
"label": "Zurück",
"tooltip": "Zurück"
},
"next": {
"label": "Nächste Folie",
"labelWithPosition": "Nächste Folie ({{current}} von {{total}})",
"tooltip": "Nächste Folie"
"label": "Weiter",
"tooltip": "Weiter"
},
"beta": {
"text": "An der Beta teilnehmen",
@@ -55,8 +53,6 @@
"slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!"
},
"carouselLabel": "Einführungs-Diashow",
"slidePosition": "Folie {{current}} von {{total}}"
}
}
}
+2 -43
View File
@@ -22,8 +22,6 @@
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"turnedOff": "Kamera deaktiviert",
"turnedOn": "Kamera aktiviert",
"label": "Kamera",
"placeholder": "Kamera aktivieren, um die Vorschau zu sehen"
},
@@ -32,8 +30,6 @@
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
"turnedOff": "Mikrofon deaktiviert",
"turnedOn": "Mikrofon aktiviert",
"label": "Mikrofon"
},
"audiooutput": {
@@ -158,9 +154,7 @@
"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?",
"settingsPrefix": "Um diese Nachricht nicht mehr zu sehen, gehe zu",
"settingsLink": "Einstellungen > Allgemein",
"settingsSuffix": ".",
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
"stayButton": "Gespräch fortsetzen",
"leaveButton": "Jetzt verlassen",
"countdownAnnouncement": "Das Gespräch endet in {{duration}}."
@@ -590,7 +584,7 @@
},
"participantTileFocus": {
"containerLabel": "Optionen für {{name}}",
"toolbarHint": "Ctrl+Shift+/: Direkt auf die Tastenkürzel zugreifen.",
"toolbarHint": "F2: zur Symbolleiste unten.",
"pin": {
"enable": "Anheften",
"disable": "Lösen"
@@ -599,41 +593,6 @@
"muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild"
},
"shortcutsPanel": {
"title": "Tastenkombinationen",
"categories": {
"navigation": "Navigation",
"media": "Medien",
"interaction": "Interaktion"
},
"actions": {
"open-shortcuts": "Tastenkürzel-Hilfe öffnen",
"focus-toolbar": "Fokus auf die untere Symbolleiste",
"toggle-microphone": "Mikrofon umschalten",
"toggle-camera": "Kamera umschalten",
"push-to-talk": "Push-to-talk (gedrückt halten zum Einschalten)",
"reaction": "Reaktionspanel",
"fullscreen": "Vollbild umschalten",
"recording": "Aufnahmepanel umschalten",
"raise-hand": "Hand heben oder senken",
"toggle-chat": "Chat anzeigen/ausblenden",
"toggle-participants": "Teilnehmer anzeigen/ausblenden",
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
},
"sr": {
"control": "Steuerung",
"command": "Befehl",
"alt": "Alt",
"option": "Option",
"shift": "Umschalt",
"plus": "plus",
"hold": "Halte {{key}} gedrückt",
"noShortcut": "Kein Tastenkürzel"
},
"visual": {
"hold": "Halte {{key}} gedrückt"
}
},
"fullScreenWarning": {
"message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
"stop": "Präsentation beenden",
+1 -7
View File
@@ -100,11 +100,6 @@
}
}
},
"shortcuts": {
"listLabel": "Tastenkürzel",
"columnAction": "Aktion",
"columnShortcut": "Tastenkürzel"
},
"dialog": {
"heading": "Einstellungen"
},
@@ -125,7 +120,6 @@
"general": "Allgemein",
"notifications": "Benachrichtigungen",
"accessibility": "Barrierefreiheit",
"transcription": "Transkription",
"shortcuts": "Tastenkürzel"
"transcription": "Transkription"
}
}
+1 -3
View File
@@ -25,7 +25,6 @@
"heading": "Verify your meeting code",
"body": "Check that you have entered the correct meeting code in the URL. Example:"
},
"selected": "selected",
"submit": "OK",
"footer": {
"links": {
@@ -46,14 +45,13 @@
"license": "etalab 2.0 license"
},
"loginHint": {
"title": "Log in with your account",
"title": "Log in with your ProConnect account",
"body": "Instead of waiting, log in with your ProConnect account.",
"button": {
"ariaLabel": "Close the suggestion",
"label": "OK"
}
},
"skipLink": "Skip to main content",
"clipboardContent": {
"url": "To join the video conference, click on this link: {{roomUrl}}",
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
+5 -9
View File
@@ -31,14 +31,12 @@
},
"introSlider": {
"previous": {
"label": "Previous slide",
"labelWithPosition": "Previous slide ({{current}} of {{total}})",
"tooltip": "Previous slide"
"label": "previous",
"tooltip": "previous"
},
"next": {
"label": "Next slide",
"labelWithPosition": "Next slide ({{current}} of {{total}})",
"tooltip": "Next slide"
"label": "next",
"tooltip": "next"
},
"beta": {
"text": "Join the beta",
@@ -55,8 +53,6 @@
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
},
"carouselLabel": "Introduction slideshow",
"slidePosition": "Slide {{current}} of {{total}}"
}
}
}
+2 -43
View File
@@ -22,8 +22,6 @@
"permissionsNeeded": "Select camera - permission needed",
"disable": "Disable camera",
"enable": "Enable camera",
"turnedOff": "Camera turned off",
"turnedOn": "Camera turned on",
"label": "Camera",
"placeholder": "Enable camera to see the preview"
},
@@ -32,8 +30,6 @@
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
"turnedOff": "Microphone turned off",
"turnedOn": "Microphone turned on",
"label": "Microphone"
},
"audiooutput": {
@@ -158,9 +154,7 @@
"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?",
"settingsPrefix": "To stop seeing this message, go to",
"settingsLink": "Settings > General",
"settingsSuffix": ".",
"settings": "To stop seeing this message, go to Settings > General.",
"stayButton": "Continue the call",
"leaveButton": "Leave now",
"countdownAnnouncement": "Call ends in {{duration}}."
@@ -590,7 +584,7 @@
},
"participantTileFocus": {
"containerLabel": "Options for {{name}}",
"toolbarHint": "Ctrl+Shift+/: access shortcuts directly.",
"toolbarHint": "F2: go to the bottom toolbar.",
"pin": {
"enable": "Pin",
"disable": "Unpin"
@@ -599,41 +593,6 @@
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen"
},
"shortcutsPanel": {
"title": "Keyboard shortcuts",
"categories": {
"navigation": "Navigation",
"media": "Media",
"interaction": "Interaction"
},
"actions": {
"open-shortcuts": "Open shortcuts help",
"focus-toolbar": "Focus bottom toolbar",
"toggle-microphone": "Toggle microphone",
"toggle-camera": "Toggle camera",
"push-to-talk": "Push-to-talk (hold to unmute)",
"reaction": "Emoji reaction panel",
"fullscreen": "Toggle fullscreen",
"recording": "Toggle recording panel",
"raise-hand": "Raise or lower hand",
"toggle-chat": "Toggle chat",
"toggle-participants": "Toggle participants",
"open-shortcuts-settings": "Open shortcuts settings"
},
"sr": {
"control": "Control",
"command": "Command",
"alt": "Alt",
"option": "Option",
"shift": "Shift",
"plus": "plus",
"hold": "Hold {{key}}",
"noShortcut": "No shortcut"
},
"visual": {
"hold": "Hold {{key}}"
}
},
"fullScreenWarning": {
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
"stop": "Stop presenting",
+1 -7
View File
@@ -100,11 +100,6 @@
}
}
},
"shortcuts": {
"listLabel": "Keyboard shortcuts",
"columnAction": "Action",
"columnShortcut": "Shortcut"
},
"dialog": {
"heading": "Settings"
},
@@ -125,7 +120,6 @@
"general": "General",
"notifications": "Notifications",
"accessibility": "Accessibility",
"transcription": "Transcription",
"shortcuts": "Shortcuts"
"transcription": "Transcription"
}
}

Some files were not shown because too many files have changed in this diff Show More