Compare commits

..

2 Commits

Author SHA1 Message Date
Cyril 2861aa143e fixup! ♻️(frontend) use react aria for side panel a11y 2026-02-23 14:55:38 +01:00
Cyril 4748b55089 ♻️(frontend) use react aria for side panel a11y
leverage FocusScope for tab containment, escape and focus restore
2026-02-20 16:08:15 +01:00
132 changed files with 2942 additions and 7713 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Download Crowdin files - name: Download Crowdin files
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2
+38 -73
View File
@@ -23,13 +23,7 @@ jobs:
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- -
name: Docker meta name: Docker meta
id: meta id: meta
@@ -43,19 +37,18 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# - -
# name: Run trivy scan name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main uses: numerique-gouv/action-trivy-cache@main
# with: with:
# docker-build-args: '--target backend-production -f Dockerfile' 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 }}' docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
- -
name: Build and push name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
target: backend-production target: backend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
@@ -66,13 +59,7 @@ jobs:
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- -
name: Docker meta name: Docker meta
id: meta id: meta
@@ -86,12 +73,12 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# - -
# name: Run trivy scan name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main uses: numerique-gouv/action-trivy-cache@main
# with: with:
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production' 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 }}' docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
- -
name: Build and push name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -99,7 +86,6 @@ jobs:
context: . context: .
file: ./src/frontend/Dockerfile file: ./src/frontend/Dockerfile
target: frontend-production target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
@@ -110,13 +96,7 @@ jobs:
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- -
name: Docker meta name: Docker meta
id: meta id: meta
@@ -130,12 +110,12 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# - -
# name: Run trivy scan name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main uses: numerique-gouv/action-trivy-cache@main
# with: with:
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production' 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 }}' docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
- -
name: Build and push name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -143,7 +123,6 @@ jobs:
context: . context: .
file: ./docker/dinum-frontend/Dockerfile file: ./docker/dinum-frontend/Dockerfile
target: frontend-production target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
@@ -154,13 +133,7 @@ jobs:
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- -
name: Docker meta name: Docker meta
id: meta id: meta
@@ -174,13 +147,13 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# - -
# name: Run trivy scan name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true continue-on-error: true
# with: with:
# docker-build-args: '-f src/summary/Dockerfile --target production' 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-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary' docker-context: './src/summary'
- -
name: Build and push name: Build and push
@@ -189,7 +162,6 @@ jobs:
context: ./src/summary context: ./src/summary
file: ./src/summary/Dockerfile file: ./src/summary/Dockerfile
target: production target: production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
@@ -200,13 +172,7 @@ jobs:
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- -
name: Docker meta name: Docker meta
id: meta id: meta
@@ -220,14 +186,14 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# - -
# name: Run trivy scan name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true continue-on-error: true
# with: with:
# docker-build-args: '-f src/agents/Dockerfile --target production' 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-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
# docker-context: './src/agents' docker-context: './src/agents'
- -
name: Build and push name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -235,7 +201,6 @@ jobs:
context: ./src/agents context: ./src/agents
file: ./src/agents/Dockerfile file: ./src/agents/Dockerfile
target: production target: production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
+20 -43
View File
@@ -7,18 +7,14 @@ on:
pull_request: pull_request:
branches: branches:
- "*" - "*"
permissions:
contents: read
jobs: jobs:
lint-git: lint-git:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests if: github.event_name == 'pull_request' # Makes sense only for pull requests
permissions:
contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: show - name: show
@@ -43,11 +39,9 @@ jobs:
if: | if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false && contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request' github.event_name == 'pull_request'
permissions:
contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 50 fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch - name: Check that the CHANGELOG has been modified in the current branch
@@ -55,11 +49,9 @@ jobs:
lint-changelog: lint-changelog:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Check CHANGELOG max line length - name: Check CHANGELOG max line length
run: | run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L) max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -70,22 +62,20 @@ jobs:
build-mails: build-mails:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: src/mail working-directory: src/mail
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version: "18" node-version: "18"
- name: Restore the mail templates - name: Restore the mail templates
uses: actions/cache@v5 uses: actions/cache@v4
id: mail-templates id: mail-templates
with: with:
path: "src/backend/core/templates/mail" path: "src/backend/core/templates/mail"
@@ -105,23 +95,21 @@ jobs:
- name: Cache mail templates - name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true' if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v5 uses: actions/cache@v4
with: with:
path: "src/backend/core/templates/mail" path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }} key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back: lint-back:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: src/backend working-directory: src/backend
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Install Python - name: Install Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: "3.13" python-version: "3.13"
cache: "pip" cache: "pip"
@@ -136,16 +124,14 @@ jobs:
lint-agents: lint-agents:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: src/agents working-directory: src/agents
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Install Python - name: Install Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: "3.13" python-version: "3.13"
cache: "pip" cache: "pip"
@@ -158,16 +144,14 @@ jobs:
lint-summary: lint-summary:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: src/summary working-directory: src/summary
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Install Python - name: Install Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: "3.13" python-version: "3.13"
cache: "pip" cache: "pip"
@@ -181,8 +165,7 @@ jobs:
test-back: test-back:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-mails needs: build-mails
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: src/backend working-directory: src/backend
@@ -233,7 +216,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Create writable /data - name: Create writable /data
run: | run: |
@@ -241,7 +224,7 @@ jobs:
sudo mkdir -p /data/static sudo mkdir -p /data/static
- name: Restore the mail templates - name: Restore the mail templates
uses: actions/cache@v5 uses: actions/cache@v4
id: mail-templates id: mail-templates
with: with:
path: "src/backend/core/templates/mail" path: "src/backend/core/templates/mail"
@@ -275,7 +258,7 @@ jobs:
mc mb meet/meet-media-storage" mc mb meet/meet-media-storage"
- name: Install Python - name: Install Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: "3.13" python-version: "3.13"
cache: "pip" cache: "pip"
@@ -296,11 +279,9 @@ jobs:
lint-front: lint-front:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: cd src/frontend/ && npm ci run: cd src/frontend/ && npm ci
@@ -313,14 +294,12 @@ jobs:
lint-sdk: lint-sdk:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: src/sdk/library working-directory: src/sdk/library
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -333,15 +312,13 @@ jobs:
build-sdk: build-sdk:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
needs: lint-sdk needs: lint-sdk
defaults: defaults:
run: run:
working-directory: src/sdk/library working-directory: src/sdk/library
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 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 }}
+1 -57
View File
@@ -8,64 +8,9 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### 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
### Changed ### Changed
- 🔒(backend) enhance API input validation to strengthen security #1053 - (frontend) Use React Aria FocusScope for side panel accessibility
- 🦺(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 ## [1.7.0] - 2026-02-19
@@ -77,7 +22,6 @@ and this project adheres to
### Changed ### Changed
- ✨(frontend) add clickable settings general link in idle modal #974 - ✨(frontend) add clickable settings general link in idle modal #974
- ♻️(backend) refactor external API token-related items #1006
## [1.6.0] - 2026-02-10 ## [1.6.0] - 2026-02-10
-3
View File
@@ -127,9 +127,6 @@ ARG MEET_STATIC_ROOT=/data/static
RUN mkdir -p /usr/local/etc/gunicorn RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/meet.py /usr/local/etc/gunicorn/meet.py 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 # Un-privileged user running the application
ARG DOCKER_USER ARG DOCKER_USER
USER ${DOCKER_USER} USER ${DOCKER_USER}
+4 -6
View File
@@ -23,10 +23,9 @@
# ============================================================================== # ==============================================================================
# VARIABLES # VARIABLES
ESC := $(shell printf '\033') BOLD := \033[1m
BOLD := $(ESC)[1m RESET := \033[0m
RESET := $(ESC)[0m GREEN := \033[1;32m
GREEN := $(ESC)[1;32m
# -- Database # -- Database
@@ -86,8 +85,7 @@ bootstrap: \
demo \ demo \
back-i18n-compile \ back-i18n-compile \
mails-install \ mails-install \
mails-build \ mails-build
run
.PHONY: bootstrap .PHONY: bootstrap
# -- Docker/compose # -- Docker/compose
+1 -5
View File
@@ -18,7 +18,6 @@ docker_build(
'localhost:5001/meet-backend:latest', 'localhost:5001/meet-backend:latest',
context='..', context='..',
dockerfile='../Dockerfile', dockerfile='../Dockerfile',
build_args={'DOCKER_USER': '1001:127'},
only=['./src/backend', './src/mail', './docker'], only=['./src/backend', './src/mail', './docker'],
target = 'backend-production', target = 'backend-production',
live_update=[ live_update=[
@@ -34,7 +33,6 @@ clean_old_images('localhost:5001/meet-backend')
docker_build( docker_build(
'localhost:5001/meet-frontend-dinum:latest', 'localhost:5001/meet-frontend-dinum:latest',
context='..', context='..',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../docker/dinum-frontend/Dockerfile', dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'], only=['./src/frontend', './docker', './.dockerignore'],
target = 'frontend-production', target = 'frontend-production',
@@ -59,7 +57,6 @@ clean_old_images('localhost:5001/meet-frontend-generic')
docker_build( docker_build(
'localhost:5001/meet-summary:latest', 'localhost:5001/meet-summary:latest',
context='../src/summary', context='../src/summary',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/summary/Dockerfile', dockerfile='../src/summary/Dockerfile',
only=['.'], only=['.'],
target = 'production', target = 'production',
@@ -72,9 +69,8 @@ clean_old_images('localhost:5001/meet-summary')
docker_build( docker_build(
'localhost:5001/meet-agents:latest', 'localhost:5001/meet-agents:latest',
context='../src/agents', context='../src/agents',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/agents/Dockerfile', dockerfile='../src/agents/Dockerfile',
only=['.'], only=['.'],
target = 'production', target = 'production',
live_update=[ live_update=[
sync('../src/agents', '/app'), sync('../src/agents', '/app'),
+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 # We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/ COPY rootCA.pem /etc/ssl/certs/
+1 -1
View File
@@ -32,7 +32,7 @@ Set the following environment variables in your Scalingo app:
### Buildpack Configuration ### Buildpack Configuration
```bash ```bash
scalingo env-set BUILDPACK_URL="https://github.com/suitenumerique/buildpack#main" scalingo env-set BUILDBACK_URL="https://github.com/suitenumerique/buildpack#main"
scalingo env-set LASUITE_APP_NAME="meet" scalingo env-set LASUITE_APP_NAME="meet"
scalingo env-set LASUITE_BACKEND_DIR="." scalingo env-set LASUITE_BACKEND_DIR="."
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/" scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
-1
View File
@@ -2,7 +2,6 @@
Gitlint extra rule to validate that the message title is of the form Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>" "<gitmoji>(<scope>) <subject>"
""" """
from __future__ import unicode_literals from __future__ import unicode_literals
import re import re
-21
View File
@@ -3,21 +3,6 @@
"dependencyDashboard": true, "dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"], "labels": ["dependencies", "noChangeLog"],
"packageRules": [ "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, "enabled": false,
"groupName": "ignored python dependencies", "groupName": "ignored python dependencies",
@@ -30,12 +15,6 @@
"matchPackageNames": ["pylint"], "matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0" "allowedVersions": "<4.0.0"
}, },
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{ {
"enabled": false, "enabled": false,
"groupName": "ignored js dependencies", "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 \ RUN apt-get update && apt-get install -y \
libglib2.0-0 \ libglib2.0-0 \
libgobject-2.0-0 \ libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
FROM base AS builder FROM base AS builder
@@ -21,9 +19,6 @@ FROM base AS production
WORKDIR /app WORKDIR /app
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
ARG DOCKER_USER ARG DOCKER_USER
USER ${DOCKER_USER} USER ${DOCKER_USER}
+2 -3
View File
@@ -1,15 +1,14 @@
[project] [project]
name = "agents" name = "agents"
version = "1.9.0" version = "1.7.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.3.10", "livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10", "livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10", "livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6", "livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.1", "python-dotenv==1.2.1"
"protobuf==6.33.5"
] ]
[project.optional-dependencies] [project.optional-dependencies]
+2 -20
View File
@@ -2,8 +2,6 @@
from rest_framework import permissions from rest_framework import permissions
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from ..models import RoleChoices from ..models import RoleChoices
ACTION_FOR_METHOD_TO_PERMISSION = { ACTION_FOR_METHOD_TO_PERMISSION = {
@@ -47,27 +45,11 @@ class RoomPermissions(permissions.BasePermission):
""" """
def has_permission(self, request, view): def has_permission(self, request, view):
"""Only allow authenticated users for unsafe methods. """Only allow authenticated users for unsafe methods."""
Room creation additionally requires the can_create entitlement.
Fail-closed: denies creation when the entitlements service is unavailable.
"""
if request.method in permissions.SAFE_METHODS: if request.method in permissions.SAFE_METHODS:
return True return True
if not request.user.is_authenticated: return request.user.is_authenticated
return False
if view.action == "create":
try:
entitlements = get_user_entitlements(
request.user.sub, request.user.email
)
return entitlements.get("can_create", False)
except EntitlementsUnavailableError:
return False
return True
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
"""Object permissions are only given to administrators of the room.""" """Object permissions are only given to administrators of the room."""
+13 -101
View File
@@ -1,20 +1,15 @@
"""Client serializers for the Meet core app.""" """Client serializers for the Meet core app."""
# pylint: disable=abstract-method,no-name-in-module # 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.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField from livekit.api import ParticipantPermission
from pydantic import BaseModel, Field
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils from core import models, utils
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
@@ -28,25 +23,6 @@ class UserSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "email", "full_name", "short_name"] read_only_fields = ["id", "email", "full_name", "short_name"]
class UserMeSerializer(UserSerializer):
"""Serialize users for me endpoint."""
can_create = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = [*UserSerializer.Meta.fields, "can_create"]
read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_create"]
def get_can_create(self, user) -> bool:
"""Check entitlements for the current user."""
try:
entitlements = get_user_entitlements(user.sub, user.email)
return entitlements.get("can_create", False)
except EntitlementsUnavailableError:
return False
class ResourceAccessSerializerMixin: class ResourceAccessSerializerMixin:
""" """
A serializer mixin to share controlling that the logged-in user submitting a room access object A serializer mixin to share controlling that the logged-in user submitting a room access object
@@ -225,27 +201,6 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only") 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): class StartRecordingSerializer(BaseValidationOnlySerializer):
"""Validate start recording requests.""" """Validate start recording requests."""
@@ -258,11 +213,10 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
"screen_recording or transcript.", "screen_recording or transcript.",
}, },
) )
options = SchemaField( options = serializers.JSONField(
schema=RecordingOptions | None,
required=False, required=False,
allow_null=True, allow_null=True,
help_text="Recording options", default=dict,
) )
@@ -307,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): class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data.""" """Validate participant update data."""
@@ -340,11 +272,10 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
allow_null=True, allow_null=True,
help_text="Participant attributes as JSON object", help_text="Participant attributes as JSON object",
) )
permission = SchemaField( permission = serializers.DictField(
schema=ParticipantPermission | None,
required=False, required=False,
allow_null=True, allow_null=True,
help_text="Participant permissions", help_text="Participant permission as JSON object",
) )
name = serializers.CharField( name = serializers.CharField(
max_length=255, max_length=255,
@@ -354,33 +285,6 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
help_text="Display name for the participant", 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): def validate(self, attrs):
"""Ensure at least one update field is provided.""" """Ensure at least one update field is provided."""
update_fields = ["metadata", "attributes", "permission", "name"] update_fields = ["metadata", "attributes", "permission", "name"]
@@ -396,4 +300,12 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
f"{', '.join(update_fields)}." 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 return attrs
+4 -8
View File
@@ -187,7 +187,7 @@ class UserViewSet(
""" """
context = {"request": request} context = {"request": request}
return drf_response.Response( return drf_response.Response(
serializers.UserMeSerializer(request.user, context=context).data self.serializer_class(request.user, context=context).data
) )
@@ -296,14 +296,12 @@ class RoomViewSet(
) )
mode = serializer.validated_data["mode"] mode = serializer.validated_data["mode"]
options = serializer.validated_data.get("options") options = serializer.validated_data["options"]
room = self.get_object() room = self.get_object()
# May raise exception if an active or initiated recording already exist for the room # May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create( recording = models.Recording.objects.create(
room=room, room=room, mode=mode, options=options
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
) )
models.RecordingAccess.objects.create( models.RecordingAccess.objects.create(
@@ -609,15 +607,13 @@ class RoomViewSet(
serializer = serializers.UpdateParticipantSerializer(data=request.data) serializer = serializers.UpdateParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
permission = serializer.validated_data.get("permission")
try: try:
ParticipantsManagement().update( ParticipantsManagement().update(
room_name=str(room.pk), room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]), identity=str(serializer.validated_data["participant_identity"]),
metadata=serializer.validated_data.get("metadata"), metadata=serializer.validated_data.get("metadata"),
attributes=serializer.validated_data.get("attributes"), 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"), name=serializer.validated_data.get("name"),
) )
except ParticipantsManagementException: except ParticipantsManagementException:
@@ -1,7 +1,6 @@
"""Authentication Backends for the Meet core app.""" """Authentication Backends for the Meet core app."""
import contextlib import contextlib
import logging
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
@@ -11,7 +10,6 @@ from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
) )
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import User from core.models import User
from core.services.marketing import ( from core.services.marketing import (
ContactCreationError, ContactCreationError,
@@ -19,8 +17,6 @@ from core.services.marketing import (
get_marketing_service, get_marketing_service,
) )
logger = logging.getLogger(__name__)
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend): class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend. """Custom OpenID Connect (OIDC) Authentication Backend.
@@ -63,21 +59,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL: if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email) self.signup_to_marketing_email(email)
# Warm the entitlements cache on login (force_refresh)
try:
get_user_entitlements(
user_sub=user.sub,
user_email=user.email,
user_info=claims,
force_refresh=True,
)
except EntitlementsUnavailableError:
email_domain = user.email.split("@")[-1] if "@" in user.email else "?"
logger.warning(
"Entitlements unavailable for user@%s during login",
email_domain,
)
@staticmethod @staticmethod
def signup_to_marketing_email(email): def signup_to_marketing_email(email):
"""Pragmatic approach to newsletter signup during authentication flow. """Pragmatic approach to newsletter signup during authentication flow.
-29
View File
@@ -1,29 +0,0 @@
"""Entitlements service layer."""
from core.entitlements.factory import get_entitlements_backend
class EntitlementsUnavailableError(Exception):
"""Raised when the entitlements backend cannot be reached or returns an error."""
def get_user_entitlements(user_sub, user_email, user_info=None, force_refresh=False):
"""Get user entitlements, delegating to the configured backend.
Args:
user_sub: The user's OIDC subject identifier.
user_email: The user's email address.
user_info: The full OIDC user_info dict (forwarded to backend).
force_refresh: If True, bypass backend cache and fetch fresh data.
Returns:
dict: {"can_create": bool}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached
and no cache exists.
"""
backend = get_entitlements_backend()
return backend.get_user_entitlements(
user_sub, user_email, user_info=user_info, force_refresh=force_refresh
)
@@ -1,27 +0,0 @@
"""Abstract base class for entitlements backends."""
from abc import ABC, abstractmethod
class EntitlementsBackend(ABC):
"""Abstract base class that defines the interface for entitlements backends."""
@abstractmethod
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
"""Fetch user entitlements.
Args:
user_sub: The user's OIDC subject identifier.
user_email: The user's email address.
user_info: The full OIDC user_info dict (backends may
extract claims from it).
force_refresh: If True, bypass any cache and fetch fresh data.
Returns:
dict: {"can_create": bool}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached.
"""
@@ -1,120 +0,0 @@
"""DeployCenter (Espace Operateur) entitlements backend."""
import logging
from django.conf import settings
from django.core.cache import cache
import requests
from core.entitlements import EntitlementsUnavailableError
from core.entitlements.backends.base import EntitlementsBackend
logger = logging.getLogger(__name__)
class DeployCenterEntitlementsBackend(EntitlementsBackend):
"""Backend that fetches entitlements from the DeployCenter API.
Args:
base_url: Full URL of the entitlements endpoint
(e.g. "https://dc.example.com/api/v1.0/entitlements/").
service_id: The service identifier in DeployCenter.
api_key: API key for X-Service-Auth header.
timeout: HTTP request timeout in seconds.
oidc_claims: List of OIDC claim names to extract from user_info
and forward as query params (e.g. ["siret"]).
"""
def __init__( # pylint: disable=too-many-arguments
self,
base_url,
service_id,
api_key,
*,
timeout=10,
oidc_claims=None,
):
self.base_url = base_url
self.service_id = service_id
self.api_key = api_key
self.timeout = timeout
self.oidc_claims = oidc_claims or []
def _cache_key(self, user_sub):
return f"entitlements:user:{user_sub}"
def _make_request(self, user_email, user_info=None):
"""Make a request to the DeployCenter entitlements API.
Returns:
dict | None: The response data, or None on failure.
"""
params = {
"service_id": self.service_id,
"account_type": "user",
"account_email": user_email,
}
# Forward configured OIDC claims as query params
if user_info:
for claim in self.oidc_claims:
if claim in user_info:
params[claim] = user_info[claim]
headers = {
"X-Service-Auth": f"Bearer {self.api_key}",
}
try:
response = requests.get(
self.base_url,
params=params,
headers=headers,
timeout=self.timeout,
)
response.raise_for_status()
return response.json()
except (requests.RequestException, ValueError):
email_domain = user_email.split("@")[-1] if "@" in user_email else "?"
logger.warning(
"DeployCenter entitlements request failed for user@%s",
email_domain,
exc_info=True,
)
return None
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
"""Fetch user entitlements from DeployCenter with caching.
On cache miss or force_refresh: fetches from the API.
On API failure: falls back to stale cache if available,
otherwise raises EntitlementsUnavailableError.
"""
cache_key = self._cache_key(user_sub)
if not force_refresh:
cached = cache.get(cache_key)
if cached is not None:
return cached
data = self._make_request(user_email, user_info=user_info)
if data is None:
# API failed — try stale cache as fallback
cached = cache.get(cache_key)
if cached is not None:
return cached
raise EntitlementsUnavailableError(
"Failed to fetch user entitlements from DeployCenter"
)
entitlements = data.get("entitlements", {})
result = {
"can_create": entitlements.get("can_create", False),
}
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
return result
@@ -1,12 +0,0 @@
"""Local entitlements backend for development and testing."""
from core.entitlements.backends.base import EntitlementsBackend
class LocalEntitlementsBackend(EntitlementsBackend):
"""Local backend that always grants access."""
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
return {"can_create": True}
-13
View File
@@ -1,13 +0,0 @@
"""Factory for creating entitlements backend instances."""
import functools
from django.conf import settings
from django.utils.module_loading import import_string
@functools.cache
def get_entitlements_backend():
"""Return a singleton instance of the configured entitlements backend."""
backend_class = import_string(settings.ENTITLEMENTS_BACKEND)
return backend_class(**settings.ENTITLEMENTS_BACKEND_PARAMETERS)
+47 -132
View File
@@ -1,51 +1,27 @@
"""Authentication Backends for external application to the Meet core app.""" """Authentication Backends for external application to the Meet core app."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
import logging import logging
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
import jwt as pyJwt
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from rest_framework import authentication, exceptions from rest_framework import authentication, exceptions
from core.models import Application from core.models import Application
from core.services import jwt_token
User = get_user_model() User = get_user_model()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BaseJWTAuthentication(authentication.BaseAuthentication): class ApplicationJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class.""" """JWT authentication for application-delegated API access.
def __init__( Validates JWT tokens issued to applications that are acting on behalf
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type of users. Tokens must include user_id, client_id, and delegation flag.
): """
"""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,
)
def authenticate(self, request): def authenticate(self, request):
"""Extract and validate JWT from Authorization header. """Extract and validate JWT from Authorization header.
@@ -72,78 +48,6 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
return self.authenticate_credentials(token) 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): def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user. """Validate JWT token and return authenticated user.
@@ -158,41 +62,36 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
Raises: Raises:
AuthenticationFailed: If token is expired, or user not found AuthenticationFailed: If token is expired, or user not found
""" """
# Decode and validate JWT
payload = self.decode_jwt(token) try:
payload = pyJwt.decode(
if payload is None: 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 return None
self.validate_payload(payload) user_id = payload.get("user_id")
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."""
client_id = payload.get("client_id") client_id = payload.get("client_id")
is_delegated = payload.get("delegated", False) 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: if not client_id:
logger.warning("Missing 'client_id' in JWT payload") logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.") raise exceptions.AuthenticationFailed("Invalid token claims.")
@@ -213,6 +112,22 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
logger.warning("Token is not marked as delegated") logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.") 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): class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval.""" """OIDC Resource Server backend for user creation and retrieval."""
+23 -17
View File
@@ -1,5 +1,6 @@
"""External API endpoints""" """External API endpoints"""
from datetime import datetime, timedelta, timezone
from logging import getLogger from logging import getLogger
from django.conf import settings 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.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email from django.core.validators import validate_email
import jwt
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import decorators, mixins, viewsets from rest_framework import decorators, mixins, viewsets
from rest_framework import ( from rest_framework import (
@@ -20,7 +22,6 @@ from rest_framework import (
) )
from core import api, models from core import api, models
from core.services.jwt_token import JwtTokenService
from . import authentication, permissions, serializers from . import authentication, permissions, serializers
@@ -127,28 +128,33 @@ class ApplicationViewSet(viewsets.ViewSet):
"Multiple user accounts share a common email." "Multiple user accounts share a common email."
) from e ) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or []) scope = " ".join(application.scopes or [])
token_service = JwtTokenService( payload = {
secret_key=settings.APPLICATION_JWT_SECRET_KEY, "iss": settings.APPLICATION_JWT_ISSUER,
algorithm=settings.APPLICATION_JWT_ALG, "aud": settings.APPLICATION_JWT_AUDIENCE,
issuer=settings.APPLICATION_JWT_ISSUER, "iat": now,
audience=settings.APPLICATION_JWT_AUDIENCE, "exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS, "client_id": client_id,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE, "scope": scope,
) "user_id": str(user.id),
"delegated": True,
}
data = token_service.generate_jwt( token = jwt.encode(
user, payload,
scope, settings.APPLICATION_JWT_SECRET_KEY,
{ algorithm=settings.APPLICATION_JWT_ALG,
"client_id": client_id,
"delegated": True,
},
) )
return drf_response.Response( 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, 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')), ('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')), ('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')), ('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)), ('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_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')), ('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( migrations.AddField(
model_name='resource', model_name='resource',
name='users', 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( migrations.AddConstraint(
model_name='resourceaccess', model_name='resourceaccess',
@@ -1,5 +1,5 @@
# Generated by Django 5.0.7 on 2024-08-07 14:39 # Generated by Django 5.0.7 on 2024-08-07 14:39
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='user', model_name='user',
name='language', 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 # Generated by Django 5.1.8 on 2025-04-22 14:52
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='user', model_name='user',
name='language', 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 owner_access.user.timezone
).strftime("%H:%M"), ).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}", "download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
} }
headers = { 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 unittest import mock
from uuid import uuid4 from uuid import uuid4
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse from django.urls import reverse
import pytest import pytest
@@ -133,7 +132,11 @@ def test_update_participant_success(mock_livekit_client):
1, 1,
2, 2,
], # [TrackSource.CAMERA, TrackSource.MICROPHONE] ], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"hidden": False,
"recorder": False,
"can_update_metadata": True, "can_update_metadata": True,
"agent": False,
"can_subscribe_metrics": False,
}, },
"name": "John Doe", "name": "John Doe",
} }
@@ -148,151 +151,6 @@ def test_update_participant_success(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once() 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(): def test_update_participant_forbidden_without_access():
"""Test update participant returns 403 when user lacks room privileges.""" """Test update participant returns 403 when user lacks room privileges."""
client = APIClient() client = APIClient()
@@ -368,17 +226,7 @@ def test_update_participant_invalid_permission():
response = client.post(url, payload, format="json") response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == { assert "Invalid permission" in str(response.data)
"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",
},
]
}
def test_update_participant_wrong_metadata_attributes(): def test_update_participant_wrong_metadata_attributes():
@@ -199,308 +199,3 @@ def test_start_recording_success(
access = recording.accesses.first() access = recording.accesses.first()
assert access.user == user assert access.user == user
assert access.role == "owner" 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
-1
View File
@@ -125,7 +125,6 @@ def test_api_users_retrieve_me_authenticated(settings):
"short_name": user.short_name, "short_name": user.short_name,
"language": user.language, "language": user.language,
"timezone": "UTC", "timezone": "UTC",
"can_create": True,
} }
-504
View File
@@ -1,504 +0,0 @@
"""Tests for the entitlements module."""
# pylint: disable=redefined-outer-name
from unittest import mock
from django.test import override_settings
import pytest
import requests
import responses
from rest_framework.status import HTTP_201_CREATED, HTTP_403_FORBIDDEN
from rest_framework.test import APIClient
from django.core.cache import cache as django_cache
from core import factories
from core.api.serializers import UserMeSerializer
from core.authentication.backends import OIDCAuthenticationBackend
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.entitlements.backends.deploycenter import DeployCenterEntitlementsBackend
from core.entitlements.backends.local import LocalEntitlementsBackend
from core.entitlements.factory import get_entitlements_backend
pytestmark = pytest.mark.django_db
DC_URL = "https://deploy.example.com/api/v1.0/entitlements/"
@pytest.fixture(autouse=True)
def _clear_cache():
"""Clear Django cache between tests to prevent entitlements cache bleed."""
django_cache.clear()
# -- LocalEntitlementsBackend --
def test_local_backend_always_grants_access():
"""The local backend should always return can_create=True."""
backend = LocalEntitlementsBackend()
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_create": True}
def test_local_backend_ignores_parameters():
"""The local backend should work regardless of parameters passed."""
backend = LocalEntitlementsBackend()
result = backend.get_user_entitlements(
"sub-123",
"user@example.com",
user_info={"some": "claim"},
force_refresh=True,
)
assert result == {"can_create": True}
# -- Factory --
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_factory_returns_local_backend():
"""The factory should instantiate the configured backend."""
get_entitlements_backend.cache_clear()
backend = get_entitlements_backend()
assert isinstance(backend, LocalEntitlementsBackend)
get_entitlements_backend.cache_clear()
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_factory_singleton():
"""The factory should return the same instance on repeated calls."""
get_entitlements_backend.cache_clear()
backend1 = get_entitlements_backend()
backend2 = get_entitlements_backend()
assert backend1 is backend2
get_entitlements_backend.cache_clear()
# -- get_user_entitlements public API --
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_get_user_entitlements_with_local_backend():
"""The public API should delegate to the configured backend."""
get_entitlements_backend.cache_clear()
result = get_user_entitlements("sub-123", "user@example.com")
assert result["can_create"] is True
get_entitlements_backend.cache_clear()
# -- DeployCenterEntitlementsBackend --
@responses.activate
def test_deploycenter_backend_grants_access():
"""DeployCenter backend should return can_create from API response."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_create": True}
# Verify request was made with correct params and header
assert len(responses.calls) == 1
request = responses.calls[0].request
assert "service_id=meet" in request.url
assert "account_email=user%40example.com" in request.url
assert request.headers["X-Service-Auth"] == "Bearer test-key"
@responses.activate
def test_deploycenter_backend_denies_access():
"""DeployCenter backend should return can_create=False when API says so."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": False}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_create": False}
@responses.activate
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
def test_deploycenter_backend_uses_cache():
"""DeployCenter should use cached results when not force_refresh."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
# First call hits the API
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result1 == {"can_create": True}
assert len(responses.calls) == 1
# Second call should use cache
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result2 == {"can_create": True}
assert len(responses.calls) == 1 # No additional API call
@responses.activate
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
def test_deploycenter_backend_force_refresh_bypasses_cache():
"""force_refresh=True should bypass cache and hit the API."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": False}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result1["can_create"] is True
result2 = backend.get_user_entitlements(
"sub-123", "user@example.com", force_refresh=True
)
assert result2["can_create"] is False
assert len(responses.calls) == 2
@responses.activate
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
def test_deploycenter_backend_fallback_to_stale_cache():
"""When API fails, should return stale cached value if available."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
# Populate cache
backend.get_user_entitlements("sub-123", "user@example.com")
# Now API fails
responses.replace(
responses.GET,
DC_URL,
body=requests.ConnectionError("Connection error"),
)
# force_refresh to hit API, but should fall back to cache
result = backend.get_user_entitlements(
"sub-123", "user@example.com", force_refresh=True
)
assert result == {"can_create": True}
@responses.activate
def test_deploycenter_backend_raises_when_no_cache():
"""When API fails and no cache exists, should raise."""
responses.add(
responses.GET,
DC_URL,
body=requests.ConnectionError("Connection error"),
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
with pytest.raises(EntitlementsUnavailableError):
backend.get_user_entitlements("sub-123", "user@example.com")
@responses.activate
def test_deploycenter_backend_sends_oidc_claims():
"""DeployCenter should forward configured OIDC claims."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
oidc_claims=["organization"],
)
backend.get_user_entitlements(
"sub-123",
"user@example.com",
user_info={"organization": "org-42", "other": "ignored"},
)
request = responses.calls[0].request
assert "organization=org-42" in request.url
assert "other" not in request.url
# -- Auth backend integration --
def test_auth_backend_warms_cache_on_login():
"""post_get_or_create_user should call get_user_entitlements with force_refresh."""
user = factories.UserFactory()
backend = OIDCAuthenticationBackend()
with mock.patch(
"core.authentication.backends.get_user_entitlements",
return_value={"can_create": True},
) as mock_ent:
backend.post_get_or_create_user(
user, {"email": user.email, "sub": "x"}, is_new_user=False
)
mock_ent.assert_called_once_with(
user_sub=user.sub,
user_email=user.email,
user_info={"email": user.email, "sub": "x"},
force_refresh=True,
)
def test_auth_backend_login_succeeds_when_access_denied():
"""Login should succeed even when can_create is False (gated in frontend)."""
user = factories.UserFactory()
backend = OIDCAuthenticationBackend()
with mock.patch(
"core.authentication.backends.get_user_entitlements",
return_value={"can_create": False},
):
# Should not raise — user logs in, frontend gates access
backend.post_get_or_create_user(
user, {"email": user.email}, is_new_user=False
)
def test_auth_backend_login_succeeds_when_entitlements_unavailable():
"""Login should succeed when entitlements service is unavailable."""
user = factories.UserFactory()
backend = OIDCAuthenticationBackend()
with mock.patch(
"core.authentication.backends.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
# Should not raise
backend.post_get_or_create_user(
user, {"email": user.email}, is_new_user=False
)
# -- UserMeSerializer (can_create field) --
def test_user_me_serializer_includes_can_create_true():
"""UserMeSerializer should include can_create=True when entitled."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
return_value={"can_create": True},
):
data = UserMeSerializer(user).data
assert data["can_create"] is True
def test_user_me_serializer_includes_can_create_false():
"""UserMeSerializer should include can_create=False when not entitled."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
return_value={"can_create": False},
):
data = UserMeSerializer(user).data
assert data["can_create"] is False
def test_user_me_serializer_can_create_fail_closed():
"""UserMeSerializer should return can_create=False when entitlements unavailable."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
data = UserMeSerializer(user).data
assert data["can_create"] is False
# -- /users/me/ endpoint integration --
def test_api_users_me_includes_can_create():
"""GET /users/me/ should include can_create in the response."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert "can_create" in response.json()
assert response.json()["can_create"] is True
def test_api_users_me_can_create_false():
"""GET /users/me/ should return can_create=False when not entitled."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.serializers.get_user_entitlements",
return_value={"can_create": False},
):
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert response.json()["can_create"] is False
# -- Room creation entitlements enforcement --
def test_room_creation_blocked_when_not_entitled():
"""Room creation should return 403 when user has can_create=False."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": False},
):
response = client.post(
"/api/v1.0/rooms/",
data={"name": "test-room"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_room_creation_blocked_when_entitlements_unavailable():
"""Room creation should return 403 when entitlements service
is unavailable (fail-closed)."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
response = client.post(
"/api/v1.0/rooms/",
data={"name": "test-room"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_room_creation_allowed_when_entitled():
"""Room creation should succeed when user has can_create=True."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": True},
):
response = client.post(
"/api/v1.0/rooms/",
data={"name": "test-room"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
# -- Non-create room actions are NOT gated by entitlements --
def test_room_retrieve_allowed_when_not_entitled():
"""Room retrieval should work even when user has can_create=False."""
user = factories.UserFactory()
room = factories.RoomFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": False},
):
response = client.get(f"/api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
def test_room_list_allowed_when_not_entitled():
"""Room listing should work even when user has can_create=False."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": False},
):
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
@@ -55,8 +55,9 @@ def test_api_rooms_list_requires_authentication():
assert response.status_code == 401 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.""" """List should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False) user1 = UserFactory(is_active=False)
RoomFactory(users=[(user1, RoleChoices.OWNER)]) 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() 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.""" """Listing rooms with valid token should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)]) 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) 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.""" """Listing rooms with a valid token returns an empty list when there are no rooms."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
@@ -108,6 +112,7 @@ def test_api_rooms_list_with_no_rooms():
def test_api_rooms_list_with_expired_token(settings): def test_api_rooms_list_with_expired_token(settings):
"""Listing rooms with expired token should return 401.""" """Listing rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0 settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory() user = UserFactory()
@@ -148,8 +153,9 @@ def test_api_rooms_list_with_invalid_rs_token(settings):
assert response.status_code == 400 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.""" """Listing rooms without required scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() 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.""" """Listing rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
@@ -183,8 +190,9 @@ def test_api_rooms_list_no_scope():
assert "insufficient permissions." in str(response.data).lower() 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.""" """List should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory() user1 = UserFactory()
user2 = UserFactory() user2 = UserFactory()
@@ -209,9 +217,11 @@ def test_api_rooms_list_filters_by_user():
assert str(room2.id) not in returned_ids 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.""" """Retrieving rooms without authentication should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory() user1 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)]) room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
@@ -221,8 +231,9 @@ def test_api_rooms_retrieve_requires_authentication():
assert response.status_code == 401 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.""" """Retrieve should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False) user1 = UserFactory(is_active=False)
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)]) 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): def test_api_rooms_retrieve_with_expired_token(settings):
"""Retrieving rooms with expired token should return 401.""" """Retrieving rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0 settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory() user = UserFactory()
@@ -283,8 +295,9 @@ def test_api_rooms_retrieve_with_invalid_rs_token(settings):
assert response.status_code == 400 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.""" """Retrieving a room requires ROOMS_RETRIEVE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)]) 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.""" """Retrieving rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
@@ -321,6 +335,7 @@ def test_api_rooms_retrieve_no_scope():
def test_api_rooms_retrieve_success(settings): def test_api_rooms_retrieve_success(settings):
"""Retrieving a room with correct scope should succeed.""" """Retrieving a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = "http://your-application.com" settings.APPLICATION_BASE_URL = "http://your-application.com"
settings.ROOM_TELEPHONY_ENABLED = True settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100" 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.""" """Retrieve should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory() user1 = UserFactory()
user2 = UserFactory() user2 = UserFactory()
@@ -392,8 +408,9 @@ def test_api_rooms_retrieve_success_by_user():
assert response.status_code == 200 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.""" """Retrieving a non-existing room with correct scope should return a 404."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE]) 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() 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.""" """Creating rooms without authentication should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
client = APIClient() client = APIClient()
response = client.post("/external-api/v1.0/rooms/") 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): def test_api_rooms_create_with_expired_token(settings):
"""Creating rooms with expired token should return 401.""" """Creating rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0 settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory() user = UserFactory()
@@ -457,8 +477,9 @@ def test_api_rooms_create_with_invalid_rs_token(settings):
assert response.status_code == 400 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.""" """Create should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False) 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() 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.""" """Creating a room requires ROOMS_CREATE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
# Token without ROOMS_CREATE scope # 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.""" """Creating rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
@@ -506,8 +529,9 @@ def test_api_rooms_create_no_scope():
assert "insufficient permissions." in str(response.data).lower() 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.""" """Creating a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
@@ -531,8 +555,9 @@ def test_api_rooms_create_success():
assert room.access_level == "trusted" 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.""" """Creating a room succeeds and any provided read-only fields are ignored."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
@@ -565,8 +590,9 @@ def test_api_rooms_create_readonly_enforcement():
assert room.access_level == "trusted" 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.""" """Updating or deleting a room are not supported yet."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)]) room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -597,6 +623,7 @@ def test_api_rooms_unknown_actions():
def test_api_rooms_response_no_url(settings): def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None.""" """Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = None settings.APPLICATION_BASE_URL = None
user = UserFactory() user = UserFactory()
@@ -615,6 +642,7 @@ def test_api_rooms_response_no_url(settings):
def test_api_rooms_response_no_telephony(settings): def test_api_rooms_response_no_telephony(settings):
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False.""" """Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.ROOM_TELEPHONY_ENABLED = False settings.ROOM_TELEPHONY_ENABLED = False
user = UserFactory() user = UserFactory()
@@ -633,6 +661,7 @@ def test_api_rooms_response_no_telephony(settings):
def test_api_rooms_token_scope_case_insensitive(settings): def test_api_rooms_token_scope_case_insensitive(settings):
"""Token's scope should be case-insensitive.""" """Token's scope should be case-insensitive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
application = ApplicationFactory() application = ApplicationFactory()
@@ -664,6 +693,7 @@ def test_api_rooms_token_scope_case_insensitive(settings):
def test_api_rooms_token_without_delegated_flag(settings): def test_api_rooms_token_without_delegated_flag(settings):
"""Token without delegated flag should be rejected.""" """Token without delegated flag should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
application = ApplicationFactory() application = ApplicationFactory()
@@ -696,6 +726,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None) @mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings): def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
"""Token signed with an invalid key should defer to the next authentication.""" """Token signed with an invalid key should defer to the next authentication."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
application = ApplicationFactory() 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) @mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings): def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
"""Token signed with an invalid alg should defer to the next authentication.""" """Token signed with an invalid alg should defer to the next authentication."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_ALG = "RS256" settings.APPLICATION_JWT_ALG = "RS256"
user = UserFactory() 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): def test_api_rooms_token_missing_client_id(settings):
"""Token without client_id should be rejected.""" """Token without client_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
now = datetime.now(timezone.utc) 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): def test_api_rooms_token_missing_user_id(settings):
"""Token without user_id should be rejected.""" """Token without user_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory() application = ApplicationFactory()
now = datetime.now(timezone.utc) 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): def test_api_rooms_token_invalid_audience(settings):
"""Token with an invalid audience should be rejected.""" """Token with an invalid audience should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory() user = UserFactory()
application = ApplicationFactory() application = ApplicationFactory()
@@ -847,6 +882,7 @@ def test_api_rooms_token_invalid_audience(settings):
def test_api_rooms_token_unknown_user(settings): def test_api_rooms_token_unknown_user(settings):
"""Token for unknown user should be rejected.""" """Token for unknown user should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory() application = ApplicationFactory()
now = datetime.now(timezone.utc) 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): def test_api_rooms_token_unknown_application(settings):
"""Token for unknown application should be rejected.""" """Token for unknown application should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
payload = { payload = {
@@ -904,6 +941,7 @@ def test_api_rooms_token_unknown_application(settings):
def test_api_rooms_token_inactive_application(settings): def test_api_rooms_token_inactive_application(settings):
"""Token for inactive application should be rejected.""" """Token for inactive application should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory(active=False) application = ApplicationFactory(active=False)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -21,6 +21,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings): def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token.""" """Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
UserFactory(email="User.Family@example.com") UserFactory(email="User.Family@example.com")
application = ApplicationFactory( application = ApplicationFactory(
active=True, 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) 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.""" """Application with domain authorization should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@allowed.com") user = UserFactory(email="user@allowed.com")
application = ApplicationFactory( application = ApplicationFactory(
active=True, active=True,
@@ -228,6 +230,7 @@ def test_api_applications_generate_token_user_not_found():
@freeze_time("2023-01-15 12:00:00") @freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_payload_structure(settings): def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure.""" """Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com") user = UserFactory(email="user@example.com")
application = ApplicationFactory( application = ApplicationFactory(
@@ -277,6 +280,7 @@ def test_api_applications_token_payload_structure(settings):
def test_api_applications_token_new_user(settings): def test_api_applications_token_new_user(settings):
"""Should create a new pending user when creation is allowed and user doesn't exist.""" """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.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False 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): def test_api_applications_token_existing_user(settings):
"""Application should not create a new user when user exist.""" """Application should not create a new user when user exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com") user = UserFactory(email="user@example.com")
settings.APPLICATION_ALLOW_USER_CREATION = True settings.APPLICATION_ALLOW_USER_CREATION = True
+1 -27
View File
@@ -596,12 +596,6 @@ class Base(Configuration):
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue( ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None 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 settings
RECORDING_ENABLE = values.BooleanValue( RECORDING_ENABLE = values.BooleanValue(
@@ -673,7 +667,7 @@ class Base(Configuration):
[], [],
environ_name="BREVO_API_CONTACT_LIST_IDS", environ_name="BREVO_API_CONTACT_LIST_IDS",
environ_prefix=None, 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_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue( BREVO_API_TIMEOUT = values.PositiveIntegerValue(
@@ -706,23 +700,6 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
# Entitlements
ENTITLEMENTS_BACKEND = values.Value(
"core.entitlements.backends.local.LocalEntitlementsBackend",
environ_name="ENTITLEMENTS_BACKEND",
environ_prefix=None,
)
ENTITLEMENTS_BACKEND_PARAMETERS = values.DictValue(
{},
environ_name="ENTITLEMENTS_BACKEND_PARAMETERS",
environ_prefix=None,
)
ENTITLEMENTS_CACHE_TIMEOUT = values.PositiveIntegerValue(
300, # 5 minutes
environ_name="ENTITLEMENTS_CACHE_TIMEOUT",
environ_prefix=None,
)
# Calendar integrations # Calendar integrations
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue( ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
600, # 10 minutes 600, # 10 minutes
@@ -940,9 +917,6 @@ class Test(Base):
USE_SWAGGER = True USE_SWAGGER = True
EXTERNAL_API_ENABLED = True EXTERNAL_API_ENABLED = True
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True) CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
def __init__(self): def __init__(self):
+24 -26
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "meet" name = "meet"
version = "1.9.0" version = "1.7.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
@@ -17,48 +17,46 @@ classifiers = [
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Natural Language :: English", "Natural Language :: English",
"Programming Language :: Python :: 3", "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" description = "A simple video and phone conferencing tool, powered by LiveKit"
keywords = ["Django", "Contacts", "Templates", "RBAC"] keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" } license = { file = "LICENSE" }
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.10"
dependencies = [ dependencies = [
"boto3==1.42.49", "boto3==1.40.69",
"Brotli==1.2.0", "Brotli==1.2.0",
"brevo-python==1.2.0", "brevo-python==1.2.0",
"celery[redis]==5.6.2", "celery[redis]==5.5.3",
"dj-database-url==3.1.0", "dj-database-url==3.1.0",
"django-configurations==2.5.1", "django-configurations==2.5.1",
"django-cors-headers==4.9.0", "django-cors-headers==4.9.0",
"django-countries==8.2.0", "django-countries==8.0.0",
"django-lasuite[all]==0.0.24", "django-lasuite[all]==0.0.19",
"django-parler==2.3", "django-parler==2.3",
"redis==5.2.1", "redis==5.2.1",
"django-redis==6.0.0", "django-redis==6.0.0",
"django-storages[s3]==1.14.6", "django-storages[s3]==1.14.6",
"django-timezone-field>=5.1", "django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.11", "django==5.2.11",
"djangorestframework==3.16.1", "djangorestframework==3.16.1",
"drf_spectacular==0.29.0", "drf_spectacular==0.29.0",
"dockerflow==2026.1.26", "dockerflow==2024.4.2",
"easy_thumbnails==2.10.1", "easy_thumbnails==2.10.1",
"factory_boy==3.3.3", "factory_boy==3.3.3",
"gunicorn==25.1.0", "gunicorn==23.0.0",
"jsonschema==4.26.0", "jsonschema==4.25.1",
"markdown==3.10.2", "markdown==3.10",
"nested-multipart-parser==1.6.0", "nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2", "psycopg[binary]==3.2.12",
"pydantic==2.12.4", "PyJWT==2.10.1",
"PyJWT==2.11.0",
"python-frontmatter==1.1.0", "python-frontmatter==1.1.0",
"requests==2.32.5", "requests==2.32.5",
"sentry-sdk==2.53.0", "sentry-sdk==2.43.0",
"whitenoise==6.11.0", "whitenoise==6.11.0",
"mozilla-django-oidc==5.0.2", "mozilla-django-oidc==4.0.1",
"livekit-api==1.1.0", "livekit-api==1.0.7",
"aiohttp==3.13.3", "aiohttp==3.13.3",
] ]
@@ -71,21 +69,21 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"django-extensions==4.1", "django-extensions==4.1",
"drf-spectacular-sidecar==2026.1.1", "drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5", "freezegun==1.5.5",
"ipdb==0.13.13", "ipdb==0.13.13",
"ipython==9.10.0", "ipython==9.7.0",
"pyfakefs==6.1.1", "pyfakefs==5.10.2",
"pylint-django==2.7.0", "pylint-django==2.6.1",
"pylint<4.0.0", "pylint<4.0.0",
"pytest-cov==7.0.0", "pytest-cov==7.0.0",
"pytest-django==4.12.0", "pytest-django==4.11.1",
"pytest==9.0.2", "pytest==9.0.0",
"pytest-icdiff==0.9", "pytest-icdiff==0.9",
"pytest-xdist==3.8.0", "pytest-xdist==3.8.0",
"responses==0.25.8", "responses==0.25.8",
"ruff==0.15.1", "ruff==0.14.4",
"types-requests==2.32.4.20260107", "types-requests==2.32.4.20250913",
] ]
[tool.setuptools] [tool.setuptools]
+1 -2
View File
@@ -43,8 +43,7 @@ RUN apk update && apk upgrade libssl3 \
libxml2>=2.12.7-r2 \ libxml2>=2.12.7-r2 \
libxslt>=1.1.39-r2 \ libxslt>=1.1.39-r2 \
libexpat>=2.7.2-r0 \ libexpat>=2.7.2-r0 \
libpng>=1.6.53-r0 \ libpng>=1.6.53-r0
&& apk del curl
USER nginx USER nginx
+2 -2
View File
@@ -5,9 +5,9 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
location = /.well-known/windows-app-web-link { location = /.wellknown/windows-app-web-link {
default_type application/json; default_type application/json;
alias /usr/share/nginx/html/.well-known/windows-app-web-link; alias /usr/share/nginx/html/.wellknown/windows-app-web-link;
add_header Content-Disposition "attachment; filename=windows-app-web-link"; add_header Content-Disposition "attachment; filename=windows-app-web-link";
} }
+1679 -3292
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.9.0", "version": "1.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -15,28 +15,28 @@
"dependencies": { "dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34", "@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource/material-icons-outlined": "5.2.6", "@fontsource/material-icons-outlined": "5.2.6",
"@livekit/components-react": "2.9.19", "@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.2.0", "@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.7.0", "@livekit/track-processors": "0.6.1",
"@pandacss/preset-panda": "1.8.2", "@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.10", "@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.9.3", "@react-types/overlays": "3.9.3",
"@remixicon/react": "4.6.0", "@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.90.21", "@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.27", "crisp-sdk-web": "1.0.27",
"derive-valtio": "0.2.0", "derive-valtio": "0.2.0",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.33.2",
"i18next": "25.8.8", "i18next": "25.8.4",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0", "i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1", "i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10", "libphonenumber-js": "1.12.10",
"livekit-client": "2.17.1", "livekit-client": "2.15.7",
"posthog-js": "1.342.1", "posthog-js": "1.342.1",
"react": "18.3.1", "react": "18.3.1",
"react-aria-components": "1.14.0", "react-aria-components": "1.10.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "15.1.1", "react-i18next": "15.1.1",
"use-sound": "5.0.0", "use-sound": "5.0.0",
@@ -44,16 +44,16 @@
"wouter": "3.9.0" "wouter": "3.9.0"
}, },
"devDependencies": { "devDependencies": {
"@pandacss/dev": "1.8.2", "@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.91.4", "@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.91.3", "@tanstack/react-query-devtools": "5.81.5",
"@types/humanize-duration": "3.27.4", "@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0", "@types/node": "22.16.0",
"@types/react": "18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.35.1", "@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "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": "8.57.0",
"eslint-config-prettier": "10.1.5", "eslint-config-prettier": "10.1.5",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
@@ -62,7 +62,7 @@
"postcss": "8.5.6", "postcss": "8.5.6",
"prettier": "3.8.1", "prettier": "3.8.1",
"typescript": "5.8.3", "typescript": "5.8.3",
"vite": "7.3.1", "vite": "7.0.8",
"vite-tsconfig-paths": "6.1.1" "vite-tsconfig-paths": "5.1.4"
} }
} }
@@ -7,5 +7,4 @@ export type ApiUser = {
last_name: string last_name: string
language: BackendLanguage language: BackendLanguage
timezone: string timezone: string
can_create?: boolean
} }
@@ -4,7 +4,6 @@ import { Button } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const Heading = styled('h2', { const Heading = styled('h2', {
base: { base: {
@@ -145,21 +144,6 @@ type Slide = {
isAvailableInBeta?: boolean 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 // todo - optimize how images are imported
const SLIDES: Slide[] = [ const SLIDES: Slide[] = [
{ {
@@ -179,45 +163,11 @@ const SLIDES: Slide[] = [
export const IntroSlider = () => { export const IntroSlider = () => {
const [slideIndex, setSlideIndex] = useState(0) const [slideIndex, setSlideIndex] = useState(0)
const { t } = useTranslation('home', { keyPrefix: 'introSlider' }) const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
const announce = useScreenReaderAnnounce()
const NUMBER_SLIDES = SLIDES.length 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 ( return (
<Container <Container>
role="region"
aria-roledescription="carousel"
aria-label={t('carouselLabel')}
>
<div <div
className={css({ className={css({
display: 'flex', display: 'flex',
@@ -230,10 +180,10 @@ export const IntroSlider = () => {
<Button <Button
variant="secondaryText" variant="secondaryText"
square square
className={carouselNavButton} aria-label={t('previous.label')}
aria-label={previousAriaLabel} tooltip={t('previous.tooltip')}
aria-disabled={slideIndex === 0} onPress={() => setSlideIndex(slideIndex - 1)}
onPress={goPrev} isDisabled={slideIndex == 0}
> >
<RiArrowLeftSLine /> <RiArrowLeftSLine />
</Button> </Button>
@@ -241,11 +191,7 @@ export const IntroSlider = () => {
</ButtonContainer> </ButtonContainer>
<SlideContainer> <SlideContainer>
{SLIDES.map((slide, index) => ( {SLIDES.map((slide, index) => (
<Slide <Slide visible={index == slideIndex} key={index}>
aria-hidden={index !== slideIndex}
visible={index === slideIndex}
key={index}
>
<Image src={slide.src} alt="" role="presentation" /> <Image src={slide.src} alt="" role="presentation" />
<TextAnimation visible={index == slideIndex}> <TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading> <Heading>{t(`${slide.key}.title`)}</Heading>
@@ -259,10 +205,10 @@ export const IntroSlider = () => {
<Button <Button
variant="secondaryText" variant="secondaryText"
square square
className={carouselNavButton} aria-label={t('next.label')}
aria-label={nextAriaLabel} tooltip={t('next.tooltip')}
aria-disabled={slideIndex === NUMBER_SLIDES - 1} onPress={() => setSlideIndex(slideIndex + 1)}
onPress={goNext} isDisabled={slideIndex == NUMBER_SLIDES - 1}
> >
<RiArrowRightSLine /> <RiArrowRightSLine />
</Button> </Button>
@@ -5,42 +5,37 @@ import { isRoomValid } from '@/features/rooms'
export const JoinMeetingDialog = () => { export const JoinMeetingDialog = () => {
const { t } = useTranslation('home') 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 ( return (
<Dialog title={t('joinMeeting')}> <Dialog title={t('joinMeeting')}>
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}> <Form
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */} onSubmit={(data) => {
navigateTo(
'room',
(data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
)
}}
submitLabel={t('joinInputSubmit')}
>
<Field <Field
type="text" type="text"
autoFocus
isRequired
name="roomId" name="roomId"
label={t('joinInputLabel')} label={t('joinInputLabel')}
description={t('joinInputExample', { description={t('joinInputExample', {
example: window.origin + '/azer-tyu-qsdf', 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> </Form>
<H lvl={2}>{t('joinMeetingTipHeading')}</H> <H lvl={2}>{t('joinMeetingTipHeading')}</H>
+40 -52
View File
@@ -148,8 +148,7 @@ const IntroText = styled('div', {
export const Home = () => { export const Home = () => {
const { t } = useTranslation('home') const { t } = useTranslation('home')
const { isLoggedIn, user } = useUser() const { isLoggedIn } = useUser()
const canCreate = user?.can_create === true
const { const {
userChoices: { username }, userChoices: { username },
@@ -201,56 +200,45 @@ export const Home = () => {
})} })}
> >
{isLoggedIn ? ( {isLoggedIn ? (
canCreate ? ( <Menu>
<Menu> <Button variant="primary" data-attr="create-meeting">
<Button variant="primary" data-attr="create-meeting"> {t('createMeeting')}
{t('createMeeting')} </Button>
</Button> <RACMenu>
<RACMenu> <MenuItem
<MenuItem className={
className={ menuRecipe({ icon: true, variant: 'light' }).item
menuRecipe({ icon: true, variant: 'light' }).item }
} onAction={async () => {
onAction={async () => { const slug = generateRoomId()
const slug = generateRoomId() createRoom({ slug, username }).then((data) =>
createRoom({ slug, username }).then((data) => navigateTo('room', data.slug, {
navigateTo('room', data.slug, { state: { create: true, initialRoomData: data },
state: { create: true, initialRoomData: data }, })
}) )
) }}
}} data-attr="create-option-instant"
data-attr="create-option-instant" >
> <RiAddLine size={18} />
<RiAddLine size={18} /> {t('createMenu.instantOption')}
{t('createMenu.instantOption')} </MenuItem>
</MenuItem> <MenuItem
<MenuItem className={
className={ menuRecipe({ icon: true, variant: 'light' }).item
menuRecipe({ icon: true, variant: 'light' }).item }
} onAction={() => {
onAction={() => { const slug = generateRoomId()
const slug = generateRoomId() createRoom({ slug, username }).then((data) =>
createRoom({ slug, username }).then((data) => setLaterRoom(data)
setLaterRoom(data) )
) }}
}} data-attr="create-option-later"
data-attr="create-option-later" >
> <RiLink size={18} />
<RiLink size={18} /> {t('createMenu.laterOption')}
{t('createMenu.laterOption')} </MenuItem>
</MenuItem> </RACMenu>
</RACMenu> </Menu>
</Menu>
) : (
<p
className={css({
color: 'greyscale.700',
fontSize: '0.95rem',
})}
>
{t('noAccess')}
</p>
)
) : ( ) : (
<LoginButton proConnectHint={false} /> <LoginButton proConnectHint={false} />
)} )}
@@ -65,7 +65,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
alignItems="left" alignItems="left"
justify="start" justify="start"
gap={0} gap={0}
style={{ maxWidth: '100%', overflow: 'visible' }} style={{ maxWidth: '100%', overflow: 'hidden' }}
> >
<Heading slot="title" level={2} className={text({ variant: 'h2' })}> <Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{t('heading')} {t('heading')}
@@ -93,7 +93,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
flexDirection: 'column', flexDirection: 'column',
marginTop: '0.5rem', marginTop: '0.5rem',
gap: '1rem', gap: '1rem',
overflow: 'visible', overflow: 'hidden',
})} })}
> >
<div <div
@@ -753,7 +753,7 @@ export const Join = ({
try { try {
saveVideoInputDeviceId(id) saveVideoInputDeviceId(id)
if (videoTrack) { if (videoTrack) {
await videoTrack.setDeviceId({ exact: id }) await await videoTrack.setDeviceId({ exact: id })
} }
} catch (err) { } catch (err) {
console.error('Failed to switch camera device', 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 { useTranslation } from 'react-i18next'
import { styled, VStack } from '@/styled-system/jsx' import { styled, VStack } from '@/styled-system/jsx'
import { usePostHog } from 'posthog-js/react' 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 { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled' import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
@@ -74,17 +74,13 @@ export const useWaitingParticipants = () => {
): Promise<void> => { ): Promise<void> => {
try { try {
setListEnabled(false) setListEnabled(false)
for (const participant of waitingParticipants) {
await Promise.all( await enterRoom({
waitingParticipants.map((participant) => roomId: roomId,
enterRoom({ allowEntry,
roomId: roomId, participantId: participant.id,
allowEntry, })
participantId: participant.id, }
})
)
)
await refetchWaiting() await refetchWaiting()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@@ -13,7 +13,7 @@ import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { SettingsDialogExtendedKey } from '@/features/settings/type' import { SettingsDialogExtendedKey } from '@/features/settings/type'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes 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 const FINAL_COUNTDOWN_SECONDS = 10
export const IsIdleDisconnectModal = () => { export const IsIdleDisconnectModal = () => {
@@ -58,7 +58,7 @@ export const IsIdleDisconnectModal = () => {
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
const shouldAnnounce = const shouldAnnounce =
COUNTDOWN_ANNOUNCEMENT_SECONDS.has(remainingSeconds) || COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS remainingSeconds <= FINAL_COUNTDOWN_SECONDS
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) { if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
@@ -1,28 +1,5 @@
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react'
import { styled } from '@/styled-system/jsx' import { css } from '@/styled-system/css'
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',
},
},
})
export interface KeyboardShortcutHintProps { export interface KeyboardShortcutHintProps {
children: ReactNode children: ReactNode
@@ -35,5 +12,21 @@ export interface KeyboardShortcutHintProps {
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({ export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
children, 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 { Text } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useParticipantInfo } from '@livekit/components-react' import { useParticipantInfo } from '@livekit/components-react'
import { Participant } from 'livekit-client' 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 = ({ export const ParticipantName = ({
participant, participant,
isScreenShare = false, isScreenShare = false,
@@ -30,14 +17,26 @@ export const ParticipantName = ({
if (isScreenShare) { if (isScreenShare) {
return ( return (
<Text variant="sm" style={participantNameScreenShareStyles}> <Text
variant="sm"
style={{
paddingBottom: '0.1rem',
marginLeft: '0.4rem',
}}
>
{t('screenShare', { name: displayedName })} {t('screenShare', { name: displayedName })}
</Text> </Text>
) )
} }
return ( return (
<Text variant="sm" style={participantNameStyles} aria-hidden="true"> <Text
variant="sm"
style={{
paddingBottom: '0.1rem',
}}
aria-hidden="true"
>
{displayedName} {displayedName}
</Text> </Text>
) )
@@ -183,7 +183,7 @@ export const ParticipantTile: (
}} }}
> >
{isHandRaised && !isScreenShare && ( {isHandRaised && !isScreenShare && (
<span> <>
<span>{positionInQueue}</span> <span>{positionInQueue}</span>
<RiHand <RiHand
color="black" color="black"
@@ -197,7 +197,7 @@ export const ParticipantTile: (
animationIterationCount: '2', animationIterationCount: '2',
}} }}
/> />
</span> </>
)} )}
{isScreenShare && ( {isScreenShare && (
<ScreenShareIcon <ScreenShareIcon
@@ -210,12 +210,10 @@ export const ParticipantTile: (
{isEncrypted && !isScreenShare && ( {isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} /> <LockLockedIcon style={{ marginRight: '0.25rem' }} />
)} )}
<div className="lk-participant-name-wrapper"> <ParticipantName
<ParticipantName isScreenShare={isScreenShare}
isScreenShare={isScreenShare} participant={trackReference.participant}
participant={trackReference.participant} />
/>
</div>
</div> </div>
</HStack> </HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" /> <ConnectionQualityIndicator className="lk-participant-metadata-item" />
@@ -231,7 +229,9 @@ export const ParticipantTile: (
)} )}
</ParticipantContextIfNeeded> </ParticipantContextIfNeeded>
</TrackRefContextIfNeeded> </TrackRefContextIfNeeded>
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint> {hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
</div> </div>
) )
}) })
@@ -7,7 +7,7 @@ import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList' import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useSidePanel } from '../hooks/useSidePanel' import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react' import { ReactNode, useEffect, useRef } from 'react'
import { Chat } from '../prefabs/Chat' import { Chat } from '../prefabs/Chat'
import { Effects } from './effects/Effects' import { Effects } from './effects/Effects'
import { Admin } from './Admin' import { Admin } from './Admin'
@@ -15,9 +15,11 @@ import { Tools } from './Tools'
import { Info } from './Info' import { Info } from './Info'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
const SIDE_PANEL_HEADING_ID = 'side-panel-heading'
const SIDE_PANEL_CLOSE_ID = 'side-panel-close'
type StyledSidePanelProps = { type StyledSidePanelProps = {
title: string title: string
ariaLabel: string
children: ReactNode children: ReactNode
onClose: () => void onClose: () => void
isClosed: boolean isClosed: boolean
@@ -29,7 +31,6 @@ type StyledSidePanelProps = {
const StyledSidePanel = ({ const StyledSidePanel = ({
title, title,
ariaLabel,
children, children,
onClose, onClose,
isClosed, isClosed,
@@ -38,7 +39,14 @@ const StyledSidePanel = ({
onBack, onBack,
backButtonLabel, backButtonLabel,
}: StyledSidePanelProps) => ( }: StyledSidePanelProps) => (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- role="dialog" makes this interactive
<aside <aside
role="dialog"
aria-labelledby={!isClosed ? SIDE_PANEL_HEADING_ID : undefined}
aria-hidden={isClosed || undefined}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose()
}}
className={css({ className={css({
borderWidth: '1px', borderWidth: '1px',
borderStyle: 'solid', borderStyle: 'solid',
@@ -63,57 +71,57 @@ const StyledSidePanel = ({
style={{ style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none', transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}} }}
aria-hidden={isClosed}
aria-label={ariaLabel}
> >
<HStack alignItems="center"> <HStack alignItems="center">
{isSubmenu && ( {isSubmenu && (
<Button <Button
variant="secondaryText" variant="secondaryText"
size="sm" size="sm"
square square
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })} className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel} aria-label={backButtonLabel}
onPress={onBack} onPress={onBack}
>
<RiArrowLeftLine size={20} aria-hidden="true" />
</Button>
)}
<Heading
id={SIDE_PANEL_HEADING_ID}
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
> >
<RiArrowLeftLine size={20} aria-hidden="true" /> {title}
</Button> </Heading>
)} </HStack>
<Heading <Div
slot="title" position="absolute"
level={1} top="5"
className={text({ variant: 'h2' })} right="5"
style={{ style={{
paddingLeft: isSubmenu ? 0 : '1.5rem', display: isClosed ? 'none' : undefined,
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}} }}
> >
{title} <Button
</Heading> id={SIDE_PANEL_CLOSE_ID}
</HStack> invisible
<Div variant="tertiaryText"
position="absolute" size="xs"
top="5" onPress={onClose}
right="5" aria-label={closeButtonTooltip}
style={{ tooltip={closeButtonTooltip}
display: isClosed ? 'none' : undefined, >
}} <RiCloseLine />
> </Button>
<Button </Div>
invisible {children}
variant="tertiaryText"
size="xs"
onPress={onClose}
aria-label={closeButtonTooltip}
tooltip={closeButtonTooltip}
>
<RiCloseLine />
</Button>
</Div>
{children}
</aside> </aside>
) )
@@ -135,6 +143,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null} {keepAlive || isOpen ? children : null}
</div> </div>
) )
export const SidePanel = () => { export const SidePanel = () => {
const { const {
activePanelId, activePanelId,
@@ -150,14 +159,51 @@ export const SidePanel = () => {
} = useSidePanel() } = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const triggerRef = useRef<HTMLElement | null>(null)
// The aside stays mounted (CSS slide + keepAlive), so we manually handle
// auto-focus on open and restore focus on close (via handleClose).
useEffect(() => {
if (!isSidePanelOpen) return
const active = document.activeElement as HTMLElement
// Menu items render as DIVs that unmount when the menu closes — resolve to the menu trigger
triggerRef.current =
active?.tagName === 'DIV'
? (document.querySelector<HTMLElement>('#room-options-trigger') ??
active)
: active
requestAnimationFrame(() => {
const closeBtn = document.getElementById(SIDE_PANEL_CLOSE_ID)
// Skip if a child panel already moved focus inside (e.g. Chat input)
if (closeBtn?.closest('aside')?.contains(document.activeElement)) return
closeBtn?.focus({ preventScroll: true })
})
}, [isSidePanelOpen])
const handleClose = () => {
const trigger = triggerRef.current
triggerRef.current = null
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
// Double RAF: first lets React re-render, second lets FocusScope release containment
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (trigger?.isConnected) {
trigger.focus({ preventScroll: true })
} else {
document
.querySelector<HTMLElement>('#room-options-trigger')
?.focus({ preventScroll: true })
}
})
})
}
return ( return (
<StyledSidePanel <StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)} title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')} onClose={handleClose}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
closeButtonTooltip={t('closeButton', { closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`), content: t(`content.${activeSubPanelId || activePanelId}`),
})} })}
@@ -4,7 +4,6 @@ import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel' import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { import {
useIsRecordingModeEnabled, useIsRecordingModeEnabled,
RecordingMode, RecordingMode,
@@ -95,26 +94,10 @@ const ToolButton = ({
export const Tools = () => { export const Tools = () => {
const { data } = useConfig() const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } = const { openTranscript, openScreenRecording, activeSubPanelId } =
useSidePanel() useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' }) const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
useRestoreFocus(isToolsOpen, {
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
preventScroll: true,
})
const isTranscriptEnabled = useIsRecordingModeEnabled( const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript RecordingMode.Transcript
) )
@@ -80,10 +80,12 @@ export const ChatInput = ({
<TextArea <TextArea
ref={inputRef} ref={inputRef}
onKeyDown={(e) => { onKeyDown={(e) => {
e.stopPropagation() if (e.key !== 'Escape') e.stopPropagation()
submitOnEnter(e) submitOnEnter(e)
}} }}
onKeyUp={(e) => e.stopPropagation()} onKeyUp={(e) => {
if (e.key !== 'Escape') e.stopPropagation()
}}
placeholder={t('textArea.placeholder')} placeholder={t('textArea.placeholder')}
value={text} value={text}
onChange={(e) => { onChange={(e) => {
@@ -1,6 +1,5 @@
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils' import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -88,24 +87,10 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind) const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind) const cannotUseDevice = useCannotUseDevice(kind)
const deviceShortcut = useDeviceShortcut(kind) const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce()
useRegisterKeyboardShortcut({ useRegisterKeyboardShortcut({
id: deviceShortcut?.id, id: deviceShortcut?.id,
handler: async () => { handler: async () => await toggle(),
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
}
},
isDisabled: cannotUseDevice, isDisabled: cannotUseDevice,
}) })
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react' import { RiEmotionLine } from '@remixicon/react'
import { useState, useRef } from 'react' import { useState, useRef, useEffect } from 'react'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { ToggleButton, Button } from '@/primitives' import { ToggleButton, Button } from '@/primitives'
@@ -12,12 +12,7 @@ import {
} from '@/features/rooms/livekit/components/ReactionPortal' } from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils' import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { import { Toolbar as RACToolbar } from 'react-aria-components'
Popover as RACPopover,
Dialog,
DialogTrigger,
} from 'react-aria-components'
import { FocusScope } from '@react-aria/focus'
import { Participant } from 'livekit-client' import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter' import useRateLimiter from '@/hooks/useRateLimiter'
@@ -45,11 +40,11 @@ export const ReactionsToggle = () => {
const instanceIdRef = useRef(0) const instanceIdRef = useRef(0)
const room = useRoomContext() const room = useRoomContext()
const [isOpen, setIsOpen] = useState(false) const [isVisible, setIsVisible] = useState(false)
useRegisterKeyboardShortcut({ useRegisterKeyboardShortcut({
id: 'reaction', id: 'reaction',
handler: () => setIsOpen((prev) => !prev), handler: () => setIsVisible((prev) => !prev),
}) })
const sendReaction = async (emoji: string) => { const sendReaction = async (emoji: string) => {
@@ -84,76 +79,100 @@ export const ReactionsToggle = () => {
windowMs: 1000, 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 ( return (
<> <>
<div className={css({ position: 'relative' })}> <div
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}> className={css({
<ToggleButton position: 'relative',
square })}
variant="primaryDark" >
aria-label={t('button')} <ToggleButton
tooltip={t('button')} square
isSelected={isOpen} variant="primaryDark"
onChange={setIsOpen} aria-label={t('button')}
> tooltip={t('button')}
<RiEmotionLine /> onPress={() => setIsVisible(!isVisible)}
</ToggleButton> >
<RACPopover <RiEmotionLine />
placement="top" </ToggleButton>
offset={8} {isRendered && (
isNonModal <div
shouldCloseOnInteractOutside={() => false}
className={css({ className={css({
position: 'absolute',
top: -63,
left: -162,
borderRadius: '8px', borderRadius: '8px',
padding: '0.35rem', padding: '0.35rem',
backgroundColor: 'primaryDark.50', backgroundColor: 'primaryDark.50',
'&[data-entering]': { opacity: opacity,
animation: 'fade 200ms ease', transition: 'opacity 0.2s ease',
},
'&[data-exiting]': {
animation: 'fade 200ms ease-in reverse',
},
})} })}
onTransitionEnd={() => {
if (!isVisible) {
setIsRendered(false)
}
}}
> >
<Dialog className={css({ outline: 'none' })}> <RACToolbar
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */} className={css({
<FocusScope contain autoFocus restoreFocus> display: 'flex',
<div gap: '0.5rem',
role="toolbar" })}
aria-orientation="horizontal" >
aria-label={t('button')} {Object.values(Emoji).map((emoji, index) => (
className={css({ <Button
display: 'flex', key={index}
gap: '0.5rem', 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) => ( <img
<Button src={`/assets/reactions/${emoji}.png`}
key={index} alt=""
onPress={() => debouncedSendReaction(emoji)} className={css({
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })} minHeight: '28px',
variant="primaryTextDark" minWidth: '28px',
size="sm" pointerEvents: 'none',
square userSelect: 'none',
data-attr={`send-reaction-${emoji}`} })}
> />
<img </Button>
src={`/assets/reactions/${emoji}.png`} ))}
alt="" </RACToolbar>
className={css({ </div>
width: '28px', )}
height: '28px',
pointerEvents: 'none',
userSelect: 'none',
})}
/>
</Button>
))}
</div>
</FocusScope>
</Dialog>
</RACPopover>
</DialogTrigger>
</div> </div>
<ReactionPortals reactions={reactions} /> <ReactionPortals reactions={reactions} />
</> </>
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel' import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ToolsToggle = ({ export const ToolsToggle = ({
variant = 'primaryTextDark', variant = 'primaryTextDark',
@@ -16,11 +15,6 @@ export const ToolsToggle = ({
const { isToolsOpen, toggleTools } = useSidePanel() const { isToolsOpen, toggleTools } = useSidePanel()
const tooltipLabel = isToolsOpen ? 'open' : 'closed' const tooltipLabel = isToolsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'recording',
handler: toggleTools,
})
return ( return (
<div <div
className={css({ className={css({
@@ -15,7 +15,6 @@ import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel' import { useSidePanel } from '../hooks/useSidePanel'
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client' import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {} extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
@@ -36,18 +35,12 @@ export function Chat({ ...props }: ChatProps) {
const { isChatOpen } = useSidePanel() const { isChatOpen } = useSidePanel()
const chatSnap = useSnapshot(chatStore) const chatSnap = useSnapshot(chatStore)
// Keep track of the element that opened the chat so we can restore focus React.useEffect(() => {
// when the chat panel is closed. if (!isChatOpen) return
useRestoreFocus(isChatOpen, { requestAnimationFrame(() => {
// Avoid layout "jump" during the side panel slide-in animation. inputRef.current?.focus({ preventScroll: true })
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth. })
onOpened: () => { }, [isChatOpen])
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true })
})
},
preventScroll: true,
})
// Use useParticipants hook to trigger a re-render when the participant list changes. // Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants() const participants = useParticipants()
@@ -12,7 +12,6 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions' import { MoreOptions } from './MoreOptions'
import { useRef } from 'react' import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl' import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl' import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
@@ -22,8 +21,6 @@ export function DesktopControlBar({
const browserSupportsScreenSharing = supportsScreenSharing() const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null) const desktopControlBarEl = useRef<HTMLDivElement>(null)
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
useRegisterKeyboardShortcut({ useRegisterKeyboardShortcut({
id: 'focus-toolbar', id: 'focus-toolbar',
handler: () => { handler: () => {
@@ -35,13 +32,6 @@ export function DesktopControlBar({
firstButton?.focus() firstButton?.focus()
}, },
}) })
useRegisterKeyboardShortcut({
id: 'fullscreen',
handler: toggleFullScreen,
isDisabled: !isFullscreenAvailable,
})
return ( return (
<div <div
ref={desktopControlBarEl} ref={desktopControlBarEl}
@@ -31,9 +31,6 @@ import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal' import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver' import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction' 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 { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider' import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles' import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
@@ -100,7 +97,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const { t: tRooms } = useTranslation('rooms') const { t: tRooms } = useTranslation('rooms')
const room = useRoomContext() const room = useRoomContext()
const announce = useScreenReaderAnnounce() const announce = useScreenReaderAnnounce()
const { toggleSettingsDialog } = useSettingsDialog()
const getAnnouncementName = useCallback( const getAnnouncementName = useCallback(
(participant?: Participant | null) => { (participant?: Participant | null) => {
@@ -115,13 +111,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useConnectionObserver() useConnectionObserver()
useVideoResolutionSubscription() useVideoResolutionSubscription()
useRegisterKeyboardShortcut({
id: 'open-shortcuts',
handler: useCallback(() => {
toggleSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS)
}, [toggleSettingsDialog]),
})
const tracks = useTracks( const tracks = useTracks(
[ [
{ source: Track.Source.Camera, withPlaceholder: true }, { source: Track.Source.Camera, withPlaceholder: true },
@@ -4,10 +4,8 @@ export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
export const flexibleRoomIdPattern = export const flexibleRoomIdPattern =
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})' '(?:[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) => export const isRoomValid = (roomIdOrUrl: string) =>
roomRegex.test(roomIdOrUrl) || new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl) new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
export const normalizeRoomId = (roomId: string) => { export const normalizeRoomId = (roomId: string) => {
@@ -10,10 +10,7 @@ const callbackIdHandler = new CallbackIdHandler()
const popupWindow = new PopupWindow() const popupWindow = new PopupWindow()
export const CreatePopup = () => { export const CreatePopup = () => {
const { isLoggedIn, user } = useUser({ const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
fetchUserOptions: { attemptSilent: false },
})
const canCreate = user?.can_create === true
const { mutateAsync: createRoom } = useCreateRoom() const { mutateAsync: createRoom } = useCreateRoom()
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), []) const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
@@ -58,10 +55,10 @@ export const CreatePopup = () => {
console.error('Failed to create meeting room:', error) console.error('Failed to create meeting room:', error)
} }
} }
if (isLoggedIn && canCreate && callbackId) { if (isLoggedIn && callbackId) {
createMeetingRoom() createMeetingRoom()
} }
}, [isLoggedIn, canCreate, callbackId, createRoom]) }, [isLoggedIn, callbackId, createRoom])
return ( return (
<div <div
@@ -12,7 +12,6 @@ import {
RiSpeakerLine, RiSpeakerLine,
RiVideoOnLine, RiVideoOnLine,
RiEyeLine, RiEyeLine,
RiKeyboardBoxLine,
} from '@remixicon/react' } from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab' import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab' import { NotificationsTab } from './tabs/NotificationsTab'
@@ -20,12 +19,11 @@ import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab' import { AudioTab } from './tabs/AudioTab'
import { VideoTab } from './tabs/VideoTab' import { VideoTab } from './tabs/VideoTab'
import { TranscriptionTab } from './tabs/TranscriptionTab' import { TranscriptionTab } from './tabs/TranscriptionTab'
import { ShortcutTab } from './tabs/ShortcutTab'
import { useRef } from 'react' import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery' import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type' import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { AccessibilityTab } from './tabs/AccessibilityTab' import AccessibilityTab from './tabs/AccessibilityTab'
const tabsStyle = css({ const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -41,7 +39,6 @@ const tabListContainerStyle = css({
flexDirection: 'column', flexDirection: 'column',
borderRight: '1px solid lightGray', // fixme poor color management borderRight: '1px solid lightGray', // fixme poor color management
paddingY: '1rem', paddingY: '1rem',
paddingLeft: '0.2rem',
paddingRight: '1.5rem', paddingRight: '1.5rem',
}) })
@@ -110,10 +107,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{isWideScreen && {isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)} t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
</Tab> </Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS}>
<RiKeyboardBoxLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
</Tab>
{isAdminOrOwner && ( {isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}> <Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Icon type="symbols" name="speech_to_text" /> <Icon type="symbols" name="speech_to_text" />
@@ -137,7 +130,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<VideoTab id={SettingsDialogExtendedKey.VIDEO} /> <VideoTab id={SettingsDialogExtendedKey.VIDEO} />
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} /> <GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} /> <NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */} {/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} /> <TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} /> <AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
@@ -36,3 +36,5 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
</TabPanel> </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 { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices' 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 { css } from '@/styled-system/css'
import { import {
createLocalVideoTrack, createLocalVideoTrack,
@@ -22,8 +22,6 @@ export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
type DeviceItems = Array<{ value: string; label: string }> type DeviceItems = Array<{ value: string; label: string }>
const EMPTY_PROPS = {}
export const VideoTab = ({ id }: VideoTabProps) => { export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' }) const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant, remoteParticipants } = useRoomContext() const { localParticipant, remoteParticipants } = useRoomContext()
@@ -61,7 +59,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
const isCamEnabled = devicesIn?.length > 0 const isCamEnabled = devicesIn?.length > 0
const disabledProps = isCamEnabled const disabledProps = isCamEnabled
? EMPTY_PROPS ? {}
: { : {
placeholder: t('permissionsRequired'), placeholder: t('permissionsRequired'),
isDisabled: true, isDisabled: true,
@@ -119,40 +117,6 @@ export const VideoTab = ({ id }: VideoTabProps) => {
} }
}, [videoDeviceId, videoElement]) }, [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 ( return (
<TabPanel padding={'md'} flex id={id}> <TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('camera.heading')}> <RowWrapper heading={t('camera.heading')}>
@@ -214,7 +178,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
<Field <Field
type="select" type="select"
label={t('resolution.publish.label')} 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} selectedKey={videoPublishResolution}
onSelectionChange={async (key) => { onSelectionChange={async (key) => {
await handleVideoResolutionChange(key as VideoResolution) await handleVideoResolutionChange(key as VideoResolution)
@@ -229,7 +206,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
<Field <Field
type="select" type="select"
label={t('resolution.subscribe.label')} 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()} selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => { onSelectionChange={(key) => {
if (key == undefined) return if (key == undefined) return
@@ -14,25 +14,7 @@ export const useSettingsDialog = () => {
settingsStore.areSettingsOpen = true 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 { return {
openSettingsDialog, openSettingsDialog,
closeSettingsDialog,
toggleSettingsDialog,
} }
} }
@@ -5,6 +5,5 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general', GENERAL = 'general',
NOTIFICATIONS = 'notifications', NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription', TRANSCRIPTION = 'transcription',
SHORTCUTS = 'shortcuts',
ACCESSIBILITY = 'accessibility', ACCESSIBILITY = 'accessibility',
} }
@@ -5,7 +5,6 @@ import { Shortcut } from './types'
export type ShortcutCategory = 'navigation' | 'media' | 'interaction' export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId = export type ShortcutId =
| 'open-shortcuts'
| 'focus-toolbar' | 'focus-toolbar'
| 'toggle-microphone' | 'toggle-microphone'
| 'toggle-camera' | 'toggle-camera'
@@ -30,11 +29,6 @@ export type ShortcutDescriptor = {
} }
export const shortcutCatalog: ShortcutDescriptor[] = [ export const shortcutCatalog: ShortcutDescriptor[] = [
{
id: 'open-shortcuts',
category: 'navigation',
shortcut: { key: '/', ctrlKey: true, shiftKey: true },
},
{ {
id: 'focus-toolbar', id: 'focus-toolbar',
category: 'navigation', category: 'navigation',
@@ -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,
}
}
@@ -19,10 +19,7 @@ export const useKeyboardShortcuts = () => {
shiftKey, shiftKey,
altKey, altKey,
}) })
let shortcut = shortcutsSnap.shortcuts.get(shortcutKey) const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut && shortcutKey === 'ctrl+shift+?') {
shortcut = shortcutsSnap.shortcuts.get('ctrl+shift+/')
}
if (!shortcut) return if (!shortcut) return
e.preventDefault() e.preventDefault()
await shortcut() await shortcut()
-3
View File
@@ -5,7 +5,6 @@ import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer' import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives' import { ScreenReaderAnnouncer } from '@/primitives'
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
export type Layout = 'fullpage' | 'centered' export type Layout = 'fullpage' | 'centered'
@@ -22,7 +21,6 @@ export const Layout = ({ children }: { children: ReactNode }) => {
return ( return (
<> <>
{showHeader && <SkipLink />}
<div <div
className={css({ className={css({
display: 'flex', display: 'flex',
@@ -37,7 +35,6 @@ export const Layout = ({ children }: { children: ReactNode }) => {
> >
{showHeader && <Header />} {showHeader && <Header />}
<main <main
id={MAIN_CONTENT_ID}
className={css({ className={css({
flexGrow: 1, flexGrow: 1,
overflow: 'auto', 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", "heading": "Überprüfen Sie Ihren Meeting-Code",
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:" "body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
}, },
"selected": "ausgewählt",
"submit": "OK", "submit": "OK",
"footer": { "footer": {
"links": { "links": {
@@ -46,14 +45,13 @@
"license": "Etalab 2.0 Lizenz" "license": "Etalab 2.0 Lizenz"
}, },
"loginHint": { "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.", "body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
"button": { "button": {
"ariaLabel": "Hinweis schließen", "ariaLabel": "Hinweis schließen",
"label": "OK" "label": "OK"
} }
}, },
"skipLink": "Zum Hauptinhalt springen",
"clipboardContent": { "clipboardContent": {
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}", "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}}" "numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
+5 -10
View File
@@ -10,7 +10,6 @@
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.", "joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
"joinMeetingTipHeading": "Wussten Sie schon?", "joinMeetingTipHeading": "Wussten Sie schon?",
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen", "loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
"noAccess": "Sie haben keinen Zugang zur Erstellung von Meetings. Bitte kontaktieren Sie Ihren Administrator.",
"moreLinkLabel": "Mehr erfahren neues Tab", "moreLinkLabel": "Mehr erfahren neues Tab",
"moreLink": "Mehr erfahren", "moreLink": "Mehr erfahren",
"moreAbout": "über {{appTitle}}", "moreAbout": "über {{appTitle}}",
@@ -32,14 +31,12 @@
}, },
"introSlider": { "introSlider": {
"previous": { "previous": {
"label": "Vorherige Folie", "label": "Zurück",
"labelWithPosition": "Vorherige Folie ({{current}} von {{total}})", "tooltip": "Zurück"
"tooltip": "Vorherige Folie"
}, },
"next": { "next": {
"label": "Nächste Folie", "label": "Weiter",
"labelWithPosition": "Nächste Folie ({{current}} von {{total}})", "tooltip": "Weiter"
"tooltip": "Nächste Folie"
}, },
"beta": { "beta": {
"text": "An der Beta teilnehmen", "text": "An der Beta teilnehmen",
@@ -56,8 +53,6 @@
"slide3": { "slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI", "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!" "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}}"
} }
} }
+1 -40
View File
@@ -22,8 +22,6 @@
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich", "permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"disable": "Kamera deaktivieren", "disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren", "enable": "Kamera aktivieren",
"turnedOff": "Kamera deaktiviert",
"turnedOn": "Kamera aktiviert",
"label": "Kamera", "label": "Kamera",
"placeholder": "Kamera aktivieren, um die Vorschau zu sehen" "placeholder": "Kamera aktivieren, um die Vorschau zu sehen"
}, },
@@ -32,8 +30,6 @@
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich", "permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
"disable": "Mikrofon deaktivieren", "disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren", "enable": "Mikrofon aktivieren",
"turnedOff": "Mikrofon deaktiviert",
"turnedOn": "Mikrofon aktiviert",
"label": "Mikrofon" "label": "Mikrofon"
}, },
"audiooutput": { "audiooutput": {
@@ -590,7 +586,7 @@
}, },
"participantTileFocus": { "participantTileFocus": {
"containerLabel": "Optionen für {{name}}", "containerLabel": "Optionen für {{name}}",
"toolbarHint": "Ctrl+Shift+/: Direkt auf die Tastenkürzel zugreifen.", "toolbarHint": "F2: zur Symbolleiste unten.",
"pin": { "pin": {
"enable": "Anheften", "enable": "Anheften",
"disable": "Lösen" "disable": "Lösen"
@@ -599,41 +595,6 @@
"muteParticipant": "{{name}} stummschalten", "muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild" "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": { "fullScreenWarning": {
"message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.", "message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
"stop": "Präsentation beenden", "stop": "Präsentation beenden",
+1 -7
View File
@@ -100,11 +100,6 @@
} }
} }
}, },
"shortcuts": {
"listLabel": "Tastenkürzel",
"columnAction": "Aktion",
"columnShortcut": "Tastenkürzel"
},
"dialog": { "dialog": {
"heading": "Einstellungen" "heading": "Einstellungen"
}, },
@@ -125,7 +120,6 @@
"general": "Allgemein", "general": "Allgemein",
"notifications": "Benachrichtigungen", "notifications": "Benachrichtigungen",
"accessibility": "Barrierefreiheit", "accessibility": "Barrierefreiheit",
"transcription": "Transkription", "transcription": "Transkription"
"shortcuts": "Tastenkürzel"
} }
} }
+1 -3
View File
@@ -25,7 +25,6 @@
"heading": "Verify your meeting code", "heading": "Verify your meeting code",
"body": "Check that you have entered the correct meeting code in the URL. Example:" "body": "Check that you have entered the correct meeting code in the URL. Example:"
}, },
"selected": "selected",
"submit": "OK", "submit": "OK",
"footer": { "footer": {
"links": { "links": {
@@ -46,14 +45,13 @@
"license": "etalab 2.0 license" "license": "etalab 2.0 license"
}, },
"loginHint": { "loginHint": {
"title": "Log in with your account", "title": "Log in with your ProConnect account",
"body": "Instead of waiting, log in with your ProConnect account.", "body": "Instead of waiting, log in with your ProConnect account.",
"button": { "button": {
"ariaLabel": "Close the suggestion", "ariaLabel": "Close the suggestion",
"label": "OK" "label": "OK"
} }
}, },
"skipLink": "Skip to main content",
"clipboardContent": { "clipboardContent": {
"url": "To join the video conference, click on this link: {{roomUrl}}", "url": "To join the video conference, click on this link: {{roomUrl}}",
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}" "numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
+5 -10
View File
@@ -10,7 +10,6 @@
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.", "joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
"joinMeetingTipHeading": "Did you know?", "joinMeetingTipHeading": "Did you know?",
"loginToCreateMeeting": "Login to create a meeting", "loginToCreateMeeting": "Login to create a meeting",
"noAccess": "You do not have access to create meetings. Please contact your administrator.",
"moreLinkLabel": "Learn more - new tab", "moreLinkLabel": "Learn more - new tab",
"moreLink": "Learn more", "moreLink": "Learn more",
"moreAbout": "about {{appTitle}}", "moreAbout": "about {{appTitle}}",
@@ -32,14 +31,12 @@
}, },
"introSlider": { "introSlider": {
"previous": { "previous": {
"label": "Previous slide", "label": "previous",
"labelWithPosition": "Previous slide ({{current}} of {{total}})", "tooltip": "previous"
"tooltip": "Previous slide"
}, },
"next": { "next": {
"label": "Next slide", "label": "next",
"labelWithPosition": "Next slide ({{current}} of {{total}})", "tooltip": "next"
"tooltip": "Next slide"
}, },
"beta": { "beta": {
"text": "Join the beta", "text": "Join the beta",
@@ -56,8 +53,6 @@
"slide3": { "slide3": {
"title": "Transform your meetings with AI", "title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!" "body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
}, }
"carouselLabel": "Introduction slideshow",
"slidePosition": "Slide {{current}} of {{total}}"
} }
} }
+1 -40
View File
@@ -22,8 +22,6 @@
"permissionsNeeded": "Select camera - permission needed", "permissionsNeeded": "Select camera - permission needed",
"disable": "Disable camera", "disable": "Disable camera",
"enable": "Enable camera", "enable": "Enable camera",
"turnedOff": "Camera turned off",
"turnedOn": "Camera turned on",
"label": "Camera", "label": "Camera",
"placeholder": "Enable camera to see the preview" "placeholder": "Enable camera to see the preview"
}, },
@@ -32,8 +30,6 @@
"permissionsNeeded": "Select microphone - permission needed", "permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone", "disable": "Disable microphone",
"enable": "Enable microphone", "enable": "Enable microphone",
"turnedOff": "Microphone turned off",
"turnedOn": "Microphone turned on",
"label": "Microphone" "label": "Microphone"
}, },
"audiooutput": { "audiooutput": {
@@ -590,7 +586,7 @@
}, },
"participantTileFocus": { "participantTileFocus": {
"containerLabel": "Options for {{name}}", "containerLabel": "Options for {{name}}",
"toolbarHint": "Ctrl+Shift+/: access shortcuts directly.", "toolbarHint": "F2: go to the bottom toolbar.",
"pin": { "pin": {
"enable": "Pin", "enable": "Pin",
"disable": "Unpin" "disable": "Unpin"
@@ -599,41 +595,6 @@
"muteParticipant": "Mute {{name}}", "muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen" "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": { "fullScreenWarning": {
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.", "message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
"stop": "Stop presenting", "stop": "Stop presenting",
+1 -7
View File
@@ -100,11 +100,6 @@
} }
} }
}, },
"shortcuts": {
"listLabel": "Keyboard shortcuts",
"columnAction": "Action",
"columnShortcut": "Shortcut"
},
"dialog": { "dialog": {
"heading": "Settings" "heading": "Settings"
}, },
@@ -125,7 +120,6 @@
"general": "General", "general": "General",
"notifications": "Notifications", "notifications": "Notifications",
"accessibility": "Accessibility", "accessibility": "Accessibility",
"transcription": "Transcription", "transcription": "Transcription"
"shortcuts": "Shortcuts"
} }
} }
+1 -3
View File
@@ -25,7 +25,6 @@
"heading": "Vérifier votre code de réunion", "heading": "Vérifier votre code de réunion",
"body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :" "body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :"
}, },
"selected": "sélectionné",
"submit": "OK", "submit": "OK",
"footer": { "footer": {
"links": { "links": {
@@ -46,14 +45,13 @@
"license": "licence etalab 2.0" "license": "licence etalab 2.0"
}, },
"loginHint": { "loginHint": {
"title": "Connectez-vous avec votre compte", "title": "Connectez-vous avec votre compte ProConnect",
"body": "Au lieu de patienter, connectez-vous avec votre compte ProConnect.", "body": "Au lieu de patienter, connectez-vous avec votre compte ProConnect.",
"button": { "button": {
"ariaLabel": "Fermer la suggestion", "ariaLabel": "Fermer la suggestion",
"label": "OK" "label": "OK"
} }
}, },
"skipLink": "Aller au contenu principal",
"clipboardContent": { "clipboardContent": {
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}", "url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}" "numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
+4 -9
View File
@@ -10,7 +10,6 @@
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.", "joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
"joinMeetingTipHeading": "Astuce", "joinMeetingTipHeading": "Astuce",
"loginToCreateMeeting": "Connectez-vous pour créer une réunion", "loginToCreateMeeting": "Connectez-vous pour créer une réunion",
"noAccess": "Vous n'avez pas accès à la création de réunions. Veuillez contacter votre administrateur.",
"moreLinkLabel": "En savoir plus - nouvelle fenêtre", "moreLinkLabel": "En savoir plus - nouvelle fenêtre",
"moreLink": "En savoir plus", "moreLink": "En savoir plus",
"moreAbout": "sur {{appTitle}}", "moreAbout": "sur {{appTitle}}",
@@ -31,18 +30,14 @@
} }
}, },
"introSlider": { "introSlider": {
"carouselLabel": "Diaporama de présentation",
"previous": { "previous": {
"label": "Diapositive précédente", "label": "précédent",
"labelWithPosition": "Diapositive précédente ({{current}} sur {{total}})", "tooltip": "précédent"
"tooltip": "Diapositive précédente"
}, },
"next": { "next": {
"label": "Diapositive suivante", "label": "suivant",
"labelWithPosition": "Diapositive suivante ({{current}} sur {{total}})", "tooltip": "suivant"
"tooltip": "Diapositive suivante"
}, },
"slidePosition": "Diapositive {{current}} sur {{total}}",
"beta": { "beta": {
"text": "Essayer la beta", "text": "Essayer la beta",
"tooltip": "Accéder au formulaire" "tooltip": "Accéder au formulaire"
+1 -40
View File
@@ -22,8 +22,6 @@
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires", "permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
"disable": "Désactiver la webcam", "disable": "Désactiver la webcam",
"enable": "Activer la webcam", "enable": "Activer la webcam",
"turnedOff": "Webcam désactivée",
"turnedOn": "Webcam activée",
"label": "Webcam", "label": "Webcam",
"placeholder": "Activez la webcam pour prévisualiser l'affichage" "placeholder": "Activez la webcam pour prévisualiser l'affichage"
}, },
@@ -32,8 +30,6 @@
"permissionsNeeded": "Choisir le micro - autorisations nécessaires", "permissionsNeeded": "Choisir le micro - autorisations nécessaires",
"disable": "Désactiver le micro", "disable": "Désactiver le micro",
"enable": "Activer le micro", "enable": "Activer le micro",
"turnedOff": "Micro désactivé",
"turnedOn": "Micro activé",
"label": "Microphone" "label": "Microphone"
}, },
"audiooutput": { "audiooutput": {
@@ -590,7 +586,7 @@
}, },
"participantTileFocus": { "participantTileFocus": {
"containerLabel": "Options pour {{name}}", "containerLabel": "Options pour {{name}}",
"toolbarHint": "Ctrl+Shift+/ : accéder directement aux raccourcis.", "toolbarHint": "F2 : raccourci barre d'outils en bas.",
"pin": { "pin": {
"enable": "Épingler", "enable": "Épingler",
"disable": "Annuler l'épinglage" "disable": "Annuler l'épinglage"
@@ -599,41 +595,6 @@
"muteParticipant": "Couper le micro de {{name}}", "muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran" "fullScreen": "Plein écran"
}, },
"shortcutsPanel": {
"title": "Raccourcis clavier",
"categories": {
"navigation": "Navigation",
"media": "Média",
"interaction": "Interaction"
},
"actions": {
"open-shortcuts": "Ouvrir laide des raccourcis",
"focus-toolbar": "Mettre le focus sur la barre doutils du bas",
"toggle-microphone": "Activer ou désactiver le micro",
"toggle-camera": "Activer ou désactiver la caméra",
"push-to-talk": "Appuyer pour parler (maintenir pour réactiver)",
"reaction": "Panneau des réactions",
"fullscreen": "Basculer en plein écran",
"recording": "Basculer le panneau denregistrement",
"raise-hand": "Lever ou baisser la main",
"toggle-chat": "Afficher/Masquer le chat",
"toggle-participants": "Afficher/Masquer les participants",
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
},
"sr": {
"control": "Contrôle",
"command": "Commande",
"alt": "Alt",
"option": "Option",
"shift": "Majuscule",
"plus": "plus",
"hold": "Maintenir {{key}}",
"noShortcut": "Aucun raccourci"
},
"visual": {
"hold": "Maintenir {{key}}"
}
},
"fullScreenWarning": { "fullScreenWarning": {
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.", "message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
"stop": "Arrêter la présentation", "stop": "Arrêter la présentation",
+1 -7
View File
@@ -100,11 +100,6 @@
} }
} }
}, },
"shortcuts": {
"listLabel": "Liste des raccourcis clavier",
"columnAction": "Action",
"columnShortcut": "Raccourci"
},
"dialog": { "dialog": {
"heading": "Paramètres" "heading": "Paramètres"
}, },
@@ -125,7 +120,6 @@
"general": "Général", "general": "Général",
"notifications": "Notifications", "notifications": "Notifications",
"accessibility": "Accessibilité", "accessibility": "Accessibilité",
"transcription": "Transcription", "transcription": "Transcription"
"shortcuts": "Raccourcis"
} }
} }
+1 -3
View File
@@ -24,7 +24,6 @@
"notFound": { "notFound": {
"heading": "Pagina niet gevonden" "heading": "Pagina niet gevonden"
}, },
"selected": "geselecteerd",
"submit": "OK", "submit": "OK",
"footer": { "footer": {
"links": { "links": {
@@ -45,14 +44,13 @@
"license": "etalab 2.0 licentie" "license": "etalab 2.0 licentie"
}, },
"loginHint": { "loginHint": {
"title": "Log in met je account", "title": "Log in met je ProConnect-account",
"body": "In plaats van te wachten, log in met je ProConnect-account.", "body": "In plaats van te wachten, log in met je ProConnect-account.",
"button": { "button": {
"ariaLabel": "Sluit de suggestie", "ariaLabel": "Sluit de suggestie",
"label": "OK" "label": "OK"
} }
}, },
"skipLink": "Naar de hoofdinhoud gaan",
"clipboardContent": { "clipboardContent": {
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}", "url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}" "numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"
+5 -10
View File
@@ -10,7 +10,6 @@
"joinMeetingTipContent": "U kunt deelnemen aan een vergadering door de volledige link in de adresbalk van de browser te plakken.", "joinMeetingTipContent": "U kunt deelnemen aan een vergadering door de volledige link in de adresbalk van de browser te plakken.",
"joinMeetingTipHeading": "Wist u dat?", "joinMeetingTipHeading": "Wist u dat?",
"loginToCreateMeeting": "Log in om een vergadering te maken", "loginToCreateMeeting": "Log in om een vergadering te maken",
"noAccess": "U heeft geen toegang om vergaderingen te maken. Neem contact op met uw beheerder.",
"moreLinkLabel": "Meer informatie - nieuw tabblad", "moreLinkLabel": "Meer informatie - nieuw tabblad",
"moreLink": "Meer informatie", "moreLink": "Meer informatie",
"moreAbout": "over {{appTitle}}", "moreAbout": "over {{appTitle}}",
@@ -32,14 +31,12 @@
}, },
"introSlider": { "introSlider": {
"previous": { "previous": {
"label": "Vorige dia", "label": "vorige",
"labelWithPosition": "Vorige dia ({{current}} van {{total}})", "tooltip": "vorige"
"tooltip": "Vorige dia"
}, },
"next": { "next": {
"label": "Volgende dia", "label": "volgende",
"labelWithPosition": "Volgende dia ({{current}} van {{total}})", "tooltip": "volgende"
"tooltip": "Volgende dia"
}, },
"beta": { "beta": {
"text": "Word lid van de bèta", "text": "Word lid van de bèta",
@@ -56,8 +53,6 @@
"slide3": { "slide3": {
"title": "Transformeer uw vergaderingen met AI", "title": "Transformeer uw vergaderingen met AI",
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!" "body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
}, }
"carouselLabel": "Introductie-diavoorstelling",
"slidePosition": "Dia {{current}} van {{total}}"
} }
} }
+1 -40
View File
@@ -22,8 +22,6 @@
"permissionsNeeded": "Selecteer camera - Toestemming vereist", "permissionsNeeded": "Selecteer camera - Toestemming vereist",
"disable": "Camera uitschakelen", "disable": "Camera uitschakelen",
"enable": "Camera inschakelen", "enable": "Camera inschakelen",
"turnedOff": "Camera uitgeschakeld",
"turnedOn": "Camera ingeschakeld",
"label": "Camera", "label": "Camera",
"placeholder": "Schakel de camera in om de preview te zien" "placeholder": "Schakel de camera in om de preview te zien"
}, },
@@ -32,8 +30,6 @@
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist", "permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen", "disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen", "enable": "Microfoon dempen opheffen",
"turnedOff": "Microfoon uitgeschakeld",
"turnedOn": "Microfoon ingeschakeld",
"label": "Microfoon" "label": "Microfoon"
}, },
"audiooutput": { "audiooutput": {
@@ -590,7 +586,7 @@
}, },
"participantTileFocus": { "participantTileFocus": {
"containerLabel": "Opties voor {{name}}", "containerLabel": "Opties voor {{name}}",
"toolbarHint": "Ctrl+Shift+/: direct toegang tot de sneltoetsen.", "toolbarHint": "F2: naar de werkbalk onderaan.",
"pin": { "pin": {
"enable": "Pinnen", "enable": "Pinnen",
"disable": "Losmaken" "disable": "Losmaken"
@@ -599,41 +595,6 @@
"muteParticipant": "Demp {{name}}", "muteParticipant": "Demp {{name}}",
"fullScreen": "Volledig scherm" "fullScreen": "Volledig scherm"
}, },
"shortcutsPanel": {
"title": "Sneltoetsen",
"categories": {
"navigation": "Navigatie",
"media": "Media",
"interaction": "Interactie"
},
"actions": {
"open-shortcuts": "Sneltoetsenhulp openen",
"focus-toolbar": "Focus op de onderste werkbalk",
"toggle-microphone": "Microfoon aan/uit",
"toggle-camera": "Camera aan/uit",
"push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)",
"reaction": "Reactiepaneel",
"fullscreen": "Volledig scherm wisselen",
"recording": "Opnamepaneel wisselen",
"raise-hand": "Hand opsteken of laten zakken",
"toggle-chat": "Chat tonen/verbergen",
"toggle-participants": "Deelnemers tonen/verbergen",
"open-shortcuts-settings": "Sneltoets-instellingen openen"
},
"sr": {
"control": "Control",
"command": "Command",
"alt": "Alt",
"option": "Option",
"shift": "Shift",
"plus": "plus",
"hold": "Houd {{key}} ingedrukt",
"noShortcut": "Geen sneltoets"
},
"visual": {
"hold": "Houd {{key}} ingedrukt"
}
},
"fullScreenWarning": { "fullScreenWarning": {
"message": "Om niet oneindige uw scherm in zichzelf te delen, kunt u beter niet het hele scherm delen. Deel in plaats daarvan een tab of een ander venster.", "message": "Om niet oneindige uw scherm in zichzelf te delen, kunt u beter niet het hele scherm delen. Deel in plaats daarvan een tab of een ander venster.",
"stop": "Stop met presenteren", "stop": "Stop met presenteren",
+1 -7
View File
@@ -100,11 +100,6 @@
} }
} }
}, },
"shortcuts": {
"listLabel": "Sneltoetsen",
"columnAction": "Actie",
"columnShortcut": "Sneltoets"
},
"dialog": { "dialog": {
"heading": "Instellingen" "heading": "Instellingen"
}, },
@@ -119,7 +114,6 @@
"video": "Video", "video": "Video",
"general": "Algemeen", "general": "Algemeen",
"notifications": "Meldingen", "notifications": "Meldingen",
"transcription": "Transcriptie", "transcription": "Transcriptie"
"shortcuts": "Sneltoetsen"
} }
} }
+1 -12
View File
@@ -1,7 +1,5 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { Menu, MenuProps, MenuItem } from 'react-aria-components' import { Menu, MenuProps, MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { VisuallyHidden } from '@/styled-system/jsx'
import { menuRecipe } from '@/primitives/menuRecipe.ts' import { menuRecipe } from '@/primitives/menuRecipe.ts'
import type { RecipeVariantProps } from '@/styled-system/types' import type { RecipeVariantProps } from '@/styled-system/types'
@@ -21,7 +19,6 @@ export const MenuList = <T extends string | number = string>({
} & MenuProps<unknown> & } & MenuProps<unknown> &
RecipeVariantProps<typeof menuRecipe>) => { RecipeVariantProps<typeof menuRecipe>) => {
const [variantProps] = menuRecipe.splitVariantProps(menuProps) const [variantProps] = menuRecipe.splitVariantProps(menuProps)
const { t } = useTranslation('global')
const classes = menuRecipe({ const classes = menuRecipe({
extraPadding: true, extraPadding: true,
variant: variant, variant: variant,
@@ -42,19 +39,11 @@ export const MenuList = <T extends string | number = string>({
className={classes.item} className={classes.item}
key={value} key={value}
id={value as string} id={value as string}
textValue={typeof label === 'string' ? label : undefined}
onAction={() => { onAction={() => {
onAction(value as T) onAction(value as T)
}} }}
> >
{({ isSelected }) => ( {label}
<>
{label}
{isSelected && (
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
)}
</>
)}
</MenuItem> </MenuItem>
) )
})} })}
+2 -14
View File
@@ -1,5 +1,5 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { styled, VisuallyHidden } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react' import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
import { import {
Button, Button,
@@ -9,7 +9,6 @@ import {
SelectProps as RACSelectProps, SelectProps as RACSelectProps,
SelectValue, SelectValue,
} from 'react-aria-components' } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { Box } from './Box' import { Box } from './Box'
import { StyledPopover } from './Popover' import { StyledPopover } from './Popover'
import { menuRecipe } from '@/primitives/menuRecipe.ts' import { menuRecipe } from '@/primitives/menuRecipe.ts'
@@ -111,7 +110,6 @@ export const Select = <T extends string | number>({
...props ...props
}: SelectProps<T>) => { }: SelectProps<T>) => {
const IconComponent = iconComponent const IconComponent = iconComponent
const { t } = useTranslation('global')
return ( return (
<RACSelect {...props}> <RACSelect {...props}>
{label} {label}
@@ -140,18 +138,8 @@ export const Select = <T extends string | number>({
} }
id={item.value} id={item.value}
key={item.value} key={item.value}
textValue={
typeof item.label === 'string' ? item.label : undefined
}
> >
{({ isSelected }) => ( {item.label}
<>
{item.label}
{isSelected && (
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
)}
</>
)}
</ListBoxItem> </ListBoxItem>
))} ))}
</ListBox> </ListBox>

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