Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 0fc70d4e92 wip explore room's configuration validation 2026-03-11 23:13:51 +01:00
186 changed files with 1993 additions and 11357 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ __pycache__
**/__pycache__ **/__pycache__
**/*.pyc **/*.pyc
venv venv
**/.venv .venv
# System-specific files # System-specific files
.DS_Store .DS_Store
+33 -48
View File
@@ -12,9 +12,6 @@ on:
branches: branches:
- 'main' - 'main'
permissions:
contents: read
env: env:
DOCKER_USER: 1001:127 DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
@@ -23,8 +20,6 @@ env:
jobs: jobs:
build-and-push-backend: build-and-push-backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
@@ -48,12 +43,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: '--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
@@ -68,8 +63,6 @@ jobs:
build-and-push-frontend-generic: build-and-push-frontend-generic:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
@@ -93,12 +86,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
@@ -114,8 +107,6 @@ jobs:
build-and-push-frontend-dinum: build-and-push-frontend-dinum:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
@@ -139,12 +130,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
@@ -160,8 +151,6 @@ jobs:
build-and-push-summary: build-and-push-summary:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
@@ -185,13 +174,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
@@ -208,8 +197,6 @@ jobs:
build-and-push-agents: build-and-push-agents:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
@@ -233,14 +220,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
@@ -255,8 +242,6 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
notify-argocd: notify-argocd:
permissions:
contents: read
needs: needs:
- build-and-push-frontend-generic - build-and-push-frontend-generic
- build-and-push-frontend-dinum - build-and-push-frontend-dinum
+12 -54
View File
@@ -124,17 +124,15 @@ jobs:
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv cache: "pip"
uses: astral-sh/setup-uv@v7 - name: Install development dependencies
- name: Install the project run: pip install --user .[dev]
run: uv sync --locked --all-extras
- name: Check code formatting with ruff - name: Check code formatting with ruff
run: uv run ruff format . --diff run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff - name: Lint code with ruff
run: uv run ruff check . run: ~/.local/bin/ruff check .
- name: Lint code with pylint - name: Lint code with pylint
run: uv run pylint meet demo core run: ~/.local/bin/pylint meet demo core
lint-agents: lint-agents:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -281,10 +279,10 @@ jobs:
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv cache: "pip"
uses: astral-sh/setup-uv@v7
- name: Install the dependencies - name: Install development dependencies
run: uv sync --locked --all-extras run: pip install --user .[dev]
- name: Install gettext (required to compile messages) - name: Install gettext (required to compile messages)
run: | run: |
@@ -292,50 +290,10 @@ jobs:
sudo apt-get install -y gettext sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project - name: Generate a MO file from strings extracted from the project
run: uv run python manage.py compilemessages run: python manage.py compilemessages
- name: Run tests - name: Run tests
run: uv run pytest -n 2 run: ~/.local/bin/pytest -n 2
test-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
env:
APP_API_TOKEN: "test-api-token"
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
WHISPERX_DEFAULT_LANGUAGE: "fr"
LLM_BASE_URL: "https://configure-your-url.com"
LLM_API_KEY: "test-llm-secret"
LLM_MODEL: "test-llm-model"
WEBHOOK_API_TOKEN: "test-webhook-secret"
WEBHOOK_URL: "https://configure-your-url.com"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Run summary tests
run: ~/.local/bin/pytest
lint-front: lint-front:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-1
View File
@@ -31,7 +31,6 @@ MANIFEST
# Translations # Translations # Translations # Translations
*.pot *.pot
*.mo
# Environments # Environments
.env .env
+2 -50
View File
@@ -8,74 +8,26 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Changed
- ⬆️(dependencies) update python dependencies
### Fixed
- 🔒️(backend) fix email disclosure in room invitation endpoint #1200
- 🐛(backend) fix regression in update-participant endpoint #1204
## [1.12.0] - 2026-03-24
### Changed
- ♻️(backend) configurable SESSION_ENGINE #1038 #1154
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
- ♿️(frontend) fix more tools heading hierarchy #1181
- ♿️(fronted) improve button descriptions for More tools actions #1184
- 💄(spinner) enforce spinner height #1183
- 💄(custom-background) add upload indicator with preview #1183
- ♿️(backend) improve logo accessibility in recording email notification #1092
- ♿️(summary) improve accessibility of transcription download link #1187
- 💄(frontend) show OS-specific shortcut in participant tile hint #1193
- ⬆️(frontend) bump flatted from 3.3.1 to 3.4.2 in /src/frontend #1188
- ⬆️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
- ⬆️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
- ⬆️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
### Fixed
- 🐛(frontend) disable personal custom background while deleting #1183
- 🐛(frontend) auto-select new custom background when not logged in #1183
- 🐛(frontend) fix device selection not applying during conference #1156
## [1.11.0] - 2026-03-19
### Added ### Added
- ✨(helm) support celery with our Django backend #1124 - ✨(helm) support celery with our Django backend #1124
- ✨(helm) support ingress for custom background image #1124 - ✨(helm) support ingress for custom background image #1124
- ✨(backend) add authenticated user rate throttling on request-entry #1129 - ✨(backend) add authenticated user rate throttling on request-entry #1129
- ✨(backend) expose `is_active` field for Application in Django admin #1133
- ✨(file-upload) disable by default & limit count by user #1141
- ✨(frontend) custom background #1067
### Changed ### Changed
- ♿️(frontend) Caption text size setting for accessibility #1062 - ♿️(frontend) Caption text size setting for accessibility #1062
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111 - ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112 - ♿️(frontend) improve MoreLink a11y and UX on home page #1112
-(frontend) improve chat toast a11y for screen readers #1109 - ♿(frontend) improve chat toast a11y for screen readers #1109
-(frontend) improve ui and aria labels for help article links #1108 - ♿(frontend) improve ui and aria labels for help article links #1108
- 🌐(frontend) improve German translation #1125 - 🌐(frontend) improve German translation #1125
- 🔨(python-env) migrate meet main app to UV #1120
- ♻️(backend) align Application model field with `is_active` convention #1133
- 🔐(backend) avoids revealing the inactive status of an application #1135
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
- 🔒️(backend) avoid information exposure through exception messages #1144
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
- 📌(agents) unpin OpenSSL and related dependencies #1167
- ♿️(frontend) add caption font and background color customization #1122
### Fixed ### Fixed
- 🐛(frontend) fix hand icon and queue position alignment and position #1119 - 🐛(frontend) fix hand icon and queue position alignment and position #1119
- 🩹(backend) add page_size to pagination for room endpoints #1131 - 🩹(backend) add page_size to pagination for room endpoints #1131
- 🐛(backend) refactor lobby throttling to use participant id #1129 - 🐛(backend) refactor lobby throttling to use participant id #1129
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
## [1.10.0] - 2026-03-05 ## [1.10.0] - 2026-03-05
+23 -39
View File
@@ -13,28 +13,14 @@ RUN apk update && \
# ---- Back-end builder image ---- # ---- Back-end builder image ----
FROM base AS back-builder FROM base AS back-builder
WORKDIR /builder
ENV UV_COMPILE_BYTECODE=1 # Copy required python dependencies
ENV UV_LINK_MODE=copy COPY ./src/backend /builder
# Disable Python downloads, because we want to use the system interpreter RUN mkdir /install && \
# across both images. If using a managed Python version, it needs to be pip install --prefix=/install .
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ---- # ---- mails ----
FROM node:20 AS mail-builder FROM node:20 AS mail-builder
@@ -44,7 +30,7 @@ COPY ./src/mail /mail/app
WORKDIR /mail/app WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \ RUN yarn install --frozen-lockfile && \
yarn build yarn build
# ---- static link collector ---- # ---- static link collector ----
@@ -56,17 +42,17 @@ RUN apk add \
libmagic \ libmagic \
rdfind rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic # collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the # Replace duplicated file by a symlink to decrease the overall size of the
# final image # final image
@@ -95,17 +81,14 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint). # docker user (see entrypoint).
RUN chmod g=u /etc/passwd RUN chmod g=u /etc/passwd
# Copy the application from the builder # Copy installed python dependencies
COPY --from=back-builder /app /app COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that # We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group # creates a user on-the-fly with the container user ID (see USER) and root group
# ID. # ID.
@@ -120,9 +103,10 @@ USER root:root
# Install psql # Install psql
RUN apk add postgresql-client RUN apk add postgresql-client
# Install development dependencies # Uninstall Meet and re-install it in editable mode along with development
RUN --mount=from=ghcr.io/astral-sh/uv:0.10.9,source=/uv,target=/bin/uv \ # dependencies
uv sync --all-extras --locked RUN pip uninstall -y meet
RUN pip install -e .[dev]
# Restore the un-privileged user running the application # Restore the un-privileged user running the application
ARG DOCKER_USER ARG DOCKER_USER
@@ -131,7 +115,7 @@ USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services # Target database host (e.g. database engine following docker compose services
# name) & port # name) & port
ENV DB_HOST=postgresql \ ENV DB_HOST=postgresql \
DB_PORT=5432 DB_PORT=5432
# Run django development server # Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+4 -10
View File
@@ -191,7 +191,6 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests test: ## run project tests
@$(MAKE) test-back-parallel @$(MAKE) test-back-parallel
@$(MAKE) test-summary
.PHONY: test .PHONY: test
test-back: ## run back-end tests test-back: ## run back-end tests
@@ -204,11 +203,6 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}} bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel .PHONY: test-back-parallel
test-summary: ## run summary tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args:-${1}}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project. makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)" @echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql @$(COMPOSE) up -d postgresql
@@ -229,7 +223,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser .PHONY: superuser
back-i18n-compile: ## compile the gettext files back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore=".venv/**/*" @$(MANAGE) compilemessages --ignore="venv/**/*"
.PHONY: back-i18n-compile .PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n back-i18n-generate: ## create the .pot files used for i18n
@@ -360,13 +354,13 @@ install-external-secrets: ## install the kubernetes secrets from Vaultwarden
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind start-tilt: ## start the kubernetes cluster using kind
tilt up --namespace=meet -f ./bin/Tiltfile tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
DEV_ENV=dev-dinum tilt up --namespace=meet -f ./bin/Tiltfile DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
+21 -65
View File
@@ -2,6 +2,7 @@
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%"> <img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/suitenumerique/meet/stargazers/"> <a href="https://github.com/suitenumerique/meet/stargazers/">
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt=""> <img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
@@ -11,11 +12,11 @@
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/> <img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE"> <a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/> <img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a> </a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a> <a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
</p> </p>
<p align="center"> <p align="center">
@@ -27,75 +28,25 @@
## La Suite Meet: Simple Video Conferencing ## La Suite Meet: Simple Video Conferencing
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/). Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
### Features ### Features
- Optimized for stability in large meetings (+100 p.) - Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams - Support for multiple screen sharing streams
- Non-persistent, secure chat - Non-persistent, secure chat
- End-to-end encryption with hybrid key distribution - End-to-end encryption (coming soon)
- Meeting recording - Meeting recording
- Meeting transcription & Summary (currently in beta) - Meeting transcription & Summary (currently in beta)
- Telephony integration - Telephony integration
- Secure participation with robust authentication and access control - Secure participation with robust authentication and access control
- Customizable frontend style - Customizable frontend style
- LiveKit Advances features including : - LiveKit Advances features including :
- speaker detection - speaker detection
- simulcast - simulcast
- end-to-end optimizations - end-to-end optimizations
- selective subscription - selective subscription
- SVC codecs (VP9, AV1) - SVC codecs (VP9, AV1)
### End-to-end encryption
La Suite Meet supports end-to-end encryption (E2EE) for meetings, ensuring that the media server (LiveKit SFU) cannot access audio/video content. Two encryption modes are available: La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
#### Basic encryption
- Passphrase-based — the encryption key is embedded in the meeting URL hash (`#passphrase`)
- Uses LiveKit's built-in Worker + `crypto.subtle` (AES-GCM) for frame encryption
- Sharing the meeting link shares the encryption key
- No account or onboarding required
- Security depends on keeping the link private
#### Advanced encryption
- Key managed by [La Suite Encryption](https://github.com/suitenumerique/encryption) — the symmetric key never leaves the vault iframe
- Uses XChaCha20-Poly1305 (libsodium) via the VaultClient iframe for frame encryption
- Key distribution uses `vaultClient.shareKeys()` (hybrid PKI with X25519 + post-quantum slot)
- All participants must complete encryption onboarding (key generation + backup) before joining
- Requires a Chromium-based browser (Chrome, Edge, Brave) — uses the Insertable Streams API
**Frame encryption (both modes):**
- Codec header bytes (VP8 payload descriptor) are preserved unencrypted — required for proper RTP packetization
- Only the media payload is encrypted, with a per-frame random nonce
- The server (LiveKit SFU) only forwards encrypted data it cannot read
**Trust levels (advanced mode):**
| Badge | Level | Description |
|-------|-------|-------------|
| 🟢 Green shield | Verified | User completed encryption onboarding (public key registered). Identity cryptographically verified. |
| 🔵 Blue shield | Authenticated | User signed in via ProConnect/OIDC. Identity server-verified. |
| 🟡 Orange warning | Anonymous | User not signed in. Self-declared name. Admin should verify identity before accepting. |
**Security guarantees:**
- Encrypted rooms enforce restricted access (lobby approval required)
- Trust information (`is_authenticated`, `email`) comes from server-signed JWT tokens — cannot be spoofed
- Recording and transcription are not available in encrypted rooms (server cannot decrypt media)
**Configuration:**
```env
ENCRYPTION_ENABLED=true
ENCRYPTION_VAULT_URL=https://data.encryption.example.fr
ENCRYPTION_INTERFACE_URL=https://encryption.example.fr
```
When the encryption service is deployed and configured, rooms can use advanced encryption. Without it, only basic (passphrase) encryption is available.
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
Were continuously adding new features to enhance your experience, with the latest updates coming soon! Were continuously adding new features to enhance your experience, with the latest updates coming soon!
@@ -112,6 +63,7 @@ On the 25th of January 2026, David Amiel, Frances Minister for Civil Service
- [Philosophy](#philosophy) - [Philosophy](#philosophy)
- [Open source](#open-source) - [Open source](#open-source)
## Get started ## Get started
## Docs ## Docs
@@ -130,15 +82,15 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon. > Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
#### Known instances #### Known instances
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏 We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Access | | Url | Org | Access |
| ------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | |---------------------------------------------------------------| --- | ------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up | | [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up | | [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month | | [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | | [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing ## Contributing
@@ -148,6 +100,7 @@ We <3 contributions of any kind, big and small:
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md)) - Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) - Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
## Philosophy ## Philosophy
Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics. Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
@@ -156,6 +109,7 @@ Our users come first. Were committed to making La Suite Meet as accessible an
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority. Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
## Open-source ## Open-source
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE). Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
@@ -167,13 +121,14 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr). Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
## Contributors 🧞 ## Contributors 🧞
<a href="https://github.com/suitenumerique/meet/graphs/contributors"> <a href="https://github.com/suitenumerique/meet/graphs/contributors">
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" /> <img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
</a> </a>
## Credits ## Credits
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work! We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
This project is tested with BrowserStack. This project is tested with BrowserStack.
@@ -182,3 +137,4 @@ This project is tested with BrowserStack.
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique). Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html). Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
-1
View File
@@ -103,7 +103,6 @@ k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis']) k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis']) k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend']) k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server']) k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql']) k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate']) k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
app-summary-dev \
python -m pytest "$@"
+1 -3
View File
@@ -58,7 +58,7 @@ services:
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' && /usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json && /usr/bin/mc admin service restart meet --wait --json &&
sleep 15 && sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" && /usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
exit 0;" exit 0;"
app-dev: app-dev:
@@ -80,7 +80,6 @@ services:
volumes: volumes:
- ./src/backend:/app - ./src/backend:/app
- ./data/static:/data/static - ./data/static:/data/static
- /app/.venv
depends_on: depends_on:
- postgresql - postgresql
- mailcatcher - mailcatcher
@@ -106,7 +105,6 @@ services:
volumes: volumes:
- ./src/backend:/app - ./src/backend:/app
- ./data/static:/data/static - ./data/static:/data/static
- /app/.venv
depends_on: depends_on:
- app-dev - app-dev
+3 -20
View File
@@ -60,7 +60,7 @@
}, },
{ {
"username": "user-e2e-chromium", "username": "user-e2e-chromium",
"email": "user.test@chromium.test", "email": "user@chromium.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Chromium", "lastName": "Chromium",
"enabled": "true", "enabled": "true",
@@ -74,7 +74,7 @@
}, },
{ {
"username": "user-e2e-webkit", "username": "user-e2e-webkit",
"email": "user.test@webkit.test", "email": "user@webkit.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Webkit", "lastName": "Webkit",
"enabled": "true", "enabled": "true",
@@ -88,7 +88,7 @@
}, },
{ {
"username": "user-e2e-firefox", "username": "user-e2e-firefox",
"email": "user.test@firefox.test", "email": "user@firefox.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Firefox", "lastName": "Firefox",
"enabled": "true", "enabled": "true",
@@ -845,23 +845,6 @@
"offline_access", "offline_access",
"microprofile-jwt" "microprofile-jwt"
] ]
},
{
"clientId": "encryption",
"name": "Encryption Service",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": [
"http://encryption.localhost:7200/auth/callback"
],
"webOrigins": [
"http://encryption.localhost:7200",
"http://data.encryption.localhost:7200"
],
"protocol": "openid-connect",
"fullScopeAllowed": true
} }
], ],
"clientScopes": [ "clientScopes": [
+4 -3
View File
@@ -61,10 +61,11 @@ services:
`docker compose up -d` `docker compose up -d`
``` ```
Your keycloak instance is now available on https://id.yourdomain.tld Your keycloak instance is now available on https://doc.yourdomain.tld
> [!CAUTION] > [!CAUTION]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags). > Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
```
## Creating an OIDC Client for Meet Application ## Creating an OIDC Client for Meet Application
@@ -75,7 +76,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
3. Enter the name of the realm - `meet`. 3. Enter the name of the realm - `meet`.
4. Click "Create". 4. Click "Create".
### Step 2: Create a New Client #### Step 2: Create a New Client
1. Navigate to the "Clients" tab. 1. Navigate to the "Clients" tab.
2. Click on the "Create client" button. 2. Click on the "Create client" button.
@@ -85,7 +86,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`. 1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
1. Click "Save". 1. Click "Save".
### Step 3: Get Client Credentials #### Step 3: Get Client Credentials
1. Go to the "Credentials" tab. 1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret. 2. Copy the client ID (`meet` in this example) and the client secret.
+1 -1
View File
@@ -71,7 +71,7 @@ backend:
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false # Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts: extraVolumeMounts:
- name: certs - name: certs
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
subPath: cacert.pem subPath: cacert.pem
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false # Extra volume to manage our local custom CA and avoid to set ssl_verify: false
-1
View File
@@ -190,7 +190,6 @@ paths:
'403': '403':
$ref: '#/components/responses/ForbiddenError' $ref: '#/components/responses/ForbiddenError'
/rooms/:
post: post:
tags: tags:
- Rooms - Rooms
-1
View File
@@ -113,7 +113,6 @@ paths:
'403': '403':
$ref: '#/components/responses/ForbiddenError' $ref: '#/components/responses/ForbiddenError'
/rooms/:
post: post:
tags: tags:
- Rooms - Rooms
+1 -2
View File
@@ -27,8 +27,7 @@ AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000 AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:3000 MEDIA_BASE_URL=http://localhost:8083
FILE_UPLOAD_ENABLED=True
# OIDC # OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
-6
View File
@@ -36,12 +36,6 @@
"matchPackageNames": ["django"], "matchPackageNames": ["django"],
"allowedVersions": "<6.0.0" "allowedVersions": "<6.0.0"
}, },
{
"groupName": "allowed brevo versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["brevo-python"],
"allowedVersions": "<3.0.0"
},
{ {
"enabled": false, "enabled": false,
"groupName": "ignored js dependencies", "groupName": "ignored js dependencies",
+2
View File
@@ -4,6 +4,8 @@ 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
+6 -6
View File
@@ -1,20 +1,20 @@
[project] [project]
name = "agents" name = "agents"
version = "1.12.0" version = "1.10.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.4.5", "livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.4.5", "livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.4.5", "livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6", "livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2", "python-dotenv==1.2.1",
"protobuf==6.33.5" "protobuf==6.33.5"
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"ruff==0.15.6", "ruff==0.14.4",
] ]
[build-system] [build-system]
+2 -35
View File
@@ -197,38 +197,6 @@ def resend_notification(modeladmin, request, queryset): # pylint: disable=unuse
) )
@admin.action(description=_("Mark selected recordings as 'Failed to Stop'"))
def mark_as_failed_to_stop(modeladmin, request, queryset):
"""Force selected recordings status to failed_to_stop."""
eligible_statuses = [
models.RecordingStatusChoices.ACTIVE,
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.STOPPED,
]
eligible = queryset.filter(status__in=eligible_statuses)
skipped = queryset.exclude(status__in=eligible_statuses).count()
updated = eligible.update(status=models.RecordingStatusChoices.FAILED_TO_STOP)
if updated > 0:
modeladmin.message_user(
request,
_("%(count)s recording(s) successfully marked as 'Failed to Stop'.")
% {"count": updated},
level=messages.SUCCESS,
)
if skipped > 0:
modeladmin.message_user(
request,
_("Skipped %(count)s recording(s) with an ineligible status.")
% {"count": skipped},
level=messages.WARNING,
)
@admin.register(models.Recording) @admin.register(models.Recording)
class RecordingAdmin(admin.ModelAdmin): class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration.""" """Recording admin interface declaration."""
@@ -256,7 +224,7 @@ class RecordingAdmin(admin.ModelAdmin):
"updated_at", "updated_at",
"worker_id", "worker_id",
) )
actions = [resend_notification, mark_as_failed_to_stop] actions = [resend_notification]
def get_queryset(self, request): def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries.""" """Optimize queries by prefetching related access and user data to avoid N+1 queries."""
@@ -308,7 +276,7 @@ class ApplicationAdmin(admin.ModelAdmin):
form = ApplicationAdminForm form = ApplicationAdminForm
list_display = ("id", "name", "client_id", "get_scopes_display", "is_active") list_display = ("id", "name", "client_id", "get_scopes_display")
fields = [ fields = [
"name", "name",
"id", "id",
@@ -317,7 +285,6 @@ class ApplicationAdmin(admin.ModelAdmin):
"scopes", "scopes",
"client_id", "client_id",
"client_secret", "client_secret",
"is_active",
] ]
readonly_fields = ["id", "created_at", "updated_at"] readonly_fields = ["id", "created_at", "updated_at"]
inlines = [ApplicationDomainInline] inlines = [ApplicationDomainInline]
-21
View File
@@ -43,21 +43,6 @@ def get_frontend_configuration(request):
"expiration_days": settings.RECORDING_EXPIRATION_DAYS, "expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION, "max_duration": settings.RECORDING_MAX_DURATION,
}, },
"background_image": {
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
"max_count_by_user": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"max_count_by_user"
],
"max_size": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"max_size"
],
"allowed_extensions": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"allowed_extensions"
],
"allowed_mimetypes": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"allowed_mimetypes"
],
},
"telephony": { "telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED, "enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER "phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
@@ -73,11 +58,5 @@ def get_frontend_configuration(request):
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES, "default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
}, },
} }
if settings.ENCRYPTION_ENABLED and settings.ENCRYPTION_VAULT_URL:
frontend_configuration["encryption"] = {
"enabled": True,
"vault_url": settings.ENCRYPTION_VAULT_URL,
"interface_url": settings.ENCRYPTION_INTERFACE_URL,
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION) frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration) return Response(frontend_configuration)
-1
View File
@@ -13,7 +13,6 @@ class FeatureFlag:
"recording": "RECORDING_ENABLE", "recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE", "storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED", "subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED",
} }
@classmethod @classmethod
-8
View File
@@ -1,6 +1,5 @@
"""Permission handlers for the Meet core app.""" """Permission handlers for the Meet core app."""
from django.conf import settings
from django.http import Http404 from django.http import Http404
from rest_framework import permissions from rest_framework import permissions
@@ -117,13 +116,6 @@ class FilePermission(IsAuthenticated):
Handling soft deletions specificities Handling soft deletions specificities
""" """
def has_permission(self, request, view):
"""Allow access only to authenticated users."""
if not settings.FILE_UPLOAD_ENABLED:
raise Http404
return super().has_permission(request, view)
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
""" """
Return a 404 on deleted files or if the user is not the owner Return a 404 on deleted files or if the user is not the owner
+39 -63
View File
@@ -13,7 +13,7 @@ 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 django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ValidationError
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
@@ -30,8 +30,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.User model = models.User
fields = ["id", "sub", "email", "full_name", "short_name", "timezone", "language"] fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "sub", "email", "full_name", "short_name"] read_only_fields = ["id", "email", "full_name", "short_name"]
class UserLightSerializer(serializers.ModelSerializer): class UserLightSerializer(serializers.ModelSerializer):
@@ -74,23 +74,6 @@ class ResourceAccessSerializerMixin:
raise PermissionDenied( raise PermissionDenied(
"Only owners of a room can assign other users as owners." "Only owners of a room can assign other users as owners."
) )
# In advanced encrypted rooms, new accesses require an encrypted_symmetric_key
# so the new member can decrypt the room's streams. Without it, they'd have
# access but no key — which is useless and confusing.
# Future: a sharing UI (like Docs) could provide the key via vault shareKeys.
if not self.instance and "resource" in data:
resource = data["resource"]
if (
hasattr(resource, 'encryption_mode')
and resource.encryption_mode == models.EncryptionMode.ADVANCED
and not data.get("encrypted_symmetric_key")
):
raise serializers.ValidationError(
"Adding members to advanced encrypted rooms requires "
"an encrypted_symmetric_key for the new user."
)
return data return data
def validate_resource(self, resource): def validate_resource(self, resource):
@@ -115,7 +98,7 @@ class ResourceAccessSerializer(
class Meta: class Meta:
model = models.ResourceAccess model = models.ResourceAccess
fields = ["id", "user", "resource", "role", "encrypted_symmetric_key"] fields = ["id", "user", "resource", "role"]
read_only_fields = ["id"] read_only_fields = ["id"]
def update(self, instance, validated_data): def update(self, instance, validated_data):
@@ -140,31 +123,37 @@ class ListRoomSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "slug"] read_only_fields = ["id", "slug"]
class RoomConfiguration(BaseModel):
"""Wip"""
can_publish_sources: list[Literal[
"microphone", "screen_share", "screen_share_audio", "camera"
]] | None = None
model_config = {"extra": "forbid"}
class RoomSerializer(serializers.ModelSerializer): class RoomSerializer(serializers.ModelSerializer):
"""Serialize Room model for the API.""" """Serialize Room model for the API."""
class Meta: class Meta:
model = models.Room model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_mode"] fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"] read_only_fields = ["id", "slug", "pin_code"]
def validate_access_level(self, value): def validate_configuration(self, configuration):
"""Encrypted rooms must stay restricted — prevent downgrading access level.""" """Wip."""
instance = self.instance
if instance and instance.encryption_enabled and value != models.RoomAccessLevel.RESTRICTED: if configuration is None:
raise serializers.ValidationError( return configuration
"Encrypted rooms require restricted access level to enforce lobby approval."
) try:
return value RoomConfiguration.model_validate(configuration)
except ValidationError as e:
raise SuspiciousOperation("Wip, invalid room configuration")
return configuration
def validate_encryption_mode(self, value):
"""Once encryption is enabled on a room, it cannot be disabled or downgraded."""
instance = self.instance
if instance and instance.encryption_enabled and value == models.EncryptionMode.NONE:
raise serializers.ValidationError(
"Encryption cannot be disabled once enabled on a room."
)
return value
def to_representation(self, instance): def to_representation(self, instance):
""" """
@@ -207,34 +196,18 @@ class RoomSerializer(serializers.ModelSerializer):
if should_access_room: if should_access_room:
room_id = f"{instance.id!s}" room_id = f"{instance.id!s}"
username = request.query_params.get("username", None) username = request.query_params.get("username", None)
# In encrypted rooms, authenticated users must use their real name from
# the OIDC profile (ProConnect) — they cannot choose an arbitrary name.
if instance.encryption_enabled and request.user.is_authenticated:
username = request.user.full_name or request.user.email
output["livekit"] = utils.generate_livekit_config( output["livekit"] = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=request.user,
username=username, username=username,
configuration=configuration, configuration=configuration,
is_admin_or_owner=is_admin_or_owner, is_admin_or_owner=is_admin_or_owner,
encryption_mode=instance.encryption_mode,
) )
else: else:
del output["pin_code"] del output["pin_code"]
output["is_administrable"] = is_admin_or_owner output["is_administrable"] = is_admin_or_owner
# Include the current user's encrypted symmetric key for advanced E2EE
if request.user.is_authenticated and instance.encryption_mode == models.EncryptionMode.ADVANCED:
try:
access = instance.accesses.get(user=request.user)
if access.encrypted_symmetric_key:
output["encrypted_symmetric_key"] = access.encrypted_symmetric_key
except models.ResourceAccess.DoesNotExist:
pass
return output return output
@@ -316,8 +289,7 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
class RequestEntrySerializer(BaseValidationOnlySerializer): class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data.""" """Validate request entry data."""
username = serializers.CharField(required=True, allow_blank=True) username = serializers.CharField(required=True)
ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
class ParticipantEntrySerializer(BaseValidationOnlySerializer): class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -325,9 +297,6 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer):
participant_id = serializers.UUIDField(required=True) participant_id = serializers.UUIDField(required=True)
allow_entry = serializers.BooleanField(required=True) allow_entry = serializers.BooleanField(required=True)
encrypted_key = serializers.CharField(required=False, allow_blank=True, default='')
admin_ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
encrypted_vault_key = serializers.CharField(required=False, allow_blank=True, default='')
class CreationCallbackSerializer(BaseValidationOnlySerializer): class CreationCallbackSerializer(BaseValidationOnlySerializer):
@@ -358,9 +327,6 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
) )
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
class ParticipantPermission(BaseModel): class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf. """Mirror the LiveKit ParticipantPermission protobuf.
@@ -371,7 +337,9 @@ class ParticipantPermission(BaseModel):
can_subscribe: bool | None = None can_subscribe: bool | None = None
can_publish: bool | None = None can_publish: bool | None = None
can_publish_data: bool | None = None can_publish_data: bool | None = None
can_publish_sources: list[TrackSource] = Field(default_factory=list) can_publish_sources: list[int] = Field(
default_factory=list
) # TrackSource enum values
hidden: bool | None = None hidden: bool | None = None
recorder: bool | None = None recorder: bool | None = None
can_update_metadata: bool | None = None can_update_metadata: bool | None = None
@@ -422,6 +390,14 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
f"Setting the following participant permissions is not allowed: " f"Setting the following participant permissions is not allowed: "
f"{', '.join(suspicious_fields)}." 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 return permission
+20 -96
View File
@@ -1,6 +1,7 @@
"""API endpoints""" """API endpoints"""
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
import re
import uuid import uuid
from logging import getLogger from logging import getLogger
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
@@ -11,7 +12,6 @@ from django.db.models import Q
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as django_filters from django_filters import rest_framework as django_filters
from rest_framework import ( from rest_framework import (
@@ -33,12 +33,10 @@ from rest_framework import (
from core import enums, models, utils from core import enums, models, utils
from core.api.filters import ListFileFilter from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import ( from core.recording.event.exceptions import (
InvalidBucketError, InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError, InvalidFileTypeError,
ParsingEventDataError, ParsingEventDataError,
) )
@@ -80,6 +78,17 @@ from .feature_flag import FeatureFlag
logger = getLogger(__name__) logger = getLogger(__name__)
FILE_FOLDER = settings.FILE_UPLOAD_PATH
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"[\d\w]+"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}"
rf"(?P<key>{FILE_FOLDER:s}/(?P<pk>{UUID_REGEX:s})/\.{FILE_EXT_REGEX:s})$"
)
class NestedGenericViewSet(viewsets.GenericViewSet): class NestedGenericViewSet(viewsets.GenericViewSet):
""" """
A generic Viewset aims to be used in a nested route context. A generic Viewset aims to be used in a nested route context.
@@ -281,32 +290,11 @@ class RoomViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
"""Set the current user as owner of the newly created room.""" """Set the current user as owner of the newly created room."""
encryption_mode = serializer.validated_data.get("encryption_mode", models.EncryptionMode.NONE)
# Block encrypted room creation if encryption is not enabled on this instance
if encryption_mode != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
raise drf_exceptions.ValidationError(
{"encryption_mode": "Encryption is not enabled on this server."}
)
# Advanced encryption requires the vault service to be configured
if encryption_mode == models.EncryptionMode.ADVANCED and not getattr(settings, 'ENCRYPTION_VAULT_URL', ''):
raise drf_exceptions.ValidationError(
{"encryption_mode": "Advanced encryption requires the encryption service to be configured."}
)
# Encrypted rooms must use restricted access to enforce lobby approval
# before the encryption key is shared with participants.
if encryption_mode != models.EncryptionMode.NONE:
serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED
room = serializer.save() room = serializer.save()
encrypted_symmetric_key = self.request.data.get("encrypted_symmetric_key", "")
models.ResourceAccess.objects.create( models.ResourceAccess.objects.create(
resource=room, resource=room,
user=self.request.user, user=self.request.user,
role=models.RoleChoices.OWNER, role=models.RoleChoices.OWNER,
encrypted_symmetric_key=encrypted_symmetric_key,
) )
if callback_id := self.request.data.get("callback_id"): if callback_id := self.request.data.get("callback_id"):
@@ -335,12 +323,6 @@ class RoomViewSet(
options = serializer.validated_data.get("options") options = serializer.validated_data.get("options")
room = self.get_object() room = self.get_object()
if room.encryption_enabled:
return drf_response.Response(
{"detail": "Recording is not available in encrypted rooms."},
status=drf_status.HTTP_403_FORBIDDEN,
)
# 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,
@@ -423,28 +405,12 @@ class RoomViewSet(
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
room = self.get_object() room = self.get_object()
validated_data = serializer.validated_data
# Advanced encrypted rooms require authentication
if room.encryption_mode == models.EncryptionMode.ADVANCED and not request.user.is_authenticated:
return drf_response.Response(
{"detail": "This meeting requires authentication to join."},
status=drf_status.HTTP_403_FORBIDDEN,
)
# In encrypted rooms, authenticated users must use their real name
# from the OIDC profile — they cannot choose an arbitrary name.
if room.encryption_enabled and request.user.is_authenticated:
validated_data["username"] = (
request.user.full_name or request.user.email
)
lobby_service = LobbyService() lobby_service = LobbyService()
participant, livekit = lobby_service.request_entry( participant, livekit = lobby_service.request_entry(
room=room, room=room,
request=request, request=request,
**validated_data, **serializer.validated_data,
) )
response = drf_response.Response({**participant.to_dict(), "livekit": livekit}) response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id) lobby_service.prepare_response(response, participant.id)
@@ -480,9 +446,6 @@ class RoomViewSet(
room_id=room.id, room_id=room.id,
participant_id=str(serializer.validated_data.get("participant_id")), participant_id=str(serializer.validated_data.get("participant_id")),
allow_entry=serializer.validated_data.get("allow_entry"), allow_entry=serializer.validated_data.get("allow_entry"),
encrypted_key=serializer.validated_data.get("encrypted_key", ''),
admin_ephemeral_public_key=serializer.validated_data.get("admin_ephemeral_public_key", ''),
encrypted_vault_key=serializer.validated_data.get("encrypted_vault_key", ''),
) )
return drf_response.Response({"message": "Participant was updated."}) return drf_response.Response({"message": "Participant was updated."})
@@ -510,14 +473,6 @@ class RoomViewSet(
lobby_service = LobbyService() lobby_service = LobbyService()
participants = lobby_service.list_waiting_participants(room.id) participants = lobby_service.list_waiting_participants(room.id)
# Only expose email and ephemeral keys in encrypted rooms.
# Strip them otherwise to avoid leaking personal data.
if not room.encryption_enabled:
for p in participants:
p.pop("email", None)
p.pop("ephemeral_public_key", None)
return drf_response.Response({"participants": participants}) return drf_response.Response({"participants": participants})
@decorators.action( @decorators.action(
@@ -542,7 +497,9 @@ class RoomViewSet(
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR: if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
raise e raise e
return drf_response.Response({"status": "error"}, status=status_code) return drf_response.Response(
{"status": "error", "message": str(e)}, status=status_code
)
@decorators.action( @decorators.action(
detail=False, detail=False,
@@ -620,12 +577,6 @@ class RoomViewSet(
room = self.get_object() room = self.get_object()
if room.encryption_enabled:
return drf_response.Response(
{"error": "Transcription is not available in encrypted rooms."},
status=drf_status.HTTP_403_FORBIDDEN,
)
try: try:
SubtitleService().start_subtitle(room) SubtitleService().start_subtitle(room)
except SubtitleException: except SubtitleException:
@@ -815,19 +766,14 @@ class RecordingViewSet(
recording_id = parser.get_recording_id(request.data) recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e: except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied("Invalid request data.") from e raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
except InvalidBucketError as e: except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
except InvalidFilepathError: except InvalidFileTypeError as e:
return drf_response.Response( return drf_response.Response(
{"message": "Notification ignored."}, {"message": f"Ignore this file type, {e}"},
)
except InvalidFileTypeError:
return drf_response.Response(
{"message": "Notification ignored."},
) )
try: try:
@@ -1027,26 +973,6 @@ class FileViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
"""Set the current user as creator of the newly created file.""" """Set the current user as creator of the newly created file."""
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
file_type = serializer.validated_data["type"]
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file_type]
count = models.File.objects.filter(
creator=self.request.user,
deleted_at__isnull=True,
type=file_type,
).count()
if count >= config_for_file_type["max_count_by_user"]:
logger.info(
"create_item: user reached max files per user for type %s",
file_type,
)
raise serializers.PermissionDenied(
_("You have reached the maximum number of files for this type.")
)
serializer.save(creator=self.request.user) serializer.save(creator=self.request.user)
def perform_destroy(self, instance): def perform_destroy(self, instance):
@@ -1054,7 +980,6 @@ class FileViewSet(
instance.soft_delete() instance.soft_delete()
@decorators.action(detail=True, methods=["post"], url_path="upload-ended") @decorators.action(detail=True, methods=["post"], url_path="upload-ended")
@FeatureFlag.require("file_upload")
def upload_ended(self, request, *args, **kwargs): def upload_ended(self, request, *args, **kwargs):
""" """
Check the actual uploaded file and mark it as ready. Check the actual uploaded file and mark it as ready.
@@ -1237,7 +1162,6 @@ class FileViewSet(
return url_params, request.user.id, file return url_params, request.user.id, file
@decorators.action(detail=False, methods=["get"], url_path="media-auth") @decorators.action(detail=False, methods=["get"], url_path="media-auth")
@FeatureFlag.require("file_upload")
def media_auth(self, request, *args, **kwargs): def media_auth(self, request, *args, **kwargs):
""" """
This view is used by an Nginx subrequest to control access to an file's This view is used by an Nginx subrequest to control access to an file's
+1 -7
View File
@@ -14,15 +14,9 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long # pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile( RECORDING_STORAGE_URL_PATTERN = re.compile(
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})" f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
) )
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}"
rf"(?P<key>{settings.FILE_UPLOAD_PATH:s}/(?P<pk>{UUID_REGEX:s})\.{FILE_EXT_REGEX:s})$"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for # Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in # the choice of languages which should not be limited to the few languages active in
# the app. # the app.
@@ -203,7 +203,7 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
logger.warning("Application not found: %s", client_id) logger.warning("Application not found: %s", client_id)
raise exceptions.AuthenticationFailed("Application not found.") from e raise exceptions.AuthenticationFailed("Application not found.") from e
if not application.is_active: if not application.active:
logger.warning( logger.warning(
"Inactive application attempted authentication: %s", client_id "Inactive application attempted authentication: %s", client_id
) )
+3 -3
View File
@@ -61,12 +61,12 @@ class ApplicationViewSet(viewsets.ViewSet):
except models.Application.DoesNotExist as e: except models.Application.DoesNotExist as e:
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
if not application.active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
if not check_password(client_secret, application.client_secret): if not check_password(client_secret, application.client_secret):
raise drf_exceptions.AuthenticationFailed("Invalid credentials") raise drf_exceptions.AuthenticationFailed("Invalid credentials")
if not application.is_active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
email = serializer.validated_data["scope"] email = serializer.validated_data["scope"]
try: try:
validate_email(email) validate_email(email)
+1 -1
View File
@@ -129,7 +129,7 @@ class ApplicationFactory(factory.django.DjangoModelFactory):
model = models.Application model = models.Application
name = factory.Faker("company") name = factory.Faker("company")
is_active = True active = True
client_id = factory.LazyFunction(utils.generate_client_id) client_id = factory.LazyFunction(utils.generate_client_id)
client_secret = factory.LazyFunction(utils.generate_client_secret) client_secret = factory.LazyFunction(utils.generate_client_secret)
scopes = [] scopes = []
@@ -1,18 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-11 14:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0017_file'),
]
operations = [
migrations.RenameField(
model_name='application',
old_name='active',
new_name='is_active',
),
]
@@ -1,20 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0018_rename_active_application_is_active"),
]
operations = [
migrations.AddField(
model_name="room",
name="encryption_enabled",
field=models.BooleanField(
default=False,
help_text="Whether end-to-end encryption is enabled for this room.",
verbose_name="Encryption enabled",
),
),
]
@@ -1,51 +0,0 @@
"""Replace encryption_enabled boolean with encryption_mode enum."""
from django.db import migrations, models
def migrate_encryption_enabled_to_mode(apps, schema_editor):
"""Convert existing encryption_enabled=True rooms to encryption_mode='basic'."""
Room = apps.get_model("core", "Room")
Room.objects.filter(encryption_enabled=True).update(encryption_mode="basic")
def migrate_mode_to_encryption_enabled(apps, schema_editor):
"""Reverse: set encryption_enabled=True for any non-'none' encryption_mode."""
Room = apps.get_model("core", "Room")
Room.objects.exclude(encryption_mode="none").update(encryption_enabled=True)
class Migration(migrations.Migration):
dependencies = [
("core", "0019_room_encryption_enabled"),
]
operations = [
# 1. Add the new encryption_mode field
migrations.AddField(
model_name="room",
name="encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Basic encryption"),
("advanced", "Advanced encryption"),
],
default="none",
help_text="End-to-end encryption mode for this room.",
max_length=20,
verbose_name="Encryption mode",
),
),
# 2. Migrate existing data
migrations.RunPython(
migrate_encryption_enabled_to_mode,
migrate_mode_to_encryption_enabled,
),
# 3. Remove the old boolean field
migrations.RemoveField(
model_name="room",
name="encryption_enabled",
),
]
@@ -1,23 +0,0 @@
"""Add encrypted_symmetric_key to ResourceAccess for advanced E2EE mode."""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0020_room_encryption_mode"),
]
operations = [
migrations.AddField(
model_name="resourceaccess",
name="encrypted_symmetric_key",
field=models.TextField(
blank=True,
default="",
help_text="Vault-wrapped symmetric encryption key for advanced E2EE mode. Each user's copy is encrypted for their own vault public key.",
verbose_name="Encrypted symmetric key",
),
),
]
+2 -31
View File
@@ -98,14 +98,6 @@ class RoomAccessLevel(models.TextChoices):
RESTRICTED = "restricted", _("Restricted Access") RESTRICTED = "restricted", _("Restricted Access")
class EncryptionMode(models.TextChoices):
"""Encryption mode choices for rooms."""
NONE = "none", _("No encryption")
BASIC = "basic", _("Basic encryption")
ADVANCED = "advanced", _("Advanced encryption")
class BaseModel(models.Model): class BaseModel(models.Model):
""" """
Serves as an abstract base model for other models, ensuring that records are validated Serves as an abstract base model for other models, ensuring that records are validated
@@ -332,15 +324,6 @@ class ResourceAccess(BaseModel):
role = models.CharField( role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
) )
encrypted_symmetric_key = models.TextField(
blank=True,
default='',
verbose_name=_("Encrypted symmetric key"),
help_text=_(
"Vault-wrapped symmetric encryption key for advanced E2EE mode. "
"Each user's copy is encrypted for their own vault public key."
),
)
class Meta: class Meta:
db_table = "meet_resource_access" db_table = "meet_resource_access"
@@ -405,13 +388,6 @@ class Room(Resource):
choices=RoomAccessLevel.choices, choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL, default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
) )
encryption_mode = models.CharField(
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
verbose_name=_("Encryption mode"),
help_text=_("End-to-end encryption mode for this room."),
)
configuration = models.JSONField( configuration = models.JSONField(
blank=True, blank=True,
default=dict, default=dict,
@@ -466,11 +442,6 @@ class Room(Resource):
"""Check if a room is public""" """Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC return self.access_level == RoomAccessLevel.PUBLIC
@property
def encryption_enabled(self):
"""Check if any encryption mode is active."""
return self.encryption_mode != EncryptionMode.NONE
@staticmethod @staticmethod
def generate_unique_pin_code(length): def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code""" """Generate a unique n-digit PIN code"""
@@ -788,7 +759,7 @@ class Application(BaseModel):
verbose_name=_("Application name"), verbose_name=_("Application name"),
help_text=_("Descriptive name for this application."), help_text=_("Descriptive name for this application."),
) )
is_active = models.BooleanField(default=True) active = models.BooleanField(default=True)
client_id = models.CharField( client_id = models.CharField(
max_length=100, unique=True, default=utils.generate_client_id max_length=100, unique=True, default=utils.generate_client_id
) )
@@ -981,7 +952,7 @@ class File(BaseModel):
_, extension = splitext(self.filename) _, extension = splitext(self.filename)
# We store only the extension in the storage system to avoid # We store only the extension in the storage system to avoid
# leaking Personal Information in logs, etc. # leaking Personal Information in logs, etc.
return f"{self.key_base}{extension!s}" return f"{self.key_base}/{extension!s}"
def get_abilities(self, user): def get_abilities(self, user):
""" """
+1 -3
View File
@@ -9,8 +9,6 @@ from typing import Any, Dict, Optional, Protocol
from django.conf import settings from django.conf import settings
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from core.enums import FILE_EXT_REGEX, UUID_REGEX
from .exceptions import ( from .exceptions import (
InvalidBucketError, InvalidBucketError,
InvalidFilepathError, InvalidFilepathError,
@@ -88,7 +86,7 @@ class MinioParser:
# pylint: disable=line-too-long # pylint: disable=line-too-long
self._filepath_regex = re.compile( self._filepath_regex = re.compile(
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})" r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
) )
@staticmethod @staticmethod
+10 -13
View File
@@ -4,7 +4,7 @@ import smtplib
from logging import getLogger from logging import getLogger
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives from django.core.mail import send_mail
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import get_language, override from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -45,18 +45,15 @@ class InvitationService:
) )
) # Force translation ) # Force translation
email = EmailMultiAlternatives(
subject=subject,
body=msg_plain,
from_email=settings.EMAIL_FROM,
to=[],
bcc=emails,
)
email.attach_alternative(msg_html, "text/html")
try: try:
email.send() send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as e: except smtplib.SMTPException as e:
logger.error("invitations were not sent: %s", e) logger.error("invitation to %s was not sent: %s", emails, e)
raise InvitationError("Could not send invitation") from e raise InvitationError("Could not send invitation") from e
+6 -90
View File
@@ -46,36 +46,15 @@ class LobbyParticipant:
username: str username: str
color: str color: str
id: str id: str
is_authenticated: bool = False
email: Optional[str] = None
suite_user_id: Optional[str] = None
ephemeral_public_key: str = ''
encrypted_key: str = ''
admin_ephemeral_public_key: str = ''
encrypted_vault_key: str = ''
def to_dict(self) -> Dict[str, str]: def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation.""" """Serialize the participant object to a dict representation."""
result = { return {
"status": self.status.value, "status": self.status.value,
"username": self.username, "username": self.username,
"id": self.id, "id": self.id,
"color": self.color, "color": self.color,
"is_authenticated": self.is_authenticated,
} }
if self.email:
result["email"] = self.email
if self.suite_user_id:
result["suite_user_id"] = self.suite_user_id
if self.ephemeral_public_key:
result["ephemeral_public_key"] = self.ephemeral_public_key
if self.encrypted_key:
result["encrypted_key"] = self.encrypted_key
if self.admin_ephemeral_public_key:
result["admin_ephemeral_public_key"] = self.admin_ephemeral_public_key
if self.encrypted_vault_key:
result["encrypted_vault_key"] = self.encrypted_vault_key
return result
@classmethod @classmethod
def from_dict(cls, data: dict) -> "LobbyParticipant": def from_dict(cls, data: dict) -> "LobbyParticipant":
@@ -89,13 +68,6 @@ class LobbyParticipant:
username=data["username"], username=data["username"],
id=data["id"], id=data["id"],
color=data["color"], color=data["color"],
is_authenticated=data.get("is_authenticated", False),
email=data.get("email"),
suite_user_id=data.get("suite_user_id"),
ephemeral_public_key=data.get("ephemeral_public_key", ''),
encrypted_key=data.get("encrypted_key", ''),
admin_ephemeral_public_key=data.get("admin_ephemeral_public_key", ''),
encrypted_vault_key=data.get("encrypted_vault_key", ''),
) )
except (KeyError, ValueError) as e: except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:") logger.exception("Error creating Participant from dict:")
@@ -127,7 +99,7 @@ class LobbyService:
key=settings.LOBBY_COOKIE_NAME, key=settings.LOBBY_COOKIE_NAME,
value=participant_id, value=participant_id,
httponly=True, httponly=True,
secure=not settings.DEBUG, secure=True,
samesite="Lax", samesite="Lax",
) )
@@ -139,16 +111,11 @@ class LobbyService:
1. The room is public (open to everyone) 1. The room is public (open to everyone)
2. The room has TRUSTED access level and the user is authenticated 2. The room has TRUSTED access level and the user is authenticated
Encrypted rooms never bypass the lobby — participants must go through
the lobby key exchange to receive the encryption key.
Note: Room access levels can change while participants are waiting in the lobby. Note: Room access levels can change while participants are waiting in the lobby.
This function only checks the current state and should be called each time This function only checks the current state and should be called each time
a participant requests entry to ensure consistent access control, even for a participant requests entry to ensure consistent access control, even for
participants who have already begun waiting. participants who have already begun waiting.
""" """
if hasattr(room, 'encryption_mode') and room.encryption_mode != 'none':
return False
return room.is_public or ( return room.is_public or (
room.access_level == models.RoomAccessLevel.TRUSTED room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated and user.is_authenticated
@@ -159,7 +126,6 @@ class LobbyService:
room, room,
request, request,
username: str, username: str,
ephemeral_public_key: str = '',
) -> Tuple[LobbyParticipant, Optional[Dict]]: ) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant. """Request entry to a room for a participant.
@@ -198,42 +164,19 @@ class LobbyService:
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False, is_admin_or_owner=False,
participant_id=participant_id, participant_id=participant_id,
encryption_mode=room.encryption_mode,
) )
return participant, livekit_config return participant, livekit_config
livekit_config = None livekit_config = None
if participant is None: if participant is None:
participant = self.enter( participant = self.enter(room.id, participant_id, username)
room.id, participant_id, username,
is_authenticated=request.user.is_authenticated,
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
ephemeral_public_key=ephemeral_public_key,
)
elif participant.status == LobbyParticipantStatus.WAITING: elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id) self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED: elif participant.status == LobbyParticipantStatus.ACCEPTED:
# If the joiner comes back with a different ephemeral key (e.g. browser # wrongly named, contains access token to join a room
# closed and reopened), they can no longer decrypt the encrypted symmetric
# key. Reset them to WAITING so the admin re-accepts with the new key.
if (
ephemeral_public_key
and participant.ephemeral_public_key
and ephemeral_public_key != participant.ephemeral_public_key
):
participant = self.enter(
room.id, participant_id, username,
is_authenticated=request.user.is_authenticated,
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
ephemeral_public_key=ephemeral_public_key,
)
return participant, None
livekit_config = utils.generate_livekit_config( livekit_config = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=request.user,
@@ -242,7 +185,6 @@ class LobbyService:
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False, is_admin_or_owner=False,
participant_id=participant_id, participant_id=participant_id,
encryption_mode=room.encryption_mode,
) )
return participant, livekit_config return participant, livekit_config
@@ -259,11 +201,7 @@ class LobbyService:
) )
def enter( def enter(
self, room_id: UUID, participant_id: str, username: str, self, room_id: UUID, participant_id: str, username: str
is_authenticated: bool = False,
email: Optional[str] = None,
suite_user_id: Optional[str] = None,
ephemeral_public_key: str = '',
) -> LobbyParticipant: ) -> LobbyParticipant:
"""Add participant to waiting lobby. """Add participant to waiting lobby.
@@ -278,10 +216,6 @@ class LobbyService:
username=username, username=username,
id=participant_id, id=participant_id,
color=color, color=color,
is_authenticated=is_authenticated,
email=email,
suite_user_id=suite_user_id,
ephemeral_public_key=ephemeral_public_key,
) )
try: try:
@@ -350,9 +284,6 @@ class LobbyService:
room_id: UUID, room_id: UUID,
participant_id: str, participant_id: str,
allow_entry: bool, allow_entry: bool,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
encrypted_vault_key: str = '',
) -> None: ) -> None:
"""Handle decision on participant entry. """Handle decision on participant entry.
@@ -371,13 +302,7 @@ class LobbyService:
"timeout": settings.LOBBY_DENIED_TIMEOUT, "timeout": settings.LOBBY_DENIED_TIMEOUT,
} }
self._update_participant_status( self._update_participant_status(room_id, participant_id, **decision)
room_id, participant_id,
encrypted_key=encrypted_key,
admin_ephemeral_public_key=admin_ephemeral_public_key,
encrypted_vault_key=encrypted_vault_key,
**decision,
)
def _update_participant_status( def _update_participant_status(
self, self,
@@ -385,9 +310,6 @@ class LobbyService:
participant_id: str, participant_id: str,
status: LobbyParticipantStatus, status: LobbyParticipantStatus,
timeout: int, timeout: int,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
encrypted_vault_key: str = '',
) -> None: ) -> None:
"""Update participant status with appropriate timeout.""" """Update participant status with appropriate timeout."""
@@ -408,12 +330,6 @@ class LobbyService:
raise raise
participant.status = status participant.status = status
if encrypted_key:
participant.encrypted_key = encrypted_key
if admin_ephemeral_public_key:
participant.admin_ephemeral_public_key = admin_ephemeral_public_key
if encrypted_vault_key:
participant.encrypted_vault_key = encrypted_vault_key
cache.set(cache_key, participant.to_dict(), timeout=timeout) cache.set(cache_key, participant.to_dict(), timeout=timeout)
def clear_room_cache(self, room_id: UUID) -> None: def clear_room_cache(self, room_id: UUID) -> None:
@@ -118,7 +118,7 @@ def test_api_files_create_file_authenticated_success():
assert policy_parsed.scheme == "http" assert policy_parsed.scheme == "http"
assert policy_parsed.netloc == "localhost:9000" assert policy_parsed.netloc == "localhost:9000"
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png" assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}/.png"
query_params = parse_qs(policy_parsed.query) query_params = parse_qs(policy_parsed.query)
@@ -174,26 +174,6 @@ def test_api_files_create_file_authenticated_extension_case_insensitive():
assert file.title == "file" assert file.title == "file"
def test_api_files_create_file_disabled(settings):
"""
Creating a file is denied if file upload is disabled
"""
settings.FILE_UPLOAD_ENABLED = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file.JPG",
},
format="json",
)
assert response.status_code == 404
assert not File.objects.exists()
def test_api_files_create_file_authenticated_not_checking_extension(settings): def test_api_files_create_file_authenticated_not_checking_extension(settings):
""" """
Creating a file with an extension not allowed should not fail when restrictions are disabled. Creating a file with an extension not allowed should not fail when restrictions are disabled.
@@ -259,48 +239,6 @@ def test_api_files_create_file_authenticated_hidden_file_but_checking_extension_
assert response.json() == {"filename": ["This file extension is not allowed."]} assert response.json() == {"filename": ["This file extension is not allowed."]}
def test_api_files_create_file_too_many(
settings,
):
"""
Creating a file is forbidden if above user limit.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"max_count_by_user": 1,
},
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "1.png",
},
)
assert response.status_code == 201
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "2.png",
},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You have reached the maximum number of files for this type."
}
assert File.objects.count() == 1
def test_api_files_create_force_id_success(): def test_api_files_create_force_id_success():
"""It should be possible to force the item ID when creating a item.""" """It should be possible to force the item ID when creating a item."""
user = factories.UserFactory() user = factories.UserFactory()
@@ -39,7 +39,7 @@ def test_api_files_update_anonymous_forbidden():
def test_api_files_update_description_and_title(): def test_api_files_update_description_and_title():
""" """
Test the description and title of a file can be updated. Test the description and title of an file can be updated.
""" """
user = factories.UserFactory() user = factories.UserFactory()
@@ -32,7 +32,7 @@ def valid_minio_event():
"s3": { "s3": {
"bucket": {"name": "test-bucket"}, "bucket": {"name": "test-bucket"},
"object": { "object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", "key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": "audio/ogg", "contentType": "audio/ogg",
}, },
} }
@@ -51,7 +51,7 @@ def test_parse_valid_event(minio_parser, valid_minio_event):
"""Test parsing a valid Minio event.""" """Test parsing a valid Minio event."""
event = minio_parser.parse(valid_minio_event) event = minio_parser.parse(valid_minio_event)
assert isinstance(event, StorageEvent) assert isinstance(event, StorageEvent)
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg" assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filetype == "audio/ogg" assert event.filetype == "audio/ogg"
assert event.bucket_name == "test-bucket" assert event.bucket_name == "test-bucket"
assert event.metadata is None assert event.metadata is None
@@ -130,13 +130,11 @@ def test_validate_invalid_filetype(minio_parser):
"invalid_filepath", "invalid_filepath",
[ [
"invalid_filepath", # totally invalid string "invalid_filepath", # totally invalid string
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", "recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension "recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension "46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string "", # empty string
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all "recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
], ],
) )
def test_validate_invalid_filepath(invalid_filepath, minio_parser): def test_validate_invalid_filepath(invalid_filepath, minio_parser):
@@ -154,7 +152,7 @@ def test_validate_invalid_filepath(invalid_filepath, minio_parser):
def test_validate_valid_event(minio_parser): def test_validate_valid_event(minio_parser):
"""Test validation with valid event data.""" """Test validation with valid event data."""
event = StorageEvent( event = StorageEvent(
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg", filetype="audio/ogg",
bucket_name="test-bucket", bucket_name="test-bucket",
metadata=None, metadata=None,
@@ -172,7 +170,7 @@ def test_get_recording_id_success(minio_parser, valid_minio_event):
def test_validate_filepath_with_folder(minio_parser): def test_validate_filepath_with_folder(minio_parser):
"""Test validation of filepath with folder structure.""" """Test validation of filepath with folder structure."""
event = StorageEvent( event = StorageEvent(
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg", filetype="audio/ogg",
bucket_name="test-bucket", bucket_name="test-bucket",
metadata=None, metadata=None,
@@ -221,7 +219,7 @@ def test_validate_custom_filetypes():
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"}) parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
event = StorageEvent( event = StorageEvent(
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/mp3", filetype="audio/mp3",
bucket_name="test-bucket", bucket_name="test-bucket",
metadata=None, metadata=None,
@@ -14,7 +14,6 @@ from ...factories import RecordingFactory
from ...models import Recording, RecordingStatusChoices from ...models import Recording, RecordingStatusChoices
from ...recording.event.exceptions import ( from ...recording.event.exceptions import (
InvalidBucketError, InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError, InvalidFileTypeError,
ParsingEventDataError, ParsingEventDataError,
) )
@@ -95,7 +94,7 @@ def test_save_recording_parsing_error(recording_settings, mock_get_parser, clien
) )
assert response.status_code == 403 assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data."} assert response.json() == {"detail": "Invalid request data: Error message"}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client): def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
@@ -112,7 +111,7 @@ def test_save_recording_bucket_error(recording_settings, mock_get_parser, client
) )
assert response.status_code == 403 assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified."} assert response.json() == {"detail": "Invalid bucket specified"}
def test_save_recording_filetype_error(recording_settings, mock_get_parser): def test_save_recording_filetype_error(recording_settings, mock_get_parser):
@@ -133,28 +132,7 @@ def test_save_recording_filetype_error(recording_settings, mock_get_parser):
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"message": "Notification ignored."} assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
def test_save_recording_filepath_error(recording_settings, mock_get_parser):
"""Test handling of unsupported filepath in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFilepathError(
"Invalid filepath structure: parent/folder/recording.jpeg"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Notification ignored."}
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client): def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
@@ -240,9 +240,10 @@ def test_api_rooms_invite_error(mock_invite_to_room):
mock_invite_to_room.assert_called_once() mock_invite_to_room.assert_called_once()
@mock.patch("core.services.invitation.EmailMultiAlternatives") @mock.patch("core.services.invitation.send_mail")
def test_api_rooms_invite_success(mock_email_class, settings): def test_api_rooms_invite_success(mock_send_mail, settings):
"""Test privileged users should successfully send invitation emails.""" """Test privileged users should successfully send invitation emails."""
settings.EMAIL_BRAND_NAME = "ACME" settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo" settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_APP_BASE_URL = "https://acme.com" settings.EMAIL_APP_BASE_URL = "https://acme.com"
@@ -254,6 +255,7 @@ def test_api_rooms_invite_success(mock_email_class, settings):
user = UserFactory() user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"])) room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user) client.force_login(user)
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]} data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
@@ -267,38 +269,26 @@ def test_api_rooms_invite_success(mock_email_class, settings):
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"status": "success", "message": "invitations sent"} assert response.json() == {"status": "success", "message": "invitations sent"}
mock_email_class.assert_called_once() mock_send_mail.assert_called_once()
# Check constructor arguments subject, body, sender, recipients = mock_send_mail.call_args[0]
call_kwargs = mock_email_class.call_args[1] # EmailMultiAlternatives(**kwargs)
assert call_kwargs["subject"] == ( assert (
f"Video call in progress: {user.email} is waiting for you to connect" subject == f"Video call in progress: {user.email} is waiting for you to connect"
)
assert call_kwargs["from_email"] == "notifications@acme.com"
assert call_kwargs["to"] == []
assert sorted(call_kwargs["bcc"]) == sorted(
["fabien@yopmail.com", "gerald@yopmail.com"]
) )
# Check plain text body # Verify email contains expected content
plain_body = call_kwargs["body"]
required_content = [ required_content = [
"ACME", "ACME", # Brand name
"https://acme.com/logo", "https://acme.com/logo", # Logo URL
f"https://acme.com/{room.slug}", f"https://acme.com/{room.slug}", # Room url
f"acme.com/{room.slug}", f"acme.com/{room.slug}", # Room link
] ]
for content in required_content:
assert content in plain_body
# Check HTML alternative was attached
mock_instance = mock_email_class.return_value
mock_instance.attach_alternative.assert_called_once()
html_body, mimetype = mock_instance.attach_alternative.call_args[0]
assert mimetype == "text/html"
for content in required_content: for content in required_content:
assert content in html_body assert content in body
# Check send was called assert sender == "notifications@acme.com"
mock_instance.send.assert_called_once()
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
@@ -130,11 +130,10 @@ def test_update_participant_success(mock_livekit_client):
"can_publish": True, "can_publish": True,
"can_publish_data": True, "can_publish_data": True,
"can_publish_sources": [ "can_publish_sources": [
"CAMERA", 1,
"MICROPHONE", 2,
], ], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"can_update_metadata": True, "can_update_metadata": True,
"can_subscribe_metrics": True,
}, },
"name": "John Doe", "name": "John Doe",
} }
@@ -156,14 +155,8 @@ def test_update_participant_success(mock_livekit_client):
{"can_subscribe": True}, {"can_subscribe": True},
{"can_publish": True}, {"can_publish": True},
{"can_publish_data": True}, {"can_publish_data": True},
{ {"can_publish_sources": [1, 2]},
"can_publish_sources": [
"CAMERA",
"MICROPHONE",
]
},
{"can_update_metadata": True}, {"can_update_metadata": True},
{"can_subscribe_metrics": False},
], ],
) )
def test_update_participant_permission_fields_are_optional( def test_update_participant_permission_fields_are_optional(
@@ -271,6 +264,35 @@ def test_update_participant_suspicious_permission_multiple(mock_suspicious):
) )
@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()
@@ -232,7 +232,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
""" """
room = RoomFactory( room = RoomFactory(
access_level=RoomAccessLevel.PUBLIC, access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["mock-source"]}, configuration={"can_publish_sources": ["camera"]},
) )
user = UserFactory() user = UserFactory()
@@ -264,7 +264,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
user=user, user=user,
username=None, username=None,
color=None, color=None,
sources=["mock-source"], sources=["camera"],
is_admin_or_owner=False, is_admin_or_owner=False,
participant_id=None, participant_id=None,
) )
@@ -363,7 +363,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
other_user = UserFactory() other_user = UserFactory()
room = RoomFactory( room = RoomFactory(
configuration={"can_publish_sources": ["mock-source"]}, configuration={"can_publish_sources": ["camera"]},
) )
UserResourceAccessFactory(resource=room, user=user, role="member") UserResourceAccessFactory(resource=room, user=user, role="member")
UserResourceAccessFactory(resource=room, user=other_user, role="member") UserResourceAccessFactory(resource=room, user=other_user, role="member")
@@ -401,7 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
user=user, user=user,
username=None, username=None,
color=None, color=None,
sources=["mock-source"], sources=["camera"],
is_admin_or_owner=False, is_admin_or_owner=False,
participant_id=None, participant_id=None,
) )
@@ -95,7 +95,7 @@ def test_api_rooms_update_administrators():
"name": "New name", "name": "New name",
"slug": "should-be-ignored", "slug": "should-be-ignored",
"access_level": RoomAccessLevel.PUBLIC, "access_level": RoomAccessLevel.PUBLIC,
"configuration": {"the_key": "the_value"}, "configuration": {"can_publish_sources": ["camera"]},
}, },
format="json", format="json",
) )
@@ -104,7 +104,7 @@ def test_api_rooms_update_administrators():
assert room.name == "New name" assert room.name == "New name"
assert room.slug == "new-name" assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.PUBLIC assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"the_key": "the_value"} assert room.configuration == {"can_publish_sources": ["camera"]}
def test_api_rooms_update_administrators_of_another(): def test_api_rooms_update_administrators_of_another():
@@ -77,6 +77,7 @@ def test_missing_auth_header(client, serialized_event_data, mock_livekit_config)
assert response.status_code == 401 assert response.status_code == 401
assert response.json() == { assert response.json() == {
"status": "error", "status": "error",
"message": "Authorization header missing",
} }
@@ -90,7 +91,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
) )
assert response.status_code == 400 assert response.status_code == 400
assert response.json() == {"status": "error"} assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
def test_unknown_event_type(client, mock_livekit_config): def test_unknown_event_type(client, mock_livekit_config):
@@ -115,6 +116,7 @@ def test_unknown_event_type(client, mock_livekit_config):
assert response.status_code == 422 assert response.status_code == 422
assert response.json() == { assert response.json() == {
"status": "error", "status": "error",
"message": "Unknown webhook type: unknown_event_type",
} }
@@ -904,7 +904,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."""
application = ApplicationFactory(is_active=False) application = ApplicationFactory(active=False)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
payload = { payload = {
@@ -23,7 +23,7 @@ def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token.""" """Valid credentials should return a JWT token."""
UserFactory(email="User.Family@example.com") UserFactory(email="User.Family@example.com")
application = ApplicationFactory( application = ApplicationFactory(
is_active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
) )
@@ -79,7 +79,7 @@ def test_api_applications_generate_token_invalid_client_id():
def test_api_applications_generate_token_invalid_client_secret(): def test_api_applications_generate_token_invalid_client_secret():
"""Invalid client_secret should return 401.""" """Invalid client_secret should return 401."""
user = UserFactory(email="user@example.com") user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True) application = ApplicationFactory(active=True)
client = APIClient() client = APIClient()
response = client.post( response = client.post(
@@ -100,7 +100,7 @@ def test_api_applications_generate_token_invalid_client_secret():
def test_api_applications_generate_token_inactive_application(): def test_api_applications_generate_token_inactive_application():
"""Inactive application should return 401.""" """Inactive application should return 401."""
user = UserFactory(email="user@example.com") user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=False) application = ApplicationFactory(active=False)
plain_secret = "test-secret-123" plain_secret = "test-secret-123"
application.client_secret = plain_secret application.client_secret = plain_secret
@@ -122,31 +122,9 @@ def test_api_applications_generate_token_inactive_application():
assert "Application is inactive" in str(response.data) assert "Application is inactive" in str(response.data)
def test_api_applications_generate_token_inactive_application_wrong_secret():
"""An inactive application with a wrong secret should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=False)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
assert "inactive" not in str(response.data).lower()
def test_api_applications_generate_token_invalid_email_format(): def test_api_applications_generate_token_invalid_email_format():
"""Invalid email format should return 400.""" """Invalid email format should return 400."""
application = ApplicationFactory(is_active=True) application = ApplicationFactory(active=True)
plain_secret = "test-secret-123" plain_secret = "test-secret-123"
application.client_secret = plain_secret application.client_secret = plain_secret
@@ -171,7 +149,7 @@ def test_api_applications_generate_token_invalid_email_format():
def test_api_applications_generate_token_domain_not_authorized(): def test_api_applications_generate_token_domain_not_authorized():
"""Application without domain authorization should return 403.""" """Application without domain authorization should return 403."""
user = UserFactory(email="user@denied.com") user = UserFactory(email="user@denied.com")
application = ApplicationFactory(is_active=True) application = ApplicationFactory(active=True)
ApplicationDomainFactory(application=application, domain="allowed.com") ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123" plain_secret = "test-secret-123"
@@ -198,7 +176,7 @@ def test_api_applications_generate_token_domain_authorized():
"""Application with domain authorization should succeed.""" """Application with domain authorization should succeed."""
user = UserFactory(email="user@allowed.com") user = UserFactory(email="user@allowed.com")
application = ApplicationFactory( application = ApplicationFactory(
is_active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST], scopes=[ApplicationScope.ROOMS_LIST],
) )
ApplicationDomainFactory(application=application, domain="allowed.com") ApplicationDomainFactory(application=application, domain="allowed.com")
@@ -225,7 +203,7 @@ def test_api_applications_generate_token_domain_authorized():
def test_api_applications_generate_token_user_not_found(): def test_api_applications_generate_token_user_not_found():
"""Non-existent user should return 404.""" """Non-existent user should return 404."""
application = ApplicationFactory(is_active=True) application = ApplicationFactory(active=True)
plain_secret = "test-secret-123" plain_secret = "test-secret-123"
application.client_secret = plain_secret application.client_secret = plain_secret
@@ -253,7 +231,7 @@ def test_api_applications_token_payload_structure(settings):
user = UserFactory(email="user@example.com") user = UserFactory(email="user@example.com")
application = ApplicationFactory( application = ApplicationFactory(
is_active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
) )
@@ -306,7 +284,7 @@ def test_api_applications_token_new_user(settings):
assert len(User.objects.all()) == 0 assert len(User.objects.all()) == 0
application = ApplicationFactory( application = ApplicationFactory(
is_active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
) )
@@ -364,7 +342,7 @@ def test_api_applications_token_existing_user(settings):
assert len(User.objects.all()) == 1 assert len(User.objects.all()) == 1
application = ApplicationFactory( application = ApplicationFactory(
is_active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
) )
@@ -41,7 +41,7 @@ def test_models_application_name_maxlength():
def test_models_application_active_default(): def test_models_application_active_default():
"""An application should be active by default.""" """An application should be active by default."""
application = Application.objects.create(name="Test App") application = Application.objects.create(name="Test App")
assert application.is_active is True assert application.active is True
def test_models_application_scopes_default(): def test_models_application_scopes_default():
+4 -45
View File
@@ -66,7 +66,6 @@ def generate_token(
sources: Optional[List[str]] = None, sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False, is_admin_or_owner: bool = False,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
encryption_mode: str = 'none',
) -> str: ) -> str:
"""Generate a LiveKit access token for a user in a specific room. """Generate a LiveKit access token for a user in a specific room.
@@ -93,15 +92,11 @@ def generate_token(
if sources is None: if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES sources = settings.LIVEKIT_DEFAULT_SOURCES
# In encrypted rooms, no one can change their name/metadata to prevent
# identity spoofing — the admin accepted them based on their declared identity.
can_update_metadata = encryption_mode == 'none'
video_grants = VideoGrants( video_grants = VideoGrants(
room=room, room=room,
room_join=True, room_join=True,
room_admin=is_admin_or_owner, room_admin=is_admin_or_owner,
can_update_own_metadata=can_update_metadata, can_update_own_metadata=True,
can_publish=bool(sources), can_publish=bool(sources),
can_publish_sources=sources, can_publish_sources=sources,
can_subscribe=True, can_subscribe=True,
@@ -117,42 +112,6 @@ def generate_token(
if color is None: if color is None:
color = generate_color(identity) color = generate_color(identity)
# Build participant attributes — these are server-signed in the JWT
# and visible to all participants in the room.
attributes = {
"color": color,
"room_admin": "true" if is_admin_or_owner else "false",
"is_authenticated": "true" if not user.is_anonymous else "false",
}
# Add identity info for authenticated users in encrypted rooms only.
#
# Email and suite_user_id are included in the JWT attributes for encrypted
# rooms because:
# - Email: allows admins to verify participant identity in the lobby and
# participant list (important for trust decisions in encrypted meetings)
# - suite_user_id: required for vault key exchange in advanced encryption
# (vaultClient.shareKeys needs the recipient's user ID)
#
# These attributes are NOT included in non-encrypted rooms because:
# - Non-encrypted rooms have no waiting room, so anonymous users can join
# freely and would see everyone's email via LiveKit signaling
# - LiveKit JWT attributes are immutable and broadcast to ALL participants
# equally — there is no way to show them only to authenticated users
# at the protocol level
# - The frontend additionally hides email from anonymous users in the UI,
# but this is defense-in-depth, not the primary protection
#
# Future improvement: serve email via a Django API endpoint that checks
# the requester's authentication, removing it from the JWT entirely.
# This would require the backend to call LiveKit's ListParticipants API
# to cross-reference identities with the user database.
if not user.is_anonymous and encryption_mode != 'none':
if user.email:
attributes["email"] = user.email
if user.sub:
attributes["suite_user_id"] = str(user.sub)
token = ( token = (
AccessToken( AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"], api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -161,7 +120,9 @@ def generate_token(
.with_grants(video_grants) .with_grants(video_grants)
.with_identity(identity) .with_identity(identity)
.with_name(username or default_username) .with_name(username or default_username)
.with_attributes(attributes) .with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
) )
return token.to_jwt() return token.to_jwt()
@@ -175,7 +136,6 @@ def generate_livekit_config(
color: Optional[str] = None, color: Optional[str] = None,
configuration: Optional[dict] = None, configuration: Optional[dict] = None,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
encryption_mode: str = 'none',
) -> dict: ) -> dict:
"""Generate LiveKit configuration for room access. """Generate LiveKit configuration for room access.
@@ -208,7 +168,6 @@ def generate_livekit_config(
sources=sources, sources=sources,
is_admin_or_owner=is_admin_or_owner, is_admin_or_owner=is_admin_or_owner,
participant_id=participant_id, participant_id=participant_id,
encryption_mode=encryption_mode,
), ),
} }
Binary file not shown.
+96 -115
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n" "POT-Creation-Date: 2026-02-26 17:34+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,11 +29,11 @@ msgstr "Berechtigungen"
msgid "Important dates" msgid "Important dates"
msgstr "Wichtige Daten" msgstr "Wichtige Daten"
#: core/admin.py:132 core/admin.py:275 #: core/admin.py:132 core/admin.py:243
msgid "No owner" msgid "No owner"
msgstr "Kein Eigentümer" msgstr "Kein Eigentümer"
#: core/admin.py:135 core/admin.py:278 #: core/admin.py:135 core/admin.py:246
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Mehrere Eigentümer" msgstr "Mehrere Eigentümer"
@@ -61,136 +61,117 @@ msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
msgid "Skipped %(count)s expired recording(s)." msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen." msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:200 #: core/admin.py:309
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Ausgewählte Aufnahmen als Fehler beim Stoppen markieren"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s Aufnahme(n) erfolgreich als Fehler beim Stoppen markiert."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:342
msgid "No scopes" msgid "No scopes"
msgstr "Keine Scopes" msgstr "Keine Scopes"
#: core/admin.py:344 #: core/admin.py:311
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
#: core/api/filters.py:25 #: core/api/filters.py:24
msgid "Creator is me" msgid "Creator is me"
msgstr "Ersteller bin ich" msgstr "Ersteller bin ich"
#: core/api/serializers.py:88 #: core/api/serializers.py:84
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe " "Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen." "hinzuzufügen."
#: core/api/serializers.py:516 #: core/api/serializers.py:443
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt." msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/serializers.py:533 #: core/models.py:35
msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
#: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Mitglied" msgstr "Mitglied"
#: core/models.py:38 #: core/models.py:36
msgid "Administrator" msgid "Administrator"
msgstr "Administrator" msgstr "Administrator"
#: core/models.py:39 #: core/models.py:37
msgid "Owner" msgid "Owner"
msgstr "Eigentümer" msgstr "Eigentümer"
#: core/models.py:55 #: core/models.py:53
msgid "Initiated" msgid "Initiated"
msgstr "Gestartet" msgstr "Gestartet"
#: core/models.py:56 #: core/models.py:54
msgid "Active" msgid "Active"
msgstr "Aktiv" msgstr "Aktiv"
#: core/models.py:57 #: core/models.py:55
msgid "Stopped" msgid "Stopped"
msgstr "Beendet" msgstr "Beendet"
#: core/models.py:58 #: core/models.py:56
msgid "Saved" msgid "Saved"
msgstr "Gespeichert" msgstr "Gespeichert"
#: core/models.py:59 #: core/models.py:57
msgid "Aborted" msgid "Aborted"
msgstr "Abgebrochen" msgstr "Abgebrochen"
#: core/models.py:60 #: core/models.py:58
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Start fehlgeschlagen" msgstr "Start fehlgeschlagen"
#: core/models.py:61 #: core/models.py:59
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen" msgstr "Stopp fehlgeschlagen"
#: core/models.py:62 #: core/models.py:60
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich" msgstr "Benachrichtigung erfolgreich"
#: core/models.py:89 #: core/models.py:87
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG" msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:90 #: core/models.py:88
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSKRIPT" msgstr "TRANSKRIPT"
#: core/models.py:96 #: core/models.py:94
msgid "Public Access" msgid "Public Access"
msgstr "Öffentlicher Zugriff" msgstr "Öffentlicher Zugriff"
#: core/models.py:97 #: core/models.py:95
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff" msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:98 #: core/models.py:96
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff" msgstr "Eingeschränkter Zugriff"
#: core/models.py:110 #: core/models.py:108
msgid "id" msgid "id"
msgstr "ID" msgstr "ID"
#: core/models.py:111 #: core/models.py:109
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID" msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:117 #: core/models.py:115
msgid "created on" msgid "created on"
msgstr "erstellt am" msgstr "erstellt am"
#: core/models.py:118 #: core/models.py:116
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags" msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:123 #: core/models.py:121
msgid "updated on" msgid "updated on"
msgstr "aktualisiert am" msgstr "aktualisiert am"
#: core/models.py:124 #: core/models.py:122
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags" msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:144 #: core/models.py:142
msgid "" msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters." "_ characters."
@@ -198,11 +179,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen " "Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten." "und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:150 #: core/models.py:148
msgid "sub" msgid "sub"
msgstr "Sub" msgstr "Sub"
#: core/models.py:152 #: core/models.py:150
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -210,55 +191,55 @@ msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. " "Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt." "Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:161 #: core/models.py:159
msgid "identity email address" msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse" msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:166 #: core/models.py:164
msgid "admin email address" msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse" msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:168 #: core/models.py:166
msgid "full name" msgid "full name"
msgstr "Vollständiger Name" msgstr "Vollständiger Name"
#: core/models.py:170 #: core/models.py:168
msgid "short name" msgid "short name"
msgstr "Kurzname" msgstr "Kurzname"
#: core/models.py:176 #: core/models.py:174
msgid "language" msgid "language"
msgstr "Sprache" msgstr "Sprache"
#: core/models.py:177 #: core/models.py:175
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte." msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:183 #: core/models.py:181
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte." msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:186 #: core/models.py:184
msgid "device" msgid "device"
msgstr "Gerät" msgstr "Gerät"
#: core/models.py:188 #: core/models.py:186
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt." msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:191 #: core/models.py:189
msgid "staff status" msgid "staff status"
msgstr "Mitarbeiterstatus" msgstr "Mitarbeiterstatus"
#: core/models.py:193 #: core/models.py:191
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann." msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:196 #: core/models.py:194
msgid "active" msgid "active"
msgstr "aktiv" msgstr "aktiv"
#: core/models.py:199 #: core/models.py:197
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -266,66 +247,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies " "Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos." "anstelle des Löschens des Kontos."
#: core/models.py:212 #: core/models.py:210
msgid "user" msgid "user"
msgstr "Benutzer" msgstr "Benutzer"
#: core/models.py:213 #: core/models.py:211
msgid "users" msgid "users"
msgstr "Benutzer" msgstr "Benutzer"
#: core/models.py:272 #: core/models.py:270
msgid "Resource" msgid "Resource"
msgstr "Ressource" msgstr "Ressource"
#: core/models.py:273 #: core/models.py:271
msgid "Resources" msgid "Resources"
msgstr "Ressourcen" msgstr "Ressourcen"
#: core/models.py:331 #: core/models.py:329
msgid "Resource access" msgid "Resource access"
msgstr "Ressourcenzugriff" msgstr "Ressourcenzugriff"
#: core/models.py:332 #: core/models.py:330
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Ressourcenzugriffe" msgstr "Ressourcenzugriffe"
#: core/models.py:338 #: core/models.py:336
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "" msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert " "Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits." "bereits."
#: core/models.py:394 #: core/models.py:392
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration" msgstr "Visio-Raumkonfiguration"
#: core/models.py:395 #: core/models.py:393
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums." msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:402 #: core/models.py:400
msgid "Room PIN code" msgid "Room PIN code"
msgstr "PIN-Code für den Raum" msgstr "PIN-Code für den Raum"
#: core/models.py:403 #: core/models.py:401
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "" msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert." "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:409 core/models.py:563 #: core/models.py:407 core/models.py:561
msgid "Room" msgid "Room"
msgstr "Raum" msgstr "Raum"
#: core/models.py:410 #: core/models.py:408
msgid "Rooms" msgid "Rooms"
msgstr "Räume" msgstr "Räume"
#: core/models.py:574 #: core/models.py:572
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker-ID" msgstr "Worker-ID"
#: core/models.py:576 #: core/models.py:574
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -334,120 +315,120 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen " "erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht." "ermöglicht."
#: core/models.py:584 #: core/models.py:582
msgid "Recording mode" msgid "Recording mode"
msgstr "Aufzeichnungsmodus" msgstr "Aufzeichnungsmodus"
#: core/models.py:585 #: core/models.py:583
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus." msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:590 core/models.py:591 #: core/models.py:588 core/models.py:589
msgid "Recording options" msgid "Recording options"
msgstr "Aufnahmeoptionen" msgstr "Aufnahmeoptionen"
#: core/models.py:597 #: core/models.py:595
msgid "Recording" msgid "Recording"
msgstr "Aufzeichnung" msgstr "Aufzeichnung"
#: core/models.py:598 #: core/models.py:596
msgid "Recordings" msgid "Recordings"
msgstr "Aufzeichnungen" msgstr "Aufzeichnungen"
#: core/models.py:706 #: core/models.py:704
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer" msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:707 #: core/models.py:705
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer" msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:713 #: core/models.py:711
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung." msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:719 #: core/models.py:717
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung." msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:725 #: core/models.py:723
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides." msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:742 #: core/models.py:740
msgid "Create rooms" msgid "Create rooms"
msgstr "Räume erstellen" msgstr "Räume erstellen"
#: core/models.py:743 #: core/models.py:741
msgid "List rooms" msgid "List rooms"
msgstr "Räume auflisten" msgstr "Räume auflisten"
#: core/models.py:744 #: core/models.py:742
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Raumdetails abrufen" msgstr "Raumdetails abrufen"
#: core/models.py:745 #: core/models.py:743
msgid "Update rooms" msgid "Update rooms"
msgstr "Räume aktualisieren" msgstr "Räume aktualisieren"
#: core/models.py:746 #: core/models.py:744
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Räume löschen" msgstr "Räume löschen"
#: core/models.py:759 #: core/models.py:757
msgid "Application name" msgid "Application name"
msgstr "Anwendungsname" msgstr "Anwendungsname"
#: core/models.py:760 #: core/models.py:758
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung." msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:770 #: core/models.py:768
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "" msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist." "Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:781 #: core/models.py:779
msgid "Application" msgid "Application"
msgstr "Anwendung" msgstr "Anwendung"
#: core/models.py:782 #: core/models.py:780
msgid "Applications" msgid "Applications"
msgstr "Anwendungen" msgstr "Anwendungen"
#: core/models.py:805 #: core/models.py:803
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein" msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:808 #: core/models.py:806
msgid "Domain" msgid "Domain"
msgstr "Domain" msgstr "Domain"
#: core/models.py:809 #: core/models.py:807
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann." msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:821 #: core/models.py:819
msgid "Application domain" msgid "Application domain"
msgstr "Anwendungsdomain" msgstr "Anwendungsdomain"
#: core/models.py:822 #: core/models.py:820
msgid "Application domains" msgid "Application domains"
msgstr "Anwendungsdomains" msgstr "Anwendungsdomains"
#: core/models.py:840 #: core/models.py:838
msgid "Pending" msgid "Pending"
msgstr "Ausstehend" msgstr "Ausstehend"
#: core/models.py:848 #: core/models.py:846
msgid "Ready" msgid "Ready"
msgstr "Bereit" msgstr "Bereit"
#: core/models.py:854 #: core/models.py:852
msgid "Background image" msgid "Background image"
msgstr "Hintergrundbild" msgstr "Hintergrundbild"
#: core/models.py:866 #: core/models.py:864
msgid "title" msgid "title"
msgstr "Titel" msgstr "Titel"
@@ -464,11 +445,11 @@ msgstr "Datei"
msgid "Files" msgid "Files"
msgstr "Dateien" msgstr "Dateien"
#: core/models.py:1000 #: core/models.py:970
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht." msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1010 #: core/models.py:980
#, fuzzy #, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted." #| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
@@ -604,18 +585,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte " " Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. " "an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:223 #: meet/settings.py:224
msgid "English" msgid "English"
msgstr "Englisch" msgstr "Englisch"
#: meet/settings.py:224 #: meet/settings.py:225
msgid "French" msgid "French"
msgstr "Französisch" msgstr "Französisch"
#: meet/settings.py:225 #: meet/settings.py:226
msgid "Dutch" msgid "Dutch"
msgstr "Niederländisch" msgstr "Niederländisch"
#: meet/settings.py:226 #: meet/settings.py:227
msgid "German" msgid "German"
msgstr "Deutsch" msgstr "Deutsch"
Binary file not shown.
+96 -115
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n" "POT-Creation-Date: 2026-02-26 17:26+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,11 +29,11 @@ msgstr "Permissions"
msgid "Important dates" msgid "Important dates"
msgstr "Important dates" msgstr "Important dates"
#: core/admin.py:132 core/admin.py:275 #: core/admin.py:132 core/admin.py:243
msgid "No owner" msgid "No owner"
msgstr "No owner" msgstr "No owner"
#: core/admin.py:135 core/admin.py:278 #: core/admin.py:135 core/admin.py:246
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Multiple owners" msgstr "Multiple owners"
@@ -61,134 +61,115 @@ msgstr "Successfully sent notifications for %(count)s recording(s)."
msgid "Skipped %(count)s expired recording(s)." msgid "Skipped %(count)s expired recording(s)."
msgstr "Skipped %(count)s expired recording(s)." msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:200 #: core/admin.py:309
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Mark selected recordings as 'Failed to Stop'"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:342
msgid "No scopes" msgid "No scopes"
msgstr "No scopes" msgstr "No scopes"
#: core/admin.py:344 #: core/admin.py:311
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
#: core/api/filters.py:25 #: core/api/filters.py:24
msgid "Creator is me" msgid "Creator is me"
msgstr "Creator is me" msgstr "Creator is me"
#: core/api/serializers.py:88 #: core/api/serializers.py:84
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it." msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/api/serializers.py:516 #: core/api/serializers.py:443
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "This file extension is not allowed." msgstr "This file extension is not allowed."
#: core/api/serializers.py:533 #: core/models.py:35
msgid "You have reached the maximum number of files for this type."
msgstr "You have reached the maximum number of files for this type."
#: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Member" msgstr "Member"
#: core/models.py:38 #: core/models.py:36
msgid "Administrator" msgid "Administrator"
msgstr "Administrator" msgstr "Administrator"
#: core/models.py:39 #: core/models.py:37
msgid "Owner" msgid "Owner"
msgstr "Owner" msgstr "Owner"
#: core/models.py:55 #: core/models.py:53
msgid "Initiated" msgid "Initiated"
msgstr "Initiated" msgstr "Initiated"
#: core/models.py:56 #: core/models.py:54
msgid "Active" msgid "Active"
msgstr "Active" msgstr "Active"
#: core/models.py:57 #: core/models.py:55
msgid "Stopped" msgid "Stopped"
msgstr "Stopped" msgstr "Stopped"
#: core/models.py:58 #: core/models.py:56
msgid "Saved" msgid "Saved"
msgstr "Saved" msgstr "Saved"
#: core/models.py:59 #: core/models.py:57
msgid "Aborted" msgid "Aborted"
msgstr "Aborted" msgstr "Aborted"
#: core/models.py:60 #: core/models.py:58
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Failed to Start" msgstr "Failed to Start"
#: core/models.py:61 #: core/models.py:59
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Failed to Stop" msgstr "Failed to Stop"
#: core/models.py:62 #: core/models.py:60
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notification succeeded" msgstr "Notification succeeded"
#: core/models.py:89 #: core/models.py:87
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING" msgstr "SCREEN_RECORDING"
#: core/models.py:90 #: core/models.py:88
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPT" msgstr "TRANSCRIPT"
#: core/models.py:96 #: core/models.py:94
msgid "Public Access" msgid "Public Access"
msgstr "Public Access" msgstr "Public Access"
#: core/models.py:97 #: core/models.py:95
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Trusted Access" msgstr "Trusted Access"
#: core/models.py:98 #: core/models.py:96
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Restricted Access" msgstr "Restricted Access"
#: core/models.py:110 #: core/models.py:108
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:111 #: core/models.py:109
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID" msgstr "primary key for the record as UUID"
#: core/models.py:117 #: core/models.py:115
msgid "created on" msgid "created on"
msgstr "created on" msgstr "created on"
#: core/models.py:118 #: core/models.py:116
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "date and time at which a record was created" msgstr "date and time at which a record was created"
#: core/models.py:123 #: core/models.py:121
msgid "updated on" msgid "updated on"
msgstr "updated on" msgstr "updated on"
#: core/models.py:124 #: core/models.py:122
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated" msgstr "date and time at which a record was last updated"
#: core/models.py:144 #: core/models.py:142
msgid "" msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters." "_ characters."
@@ -196,11 +177,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters." "_ characters."
#: core/models.py:150 #: core/models.py:148
msgid "sub" msgid "sub"
msgstr "sub" msgstr "sub"
#: core/models.py:152 #: core/models.py:150
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -208,55 +189,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ " "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only." "characters only."
#: core/models.py:161 #: core/models.py:159
msgid "identity email address" msgid "identity email address"
msgstr "identity email address" msgstr "identity email address"
#: core/models.py:166 #: core/models.py:164
msgid "admin email address" msgid "admin email address"
msgstr "admin email address" msgstr "admin email address"
#: core/models.py:168 #: core/models.py:166
msgid "full name" msgid "full name"
msgstr "full name" msgstr "full name"
#: core/models.py:170 #: core/models.py:168
msgid "short name" msgid "short name"
msgstr "short name" msgstr "short name"
#: core/models.py:176 #: core/models.py:174
msgid "language" msgid "language"
msgstr "language" msgstr "language"
#: core/models.py:177 #: core/models.py:175
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface." msgstr "The language in which the user wants to see the interface."
#: core/models.py:183 #: core/models.py:181
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times." msgstr "The timezone in which the user wants to see times."
#: core/models.py:186 #: core/models.py:184
msgid "device" msgid "device"
msgstr "device" msgstr "device"
#: core/models.py:188 #: core/models.py:186
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user." msgstr "Whether the user is a device or a real user."
#: core/models.py:191 #: core/models.py:189
msgid "staff status" msgid "staff status"
msgstr "staff status" msgstr "staff status"
#: core/models.py:193 #: core/models.py:191
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site." msgstr "Whether the user can log into this admin site."
#: core/models.py:196 #: core/models.py:194
msgid "active" msgid "active"
msgstr "active" msgstr "active"
#: core/models.py:199 #: core/models.py:197
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -264,63 +245,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
#: core/models.py:212 #: core/models.py:210
msgid "user" msgid "user"
msgstr "user" msgstr "user"
#: core/models.py:213 #: core/models.py:211
msgid "users" msgid "users"
msgstr "users" msgstr "users"
#: core/models.py:272 #: core/models.py:270
msgid "Resource" msgid "Resource"
msgstr "Resource" msgstr "Resource"
#: core/models.py:273 #: core/models.py:271
msgid "Resources" msgid "Resources"
msgstr "Resources" msgstr "Resources"
#: core/models.py:331 #: core/models.py:329
msgid "Resource access" msgid "Resource access"
msgstr "Resource access" msgstr "Resource access"
#: core/models.py:332 #: core/models.py:330
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Resource accesses" msgstr "Resource accesses"
#: core/models.py:338 #: core/models.py:336
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists." msgstr "Resource access with this User and Resource already exists."
#: core/models.py:394 #: core/models.py:392
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio room configuration" msgstr "Visio room configuration"
#: core/models.py:395 #: core/models.py:393
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room." msgstr "Values for Visio parameters to configure the room."
#: core/models.py:402 #: core/models.py:400
msgid "Room PIN code" msgid "Room PIN code"
msgstr "Room PIN code" msgstr "Room PIN code"
#: core/models.py:403 #: core/models.py:401
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode." msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:409 core/models.py:563 #: core/models.py:407 core/models.py:561
msgid "Room" msgid "Room"
msgstr "Room" msgstr "Room"
#: core/models.py:410 #: core/models.py:408
msgid "Rooms" msgid "Rooms"
msgstr "Rooms" msgstr "Rooms"
#: core/models.py:574 #: core/models.py:572
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker ID" msgstr "Worker ID"
#: core/models.py:576 #: core/models.py:574
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -328,125 +309,125 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
#: core/models.py:584 #: core/models.py:582
msgid "Recording mode" msgid "Recording mode"
msgstr "Recording mode" msgstr "Recording mode"
#: core/models.py:585 #: core/models.py:583
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called." msgstr "Defines the mode of recording being called."
#: core/models.py:590 core/models.py:591 #: core/models.py:588 core/models.py:589
msgid "Recording options" msgid "Recording options"
msgstr "Recording options" msgstr "Recording options"
#: core/models.py:597 #: core/models.py:595
msgid "Recording" msgid "Recording"
msgstr "Recording" msgstr "Recording"
#: core/models.py:598 #: core/models.py:596
msgid "Recordings" msgid "Recordings"
msgstr "Recordings" msgstr "Recordings"
#: core/models.py:706 #: core/models.py:704
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Recording/user relation" msgstr "Recording/user relation"
#: core/models.py:707 #: core/models.py:705
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Recording/user relations" msgstr "Recording/user relations"
#: core/models.py:713 #: core/models.py:711
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "This user is already in this recording." msgstr "This user is already in this recording."
#: core/models.py:719 #: core/models.py:717
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "This team is already in this recording." msgstr "This team is already in this recording."
#: core/models.py:725 #: core/models.py:723
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both." msgstr "Either user or team must be set, not both."
#: core/models.py:742 #: core/models.py:740
#, fuzzy #, fuzzy
#| msgid "created on" #| msgid "created on"
msgid "Create rooms" msgid "Create rooms"
msgstr "Create rooms" msgstr "Create rooms"
#: core/models.py:743 #: core/models.py:741
msgid "List rooms" msgid "List rooms"
msgstr "List rooms" msgstr "List rooms"
#: core/models.py:744 #: core/models.py:742
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Retrieve room details" msgstr "Retrieve room details"
#: core/models.py:745 #: core/models.py:743
#, fuzzy #, fuzzy
#| msgid "updated on" #| msgid "updated on"
msgid "Update rooms" msgid "Update rooms"
msgstr "Update rooms" msgstr "Update rooms"
#: core/models.py:746 #: core/models.py:744
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Delete rooms" msgstr "Delete rooms"
#: core/models.py:759 #: core/models.py:757
msgid "Application name" msgid "Application name"
msgstr "Application name" msgstr "Application name"
#: core/models.py:760 #: core/models.py:758
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Descriptive name for this application." msgstr "Descriptive name for this application."
#: core/models.py:770 #: core/models.py:768
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret." msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:781 #: core/models.py:779
msgid "Application" msgid "Application"
msgstr "Application" msgstr "Application"
#: core/models.py:782 #: core/models.py:780
msgid "Applications" msgid "Applications"
msgstr "Applications" msgstr "Applications"
#: core/models.py:805 #: core/models.py:803
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Enter a valid domain" msgstr "Enter a valid domain"
#: core/models.py:808 #: core/models.py:806
msgid "Domain" msgid "Domain"
msgstr "Domain" msgstr "Domain"
#: core/models.py:809 #: core/models.py:807
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of." msgstr "Email domain this application can act on behalf of."
#: core/models.py:821 #: core/models.py:819
msgid "Application domain" msgid "Application domain"
msgstr "Application domain" msgstr "Application domain"
#: core/models.py:822 #: core/models.py:820
msgid "Application domains" msgid "Application domains"
msgstr "Application domains" msgstr "Application domains"
#: core/models.py:840 #: core/models.py:838
#, fuzzy #, fuzzy
#| msgid "Recording" #| msgid "Recording"
msgid "Pending" msgid "Pending"
msgstr "Pending" msgstr "Pending"
#: core/models.py:848 #: core/models.py:846
msgid "Ready" msgid "Ready"
msgstr "Ready" msgstr "Ready"
#: core/models.py:854 #: core/models.py:852
msgid "Background image" msgid "Background image"
msgstr "Background image" msgstr "Background image"
#: core/models.py:866 #: core/models.py:864
msgid "title" msgid "title"
msgstr "title" msgstr "title"
@@ -462,13 +443,13 @@ msgstr "File"
msgid "Files" msgid "Files"
msgstr "Files" msgstr "Files"
#: core/models.py:1000 #: core/models.py:970
#, fuzzy #, fuzzy
#| msgid "This user is already in this recording." #| msgid "This user is already in this recording."
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "This file is already hard deleted." msgstr "This file is already hard deleted."
#: core/models.py:1010 #: core/models.py:980
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
msgstr "To hard delete a file, it must first be soft deleted." msgstr "To hard delete a file, it must first be soft deleted."
@@ -600,18 +581,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. " "team at %(support_email)s. "
#: meet/settings.py:223 #: meet/settings.py:224
msgid "English" msgid "English"
msgstr "English" msgstr "English"
#: meet/settings.py:224 #: meet/settings.py:225
msgid "French" msgid "French"
msgstr "French" msgstr "French"
#: meet/settings.py:225 #: meet/settings.py:226
msgid "Dutch" msgid "Dutch"
msgstr "Dutch" msgstr "Dutch"
#: meet/settings.py:226 #: meet/settings.py:227
msgid "German" msgid "German"
msgstr "German" msgstr "German"
Binary file not shown.
+98 -121
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n" "POT-Creation-Date: 2026-02-26 17:26+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n" "Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,11 +29,11 @@ msgstr "Permissions"
msgid "Important dates" msgid "Important dates"
msgstr "Dates importantes" msgstr "Dates importantes"
#: core/admin.py:132 core/admin.py:275 #: core/admin.py:132 core/admin.py:243
msgid "No owner" msgid "No owner"
msgstr "Pas de propriétaire" msgstr "Pas de propriétaire"
#: core/admin.py:135 core/admin.py:278 #: core/admin.py:135 core/admin.py:246
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Plusieurs propriétaires" msgstr "Plusieurs propriétaires"
@@ -61,139 +61,119 @@ msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
msgid "Skipped %(count)s expired recording(s)." msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)." msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
#: core/admin.py:200 #: core/admin.py:309
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Marquer les enregistrements sélectionnés comme « Échec darrêt »"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr ""
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec darrêt »."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
#: core/admin.py:342
msgid "No scopes" msgid "No scopes"
msgstr "Aucun scopes" msgstr "Aucun scopes"
#: core/admin.py:344 #: core/admin.py:311
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
#: core/api/filters.py:25 #: core/api/filters.py:24
msgid "Creator is me" msgid "Creator is me"
msgstr "Je suis le créateur" msgstr "Je suis le créateur"
#: core/api/serializers.py:88 #: core/api/serializers.py:84
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter " "Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès." "des accès."
#: core/api/serializers.py:516 #: core/api/serializers.py:443
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée" msgstr "Cette extension n'est pas autorisée"
#: core/api/serializers.py:533 #: core/models.py:35
msgid "You have reached the maximum number of files for this type."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
#: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Membre" msgstr "Membre"
#: core/models.py:38 #: core/models.py:36
msgid "Administrator" msgid "Administrator"
msgstr "Administrateur" msgstr "Administrateur"
#: core/models.py:39 #: core/models.py:37
msgid "Owner" msgid "Owner"
msgstr "Propriétaire" msgstr "Propriétaire"
#: core/models.py:55 #: core/models.py:53
msgid "Initiated" msgid "Initiated"
msgstr "Initié" msgstr "Initié"
#: core/models.py:56 #: core/models.py:54
msgid "Active" msgid "Active"
msgstr "Actif" msgstr "Actif"
#: core/models.py:57 #: core/models.py:55
msgid "Stopped" msgid "Stopped"
msgstr "Arrêté" msgstr "Arrêté"
#: core/models.py:58 #: core/models.py:56
msgid "Saved" msgid "Saved"
msgstr "Enregistré" msgstr "Enregistré"
#: core/models.py:59 #: core/models.py:57
msgid "Aborted" msgid "Aborted"
msgstr "Abandonné" msgstr "Abandonné"
#: core/models.py:60 #: core/models.py:58
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Échec au démarrage" msgstr "Échec au démarrage"
#: core/models.py:61 #: core/models.py:59
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Échec à l'arrêt" msgstr "Échec à l'arrêt"
#: core/models.py:62 #: core/models.py:60
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notification réussie" msgstr "Notification réussie"
#: core/models.py:89 #: core/models.py:87
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN" msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:90 #: core/models.py:88
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION" msgstr "TRANSCRIPTION"
#: core/models.py:96 #: core/models.py:94
msgid "Public Access" msgid "Public Access"
msgstr "Accès public" msgstr "Accès public"
#: core/models.py:97 #: core/models.py:95
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Accès de confiance" msgstr "Accès de confiance"
#: core/models.py:98 #: core/models.py:96
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Accès restreint" msgstr "Accès restreint"
#: core/models.py:110 #: core/models.py:108
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:111 #: core/models.py:109
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID" msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:117 #: core/models.py:115
msgid "created on" msgid "created on"
msgstr "créé le" msgstr "créé le"
#: core/models.py:118 #: core/models.py:116
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé" msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:123 #: core/models.py:121
msgid "updated on" msgid "updated on"
msgstr "mis à jour le" msgstr "mis à jour le"
#: core/models.py:124 #: core/models.py:122
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "" msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière " "date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois" "fois"
#: core/models.py:144 #: core/models.py:142
msgid "" msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters." "_ characters."
@@ -201,11 +181,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des " "Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_." "chiffres et les caractères @/./+/-/_."
#: core/models.py:150 #: core/models.py:148
msgid "sub" msgid "sub"
msgstr "sub" msgstr "sub"
#: core/models.py:152 #: core/models.py:150
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -213,55 +193,55 @@ msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du " "Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement." "compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:161 #: core/models.py:159
msgid "identity email address" msgid "identity email address"
msgstr "adresse e-mail d'identité" msgstr "adresse e-mail d'identité"
#: core/models.py:166 #: core/models.py:164
msgid "admin email address" msgid "admin email address"
msgstr "adresse e-mail d'administrateur" msgstr "adresse e-mail d'administrateur"
#: core/models.py:168 #: core/models.py:166
msgid "full name" msgid "full name"
msgstr "nom complet" msgstr "nom complet"
#: core/models.py:170 #: core/models.py:168
msgid "short name" msgid "short name"
msgstr "nom court" msgstr "nom court"
#: core/models.py:176 #: core/models.py:174
msgid "language" msgid "language"
msgstr "langue" msgstr "langue"
#: core/models.py:177 #: core/models.py:175
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface." msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:183 #: core/models.py:181
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures." msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:186 #: core/models.py:184
msgid "device" msgid "device"
msgstr "appareil" msgstr "appareil"
#: core/models.py:188 #: core/models.py:186
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel." msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:191 #: core/models.py:189
msgid "staff status" msgid "staff status"
msgstr "statut du personnel" msgstr "statut du personnel"
#: core/models.py:193 #: core/models.py:191
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration." msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:196 #: core/models.py:194
msgid "active" msgid "active"
msgstr "actif" msgstr "actif"
#: core/models.py:199 #: core/models.py:197
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -269,65 +249,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option " "Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes." "au lieu de supprimer des comptes."
#: core/models.py:212 #: core/models.py:210
msgid "user" msgid "user"
msgstr "utilisateur" msgstr "utilisateur"
#: core/models.py:213 #: core/models.py:211
msgid "users" msgid "users"
msgstr "utilisateurs" msgstr "utilisateurs"
#: core/models.py:272 #: core/models.py:270
msgid "Resource" msgid "Resource"
msgstr "Ressource" msgstr "Ressource"
#: core/models.py:273 #: core/models.py:271
msgid "Resources" msgid "Resources"
msgstr "Ressources" msgstr "Ressources"
#: core/models.py:331 #: core/models.py:329
msgid "Resource access" msgid "Resource access"
msgstr "Accès aux ressources" msgstr "Accès aux ressources"
#: core/models.py:332 #: core/models.py:330
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Accès aux ressources" msgstr "Accès aux ressources"
#: core/models.py:338 #: core/models.py:336
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "" msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà." "L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:394 #: core/models.py:392
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence" msgstr "Configuration de la salle de visioconférence"
#: core/models.py:395 #: core/models.py:393
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle." msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:402 #: core/models.py:400
msgid "Room PIN code" msgid "Room PIN code"
msgstr "Code PIN de la salle" msgstr "Code PIN de la salle"
#: core/models.py:403 #: core/models.py:401
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "" msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique." "Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:409 core/models.py:563 #: core/models.py:407 core/models.py:561
msgid "Room" msgid "Room"
msgstr "Salle" msgstr "Salle"
#: core/models.py:410 #: core/models.py:408
msgid "Rooms" msgid "Rooms"
msgstr "Salles" msgstr "Salles"
#: core/models.py:574 #: core/models.py:572
msgid "Worker ID" msgid "Worker ID"
msgstr "ID du Worker" msgstr "ID du Worker"
#: core/models.py:576 #: core/models.py:574
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -335,128 +315,127 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est " "Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile." "conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:584 #: core/models.py:582
msgid "Recording mode" msgid "Recording mode"
msgstr "Mode d'enregistrement" msgstr "Mode d'enregistrement"
#: core/models.py:585 #: core/models.py:583
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé." msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:590 core/models.py:591 #: core/models.py:588 core/models.py:589
msgid "Recording options" msgid "Recording options"
msgstr "Options d'enregistrement" msgstr "Options d'enregistrement"
#: core/models.py:597 #: core/models.py:595
msgid "Recording" msgid "Recording"
msgstr "Enregistrement" msgstr "Enregistrement"
#: core/models.py:598 #: core/models.py:596
msgid "Recordings" msgid "Recordings"
msgstr "Enregistrements" msgstr "Enregistrements"
#: core/models.py:706 #: core/models.py:704
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur" msgstr "Relation enregistrement/utilisateur"
#: core/models.py:707 #: core/models.py:705
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur" msgstr "Relations enregistrement/utilisateur"
#: core/models.py:713 #: core/models.py:711
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement." msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:719 #: core/models.py:717
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement." msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:725 #: core/models.py:723
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux." msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:742 #: core/models.py:740
msgid "Create rooms" msgid "Create rooms"
msgstr "Créer des salles" msgstr "Créer des salles"
#: core/models.py:743 #: core/models.py:741
msgid "List rooms" msgid "List rooms"
msgstr "Lister les salles" msgstr "Lister les salles"
#: core/models.py:744 #: core/models.py:742
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Afficher les détails dune salle" msgstr "Afficher les détails dune salle"
#: core/models.py:745 #: core/models.py:743
msgid "Update rooms" msgid "Update rooms"
msgstr "Mettre à jour les salles" msgstr "Mettre à jour les salles"
#: core/models.py:746 #: core/models.py:744
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Supprimer les salles" msgstr "Supprimer les salles"
#: core/models.py:759 #: core/models.py:757
msgid "Application name" msgid "Application name"
msgstr "Nom de lapplication" msgstr "Nom de lapplication"
#: core/models.py:760 #: core/models.py:758
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application." msgstr "Nom descriptif de cette application."
#: core/models.py:770 #: core/models.py:768
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "" msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun " "Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret." "nouveau secret."
#: core/models.py:781 #: core/models.py:779
msgid "Application" msgid "Application"
msgstr "Application" msgstr "Application"
#: core/models.py:782 #: core/models.py:780
msgid "Applications" msgid "Applications"
msgstr "Applications" msgstr "Applications"
#: core/models.py:805 #: core/models.py:803
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide" msgstr "Saisissez un domaine valide"
#: core/models.py:808 #: core/models.py:806
msgid "Domain" msgid "Domain"
msgstr "Domaine" msgstr "Domaine"
#: core/models.py:809 #: core/models.py:807
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir." msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:821 #: core/models.py:819
msgid "Application domain" msgid "Application domain"
msgstr "Domaine dapplication" msgstr "Domaine dapplication"
#: core/models.py:822 #: core/models.py:820
msgid "Application domains" msgid "Application domains"
msgstr "Domaines dapplication" msgstr "Domaines dapplication"
#: core/models.py:840 #: core/models.py:838
msgid "Pending" msgid "Pending"
msgstr "En attente" msgstr "En attente"
#: core/models.py:848 #: core/models.py:846
msgid "Ready" msgid "Ready"
msgstr "Prêt" msgstr "Prêt"
#: core/models.py:854 #: core/models.py:852
msgid "Background image" msgid "Background image"
msgstr "Image de fond" msgstr "Image de fond"
#: core/models.py:866 #: core/models.py:864
msgid "title" msgid "title"
msgstr "Titre" msgstr "Titre"
#: core/models.py:890 #: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe." msgid "Malware detection info when the analysis status is unsafe."
msgstr "" msgstr "Information concernant la détection de Malware cand le statut n'est pas sain"
"Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:895 #: core/models.py:895
msgid "File" msgid "File"
@@ -466,17 +445,15 @@ msgstr "Fichier"
msgid "Files" msgid "Files"
msgstr "Fichiers" msgstr "Fichiers"
#: core/models.py:1000 #: core/models.py:970
#, fuzzy #, fuzzy
#| msgid "This user is already in this recording." #| msgid "This user is already in this recording."
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé." msgstr "Ce fichier a été supprimé."
#: core/models.py:1010 #: core/models.py:980
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
msgstr "" msgstr "Pour supprimer définitivement un fichier il doit d'abord avoir été marqué comme supprimé (soft delete)"
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)"
#: core/recording/event/notification.py:116 #: core/recording/event/notification.py:116
msgid "Your recording is ready" msgid "Your recording is ready"
@@ -606,18 +583,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre " " Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. " "équipe d'assistance à %(support_email)s. "
#: meet/settings.py:223 #: meet/settings.py:224
msgid "English" msgid "English"
msgstr "Anglais" msgstr "Anglais"
#: meet/settings.py:224 #: meet/settings.py:225
msgid "French" msgid "French"
msgstr "Français" msgstr "Français"
#: meet/settings.py:225 #: meet/settings.py:226
msgid "Dutch" msgid "Dutch"
msgstr "Néerlandais" msgstr "Néerlandais"
#: meet/settings.py:226 #: meet/settings.py:227
msgid "German" msgid "German"
msgstr "Allemand" msgstr "Allemand"
Binary file not shown.
+96 -115
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n" "POT-Creation-Date: 2026-02-26 17:34+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,11 +29,11 @@ msgstr "Rechten"
msgid "Important dates" msgid "Important dates"
msgstr "Belangrijke datums" msgstr "Belangrijke datums"
#: core/admin.py:132 core/admin.py:275 #: core/admin.py:132 core/admin.py:243
msgid "No owner" msgid "No owner"
msgstr "Geen eigenaar" msgstr "Geen eigenaar"
#: core/admin.py:135 core/admin.py:278 #: core/admin.py:135 core/admin.py:246
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Meerdere eigenaren" msgstr "Meerdere eigenaren"
@@ -61,135 +61,116 @@ msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
msgid "Skipped %(count)s expired recording(s)." msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s verlopen opname(n) overgeslagen." msgstr "%(count)s verlopen opname(n) overgeslagen."
#: core/admin.py:200 #: core/admin.py:309
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
#: core/admin.py:342
msgid "No scopes" msgid "No scopes"
msgstr "Geen scopes" msgstr "Geen scopes"
#: core/admin.py:344 #: core/admin.py:311
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
#: core/api/filters.py:25 #: core/api/filters.py:24
msgid "Creator is me" msgid "Creator is me"
msgstr "Maker ben ik" msgstr "Maker ben ik"
#: core/api/serializers.py:88 #: core/api/serializers.py:84
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen." "Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/api/serializers.py:516 #: core/api/serializers.py:443
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan." msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/serializers.py:533 #: core/models.py:35
msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
#: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Lid" msgstr "Lid"
#: core/models.py:38 #: core/models.py:36
msgid "Administrator" msgid "Administrator"
msgstr "Beheerder" msgstr "Beheerder"
#: core/models.py:39 #: core/models.py:37
msgid "Owner" msgid "Owner"
msgstr "Eigenaar" msgstr "Eigenaar"
#: core/models.py:55 #: core/models.py:53
msgid "Initiated" msgid "Initiated"
msgstr "Gestart" msgstr "Gestart"
#: core/models.py:56 #: core/models.py:54
msgid "Active" msgid "Active"
msgstr "Actief" msgstr "Actief"
#: core/models.py:57 #: core/models.py:55
msgid "Stopped" msgid "Stopped"
msgstr "Gestopt" msgstr "Gestopt"
#: core/models.py:58 #: core/models.py:56
msgid "Saved" msgid "Saved"
msgstr "Opgeslagen" msgstr "Opgeslagen"
#: core/models.py:59 #: core/models.py:57
msgid "Aborted" msgid "Aborted"
msgstr "Afgebroken" msgstr "Afgebroken"
#: core/models.py:60 #: core/models.py:58
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Starten mislukt" msgstr "Starten mislukt"
#: core/models.py:61 #: core/models.py:59
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Stoppen mislukt" msgstr "Stoppen mislukt"
#: core/models.py:62 #: core/models.py:60
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notificatie geslaagd" msgstr "Notificatie geslaagd"
#: core/models.py:89 #: core/models.py:87
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME" msgstr "SCHERM_OPNAME"
#: core/models.py:90 #: core/models.py:88
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPT" msgstr "TRANSCRIPT"
#: core/models.py:96 #: core/models.py:94
msgid "Public Access" msgid "Public Access"
msgstr "Openbare toegang" msgstr "Openbare toegang"
#: core/models.py:97 #: core/models.py:95
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Vertrouwde toegang" msgstr "Vertrouwde toegang"
#: core/models.py:98 #: core/models.py:96
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Beperkte toegang" msgstr "Beperkte toegang"
#: core/models.py:110 #: core/models.py:108
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:111 #: core/models.py:109
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID" msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:117 #: core/models.py:115
msgid "created on" msgid "created on"
msgstr "aangemaakt op" msgstr "aangemaakt op"
#: core/models.py:118 #: core/models.py:116
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt" msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:123 #: core/models.py:121
msgid "updated on" msgid "updated on"
msgstr "bijgewerkt op" msgstr "bijgewerkt op"
#: core/models.py:124 #: core/models.py:122
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt" msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:144 #: core/models.py:142
msgid "" msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters." "_ characters."
@@ -197,11 +178,11 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/" "Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten." "_ tekens bevatten."
#: core/models.py:150 #: core/models.py:148
msgid "sub" msgid "sub"
msgstr "sub" msgstr "sub"
#: core/models.py:152 #: core/models.py:150
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -209,55 +190,55 @@ msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. " "Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan." "Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:161 #: core/models.py:159
msgid "identity email address" msgid "identity email address"
msgstr "identiteit e-mailadres" msgstr "identiteit e-mailadres"
#: core/models.py:166 #: core/models.py:164
msgid "admin email address" msgid "admin email address"
msgstr "beheerder e-mailadres" msgstr "beheerder e-mailadres"
#: core/models.py:168 #: core/models.py:166
msgid "full name" msgid "full name"
msgstr "volledige naam" msgstr "volledige naam"
#: core/models.py:170 #: core/models.py:168
msgid "short name" msgid "short name"
msgstr "korte naam" msgstr "korte naam"
#: core/models.py:176 #: core/models.py:174
msgid "language" msgid "language"
msgstr "taal" msgstr "taal"
#: core/models.py:177 #: core/models.py:175
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien." msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:183 #: core/models.py:181
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien." msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:186 #: core/models.py:184
msgid "device" msgid "device"
msgstr "apparaat" msgstr "apparaat"
#: core/models.py:188 #: core/models.py:186
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker." msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:191 #: core/models.py:189
msgid "staff status" msgid "staff status"
msgstr "personeelsstatus" msgstr "personeelsstatus"
#: core/models.py:193 #: core/models.py:191
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite." msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:196 #: core/models.py:194
msgid "active" msgid "active"
msgstr "actief" msgstr "actief"
#: core/models.py:199 #: core/models.py:197
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -265,64 +246,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in " "Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen." "plaats van accounts te verwijderen."
#: core/models.py:212 #: core/models.py:210
msgid "user" msgid "user"
msgstr "gebruiker" msgstr "gebruiker"
#: core/models.py:213 #: core/models.py:211
msgid "users" msgid "users"
msgstr "gebruikers" msgstr "gebruikers"
#: core/models.py:272 #: core/models.py:270
msgid "Resource" msgid "Resource"
msgstr "Bron" msgstr "Bron"
#: core/models.py:273 #: core/models.py:271
msgid "Resources" msgid "Resources"
msgstr "Bronnen" msgstr "Bronnen"
#: core/models.py:331 #: core/models.py:329
msgid "Resource access" msgid "Resource access"
msgstr "Brontoegang" msgstr "Brontoegang"
#: core/models.py:332 #: core/models.py:330
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Brontoegangsrechten" msgstr "Brontoegangsrechten"
#: core/models.py:338 #: core/models.py:336
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al." msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:394 #: core/models.py:392
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie" msgstr "Visio-ruimteconfiguratie"
#: core/models.py:395 #: core/models.py:393
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren." msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:402 #: core/models.py:400
msgid "Room PIN code" msgid "Room PIN code"
msgstr "Pincode van de kamer" msgstr "Pincode van de kamer"
#: core/models.py:403 #: core/models.py:401
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "" msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus." "Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:409 core/models.py:563 #: core/models.py:407 core/models.py:561
msgid "Room" msgid "Room"
msgstr "Ruimte" msgstr "Ruimte"
#: core/models.py:410 #: core/models.py:408
msgid "Rooms" msgid "Rooms"
msgstr "Ruimtes" msgstr "Ruimtes"
#: core/models.py:574 #: core/models.py:572
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker ID" msgstr "Worker ID"
#: core/models.py:576 #: core/models.py:574
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -330,120 +311,120 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, " "Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is." "zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:584 #: core/models.py:582
msgid "Recording mode" msgid "Recording mode"
msgstr "Opnamemodus" msgstr "Opnamemodus"
#: core/models.py:585 #: core/models.py:583
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen." msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:590 core/models.py:591 #: core/models.py:588 core/models.py:589
msgid "Recording options" msgid "Recording options"
msgstr "Opnameopties" msgstr "Opnameopties"
#: core/models.py:597 #: core/models.py:595
msgid "Recording" msgid "Recording"
msgstr "Opname" msgstr "Opname"
#: core/models.py:598 #: core/models.py:596
msgid "Recordings" msgid "Recordings"
msgstr "Opnames" msgstr "Opnames"
#: core/models.py:706 #: core/models.py:704
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie" msgstr "Opname/gebruiker-relatie"
#: core/models.py:707 #: core/models.py:705
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties" msgstr "Opname/gebruiker-relaties"
#: core/models.py:713 #: core/models.py:711
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname." msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:719 #: core/models.py:717
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname." msgstr "Dit team is al in deze opname."
#: core/models.py:725 #: core/models.py:723
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide." msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:742 #: core/models.py:740
msgid "Create rooms" msgid "Create rooms"
msgstr "Ruimtes aanmaken" msgstr "Ruimtes aanmaken"
#: core/models.py:743 #: core/models.py:741
msgid "List rooms" msgid "List rooms"
msgstr "Ruimtes weergeven" msgstr "Ruimtes weergeven"
#: core/models.py:744 #: core/models.py:742
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen" msgstr "Details van een ruimte ophalen"
#: core/models.py:745 #: core/models.py:743
msgid "Update rooms" msgid "Update rooms"
msgstr "Ruimtes bijwerken" msgstr "Ruimtes bijwerken"
#: core/models.py:746 #: core/models.py:744
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Ruimtes verwijderen" msgstr "Ruimtes verwijderen"
#: core/models.py:759 #: core/models.py:757
msgid "Application name" msgid "Application name"
msgstr "Naam van de applicatie" msgstr "Naam van de applicatie"
#: core/models.py:760 #: core/models.py:758
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie." msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:770 #: core/models.py:768
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "" msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is." "Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:781 #: core/models.py:779
msgid "Application" msgid "Application"
msgstr "Applicatie" msgstr "Applicatie"
#: core/models.py:782 #: core/models.py:780
msgid "Applications" msgid "Applications"
msgstr "Applicaties" msgstr "Applicaties"
#: core/models.py:805 #: core/models.py:803
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Voer een geldig domein in" msgstr "Voer een geldig domein in"
#: core/models.py:808 #: core/models.py:806
msgid "Domain" msgid "Domain"
msgstr "Domein" msgstr "Domein"
#: core/models.py:809 #: core/models.py:807
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen." msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:821 #: core/models.py:819
msgid "Application domain" msgid "Application domain"
msgstr "Applicatiedomein" msgstr "Applicatiedomein"
#: core/models.py:822 #: core/models.py:820
msgid "Application domains" msgid "Application domains"
msgstr "Applicatiedomeinen" msgstr "Applicatiedomeinen"
#: core/models.py:840 #: core/models.py:838
msgid "Pending" msgid "Pending"
msgstr "In afwachting" msgstr "In afwachting"
#: core/models.py:848 #: core/models.py:846
msgid "Ready" msgid "Ready"
msgstr "Klaar" msgstr "Klaar"
#: core/models.py:854 #: core/models.py:852
msgid "Background image" msgid "Background image"
msgstr "Achtergrondafbeelding" msgstr "Achtergrondafbeelding"
#: core/models.py:866 #: core/models.py:864
msgid "title" msgid "title"
msgstr "Titel" msgstr "Titel"
@@ -459,11 +440,11 @@ msgstr "Bestand"
msgid "Files" msgid "Files"
msgstr "Bestanden" msgstr "Bestanden"
#: core/models.py:1000 #: core/models.py:970
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd." msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1010 #: core/models.py:980
#, fuzzy #, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted." #| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
@@ -599,18 +580,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support " " Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. " "team via %(support_email)s. "
#: meet/settings.py:223 #: meet/settings.py:224
msgid "English" msgid "English"
msgstr "Engels" msgstr "Engels"
#: meet/settings.py:224 #: meet/settings.py:225
msgid "French" msgid "French"
msgstr "Frans" msgstr "Frans"
#: meet/settings.py:225 #: meet/settings.py:226
msgid "Dutch" msgid "Dutch"
msgstr "Nederlands" msgstr "Nederlands"
#: meet/settings.py:226 #: meet/settings.py:227
msgid "German" msgid "German"
msgstr "Duits" msgstr "Duits"
+3 -27
View File
@@ -176,13 +176,6 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
FILE_UPLOAD_ENABLED = values.BooleanValue(
# False to avoid a breaking change for now
default=False,
environ_name="FILE_UPLOAD_ENABLED",
environ_prefix=None,
)
FILE_UPLOAD_PATH = values.Value( FILE_UPLOAD_PATH = values.Value(
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None "files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
) )
@@ -195,7 +188,6 @@ class Base(Configuration):
{ {
"background_image": { "background_image": {
"max_size": 2 * MB, "max_size": 2 * MB,
"max_count_by_user": 10,
"allowed_extensions": [".jpeg", ".jpg", ".png"], "allowed_extensions": [".jpeg", ".jpg", ".png"],
"allowed_mimetypes": ["image/jpeg", "image/png"], "allowed_mimetypes": ["image/jpeg", "image/png"],
}, },
@@ -452,11 +444,7 @@ class Base(Configuration):
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None) CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
# Session # Session
SESSION_ENGINE = values.Value( SESSION_ENGINE = "django.contrib.sessions.backends.cache"
default="django.contrib.sessions.backends.cache",
environ_name="SESSION_ENGINE",
environ_prefix=None,
)
SESSION_CACHE_ALIAS = "default" SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = values.PositiveIntegerValue( SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
@@ -561,12 +549,12 @@ class Base(Configuration):
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None "returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
) )
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue( OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
default=["first_name", "last_name"], default=["given_name", "usual_name"],
environ_name="OIDC_USERINFO_FULLNAME_FIELDS", environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None, environ_prefix=None,
) )
OIDC_USERINFO_SHORTNAME_FIELD = values.Value( OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
default="first_name", default="given_name",
environ_name="OIDC_USERINFO_SHORTNAME_FIELD", environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None, environ_prefix=None,
) )
@@ -808,17 +796,6 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
# End-to-end encryption settings
ENCRYPTION_ENABLED = values.BooleanValue(
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
)
ENCRYPTION_VAULT_URL = values.Value(
None, environ_name="ENCRYPTION_VAULT_URL", environ_prefix=None
)
ENCRYPTION_INTERFACE_URL = values.Value(
None, environ_name="ENCRYPTION_INTERFACE_URL", environ_prefix=None
)
# External Applications # External Applications
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue( APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
40, 40,
@@ -996,7 +973,6 @@ class Test(Base):
APPLICATION_JWT_AUDIENCE = "Test inc." APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_ALWAYS_EAGER = True
FILE_UPLOAD_ENABLED = True
def __init__(self): def __init__(self):
# pylint: disable=invalid-name # pylint: disable=invalid-name
+26 -31
View File
@@ -2,12 +2,12 @@
# Meet package # Meet package
# #
[build-system] [build-system]
requires = ["uv_build>=0.10.9,<0.11.0"] requires = ["setuptools"]
build-backend = "uv_build" build-backend = "setuptools.build_meta"
[project] [project]
name = "meet" name = "meet"
version = "1.12.0" version = "1.10.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",
@@ -21,19 +21,20 @@ classifiers = [
] ]
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 = "MIT" license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"boto3==1.42.68", "boto3==1.42.49",
"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.6.2",
"dj-database-url==3.1.2", "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.2.0",
"django-filter==25.2", "django-filter==25.2",
"django-lasuite[all]==0.0.25", "django-lasuite[all]==0.0.24",
"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",
@@ -43,21 +44,21 @@ dependencies = [
"django==5.2.12", "django==5.2.12",
"djangorestframework==3.16.1", "djangorestframework==3.16.1",
"drf_spectacular==0.29.0", "drf_spectacular==0.29.0",
"dockerflow==2026.3.4", "dockerflow==2026.1.26",
"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==25.1.0",
"jsonschema==4.26.0", "jsonschema==4.26.0",
"markdown==3.10.2", "markdown==3.10.2",
"nested-multipart-parser==1.6.0", "nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.3", "psycopg[binary]==3.3.2",
"pydantic==2.12.5", "pydantic==2.12.4",
"PyJWT==2.12.1", "PyJWT==2.11.0",
"python-frontmatter==1.1.0", "python-frontmatter==1.1.0",
"python-magic==0.4.27", "python-magic==0.4.27",
"requests==2.32.5", "requests==2.32.5",
"sentry-sdk==2.54.0", "sentry-sdk==2.53.0",
"whitenoise==6.12.0", "whitenoise==6.11.0",
"mozilla-django-oidc==5.0.2", "mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0", "livekit-api==1.1.0",
"aiohttp==3.13.3", "aiohttp==3.13.3",
@@ -69,14 +70,14 @@ dependencies = [
"Homepage" = "https://github.com/suitenumerique/meet" "Homepage" = "https://github.com/suitenumerique/meet"
"Repository" = "https://github.com/suitenumerique/meet" "Repository" = "https://github.com/suitenumerique/meet"
[dependency-groups] [project.optional-dependencies]
dev = [ dev = [
"django-extensions==4.1", "django-extensions==4.1",
"drf-spectacular-sidecar==2026.3.1", "drf-spectacular-sidecar==2026.1.1",
"freezegun==1.5.5", "freezegun==1.5.5",
"ipdb==0.13.13", "ipdb==0.13.13",
"ipython==9.11.0", "ipython==9.10.0",
"pyfakefs==6.1.5", "pyfakefs==6.1.1",
"pylint-django==2.7.0", "pylint-django==2.7.0",
"pylint<4.0.0", "pylint<4.0.0",
"pytest-cov==7.0.0", "pytest-cov==7.0.0",
@@ -84,23 +85,17 @@ dev = [
"pytest==9.0.2", "pytest==9.0.2",
"pytest-icdiff==0.9", "pytest-icdiff==0.9",
"pytest-xdist==3.8.0", "pytest-xdist==3.8.0",
"responses==0.26.0", "responses==0.25.8",
"ruff==0.15.6", "ruff==0.15.1",
"types-requests==2.32.4.20260107", "types-requests==2.32.4.20260107",
] ]
[tool.uv.build-backend] [tool.setuptools]
module-name = [ packages = { find = { where = ["."], exclude = ["tests"] } }
"core", zip-safe = true
"demo",
"meet" [tool.distutils.bdist_wheel]
] universal = true
module-root = ""
source-exclude = [
"**/tests/**",
"**/test_*.py",
"**/tests.py",
]
[tool.ruff] [tool.ruff]
exclude = [ exclude = [
-2365
View File
File diff suppressed because it is too large Load Diff
+16 -889
View File
File diff suppressed because it is too large Load Diff
+3 -7
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.12.0", "version": "1.10.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -10,9 +10,7 @@
"preview": "vite preview", "preview": "vite preview",
"i18n:extract": "npx i18next -c i18next-parser.config.json", "i18n:extract": "npx i18next -c i18next-parser.config.json",
"format": "prettier --write ./src", "format": "prettier --write ./src",
"check": "prettier --check ./src", "check": "prettier --check ./src"
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34", "@fontsource-variable/material-symbols-outlined": "5.2.34",
@@ -61,12 +59,10 @@
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20", "eslint-plugin-react-refresh": "0.4.20",
"jsdom": "^29.0.2",
"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.3.1",
"vite-tsconfig-paths": "6.1.1", "vite-tsconfig-paths": "6.1.1"
"vitest": "^4.1.3"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+8 -11
View File
@@ -14,7 +14,6 @@ import './i18n/init'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization' import { AppInitialization } from '@/components/AppInitialization'
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext' import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
import { VaultClientProvider } from '@/features/encryption'
function App() { function App() {
const { i18n } = useTranslation() const { i18n } = useTranslation()
@@ -26,22 +25,20 @@ function App() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{!isSDKContext && <AppInitialization />} {!isSDKContext && <AppInitialization />}
<Suspense fallback={null}> <Suspense fallback={null}>
<VaultClientProvider> <I18nProvider locale={i18n.language}>
<I18nProvider locale={i18n.language}> <Layout>
<Layout>
<Switch> <Switch>
{Object.entries(routes).map(([, route], i) => ( {Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} /> <Route key={i} path={route.path} component={route.Component} />
))} ))}
<Route component={NotFoundScreen} /> <Route component={NotFoundScreen} />
</Switch> </Switch>
</Layout> </Layout>
<ReactQueryDevtools <ReactQueryDevtools
initialIsOpen={false} initialIsOpen={false}
buttonPosition="bottom-left" buttonPosition="bottom-left"
/> />
</I18nProvider> </I18nProvider>
</VaultClientProvider>
</Suspense> </Suspense>
</QueryClientProvider> </QueryClientProvider>
) )
+1 -13
View File
@@ -15,19 +15,7 @@ export const fetchApi = async <T = Record<string, unknown>>(
...options?.headers, ...options?.headers,
}, },
}) })
const result = await response.json()
let result: T
if (response.status === 204) {
result = undefined as T
} else {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
result = undefined as T
} else {
result = (await response.json()) as T
}
}
if (!response.ok) { if (!response.ok) {
throw new ApiError(response.status, result) throw new ApiError(response.status, result)
} }
-1
View File
@@ -5,5 +5,4 @@ export const keys = {
requestEntry: 'requestEntry', requestEntry: 'requestEntry',
waitingParticipants: 'waitingParticipants', waitingParticipants: 'waitingParticipants',
roomCreationCallback: 'roomCreationCallback', roomCreationCallback: 'roomCreationCallback',
files: 'files',
} }
-12
View File
@@ -30,13 +30,6 @@ export interface ApiConfig {
expiration_days?: number expiration_days?: number
max_duration?: number max_duration?: number
} }
background_image: {
upload_is_enabled: boolean
max_size: number
max_count_by_user: number
allowed_extensions: string[]
allowed_mimetypes: string[]
}
subtitle: { subtitle: {
enabled: boolean enabled: boolean
} }
@@ -52,11 +45,6 @@ export interface ApiConfig {
enable_firefox_proxy_workaround: boolean enable_firefox_proxy_workaround: boolean
default_sources: string[] default_sources: string[]
} }
encryption?: {
enabled: boolean
vault_url: string
interface_url: string
}
transcription_destination?: string transcription_destination?: string
} }
+2 -2
View File
@@ -57,7 +57,7 @@ export const Avatar = ({
style, style,
...props ...props
}: AvatarProps) => { }: AvatarProps) => {
const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? '' const initial = name?.trim()?.charAt(0) ?? ''
return ( return (
<div <div
style={{ style={{
@@ -70,7 +70,7 @@ export const Avatar = ({
<span <span
aria-hidden="true" aria-hidden="true"
className={css({ className={css({
lineHeight: 1, marginTop: '-0.3rem',
})} })}
> >
{initial} {initial}
@@ -3,8 +3,7 @@ import { BackendLanguage } from '@/utils/languages'
export type ApiUser = { export type ApiUser = {
id: string id: string
email: string email: string
full_name: string | null full_name: string
short_name: string | null
last_name: string last_name: string
language: BackendLanguage language: BackendLanguage
timezone: string timezone: string
@@ -1,181 +0,0 @@
/**
* Indicator shown at the top-left of an encrypted meeting.
*
* Initially shows the full label "End-to-end encrypted" with a lock icon.
* After a few seconds, collapses to just the lock icon.
* On hover, expands back with a smooth animation.
* Clicking opens a modal explaining what E2EE means and its limitations.
*/
import { css } from '@/styled-system/css'
import { VStack } from '@/styled-system/jsx'
import { RiLockFill, RiShieldCheckFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useEffect, useState } from 'react'
import { Dialog, Text } from '@/primitives'
const COLLAPSE_DELAY = 4000
export function EncryptedMeetingBanner() {
const roomData = useRoomData()
const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
const [isCollapsed, setIsCollapsed] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const isStrongEncryption = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
useEffect(() => {
const timer = setTimeout(() => setIsCollapsed(true), COLLAPSE_DELAY)
return () => clearTimeout(timer)
}, [])
if (!isEncryptedRoom(roomData)) return null
const bgColor = isStrongEncryption ? '#166534' : '#1e3a5f'
const hoverBgColor = isStrongEncryption ? '#15803d' : '#2563eb'
const icon = isStrongEncryption
? <RiShieldCheckFill size={13} color="white" className={css({ flexShrink: 0 })} />
: <RiLockFill size={13} color="white" className={css({ flexShrink: 0 })} />
const label = isStrongEncryption ? t('bannerStrong') : t('banner')
return (
<>
<div
onMouseEnter={() => setIsCollapsed(false)}
onMouseLeave={() => setIsCollapsed(true)}
onClick={() => setIsModalOpen(true)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && setIsModalOpen(true)}
aria-label={label}
className={css({
position: 'absolute',
top: '0.5rem',
left: '0.5rem',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.3rem 0.6rem',
borderRadius: '1rem',
border: '2px solid rgba(0, 0, 0, 0.3)',
cursor: 'pointer',
overflow: 'hidden',
transition: 'all 300ms ease',
maxWidth: isCollapsed ? '2.2rem' : '16rem',
whiteSpace: 'nowrap',
})}
style={{
backgroundColor: bgColor,
paddingRight: isCollapsed ? '0.3rem' : '0.6rem',
}}
onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = hoverBgColor }}
onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = bgColor }}
>
{icon}
<span
className={css({
fontSize: '0.7rem',
fontWeight: 600,
color: 'white',
letterSpacing: '0.02em',
transition: 'opacity 200ms ease',
})}
style={{
opacity: isCollapsed ? 0 : 1,
}}
>
{label}
</span>
</div>
<Dialog
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
role="dialog"
type="flex"
title={t('bannerModal.title')}
>
<VStack
gap="1rem"
alignItems="start"
className={css({ maxWidth: '24rem' })}
>
<Text variant="sm">
{isStrongEncryption
? t('bannerModal.descriptionAdvanced')
: t('bannerModal.descriptionBasic')}
</Text>
<VStack gap="0.5rem" alignItems="start" className={css({ width: '100%' })}>
<Text variant="sm" className={css({ fontWeight: 600 })}>
{t('bannerModal.guarantees')}
</Text>
<ul
className={css({
paddingLeft: '1.5rem',
fontSize: '0.85rem',
listStyleType: 'disc',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
'& li': {
paddingLeft: '0.25rem',
},
'& li::marker': {
color: '#22c55e',
},
})}
>
<li>{t('bannerModal.guarantee1')}</li>
<li>{t('bannerModal.guarantee2')}</li>
<li>{t('bannerModal.guarantee3')}</li>
</ul>
</VStack>
<VStack gap="0.5rem" alignItems="start" className={css({ width: '100%' })}>
<Text variant="sm" className={css({ fontWeight: 600 })}>
{t('bannerModal.limitations')}
</Text>
<ul
className={css({
paddingLeft: '1.5rem',
fontSize: '0.85rem',
listStyleType: 'disc',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
'& li': {
paddingLeft: '0.25rem',
},
'& li::marker': {
color: '#f59e0b',
},
})}
>
<li>{t('bannerModal.limitation1')}</li>
<li>{isStrongEncryption
? t('bannerModal.limitation2Advanced')
: t('bannerModal.limitation2Basic')}
</li>
</ul>
</VStack>
<Text
variant="note"
className={css({
fontSize: '0.75rem',
borderTop: '1px solid',
borderColor: 'greyscale.200',
paddingTop: '0.75rem',
width: '100%',
})}
>
{t('bannerModal.note')}
</Text>
</VStack>
</Dialog>
</>
)
}
@@ -1,82 +0,0 @@
/**
* Per-participant encryption trust badge.
*
* In advanced mode:
* - "verified": Green shield fingerprint explicitly trusted
* - "unknown": Grey shield has public key, not yet verified
* - "refused": Red shield fingerprint previously refused
* - "authenticated": Blue shield ProConnect, no vault keys
* - "anonymous": Orange warning not signed in
*
* In basic mode:
* - "authenticated": Blue shield ProConnect
* - "anonymous": Orange warning not signed in
*/
import {
RiShieldCheckFill,
RiShieldFill,
RiShieldCrossFill,
RiErrorWarningFill,
RiLockFill,
} from '@remixicon/react'
import type { TrustLevel } from './types'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
interface EncryptionBadgeProps {
trustLevel: TrustLevel | null
isEncrypted: boolean
}
export function EncryptionBadge({
trustLevel,
isEncrypted,
}: EncryptionBadgeProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
if (!isEncrypted) return null
let icon: React.ReactNode
let label: string
switch (trustLevel) {
case 'verified':
icon = <RiShieldCheckFill size={14} color="#22c55e" />
label = t('verified')
break
case 'unknown':
icon = <RiShieldFill size={14} color="#9ca3af" />
label = t('unknown')
break
case 'refused':
icon = <RiShieldCrossFill size={14} color="#ef4444" />
label = t('refused')
break
case 'authenticated':
icon = <RiShieldCheckFill size={14} color="#3b82f6" />
label = t('authenticated')
break
case 'anonymous':
icon = <RiErrorWarningFill size={15} color="#d97706" />
label = t('anonymous')
break
default:
icon = <RiLockFill size={14} />
label = t('default')
break
}
return (
<span
aria-label={label}
className={css({
display: 'inline-flex',
alignItems: 'center',
marginRight: '0.15rem',
cursor: 'inherit',
})}
>
{icon}
</span>
)
}
@@ -1,10 +0,0 @@
import { createContext, useContext } from 'react'
interface EncryptionContextValue {
symmetricKey?: Uint8Array
}
const EncryptionContext = createContext<EncryptionContextValue>({})
export const EncryptionProvider = EncryptionContext.Provider
export const useEncryptionContext = () => useContext(EncryptionContext)
@@ -1,326 +0,0 @@
/**
* Dialog showing a participant's encryption fingerprint.
* Allows the admin to verify, accept, or refuse the fingerprint.
*
* This connects to the encryption library's VaultClient to check/accept/refuse
* fingerprints from the TOFU (Trust On First Use) registry.
*/
import { css } from '@/styled-system/css'
import { VStack, HStack } from '@/styled-system/jsx'
import { Dialog, Text, Button } from '@/primitives'
import { Avatar } from '@/components/Avatar'
import { useUser } from '@/features/auth'
import {
RiShieldCheckFill,
RiShieldCheckLine,
RiAlertLine,
RiCheckLine,
RiCloseLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useVaultClient } from './VaultClientProvider'
import { formatFingerprint } from './useParticipantTrustLevel'
import { useEffect, useState } from 'react'
interface EncryptionIdentityDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
participantName: string
participantEmail?: string
suiteUserId?: string
isAuthenticated: boolean
encryptionMode?: 'basic' | 'advanced' | 'none'
isSelf?: boolean
preloadedFingerprint?: string | null
preloadedFingerprintStatus?: string | null
}
type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error'
export function EncryptionIdentityDialog({
isOpen,
onOpenChange,
participantName,
participantEmail,
suiteUserId,
isAuthenticated,
encryptionMode,
isSelf,
preloadedFingerprint,
preloadedFingerprintStatus,
}: EncryptionIdentityDialogProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' })
const { client: vaultClient } = useVaultClient()
const { isLoggedIn } = useUser()
const [status, setStatus] = useState<FingerprintStatus>(
(preloadedFingerprintStatus as FingerprintStatus) || 'loading'
)
const [fingerprint, setFingerprint] = useState<string | null>(preloadedFingerprint || null)
// Sync preloaded data when it becomes available (hook resolves after mount)
useEffect(() => {
if (preloadedFingerprintStatus) setStatus(preloadedFingerprintStatus as FingerprintStatus)
if (preloadedFingerprint) setFingerprint(preloadedFingerprint)
}, [preloadedFingerprint, preloadedFingerprintStatus])
const isBasicMode = encryptionMode !== 'advanced'
useEffect(() => {
if (!isOpen) return
// In basic mode, no fingerprint check — identity is from ProConnect only
if (isBasicMode) {
setStatus(isAuthenticated ? 'no-key' : 'no-key')
return
}
if (!vaultClient) {
setStatus('error')
return
}
if (!suiteUserId) {
setStatus(isAuthenticated ? 'no-key' : 'no-key')
return
}
let cancelled = false
async function checkFingerprint() {
try {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 3000)
)
const fetchResult = await Promise.race([
vaultClient!.fetchPublicKeys([suiteUserId!]),
timeout,
])
const publicKey = fetchResult.publicKeys[suiteUserId!]
if (!publicKey || cancelled) {
setStatus('no-key')
return
}
// Compute fingerprint from the public key (SHA-256, first 16 hex chars)
const hash = await crypto.subtle.digest('SHA-256', publicKey)
const fp = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 16)
if (cancelled) return
setFingerprint(fp)
// Check local registry without triggering TOFU auto-trust
const { fingerprints: known } = await Promise.race([
vaultClient!.getKnownFingerprints(),
timeout,
])
if (cancelled) return
const knownEntry = known[suiteUserId!]
if (!knownEntry) {
setStatus('unknown')
} else if (knownEntry.fingerprint === fp) {
setStatus(knownEntry.status)
} else {
// Fingerprint changed — needs re-verification
setStatus('unknown')
}
} catch {
if (!cancelled) setStatus('error')
}
}
checkFingerprint()
return () => { cancelled = true }
}, [isOpen, vaultClient, suiteUserId, isAuthenticated])
const handleAccept = async () => {
if (!vaultClient || !suiteUserId || !fingerprint) return
try {
await vaultClient.acceptFingerprint(suiteUserId, fingerprint)
setStatus('trusted')
} catch {
// Failed to accept
}
}
const handleRefuse = async () => {
if (!vaultClient || !suiteUserId || !fingerprint) return
try {
await vaultClient.refuseFingerprint(suiteUserId, fingerprint)
setStatus('refused')
} catch {
// Failed to refuse
}
}
return (
<Dialog
isOpen={isOpen}
onOpenChange={onOpenChange}
role="dialog"
type="flex"
title={t('title')}
>
<VStack
gap="0.75rem"
alignItems="start"
className={css({ maxWidth: '22rem' })}
>
<HStack gap="0.65rem" className={css({ width: '100%' })}>
<div className={css({ flexShrink: 0, transform: 'scale(0.85)' })}>
<Avatar name={participantName} bgColor="rgb(87, 44, 216)" />
</div>
<VStack gap="0" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.9rem' })}>{participantName}</Text>
<Text variant="note" className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}>
{isLoggedIn && participantEmail ? participantEmail : (!isAuthenticated ? t('anonymous') : '')}
</Text>
</VStack>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{isSelf
? (isAuthenticated ? t('descriptionSelf') : t('descriptionSelfAnonymous'))
: t('description')}
</Text>
{status === 'loading' && (
<Text variant="note">{t('loading')}</Text>
)}
{status === 'no-key' && isBasicMode && isAuthenticated && (
<HStack
gap="0.5rem"
className={css({
backgroundColor: '#eff6ff',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #bfdbfe',
})}
>
<RiShieldCheckLine size={20} color="#3b82f6" className={css({ flexShrink: 0 })} />
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('noKeyBasicAuthenticated')}
</Text>
</HStack>
)}
{status === 'no-key' && !(isBasicMode && isAuthenticated) && !isSelf && (
<HStack
gap="0.5rem"
className={css({
backgroundColor: '#fffbeb',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #fde68a',
})}
>
<RiAlertLine size={20} color="#f59e0b" className={css({ flexShrink: 0 })} />
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{isAuthenticated ? t('noKey') : t('noKeyAnonymous')}
</Text>
</HStack>
)}
{status === 'error' && (
<Text variant="note" className={css({ color: '#ef4444' })}>
{t('error')}
</Text>
)}
{(status === 'trusted' || status === 'refused' || status === 'unknown') && fingerprint && (
<>
<VStack
gap="0.25rem"
className={css({
backgroundColor: 'greyscale.50',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
fontFamily: 'monospace',
fontSize: '0.85rem',
letterSpacing: '0.05em',
wordBreak: 'break-all',
})}
>
<Text variant="note" className={css({ fontSize: '0.7rem', fontFamily: 'inherit' })}>
{t('fingerprintLabel')}
</Text>
{formatFingerprint(fingerprint)}
</VStack>
{status === 'trusted' && (
<VStack gap="0.25rem" alignItems="start">
<HStack gap="0.5rem" className={css({ color: '#22c55e' })}>
<RiShieldCheckFill size={18} />
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
{t('trusted')}
</Text>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{isSelf ? t('descriptionSelf') : t('trustedDescription')}
</Text>
{!isSelf && (
<Text
variant="note"
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
onClick={() => setStatus('unknown')}
>
{t('changeDecision')}
</Text>
)}
</VStack>
)}
{status === 'refused' && (
<VStack gap="0.25rem" alignItems="start">
<HStack gap="0.5rem" className={css({ color: '#ef4444' })}>
<RiCloseLine size={18} />
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
{t('refused')}
</Text>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('refusedDescription')}
</Text>
<Text
variant="note"
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
onClick={() => setStatus('unknown')}
>
{t('changeDecision')}
</Text>
</VStack>
)}
{status === 'unknown' && !isSelf && (
<VStack gap="0.5rem" className={css({ width: '100%' })}>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('unknownDescription')}
</Text>
<Text variant="note" className={css({ fontSize: '0.75rem', fontStyle: 'italic' })}>
{t('fingerprintHint')}
</Text>
<HStack gap="0.5rem">
<Button size="sm" variant="primary" onPress={handleAccept}>
<RiCheckLine size={16} />
{t('accept')}
</Button>
<Button size="sm" variant="secondaryText" onPress={handleRefuse}>
<RiCloseLine size={16} />
{t('refuse')}
</Button>
</HStack>
</VStack>
)}
</>
)}
</VStack>
</Dialog>
)
}
@@ -1,118 +0,0 @@
/**
* Overlay shown during encryption key exchange.
*
* When a participant joins an encrypted room, there's a brief period
* between connection and receiving the symmetric key where media
* cannot be decrypted. This overlay provides feedback during that time.
*
* After 20 seconds without the key, shows an error with a refresh button.
*/
import { css } from '@/styled-system/css'
import { VStack } from '@/styled-system/jsx'
import { Text, Button } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { RiLockFill, RiAlertFill, RiRefreshLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
const KEY_EXCHANGE_TIMEOUT = 20000
export function EncryptionSetupOverlay({
isSettingUp,
error,
}: {
isSettingUp: boolean
error: string | null
}) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
const [timedOut, setTimedOut] = useState(false)
useEffect(() => {
if (!isSettingUp) {
setTimedOut(false)
return
}
const timer = setTimeout(() => setTimedOut(true), KEY_EXCHANGE_TIMEOUT)
return () => clearTimeout(timer)
}, [isSettingUp])
if (!isSettingUp && !error) return null
const showError = error || timedOut
return (
<div
className={css({
position: 'absolute',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.85)',
})}
>
<VStack gap="1rem" alignItems="center">
{showError ? (
<>
<RiAlertFill size={36} color="#f87171" />
<Text
className={css({
color: '#f87171',
fontSize: '1.1rem',
fontWeight: 500,
textAlign: 'center',
})}
>
{timedOut ? t('error.timeout') : t('error.title')}
</Text>
<Text
className={css({
color: 'greyscale.300',
fontSize: '0.85rem',
textAlign: 'center',
maxWidth: '20rem',
})}
>
{error || t('error.timeoutHint')}
</Text>
<Button
variant="primary"
size="sm"
onPress={() => window.location.reload()}
>
<RiRefreshLine size={16} />
{t('error.refresh')}
</Button>
</>
) : (
<>
<RiLockFill size={32} color="white" />
<Text
className={css({
color: 'white',
fontSize: '1.1rem',
fontWeight: 500,
textAlign: 'center',
})}
>
{t('settingUp.title')}
</Text>
<Text
className={css({
color: 'greyscale.300',
fontSize: '0.85rem',
textAlign: 'center',
maxWidth: '20rem',
})}
>
{t('settingUp.description')}
</Text>
<Spinner />
</>
)}
</VStack>
</div>
)
}
@@ -1,143 +0,0 @@
/**
* Modal explaining encryption trust levels.
* Shown when admin clicks the trust badge in the waiting room.
*/
import { css } from '@/styled-system/css'
import { VStack, HStack } from '@/styled-system/jsx'
import { Dialog, Text } from '@/primitives'
import { RiShieldCheckFill, RiShieldCheckLine, RiAlertLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
interface EncryptionTrustModalProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
participantName: string
isAuthenticated: boolean
}
export function EncryptionTrustModal({
isOpen,
onOpenChange,
participantName,
isAuthenticated,
}: EncryptionTrustModalProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.trustModal' })
return (
<Dialog
isOpen={isOpen}
onOpenChange={onOpenChange}
role="dialog"
type="flex"
title={t('title')}
>
<VStack
gap="1rem"
alignItems="start"
className={css({ maxWidth: '22rem' })}
>
<Text variant="sm">{t('intro', { name: participantName })}</Text>
{isAuthenticated ? (
<HStack
gap="0.75rem"
className={css({
backgroundColor: '#eff6ff',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #bfdbfe',
})}
>
<RiShieldCheckLine
size={24}
color="#3b82f6"
className={css({ flexShrink: 0 })}
/>
<VStack gap="0.25rem" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.85rem' })}>
{t('authenticated.title')}
</Text>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('authenticated.description')}
</Text>
</VStack>
</HStack>
) : (
<HStack
gap="0.75rem"
className={css({
backgroundColor: '#fffbeb',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #fde68a',
})}
>
<RiAlertLine
size={24}
color="#f59e0b"
className={css({ flexShrink: 0 })}
/>
<VStack gap="0.25rem" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.85rem' })}>
{t('anonymous.title')}
</Text>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('anonymous.description')}
</Text>
</VStack>
</HStack>
)}
<VStack
gap="0.5rem"
alignItems="start"
className={css({
borderTop: '1px solid',
borderColor: 'greyscale.200',
paddingTop: '0.75rem',
width: '100%',
})}
>
<Text
variant="note"
className={css({ fontWeight: 600, fontSize: '0.8rem' })}
>
{t('levels.title')}
</Text>
<HStack gap="0.5rem" alignItems="start">
<RiShieldCheckFill
size={16}
color="#22c55e"
className={css({ flexShrink: 0, marginTop: '2px' })}
/>
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
{t('levels.verified')}
</Text>
</HStack>
<HStack gap="0.5rem" alignItems="start">
<RiShieldCheckLine
size={16}
color="#3b82f6"
className={css({ flexShrink: 0, marginTop: '2px' })}
/>
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
{t('levels.authenticated')}
</Text>
</HStack>
<HStack gap="0.5rem" alignItems="start">
<RiAlertLine
size={16}
color="#f59e0b"
className={css({ flexShrink: 0, marginTop: '2px' })}
/>
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
{t('levels.anonymous')}
</Text>
</HStack>
</VStack>
</VStack>
</Dialog>
)
}
@@ -1,122 +0,0 @@
/**
* Hybrid key distributor: determines the best key distribution method per participant.
*
* For each participant joining an encrypted call:
* 1. Check if they have a registered public key (via VaultClient/encryption library)
* If YES: wrap symmetric key with their public key (PKI path) trust level "verified"
* 2. Check if they are authenticated via ProConnect
* If YES but no public key: use ephemeral DH trust level "authenticated"
* 3. Otherwise: use ephemeral DH trust level "anonymous"
*
* The symmetric key is always the same for everyone only the distribution channel varies.
*/
import type { TrustLevel } from './types'
import { PARTICIPANT_TRUST_ATTR } from './types'
export interface ParticipantEncryptionInfo {
identity: string
trustLevel: TrustLevel
hasPublicKey: boolean
isAuthenticated: boolean
}
/**
* Determine the trust level for a participant based on their encryption capabilities.
*/
export function determineTrustLevel(
hasPublicKey: boolean,
isAuthenticated: boolean
): TrustLevel {
if (hasPublicKey) return 'verified'
if (isAuthenticated) return 'authenticated'
return 'anonymous'
}
/**
* Derive trust level from participant's server-signed attributes.
*
* The `is_authenticated` attribute is set by the backend in the LiveKit JWT token
* and cannot be spoofed by clients. It indicates whether the participant
* authenticated via OIDC (ProConnect/Keycloak).
*
* In basic encryption mode, the "verified" level is never returned because
* PKI keys are not used encryption relies on a shared passphrase, not on
* per-user public keys. The green shield would be misleading.
*
* In advanced encryption mode, "verified" means the participant has completed
* encryption onboarding and their public key is used to encrypt the symmetric key.
*/
export function getTrustLevelFromAttributes(
attributes: Record<string, string> | undefined,
encryptionMode?: 'basic' | 'advanced' | 'none',
): TrustLevel | null {
if (!attributes) return null
const isAdvanced = encryptionMode === 'advanced'
// Check for explicit trust level (set by PKI integration)
const explicitLevel = attributes[PARTICIPANT_TRUST_ATTR]
if (explicitLevel === 'verified' && isAdvanced) {
return 'verified'
}
if (explicitLevel === 'authenticated' || explicitLevel === 'anonymous') {
return explicitLevel
}
// Derive from server-signed is_authenticated attribute
if (attributes.is_authenticated === 'true') {
return 'authenticated'
}
return 'anonymous'
}
/**
* Try to distribute the symmetric key via PKI (encryption library).
* Returns true if successful, false if the participant doesn't have a public key.
*/
export async function distributeKeyViaPKI(
vaultClient: VaultClient,
symmetricKey: Uint8Array,
participantUserId: string
): Promise<{ success: boolean; encryptedKey?: ArrayBuffer }> {
try {
const { publicKeys } = await vaultClient.fetchPublicKeys([
participantUserId,
])
const publicKey = publicKeys[participantUserId]
if (!publicKey) {
return { success: false }
}
// Use encryptWithoutKey to wrap the symmetric key for this user
const { encryptedKeys } = await vaultClient.shareKeys(
symmetricKey.buffer as ArrayBuffer,
{ [participantUserId]: publicKey }
)
const encryptedKey = encryptedKeys[participantUserId]
if (!encryptedKey) {
return { success: false }
}
return { success: true, encryptedKey }
} catch (err) {
console.warn(
'[Encryption] PKI key distribution failed for participant:',
participantUserId,
err
)
return { success: false }
}
}
/**
* Encode trust level into participant attributes for badge display.
*/
export function encodeTrustLevelAttribute(
trustLevel: TrustLevel
): Record<string, string> {
return { [PARTICIPANT_TRUST_ATTR]: trustLevel }
}
@@ -1,117 +0,0 @@
# Encryption Security Architecture
## Threat model
### What E2EE protects against
- **Server-side data access**: The LiveKit SFU and Meet backend cannot read audio/video content
- **Network interception**: Media frames are encrypted before leaving the client
- **Unauthorized participants**: Restricted access + lobby ensures only admin-approved users join
### Known limitations and mitigations
#### Compromised LiveKit server (MITM on key exchange)
**Threat**: If the LiveKit server is compromised, it could perform a Man-in-the-Middle attack on the ephemeral DH key exchange, intercepting the symmetric key.
**Current mitigation**: KEY_RESPONSE is only accepted from participants with `room_admin: "true"` in their server-signed JWT attributes. This prevents non-admin participants from injecting fake keys, but does not protect against a compromised server that can forge JWT attributes.
**Planned mitigations (3 levels):**
##### Level 1 — Signed key exchange (requires encryption onboarding)
When the admin has completed encryption onboarding via `data.encryption`:
1. Admin signs the KEY_RESPONSE with their permanent private key (stored in IndexedDB)
2. Receiving participant fetches admin's public key from `data.encryption` registry
3. Verifies the signature before accepting the symmetric key
4. If signature is invalid → **reject the key, show error, cut video**
This protects against server compromise because the server cannot forge the admin's private key signature.
**Requirement**: Admin must have completed encryption onboarding. If not, falls back to Level 2.
##### Level 2 — SAS (Short Authentication String) verification
After the ephemeral DH key exchange:
1. Both parties compute SAS = hash(DH_shared_secret) → displayed as 4 emojis or a 6-digit code
2. Each participant sees the SAS on their own screen (local rendering)
3. They read it aloud to each other during the call
4. If the SAS matches → the key exchange was not intercepted
5. If the SAS doesn't match → MITM detected → reject the key
This works because:
- A MITM results in different DH shared secrets → different SAS codes
- The SAS is rendered locally — the server cannot change what appears on screen
- Real-time audio manipulation to fake the spoken SAS is extremely difficult
**Requirement**: Participants must verbally compare the SAS. Optional but recommended.
##### Level 3 — Trust the server (current default)
Relies on the LiveKit server's integrity (JWT-signed attributes). Suitable when:
- The server infrastructure is self-hosted and trusted
- The threat model does not include server compromise
- Quick, frictionless meetings are prioritized over maximum security
#### Key propagation without admin
**Current behavior**: Any participant who has the symmetric key can relay it to new joiners.
**Risk**: If the server is compromised, it could inject a fake participant who relays a compromised key.
**Planned fix**: Only accept KEY_RESPONSE from participants whose identity can be:
- Cryptographically verified (Level 1 — signature from registered public key), or
- Manually verified (Level 2 — SAS comparison)
Non-verified key relays should show a clear warning.
## Trust levels
| Level | Badge | Identity verification | Key exchange | Server compromise protection |
|-------|-------|----------------------|-------------|------------------------------|
| Verified | 🟢 Green shield | Public key registered in `data.encryption` | Signed with permanent private key | Yes — signature cannot be forged |
| Authenticated | 🔵 Blue shield | OIDC/ProConnect login | Ephemeral DH (unsigned) | No — relies on server integrity |
| Anonymous | 🟡 Orange warning | None (self-declared name) | Ephemeral DH (unsigned) | No — relies on server integrity |
#### Basic mode: unencrypted frame window on connection
**Behavior**: LiveKit's built-in Worker passes frames through unencrypted when `!isEnabled()`.
**Mitigation**: `setE2EEEnabled(true)` is called BEFORE the room connects (in Conference.tsx),
ensuring the 'enable' message reaches the Worker before any frames flow. This eliminates the
unencrypted window in normal operation. However, edge cases (Worker message queue delays,
race conditions during reconnection) could theoretically still allow a few unencrypted frames.
**Advanced mode**: VaultE2EEManager drops frames when the key isn't ready — no pass-through.
#### Basic mode: "Decryption failed" overlay may not appear with wrong passphrase
**Behavior**: When a participant joins with a wrong passphrase, the receiver may not show the
"Decryption failed" overlay. The LiveKit Worker's error throttling (`MAX_ERRORS_PER_MINUTE = 5`)
stops emitting `EncryptionError` events after 5 failures. Additionally, when a participant
reconnects, the new `ParticipantTile` mounts fresh and may not receive errors referencing
the new participant identity.
**Impact**: The user sees a black tile but no error message explaining why.
**Advanced mode**: VaultE2EEManager emits `EncryptionError` for each failure and signals
`ParticipantEncryptionStatusChanged(true)` on first successful decrypt, ensuring the overlay
appears and clears correctly.
## Implementation status
- [x] Basic E2EE with LiveKit Worker + passphrase in URL hash
- [x] Advanced E2EE with VaultClient iframe (XChaCha20-Poly1305)
- [x] Preserved codec header bytes for RTP compatibility
- [x] Admin as key authority
- [x] Server-signed trust attributes in JWT
- [x] Trust badges (verified/unknown/refused/authenticated/anonymous)
- [x] Encryption identity dialog with fingerprint verification
- [x] Encryption settings in account menu (VaultClient onboarding)
- [x] Fingerprint accept/refuse with `fingerprint-changed` event
- [x] Disable recording/transcription in encrypted rooms (backend + frontend)
- [x] Lobby bypass disabled for encrypted rooms
- [x] Backend blocks encrypted room creation when `ENCRYPTION_ENABLED=false`
- [ ] Signed KEY_RESPONSE (Level 1)
- [ ] SAS verification (Level 2)
- [ ] Restrict key propagation to verified participants only
- [x] Mitigate unencrypted frame window (setE2EEEnabled before connection)
@@ -1,231 +0,0 @@
/**
* React context provider for the centralized encryption VaultClient SDK.
*
* The client SDK is loaded at runtime via a <script> tag from the vault domain
* (data.encryption). This provider:
* - Loads the client.js script from the vault URL
* - Creates and initializes the VaultClient instance
* - Sets auth context when the user logs in
* - Tracks key state (hasKeys, publicKey)
* - Provides the client to all downstream components
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useUser } from '@/features/auth'
import { useConfig } from '@/api/useConfig'
export interface VaultClientContextValue {
client: VaultClient | null
isReady: boolean
isLoading: boolean
error: string | null
hasKeys: boolean | null
publicKey: ArrayBuffer | null
refreshKeyState: () => Promise<void>
}
const VaultClientContext = createContext<VaultClientContextValue>({
client: null,
isReady: false,
isLoading: true,
error: null,
hasKeys: null,
publicKey: null,
refreshKeyState: async () => {},
})
function loadClientScript(vaultUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
if (window.EncryptionClient?.VaultClient) {
resolve()
return
}
const scriptSrc = `${vaultUrl}/client.js`
const existing = document.querySelector(`script[src="${scriptSrc}"]`)
if (existing) {
existing.addEventListener('load', () => resolve())
existing.addEventListener('error', () =>
reject(new Error('Failed to load encryption client SDK'))
)
return
}
const script = document.createElement('script')
script.src = scriptSrc
script.async = true
script.onload = () => resolve()
script.onerror = () =>
reject(new Error('Failed to load encryption client SDK'))
document.head.appendChild(script)
})
}
export function VaultClientProvider({
children,
}: {
children: React.ReactNode
}) {
const { data: config } = useConfig()
const { i18n } = useTranslation()
const { user } = useUser()
const clientRef = useRef<VaultClient | null>(null)
const [clientInitialized, setClientInitialized] = useState(false)
const [isReady, setIsReady] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [hasKeys, setHasKeys] = useState<boolean | null>(null)
const [publicKey, setPublicKey] = useState<ArrayBuffer | null>(null)
const initRef = useRef(false)
const vaultUrl = config?.encryption?.vault_url
const interfaceUrl = config?.encryption?.interface_url
// Load script + initialize VaultClient once
useEffect(() => {
if (initRef.current || !vaultUrl || !interfaceUrl) return
initRef.current = true
let destroyed = false
async function init() {
try {
await loadClientScript(vaultUrl!)
if (destroyed) return
const client = new window.EncryptionClient.VaultClient({
vaultUrl: vaultUrl!,
interfaceUrl: interfaceUrl!,
lang: i18n.language,
})
clientRef.current = client
client.on('onboarding:complete', () => {
setHasKeys(true)
client
.getPublicKey()
.then(({ publicKey: pk }) => setPublicKey(pk))
.catch(() => {})
})
client.on('keys-changed', () => {
client
.hasKeys()
.then(({ hasKeys: exists }) => {
setHasKeys(exists)
if (exists) {
client
.getPublicKey()
.then(({ publicKey: pk }) => setPublicKey(pk))
.catch(() => {})
}
})
.catch(() => {})
})
client.on('keys-destroyed', () => {
setHasKeys(false)
setPublicKey(null)
})
await client.init()
if (destroyed) {
client.destroy()
} else {
setClientInitialized(true)
}
} catch (err) {
if (!destroyed) {
setError((err as Error).message)
setIsLoading(false)
}
}
}
void init()
return () => {
destroyed = true
if (clientRef.current) {
clientRef.current.destroy()
clientRef.current = null
}
}
}, [vaultUrl, interfaceUrl, i18n.language])
// Set auth context when user is available
// Note: Meet may have anonymous users — VaultClient only works for authenticated users
// with a suite_user_id. For anonymous users, isReady stays false.
useEffect(() => {
const client = clientRef.current
if (!client || !clientInitialized) {
return
}
const suiteUserId = (user as Record<string, unknown>)?.sub as string | undefined
if (suiteUserId) {
client.setAuthContext({ suiteUserId })
setIsReady(true)
// Check key state now that auth context is set
client.hasKeys()
.then(({ hasKeys: exists }) => {
setHasKeys(exists)
if (exists) {
client.getPublicKey()
.then(({ publicKey: pk }) => setPublicKey(pk))
.catch(() => {})
}
})
.catch(() => {})
}
setIsLoading(false)
}, [clientInitialized, (user as Record<string, unknown>)?.sub])
const refreshKeyState = useCallback(async () => {
const client = clientRef.current
if (!client) return
try {
const { hasKeys: exists } = await client.hasKeys()
setHasKeys(exists)
if (exists) {
const { publicKey: pk } = await client.getPublicKey()
setPublicKey(pk)
} else {
setPublicKey(null)
}
} catch {
// Vault not available
}
}, [])
return (
<VaultClientContext.Provider
value={{
client: clientInitialized ? clientRef.current : null,
isReady,
isLoading,
error,
hasKeys,
publicKey,
refreshKeyState,
}}
>
{children}
</VaultClientContext.Provider>
)
}
export const useVaultClient = (): VaultClientContextValue =>
useContext(VaultClientContext)
@@ -1,396 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
VaultE2EEManager,
getUnencryptedBytes,
UNENCRYPTED_BYTES,
} from './VaultE2EEManager'
// ── getUnencryptedBytes ───────────────────────────────────────────────
describe('getUnencryptedBytes', () => {
it('returns 10 for VP8 keyframes', () => {
const frame = { type: 'key', data: new ArrayBuffer(100) }
expect(getUnencryptedBytes(frame as unknown as RTCEncodedVideoFrame)).toBe(
UNENCRYPTED_BYTES.key
)
})
it('returns 3 for VP8 delta frames', () => {
const frame = { type: 'delta', data: new ArrayBuffer(100) }
expect(getUnencryptedBytes(frame as unknown as RTCEncodedVideoFrame)).toBe(
UNENCRYPTED_BYTES.delta
)
})
it('returns 1 for audio frames (no type property)', () => {
const frame = { data: new ArrayBuffer(100) }
expect(getUnencryptedBytes(frame as unknown as RTCEncodedAudioFrame)).toBe(
UNENCRYPTED_BYTES.audio
)
})
})
// ── Mock VaultClient ──────────────────────────────────────────────────
function createMockVaultClient() {
// Simulates vault crypto: prepends 24-byte nonce + appends 16-byte MAC
const NONCE_LEN = 24
const MAC_LEN = 16
return {
encryptWithKey: vi.fn(async (data: ArrayBuffer, _key: ArrayBuffer) => {
const input = new Uint8Array(data)
const nonce = new Uint8Array(NONCE_LEN).fill(0xaa) // deterministic for tests
const ciphertext = new Uint8Array(input.length + MAC_LEN)
ciphertext.set(input) // "encrypt" = copy (for testing)
ciphertext.set(new Uint8Array(MAC_LEN).fill(0xbb), input.length) // fake MAC
const result = new Uint8Array(NONCE_LEN + ciphertext.length)
result.set(nonce)
result.set(ciphertext, NONCE_LEN)
return { encryptedData: result.buffer }
}),
decryptWithKey: vi.fn(
async (encryptedData: ArrayBuffer, _key: ArrayBuffer) => {
const input = new Uint8Array(encryptedData)
// Strip nonce (24B) and MAC (16B)
const plaintext = input.slice(NONCE_LEN, input.length - MAC_LEN)
return { data: plaintext.buffer }
}
),
} as unknown as VaultClient
}
// ── Key management ────────────────────────────────────────────────────
describe('VaultE2EEManager key management', () => {
it('stores an independent copy of the key', () => {
const vaultClient = createMockVaultClient()
const manager = new VaultE2EEManager(vaultClient)
const original = new Uint8Array([1, 2, 3, 4])
manager.setEncryptedSymmetricKey(original.buffer)
// Mutate original — should not affect stored key
original[0] = 99
// Access internal state via encryptData (which uses freshKeyBuffer)
// If the key was a view on the original, this would reflect the mutation
expect(manager.isDataChannelEncryptionEnabled).toBe(false) // _isDataChannelEncryptionEnabled not set
manager.isDataChannelEncryptionEnabled = true
expect(manager.isDataChannelEncryptionEnabled).toBe(true) // key is set
})
it('isDataChannelEncryptionEnabled is false without key', () => {
const manager = new VaultE2EEManager(createMockVaultClient())
manager.isDataChannelEncryptionEnabled = true
expect(manager.isDataChannelEncryptionEnabled).toBe(false)
})
it('isDataChannelEncryptionEnabled is true with key + flag', () => {
const manager = new VaultE2EEManager(createMockVaultClient())
manager.setEncryptedSymmetricKey(new ArrayBuffer(32))
manager.isDataChannelEncryptionEnabled = true
expect(manager.isDataChannelEncryptionEnabled).toBe(true)
})
})
// ── Data channel encrypt/decrypt round-trip ───────────────────────────
describe('VaultE2EEManager data channel encryption', () => {
let manager: VaultE2EEManager
let vaultClient: ReturnType<typeof createMockVaultClient>
beforeEach(() => {
vaultClient = createMockVaultClient()
manager = new VaultE2EEManager(vaultClient as unknown as VaultClient)
manager.setEncryptedSymmetricKey(new ArrayBuffer(32))
})
it('encryptData calls vaultClient.encryptWithKey', async () => {
const data = new Uint8Array([10, 20, 30])
const result = await manager.encryptData(data)
expect(vaultClient.encryptWithKey).toHaveBeenCalledOnce()
expect(result.payload).toBeInstanceOf(Uint8Array)
expect(result.payload.length).toBeGreaterThan(data.length) // overhead from nonce+MAC
})
it('handleEncryptedData calls vaultClient.decryptWithKey', async () => {
const data = new Uint8Array([10, 20, 30])
const encrypted = await manager.encryptData(data)
const decrypted = await manager.handleEncryptedData(
encrypted.payload,
new Uint8Array(0),
'participant-1',
0
)
expect(vaultClient.decryptWithKey).toHaveBeenCalledOnce()
expect(new Uint8Array(decrypted.payload)).toEqual(data)
})
it('encryptData throws without key', async () => {
const noKeyManager = new VaultE2EEManager(
vaultClient as unknown as VaultClient
)
await expect(noKeyManager.encryptData(new Uint8Array([1]))).rejects.toThrow(
'No encrypted symmetric key set'
)
})
it('handleEncryptedData throws without key', async () => {
const noKeyManager = new VaultE2EEManager(
vaultClient as unknown as VaultClient
)
await expect(
noKeyManager.handleEncryptedData(
new Uint8Array([1]),
new Uint8Array(0),
'p',
0
)
).rejects.toThrow('No encrypted symmetric key set')
})
})
// ── Frame format (header preservation) ────────────────────────────────
describe('Frame format — header preservation', () => {
let vaultClient: ReturnType<typeof createMockVaultClient>
beforeEach(() => {
vaultClient = createMockVaultClient()
})
it('encrypt preserves VP8 keyframe header (10 bytes)', async () => {
// Simulate what the sender transform does
const frameData = new Uint8Array(100)
// Fill with recognizable pattern: header = 0x01-0x0A, payload = 0xFF
for (let i = 0; i < 10; i++) frameData[i] = i + 1
frameData.fill(0xff, 10)
const unencryptedBytes = UNENCRYPTED_BYTES.key // 10
const header = frameData.slice(0, unencryptedBytes)
const payload = frameData.slice(unencryptedBytes)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
// Reconstruct frame: [header][encrypted payload]
const newFrame = new Uint8Array(header.length + encrypted.length)
newFrame.set(header)
newFrame.set(encrypted, header.length)
// Verify header is preserved unencrypted
expect(newFrame.slice(0, 10)).toEqual(header)
// Verify the rest is different (encrypted)
expect(newFrame.length).toBeGreaterThan(frameData.length) // overhead
})
it('encrypt + decrypt round-trip preserves original frame', async () => {
const frameData = new Uint8Array(50)
for (let i = 0; i < 50; i++) frameData[i] = i
const unencryptedBytes = UNENCRYPTED_BYTES.delta // 3
const header = frameData.slice(0, unencryptedBytes)
const payload = frameData.slice(unencryptedBytes)
// Encrypt
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
const encryptedFrame = new Uint8Array(header.length + encrypted.length)
encryptedFrame.set(header)
encryptedFrame.set(encrypted, header.length)
// Decrypt (receiver side)
const rxHeader = encryptedFrame.slice(0, unencryptedBytes)
const rxEncrypted = encryptedFrame.slice(unencryptedBytes)
const { data } = await vaultClient.decryptWithKey(
rxEncrypted.slice().buffer,
new ArrayBuffer(32)
)
const plaintext = new Uint8Array(data)
const decryptedFrame = new Uint8Array(rxHeader.length + plaintext.length)
decryptedFrame.set(rxHeader)
decryptedFrame.set(plaintext, rxHeader.length)
// Original frame should be recovered exactly
expect(decryptedFrame).toEqual(frameData)
})
it('audio frames preserve 1 byte header', async () => {
const frameData = new Uint8Array(20)
frameData[0] = 0xfc // Opus TOC byte
frameData.fill(0xab, 1)
const unencryptedBytes = UNENCRYPTED_BYTES.audio // 1
const header = frameData.slice(0, unencryptedBytes)
const payload = frameData.slice(unencryptedBytes)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
const encryptedFrame = new Uint8Array(header.length + encrypted.length)
encryptedFrame.set(header)
encryptedFrame.set(encrypted, header.length)
// First byte (Opus TOC) must be preserved
expect(encryptedFrame[0]).toBe(0xfc)
})
})
// ── Proof: data transiting through LiveKit SFU is not decipherable ────
describe('SFU sees only encrypted data', () => {
let vaultClient: ReturnType<typeof createMockVaultClient>
beforeEach(() => {
vaultClient = createMockVaultClient()
})
it('encrypted frame payload does NOT match original payload', async () => {
// Simulate a VP8 keyframe with recognizable pixel data
const frameSize = 5000 // typical small video frame
const originalFrame = new Uint8Array(frameSize)
for (let i = 0; i < frameSize; i++) originalFrame[i] = i % 256
const headerSize = UNENCRYPTED_BYTES.key // 10
const header = originalFrame.slice(0, headerSize)
const payload = originalFrame.slice(headerSize)
// Encrypt (what the sender does before sending to SFU)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
// This is what the SFU sees: [header][encrypted payload]
const sfuFrame = new Uint8Array(header.length + encrypted.length)
sfuFrame.set(header)
sfuFrame.set(encrypted, header.length)
// The SFU frame is LARGER than original (nonce + MAC overhead)
expect(sfuFrame.length).toBe(originalFrame.length + 24 + 16) // +40B
// The header bytes are the same (unencrypted, needed for RTP)
expect(sfuFrame.slice(0, headerSize)).toEqual(header)
// The payload bytes are COMPLETELY DIFFERENT from the original
const sfuPayload = sfuFrame.slice(headerSize)
const originalPayload = originalFrame.slice(headerSize)
expect(sfuPayload.length).not.toBe(originalPayload.length)
expect(sfuPayload).not.toEqual(originalPayload)
})
it('encrypted payload cannot be reversed without vault decryption', async () => {
const originalPayload = new Uint8Array([72, 101, 108, 108, 111]) // "Hello"
const { encryptedData } = await vaultClient.encryptWithKey(
originalPayload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
// The encrypted data is 40 bytes larger (24B nonce + 16B MAC)
expect(encrypted.length).toBe(originalPayload.length + 24 + 16)
// No substring of the encrypted data matches the original payload
// (the nonce prepended and MAC appended obscure everything)
for (let i = 0; i <= encrypted.length - originalPayload.length; i++) {
const slice = encrypted.slice(i, i + originalPayload.length)
if (i === 24) {
// At offset 24 (after nonce), our mock "encrypts" by copying,
// so in a real vault this would NOT match. Skip this offset for
// the mock — the real test is the overhead structure.
continue
}
expect(slice).not.toEqual(originalPayload)
}
})
it('overhead is exactly 40 bytes (24B nonce + 16B MAC) per frame', async () => {
const testSizes = [10, 100, 1000, 5000, 20000]
for (const size of testSizes) {
const payload = new Uint8Array(size)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.buffer,
new ArrayBuffer(32)
)
const overhead = new Uint8Array(encryptedData).length - size
expect(overhead).toBe(40) // 24B nonce + 16B MAC = XChaCha20-Poly1305
}
})
it('only codec header bytes leak — they contain no media content', () => {
// VP8 keyframe header is 10 bytes of codec metadata (not pixels)
// VP8 delta header is 3 bytes
// Opus audio header is 1 byte (TOC byte = codec config, not audio samples)
//
// These bytes tell the RTP packetizer how to split the frame into packets.
// They do NOT contain visual or audio content.
expect(UNENCRYPTED_BYTES.key).toBe(10) // VP8 payload descriptor
expect(UNENCRYPTED_BYTES.delta).toBe(3) // VP8 payload descriptor
expect(UNENCRYPTED_BYTES.audio).toBe(1) // Opus TOC byte
// Maximum leak per frame is 10 bytes out of typically 1000-50000 byte frames
// = 0.02% to 1% of frame data, and it's codec metadata, not content
const typicalKeyframeSize = 50000
const leakRatio = UNENCRYPTED_BYTES.key / typicalKeyframeSize
expect(leakRatio).toBeLessThan(0.001) // less than 0.1%
})
it('full sender→SFU→receiver pipeline: receiver recovers original, SFU cannot', async () => {
// Original video frame (sender side)
const originalFrame = new Uint8Array(200)
for (let i = 0; i < 200; i++) originalFrame[i] = (i * 7 + 13) % 256
const headerSize = UNENCRYPTED_BYTES.delta // 3
// ── SENDER: encrypt and send ──
const header = originalFrame.slice(0, headerSize)
const payload = originalFrame.slice(headerSize)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
const wireFrame = new Uint8Array(header.length + encrypted.length)
wireFrame.set(header)
wireFrame.set(encrypted, header.length)
// ── SFU: can only see wireFrame — cannot recover original ──
// The SFU would need to strip the nonce and decrypt the ciphertext,
// but it doesn't have the symmetric key (it's in the vault iframe).
expect(wireFrame).not.toEqual(originalFrame)
expect(wireFrame.length).not.toBe(originalFrame.length)
// ── RECEIVER: decrypt and recover ──
const rxHeader = wireFrame.slice(0, headerSize)
const rxEncrypted = wireFrame.slice(headerSize)
const { data } = await vaultClient.decryptWithKey(
rxEncrypted.slice().buffer,
new ArrayBuffer(32)
)
const decryptedPayload = new Uint8Array(data)
const recoveredFrame = new Uint8Array(rxHeader.length + decryptedPayload.length)
recoveredFrame.set(rxHeader)
recoveredFrame.set(decryptedPayload, rxHeader.length)
// Receiver gets the EXACT original frame
expect(recoveredFrame).toEqual(originalFrame)
})
})
@@ -1,328 +0,0 @@
/**
* Custom E2EE Manager that delegates crypto to the VaultClient iframe.
*
* Uses XChaCha20-Poly1305 (libsodium) via the vault the symmetric key
* never leaves the iframe. Preserves codec header bytes unencrypted so
* the WebRTC RTP packetizer can construct valid packets.
*
* Frame format (sender output / receiver input):
* [unencrypted codec header][vault-encrypted payload]
*
* Where vault-encrypted payload = [24B nonce][ciphertext + 16B Poly1305 MAC]
*
* Unencrypted header sizes (VP8):
* - keyframe: 10 bytes (VP8 payload descriptor)
* - delta: 3 bytes
* - audio: 1 byte (Opus TOC)
*/
import { EventEmitter } from 'events'
import { Encryption_Type } from '@livekit/protocol'
import type { Room, RemoteTrack, Track } from 'livekit-client'
import { RoomEvent, ParticipantEvent, ConnectionState } from 'livekit-client'
import type { RTCEngine } from 'livekit-client/src/room/RTCEngine'
const E2EE_FLAG = Symbol('e2ee')
enum EncryptionEvent {
ParticipantEncryptionStatusChanged = 'participantEncryptionStatusChanged',
EncryptionError = 'encryptionError',
}
function isInsertableStreamSupported(): boolean {
return (
typeof window.RTCRtpSender !== 'undefined' &&
// @ts-expect-error — createEncodedStreams not in TS types
typeof window.RTCRtpSender.prototype.createEncodedStreams !== 'undefined'
)
}
export const UNENCRYPTED_BYTES = { key: 10, delta: 3, audio: 1 }
export function getUnencryptedBytes(
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame
): number {
if (!('type' in frame)) return UNENCRYPTED_BYTES.audio
return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta
}
export class VaultE2EEManager extends EventEmitter {
private vaultClient: VaultClient
private room?: Room
private encryptionEnabled = false
private _isDataChannelEncryptionEnabled = false
/**
* Encrypted symmetric key (wrapped for the user's vault public key).
* Stored as an independent copy so the original ArrayBuffer can't be detached.
*/
private encryptedKeyBytes: Uint8Array | null = null
constructor(vaultClient: VaultClient) {
super()
this.vaultClient = vaultClient
}
get isEnabled() {
return this.encryptionEnabled
}
get isDataChannelEncryptionEnabled() {
return this._isDataChannelEncryptionEnabled && !!this.encryptedKeyBytes
}
set isDataChannelEncryptionEnabled(enabled: boolean) {
this._isDataChannelEncryptionEnabled = enabled
}
/** Fresh ArrayBuffer copy of the key for each vault call (avoids postMessage detachment). */
private freshKeyBuffer(): ArrayBuffer {
return new Uint8Array(this.encryptedKeyBytes!).buffer
}
setEncryptedSymmetricKey(key: ArrayBuffer): void {
this.encryptedKeyBytes = new Uint8Array(new Uint8Array(key))
}
// ── Lifecycle (mirrors built-in E2EEManager) ────────────────────────
setup(room: Room): void {
if (!isInsertableStreamSupported()) {
throw new Error(
'End-to-end encryption is not supported in this browser. ' +
'Please use a Chromium-based browser (Chrome, Edge, Brave).'
)
}
if (room !== this.room) {
this.room = room
this.setupEventListeners(room)
}
}
setupEngine(_engine: RTCEngine): void {}
setParticipantCryptorEnabled(
enabled: boolean,
participantIdentity: string
): void {
if (
participantIdentity === this.room?.localParticipant.identity &&
this.encryptionEnabled !== enabled
) {
this.encryptionEnabled = enabled
this.emit(
EncryptionEvent.ParticipantEncryptionStatusChanged,
enabled,
this.room!.localParticipant
)
} else if (participantIdentity !== this.room?.localParticipant.identity) {
const p = this.room?.getParticipantByIdentity(participantIdentity)
if (p)
this.emit(
EncryptionEvent.ParticipantEncryptionStatusChanged,
enabled,
p
)
}
}
setSifTrailer(_trailer: Uint8Array): void {}
async encryptData(data: Uint8Array) {
if (!this.encryptedKeyBytes)
throw new Error('No encrypted symmetric key set')
const r = await this.vaultClient.encryptWithKey(
data.slice().buffer,
this.freshKeyBuffer()
)
return {
uuid: crypto.randomUUID(),
payload: new Uint8Array(r.encryptedData).slice(),
iv: new Uint8Array(0),
keyIndex: 0,
}
}
async handleEncryptedData(
payload: Uint8Array,
_iv: Uint8Array,
_participantIdentity: string,
_keyIndex: number
) {
if (!this.encryptedKeyBytes)
throw new Error('No encrypted symmetric key set')
const r = await this.vaultClient.decryptWithKey(
payload.slice().buffer,
this.freshKeyBuffer()
)
return {
uuid: crypto.randomUUID(),
payload: new Uint8Array(r.data).slice(),
}
}
// ── Event listeners ─────────────────────────────────────────────────
private setupEventListeners(room: Room): void {
room.on(RoomEvent.TrackPublished, (pub, participant) => {
this.setParticipantCryptorEnabled(
pub.trackInfo!.encryption !== Encryption_Type.NONE,
participant.identity
)
})
room.on(RoomEvent.ConnectionStateChanged, (state) => {
if (state === ConnectionState.Connected) {
room.remoteParticipants.forEach((p) => {
p.trackPublications.forEach((pub) => {
this.setParticipantCryptorEnabled(
pub.trackInfo!.encryption !== Encryption_Type.NONE,
p.identity
)
})
})
}
})
room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
this.setupReceiver(track, participant.identity)
})
room.on(RoomEvent.SignalConnected, () => {
this.setParticipantCryptorEnabled(
room.localParticipant.isE2EEEnabled,
room.localParticipant.identity
)
})
room.localParticipant.on(
ParticipantEvent.LocalSenderCreated,
(sender: RTCRtpSender, track: Track) => {
this.setupSender(sender, track.mediaStreamID)
}
)
}
// ── Sender (encrypt outgoing frames) ────────────────────────────────
private setupSender(sender: RTCRtpSender, _trackId: string): void {
if (E2EE_FLAG in sender) return
if (!this.room?.localParticipant.identity) return
// @ts-expect-error — createEncodedStreams not in TS types
const streams = sender.createEncodedStreams()
const transformStream = new TransformStream({
transform: async (
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController
) => {
try {
if (!this.encryptedKeyBytes) return // drop — never send unencrypted
if (!frame.data || frame.data.byteLength === 0)
return controller.enqueue(frame)
const unencryptedBytes = getUnencryptedBytes(frame)
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
const payload = new Uint8Array(frame.data, unencryptedBytes)
const { encryptedData } = await this.vaultClient.encryptWithKey(
payload.slice().buffer,
this.freshKeyBuffer()
)
const encrypted = new Uint8Array(encryptedData)
const newData = new Uint8Array(
header.byteLength + encrypted.byteLength
)
newData.set(header)
newData.set(encrypted, header.byteLength)
frame.data = newData.buffer
controller.enqueue(frame)
} catch {
// Drop frame on error — never send unencrypted
}
},
})
streams.readable.pipeThrough(transformStream).pipeTo(streams.writable)
// @ts-expect-error
sender[E2EE_FLAG] = true
}
// ── Receiver (decrypt incoming frames) ──────────────────────────────
private setupReceiver(track: RemoteTrack, participantIdentity: string): void {
if (!track.receiver) return
const receiver = track.receiver
if (E2EE_FLAG in receiver) return
// @ts-expect-error
let writable: WritableStream = receiver.writableStream
// @ts-expect-error
let readable: ReadableStream = receiver.readableStream
if (!writable || !readable) {
// @ts-expect-error
const streams = receiver.createEncodedStreams()
// @ts-expect-error
receiver.writableStream = streams.writable
writable = streams.writable
// @ts-expect-error
receiver.readableStream = streams.readable
readable = streams.readable
}
let successEmitted = false
const transformStream = new TransformStream({
transform: async (
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController
) => {
try {
if (!this.encryptedKeyBytes) return // drop — can't decrypt without key
if (!frame.data || frame.data.byteLength === 0)
return controller.enqueue(frame)
const unencryptedBytes = getUnencryptedBytes(frame)
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
const encryptedPayload = new Uint8Array(frame.data, unencryptedBytes)
const { data } = await this.vaultClient.decryptWithKey(
encryptedPayload.slice().buffer,
this.freshKeyBuffer()
)
const plaintext = new Uint8Array(data)
const newData = new Uint8Array(
header.byteLength + plaintext.byteLength
)
newData.set(header)
newData.set(plaintext, header.byteLength)
frame.data = newData.buffer
controller.enqueue(frame)
if (!successEmitted) {
successEmitted = true
const p = this.room?.getParticipantByIdentity(participantIdentity)
if (p)
this.emit(
EncryptionEvent.ParticipantEncryptionStatusChanged,
true,
p
)
}
} catch {
// Drop frame — keeps pipe alive, avoids sending corrupt data to decoder
}
},
})
readable
.pipeThrough(transformStream)
.pipeTo(writable)
.catch(() => {})
// @ts-expect-error
receiver[E2EE_FLAG] = true
}
}
-104
View File
@@ -1,104 +0,0 @@
export {}
declare global {
interface VaultClient {
init(): Promise<void>
destroy(): void
setTheme(theme: string): void
setAuthContext(context: { suiteUserId: string }): void
hasKeys(): Promise<{ hasKeys: boolean }>
getPublicKey(): Promise<{ publicKey: ArrayBuffer }>
encryptWithoutKey(
data: ArrayBuffer,
userPublicKeys: Record<string, ArrayBuffer>,
options?: { optimizeMemory?: boolean }
): Promise<{
encryptedContent: ArrayBuffer
encryptedKeys: Record<string, ArrayBuffer>
}>
encryptWithKey(
data: ArrayBuffer,
encryptedSymmetricKey: ArrayBuffer,
encryptedKeyChain?: ArrayBuffer[],
options?: { optimizeMemory?: boolean }
): Promise<{ encryptedData: ArrayBuffer }>
decryptWithKey(
encryptedData: ArrayBuffer,
encryptedSymmetricKey: ArrayBuffer,
encryptedKeyChain?: ArrayBuffer[],
options?: { optimizeMemory?: boolean }
): Promise<{ data: ArrayBuffer }>
shareKeys(
encryptedSymmetricKey: ArrayBuffer,
userPublicKeys: Record<string, ArrayBuffer>
): Promise<{ encryptedKeys: Record<string, ArrayBuffer> }>
fetchPublicKeys(
userIds: string[]
): Promise<{ publicKeys: Record<string, ArrayBuffer> }>
checkFingerprints(
userFingerprints: Record<string, string>,
currentUserId?: string
): Promise<{
results: Array<{
userId: string
knownFingerprint: string | null
providedFingerprint: string
status: 'trusted' | 'refused' | 'unknown'
}>
}>
acceptFingerprint(userId: string, fingerprint: string): Promise<void>
refuseFingerprint(userId: string, fingerprint: string): Promise<void>
getKnownFingerprints(): Promise<{
fingerprints: Record<
string,
{ fingerprint: string; status: 'trusted' | 'refused' | 'unknown' }
>
}>
openOnboarding(container: HTMLElement): void
openBackup(container: HTMLElement): void
openRestore(container: HTMLElement): void
openDeviceTransfer(container: HTMLElement): void
openSettings(container: HTMLElement): void
closeInterface(): void
on<K extends string>(event: K, listener: (data: unknown) => void): void
off<K extends string>(event: K, listener: (data: unknown) => void): void
}
/**
* Stable error codes carried by `VaultError`. Sourced from the
* encryption SDK (re-exported on `window.EncryptionClient.VaultErrorCode`)
* meet consumers match on these via `(err as VaultError).code` rather
* than regexing message text. Keep in sync with the SDK definition.
*/
type VaultErrorCode =
| 'MISSING_KEYS'
| 'WRONG_SECRET_KEY'
| 'INVALID_BACKUP'
| 'INVALID_MNEMONIC'
| 'NOT_INITIALIZED'
| 'AUTH_REQUIRED'
| 'PRIVILEGED_ORIGIN_REQUIRED'
| 'TIMEOUT'
| 'IFRAME_REQUIRED'
| 'CIPHERTEXT_TOO_SHORT'
| 'UNKNOWN'
interface VaultError extends Error {
readonly code: VaultErrorCode
}
interface Window {
EncryptionClient: {
VaultClient: new (options: {
vaultUrl: string
interfaceUrl: string
timeout?: number
theme?: string
lang?: string
}) => VaultClient
VaultError: new (code: VaultErrorCode, message: string) => VaultError
VaultErrorCode: { readonly [K in VaultErrorCode]: K }
isVaultError: (err: unknown) => err is VaultError
}
}
}
@@ -1,17 +0,0 @@
export { VaultClientProvider, useVaultClient } from './VaultClientProvider'
export type { VaultClientContextValue } from './VaultClientProvider'
export {
determineTrustLevel,
getTrustLevelFromAttributes,
distributeKeyViaPKI,
encodeTrustLevelAttribute,
} from './HybridKeyDistributor'
export type { ParticipantEncryptionInfo } from './HybridKeyDistributor'
export { EncryptionBadge } from './EncryptionBadge'
export { EncryptedMeetingBanner } from './EncryptedMeetingBanner'
export { EncryptionTrustModal } from './EncryptionTrustModal'
export { EncryptionIdentityDialog } from './EncryptionIdentityDialog'
export { useParticipantTrustLevel } from './useParticipantTrustLevel'
export { PARTICIPANT_TRUST_ATTR } from './types'
export type { TrustLevel } from './types'
@@ -1,49 +0,0 @@
/**
* Key storage and passphrase utilities for E2EE lobby flow.
*
* Basic mode: passphrase is in the URL hash shared by sharing the link.
* Advanced mode: vault-wrapped symmetric key exchanged via lobby REST API.
*/
// ── Module-level symmetric key (basic mode) ───────────────────────────
let _symmetricKey: Uint8Array | null = null
export function setSymmetricKey(key: Uint8Array): void {
_symmetricKey = key
}
export function getSymmetricKey(): Uint8Array | null {
return _symmetricKey
}
export function clearSymmetricKey(): void {
_symmetricKey = null
}
// ── Module-level encrypted vault key (advanced mode) ──────────────────
let _encryptedVaultKey: ArrayBuffer | null = null
export function setEncryptedVaultKey(key: ArrayBuffer): void {
_encryptedVaultKey = key
}
export function getEncryptedVaultKey(): ArrayBuffer | null {
return _encryptedVaultKey
}
// ── Passphrase generation (basic mode) ────────────────────────────────
/**
* Generate a random passphrase for basic mode encryption.
* 24 random bytes encoded in base36 = 48 alphanumeric characters.
*/
export function generatePassphrase(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(24)))
.map((b) => b.toString(36).padStart(2, '0'))
.join('')
}
/** Expected length of a basic mode passphrase */
export const BASIC_KEY_LENGTH = 48
@@ -1,43 +0,0 @@
/**
* Trust level for a participant's encryption key distribution.
*
* - 'verified': Key was distributed via PKI (public key registered in encryption library).
* Identity is cryptographically verified.
* - 'authenticated': Key was distributed via ephemeral DH, but participant is authenticated
* via ProConnect. Identity is server-verified, not cryptographically.
* - 'anonymous': Key was distributed via ephemeral DH, participant is not authenticated.
* Identity is self-declared.
*/
export type TrustLevel = 'verified' | 'authenticated' | 'anonymous' | 'refused' | 'unknown'
/**
* Metadata attached to participant attributes for encryption trust level.
*/
export const PARTICIPANT_TRUST_ATTR = 'encryption.trustLevel'
/**
* Data channel topic for encryption key exchange protocol.
*/
export const KEY_EXCHANGE_TOPIC = 'encryption-key-exchange'
/**
* Message types for the in-call key exchange protocol.
*/
export enum KeyExchangeMessageType {
/** New participant sends their ephemeral public key to request the symmetric key */
KEY_REQUEST = 'KEY_REQUEST',
/** Existing participant responds with the symmetric key encrypted for the requester */
KEY_RESPONSE = 'KEY_RESPONSE',
/** Requester confirms receipt of the key */
KEY_ACK = 'KEY_ACK',
}
export interface KeyExchangeMessage {
type: KeyExchangeMessageType
/** Sender's participant identity */
senderIdentity: string
/** Target participant identity (for directed messages) */
targetIdentity?: string
/** Base64-encoded payload */
payload: string
}
@@ -1,133 +0,0 @@
/**
* Hook that determines a participant's trust level and fingerprint status
* by checking the vault (encryption library) via VaultClient.
*
* In advanced mode:
* - Checks if the participant has a registered public key
* - Checks the fingerprint status (trusted/refused/unknown)
* - Returns "verified" only if they have a public key
*
* In basic mode:
* - Only uses authentication status (no vault check)
*/
import { useEffect, useState } from 'react'
import { useVaultClient } from './VaultClientProvider'
import type { TrustLevel } from './types'
/** Compute a fingerprint from a public key (same as encryption repo: SHA-256, first 16 hex chars) */
async function computeFingerprint(publicKey: ArrayBuffer): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', publicKey)
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 16)
}
/** Format for display: "a1b2c3d4e5f67890" → "A1B2 C3D4 E5F6 7890" */
export function formatFingerprint(fp: string): string {
return fp.replace(/(.{4})/g, '$1 ').trim().toUpperCase()
}
export type FingerprintStatus = 'loading' | 'trusted' | 'refused' | 'unknown' | 'no-key' | 'error'
export function useParticipantTrustLevel(
attributes: Record<string, string> | undefined,
encryptionMode?: string,
isSelf?: boolean,
): { trustLevel: TrustLevel; fingerprintStatus: FingerprintStatus; fingerprint: string | null } {
const { client: vaultClient } = useVaultClient()
const [fingerprintStatus, setFingerprintStatus] = useState<FingerprintStatus>('loading')
const [fingerprint, setFingerprint] = useState<string | null>(null)
const isAuthenticated = attributes?.is_authenticated === 'true'
const suiteUserId = attributes?.suite_user_id
const isAdvanced = encryptionMode === 'advanced'
// Re-check when a fingerprint is accepted/refused via VaultClient
const [revision, setRevision] = useState(0)
useEffect(() => {
if (!vaultClient) return
const handler = () => setRevision((r) => r + 1)
vaultClient.on('fingerprint-changed', handler)
return () => { vaultClient.off('fingerprint-changed', handler) }
}, [vaultClient])
useEffect(() => {
if (!isAdvanced || !isAuthenticated) {
setFingerprintStatus('no-key')
return
}
if (!vaultClient || !suiteUserId) {
setFingerprintStatus(vaultClient ? 'no-key' : 'error')
return
}
let cancelled = false
async function check() {
try {
const { publicKeys } = await vaultClient!.fetchPublicKeys([suiteUserId!])
if (cancelled) return
const publicKey = publicKeys[suiteUserId!]
if (!publicKey) {
setFingerprintStatus('no-key')
return
}
// Compute the fingerprint from the public key (SHA-256, first 16 hex chars)
const fp = await computeFingerprint(publicKey)
if (cancelled) return
setFingerprint(fp)
// Own fingerprint is always trusted — we hold the private key
if (isSelf) {
setFingerprintStatus('trusted')
return
}
// Check if we have a known fingerprint in the local registry
const { fingerprints: known } = await vaultClient!.getKnownFingerprints()
if (cancelled) return
const knownEntry = known[suiteUserId!]
if (!knownEntry) {
// Never seen — unknown, needs explicit acceptance
setFingerprintStatus('unknown')
} else if (knownEntry.fingerprint === fp) {
// Same fingerprint — use stored status
setFingerprintStatus(knownEntry.status as FingerprintStatus)
} else {
// Different fingerprint — key changed, needs re-verification
setFingerprintStatus('unknown')
}
} catch {
if (!cancelled) setFingerprintStatus('error')
}
}
check()
return () => { cancelled = true }
}, [vaultClient, suiteUserId, isAuthenticated, isAdvanced, isSelf, revision])
// Derive trust level from fingerprint status
let trustLevel: TrustLevel
if (!isAuthenticated) {
trustLevel = 'anonymous'
} else if (!isAdvanced) {
// Basic mode: only authentication matters
trustLevel = 'authenticated'
} else if (fingerprintStatus === 'trusted') {
trustLevel = 'verified'
} else if (fingerprintStatus === 'refused') {
trustLevel = 'refused'
} else if (fingerprintStatus === 'no-key' || fingerprintStatus === 'error') {
// Authenticated but no vault keys — show as authenticated (blue)
trustLevel = 'authenticated'
} else {
// 'unknown' or 'loading' — has key but not yet verified
trustLevel = 'unknown'
}
return { trustLevel, fingerprintStatus, fingerprint }
}
@@ -1,93 +0,0 @@
import { fetchApi } from '@/api/fetchApi'
import { useMutation } from '@tanstack/react-query'
import { ApiFileItem } from '@/features/files/api/types.ts'
import { keys } from '@/api/queryKeys.ts'
import { queryClient } from '@/api/queryClient.ts'
/**
* Upload a file, using XHR so we can report on progress through a handler.
*
* @param url The URL to PUT the file to.
* @param file The file to upload.
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
*/
export const uploadFile = (
url: string,
file: File,
progressHandler: (progress: number) => void
) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', url)
xhr.setRequestHeader('X-amz-acl', 'private')
xhr.setRequestHeader('Content-Type', file.type)
xhr.addEventListener('error', reject)
xhr.addEventListener('abort', reject)
xhr.addEventListener('readystatechange', () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// Make sure to always set the progress to 100% when the upload is done.
// Because 'progress' event listener is not called when the file size is 0.
progressHandler(100)
return resolve(true)
}
reject(new Error(`Failed to perform the upload on ${url}.`))
}
})
xhr.upload.addEventListener('progress', (progressEvent) => {
if (progressEvent.lengthComputable) {
progressHandler(
Math.floor((progressEvent.loaded / progressEvent.total) * 100)
)
}
})
xhr.send(file)
})
/**
* Asynchronously creates a new file and uploads it to the server.
*
* @param {object} params - The parameters for the file creation and upload process.
* @param {File} params.file - The file object to be uploaded.
* @param {function} params.onProgress - A callback function that receives the upload progress as a number (0 to 100).
* @returns {Promise<ApiFileItem>} A promise that resolves when the file has been successfully uploaded and the server process is completed.
*/
export const createFile = async ({
file,
onProgress,
}: {
file: File
onProgress: (progress: number) => void
}): Promise<ApiFileItem> => {
const res = await fetchApi<ApiFileItem>(`/files/`, {
method: 'POST',
body: JSON.stringify({ filename: file.name, type: 'background_image' }),
})
if (res.upload_state !== 'pending') {
throw new Error('State should be pending right after creation')
}
const policy = res.policy
await uploadFile(policy, file, onProgress)
const createdFile = await fetchApi<ApiFileItem>(
`/files/${res.id}/upload-ended/`,
{
method: 'POST',
}
)
// We invalidate the files query to make sure the new file is immediately available.
await queryClient.invalidateQueries({
queryKey: [keys.files],
})
return createdFile
}
export const useCreateFile = () => {
return useMutation({
mutationFn: createFile,
})
}
@@ -1,33 +0,0 @@
import { fetchApi } from '@/api/fetchApi'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys.ts'
/**
* Deletes a file specified by its unique identifier.
*
* @param {Object} params - The parameters required for deleting the file.
* @param {string} params.fileId - The unique identifier of the file to be deleted.
* @returns {Promise<void>} A promise that resolves when the file is successfully deleted.
*/
export const deleteFile = async ({
fileId,
}: {
fileId: string
}): Promise<void> => {
await fetchApi<void>(`/files/${fileId}/`, {
method: 'DELETE',
})
}
export const useDeleteFile = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteFile,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [keys.files],
})
},
})
}
@@ -1,70 +0,0 @@
import { fetchApi } from '@/api/fetchApi'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import {
ApiFileItem,
ApiFileType,
ApiFileUploadState,
} from '@/features/files/api/types.ts'
import { useUser } from '@/features/auth'
import { useConfig } from '@/api/useConfig.ts'
type ListFilesResponse = {
count: number
next: string | null
previous: string | null
results: ApiFileItem[]
}
type ListFilesFilters = {
is_creator_me?: boolean
type?: ApiFileType
upload_state?: ApiFileUploadState
is_deleted?: boolean
}
export type ListFilesParams = {
filters?: ListFilesFilters
pagination: {
page: number
pageSize: number
}
}
export const listMyFiles = async ({
filters = {},
pagination: { page, pageSize },
}: ListFilesParams): Promise<ListFilesResponse> => {
const query = new URLSearchParams()
query.append('page', page.toString())
query.append('page_size', pageSize.toString())
if (filters?.is_creator_me ?? true) {
query.append('is_creator_me', 'true')
}
if (filters?.type) {
query.append('type', filters.type)
}
if (filters?.upload_state) {
query.append('upload_state', filters.upload_state)
}
if (typeof filters?.is_deleted === 'boolean') {
query.append('is_deleted', filters.is_deleted ? 'true' : 'false')
}
return fetchApi<ListFilesResponse>(`/files?${query.toString()}`, {
method: 'GET',
})
}
export const useListMyFiles = (params: Parameters<typeof listMyFiles>[0]) => {
const { isLoggedIn } = useUser()
const { data: appConfig } = useConfig()
return useQuery({
queryKey: [keys.files, params],
queryFn: () => listMyFiles(params),
refetchOnMount: 'always',
placeholderData: keepPreviousData,
enabled:
isLoggedIn && appConfig?.background_image?.upload_is_enabled === true,
})
}
@@ -1,34 +0,0 @@
export type ApiFileCreator = {
id: string // UUID
full_name: string | null
short_name: string | null
}
export type ApiFileType = 'background_image'
export type ApiFileUploadState = 'pending' | 'ready'
export type ApiFileItem = {
id: string // UUID
created_at: string // ISO datetime string
updated_at: string // ISO datetime string
title: string
type: ApiFileType
creator: ApiFileCreator
deleted_at: string | null
hard_deleted_at: string | null
filename: string
upload_state: ApiFileUploadState
mimetype: string // e.g. "image/png"
size: number // file size in bytes
description: string | null
} & (
| {
upload_state: 'ready'
url: string
}
| {
upload_state: 'pending'
policy: string
url: null
}
)
@@ -1,142 +0,0 @@
import { Button, Dialog, type DialogProps, Text } from '@/primitives'
import { VStack, HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiLockFill, RiShieldCheckFill, RiAlertLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useVaultClient } from '@/features/encryption'
export const EncryptionModeDialog = ({
onSelect,
isForLater = false,
...dialogProps
}: {
onSelect: (mode: ApiEncryptionMode) => void
isForLater?: boolean
} & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'encryptionModeDialog' })
const { hasKeys, client: vaultClient, error: vaultError, isLoading: vaultLoading } = useVaultClient()
const vaultUnavailable = !vaultClient && !vaultLoading
const canUseAdvanced = !!hasKeys && !vaultUnavailable
return (
<Dialog title={t('title')} isOpen {...dialogProps}>
<VStack gap="1rem" alignItems="stretch">
<Text variant="sm" className={css({ color: 'greyscale.700' })}>
{t('description')}
</Text>
<button
className={css({
display: 'flex',
gap: '0.75rem',
padding: '1rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.200',
backgroundColor: 'white',
cursor: 'pointer',
textAlign: 'left',
transition: 'border-color 150ms ease, background-color 150ms ease',
_hover: {
borderColor: 'primary.500',
backgroundColor: 'primary.50',
},
})}
onClick={() => onSelect(ApiEncryptionMode.BASIC)}
>
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
<RiLockFill size={20} color="#2563eb" />
</div>
<VStack gap="0.25rem" alignItems="flex-start">
<Text
variant="sm"
bold
className={css({ color: 'greyscale.900' })}
>
{t('basic.title')}
</Text>
<Text variant="sm" className={css({ color: 'greyscale.600' })}>
{t('basic.description')}
</Text>
</VStack>
</button>
<div style={{ position: 'relative' }}>
<button
className={css({
display: 'flex',
gap: '0.75rem',
padding: '1rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.200',
backgroundColor: 'white',
cursor: canUseAdvanced ? 'pointer' : 'not-allowed',
textAlign: 'left',
opacity: canUseAdvanced ? 1 : 0.5,
transition:
'border-color 150ms ease, background-color 150ms ease',
_hover: canUseAdvanced
? {
borderColor: 'green.500',
backgroundColor: 'green.50',
}
: {},
})}
onClick={() => canUseAdvanced && onSelect(ApiEncryptionMode.ADVANCED)}
disabled={!canUseAdvanced}
>
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
<RiShieldCheckFill
size={20}
color={canUseAdvanced ? '#166534' : '#9ca3af'}
/>
</div>
<VStack gap="0.25rem" alignItems="flex-start">
<Text
variant="sm"
bold
className={css({
color: canUseAdvanced ? 'greyscale.900' : 'greyscale.400',
})}
>
{t('advanced.title')}
</Text>
<Text
variant="sm"
className={css({
color: canUseAdvanced ? 'greyscale.600' : 'greyscale.400',
})}
>
{t('advanced.description')}
</Text>
</VStack>
</button>
{!canUseAdvanced && (
<HStack
gap="0.4rem"
className={css({
marginTop: '0.5rem',
padding: '0.5rem 0.75rem',
backgroundColor: vaultUnavailable ? 'red.50' : 'orange.50',
borderRadius: '0.375rem',
})}
>
<RiAlertLine
size={14}
color={vaultUnavailable ? '#dc2626' : '#d97706'}
className={css({ flexShrink: 0 })}
/>
<Text variant="note" className={css({ color: vaultUnavailable ? 'red.800' : 'orange.800' })}>
{vaultUnavailable
? t('advanced.serviceUnavailable')
: t('advanced.onboardingRequired')}
</Text>
</HStack>
)}
</div>
</VStack>
</Dialog>
)
}
@@ -1,147 +1,35 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Field, Ul, H, P, Form, Dialog } from '@/primitives' import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
import { css } from '@/styled-system/css'
import { navigateTo } from '@/navigation/navigateTo' import { navigateTo } from '@/navigation/navigateTo'
import { isRoomValid } from '@/features/rooms' import { isRoomValid } from '@/features/rooms'
import { normalizeRoomId } from '@/features/rooms/utils/isRoomValid'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export const JoinMeetingDialog = () => { export const JoinMeetingDialog = () => {
const { t } = useTranslation('home') const { t } = useTranslation('home')
const [step, setStep] = useState<'room' | 'passphrase'>('room')
const [roomId, setRoomId] = useState('')
const [isLoading, setIsLoading] = useState(false)
const parseInput = (input: string): { roomId: string; hash: string } => { const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
const trimmed = input.trim() const roomId = (data.roomId as string)
try { .trim()
const url = new URL(trimmed) .replace(`${window.location.origin}/`, '')
const id = url.pathname.replace(/^\//, '')
return { roomId: id, hash: url.hash.slice(1) }
} catch {
// Not a URL — treat as room code, normalize (add hyphens if 10 chars)
const raw = trimmed.replace(`${window.location.origin}/`, '')
return { roomId: normalizeRoomId(raw), hash: '' }
}
}
const handleRoomSubmit = async (data: { roomId?: FormDataEntryValue }) => {
const input = data.roomId as string
const parsed = parseInput(input)
// If URL already has a hash, navigate directly with it
if (parsed.hash) {
navigateTo('room', parsed.roomId)
window.location.hash = parsed.hash
return
}
// Check if the room uses basic encryption (needs passphrase)
setIsLoading(true)
try {
const room = await fetchRoom({ roomId: parsed.roomId })
if (room.encryption_mode === ApiEncryptionMode.BASIC) {
setRoomId(parsed.roomId)
setStep('passphrase')
return
}
navigateTo('room', parsed.roomId)
} catch {
// Room doesn't exist yet or error — navigate anyway
navigateTo('room', parsed.roomId)
} finally {
setIsLoading(false)
}
}
const handlePassphraseSubmit = (data: { passphrase?: FormDataEntryValue }) => {
const passphrase = (data.passphrase as string).trim()
navigateTo('room', roomId) navigateTo('room', roomId)
window.location.hash = passphrase
} }
const validateRoomId = (value: string) => { const validateRoomId = (value: string) => {
const trimmed = value.trim() const trimmed = value.trim()
if (!trimmed) return null if (!trimmed) return null
const { roomId: id } = parseInput(trimmed) return !isRoomValid(trimmed) ? (
return !isRoomValid(id) ? (
<> <>
<p>{t('joinInputError')}</p> <p>{t('joinInputError')}</p>
<Ul> <Ul>
<li>{window.location.origin}/uio-azer-jkl</li> <li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li> <li>uio-azer-jkl</li>
<li>uioazerjkl</li>
</Ul> </Ul>
</> </>
) : null ) : null
} }
if (step === 'passphrase') {
return (
<Dialog title={t('joinMeeting')}>
<Form onSubmit={handlePassphraseSubmit} submitLabel={t('joinPassphraseSubmit')}>
<P
dangerouslySetInnerHTML={{
__html: t('joinPassphraseDescription', {
interpolation: { escapeValue: false },
}),
}}
/>
<div
className={css({
backgroundColor: 'greyscale.100',
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
marginBottom: '1rem',
fontSize: '0.8rem',
fontFamily: 'monospace',
wordBreak: 'break-all',
lineHeight: '1.5',
border: '1px solid',
borderColor: 'greyscale.200',
'& strong': {
color: '#16a34a',
fontWeight: 700,
},
})}
dangerouslySetInnerHTML={{
__html: t('joinPassphraseExample', {
origin: window.location.origin,
interpolation: { escapeValue: false },
}),
}}
/>
{/* eslint-disable jsx-a11y/no-autofocus */}
<Field
type="text"
autoFocus
isRequired
name="passphrase"
label={t('joinPassphraseLabel')}
errorMessage={t('joinPassphraseError')}
/>
<P
className={css({
fontSize: '0.8rem',
color: '#b45309',
marginTop: '0.5rem',
})}
>
{t('joinPassphraseWarning')}
</P>
</Form>
</Dialog>
)
}
return ( return (
<Dialog title={t('joinMeeting')}> <Dialog title={t('joinMeeting')}>
<Form onSubmit={handleRoomSubmit} submitLabel={isLoading ? '...' : t('joinInputSubmit')}> <Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */} {/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
<Field <Field
type="text" type="text"
@@ -13,12 +13,11 @@ import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRo
// fixme - duplication with the InviteDialog // fixme - duplication with the InviteDialog
export const LaterMeetingDialog = ({ export const LaterMeetingDialog = ({
room, room,
hash,
...dialogProps ...dialogProps
}: { room: null | ApiRoom; hash?: string } & Omit<DialogProps, 'title'>) => { }: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' }) const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
const roomUrl = room ? `${getRouteUrl('room', room.slug)}${hash ? `#${hash}` : ''}` : null const roomUrl = room && getRouteUrl('room', room?.slug)
const telephony = useTelephony() const telephony = useTelephony()
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
@@ -32,7 +31,7 @@ export const LaterMeetingDialog = ({
copyRoomToClipboard, copyRoomToClipboard,
isRoomUrlCopied, isRoomUrlCopied,
copyRoomUrlToClipboard, copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(room || undefined, hash) } = useCopyRoomToClipboard(room || undefined)
return ( return (
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}> <Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
+5 -91
View File
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DialogTrigger, MenuItem, Menu as RACMenu, Separator as RACSeparator } from 'react-aria-components' import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
import { Button, Menu } from '@/primitives' import { Button, Menu } from '@/primitives'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo' import { navigateTo } from '@/navigation/navigateTo'
@@ -7,12 +7,8 @@ import { Screen } from '@/layout/Screen'
import { generateRoomId, useCreateRoom } from '@/features/rooms' import { generateRoomId, useCreateRoom } from '@/features/rooms'
import { useUser, UserAware } from '@/features/auth' import { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog' import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { RiAddLine, RiLink, RiLockLine, RiShieldKeyholeLine } from '@remixicon/react' import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog' import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { EncryptionModeDialog } from '@/features/home/components/EncryptionModeDialog'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
import { useVaultClient } from '@/features/encryption'
import { IntroSlider } from '@/features/home/components/IntroSlider' import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink' import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useEffect, useState } from 'react' import { ReactNode, useEffect, useState } from 'react'
@@ -159,9 +155,7 @@ export const Home = () => {
} = usePersistentUserChoices() } = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom() const { mutateAsync: createRoom } = useCreateRoom()
const { client: vaultClient } = useVaultClient() const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom; hash?: string }>(null)
const [encryptionDialogMode, setEncryptionDialogMode] = useState<null | 'instant' | 'later'>(null)
const [redirectFailed, setRedirectFailed] = useState(false) const [redirectFailed, setRedirectFailed] = useState(false)
const { data } = useConfig() const { data } = useConfig()
@@ -235,7 +229,7 @@ export const Home = () => {
onAction={() => { onAction={() => {
const slug = generateRoomId() const slug = generateRoomId()
createRoom({ slug, username }).then((data) => createRoom({ slug, username }).then((data) =>
setLaterRoom({ room: data }) setLaterRoom(data)
) )
}} }}
data-attr="create-option-later" data-attr="create-option-later"
@@ -243,37 +237,6 @@ export const Home = () => {
<RiLink size={18} /> <RiLink size={18} />
{t('createMenu.laterOption')} {t('createMenu.laterOption')}
</MenuItem> </MenuItem>
{data?.encryption?.enabled && (
<>
<RACSeparator
className={css({
borderTop: '1px solid',
borderColor: 'greyscale.200',
margin: '0.25rem 0',
})}
/>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => setEncryptionDialogMode('instant')}
data-attr="create-option-encrypted-instant"
>
<RiLockLine size={18} />
{t('createMenu.encryptedInstantOption')}
</MenuItem>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => setEncryptionDialogMode('later')}
data-attr="create-option-encrypted-later"
>
<RiShieldKeyholeLine size={18} />
{t('createMenu.encryptedLaterOption')}
</MenuItem>
</>
)}
</RACMenu> </RACMenu>
</Menu> </Menu>
) : ( ) : (
@@ -302,58 +265,9 @@ export const Home = () => {
</RightColumn> </RightColumn>
</Columns> </Columns>
<LaterMeetingDialog <LaterMeetingDialog
room={laterRoom?.room ?? null} room={laterRoom}
hash={laterRoom?.hash}
onOpenChange={() => setLaterRoom(null)} onOpenChange={() => setLaterRoom(null)}
/> />
{encryptionDialogMode && (
<EncryptionModeDialog
onSelect={async (mode) => {
const dialogMode = encryptionDialogMode
setEncryptionDialogMode(null)
const slug = generateRoomId()
const hash = mode === ApiEncryptionMode.BASIC ? generatePassphrase() : undefined
let encryptedSymmetricKey = ''
if (mode === ApiEncryptionMode.ADVANCED && vaultClient) {
// encryptWithoutKey requires data to encrypt, but we only care about
// the generated symmetric key (encryptedKeys), not the encrypted content.
// The same symmetric key will be used for all streams (video/audio/chat).
const dummyData = new Uint8Array(32).buffer
const { publicKey } = await vaultClient.getPublicKey()
const { encryptedKeys } = await vaultClient.encryptWithoutKey(
dummyData,
{ self: publicKey }
)
const keyBytes = new Uint8Array(encryptedKeys['self'])
encryptedSymmetricKey = btoa(String.fromCharCode(...keyBytes))
}
createRoom({
slug,
username,
encryptionMode: mode,
encryptedSymmetricKey,
}).then((data) => {
if (dialogMode === 'instant') {
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
if (hash) {
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}#${hash}`
)
}
} else {
setLaterRoom({ room: data, hash })
}
})
}}
onOpenChange={() => setEncryptionDialogMode(null)}
/>
)}
</Screen> </Screen>
</UserAware> </UserAware>
) )
@@ -12,112 +12,10 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useNotificationSound } from '../hooks/useSoundNotification' import { useNotificationSound } from '../hooks/useSoundNotification'
import { NotificationType } from '@/features/notifications' import { NotificationType } from '@/features/notifications'
import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
import { useParticipantTrustLevel, formatFingerprint } from '@/features/encryption/useParticipantTrustLevel'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
const WaitingParticipantIdentity = ({ participant }: { participant: WaitingParticipant }) => {
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const roomData = useRoomData()
const [isIdentityOpen, setIsIdentityOpen] = useState(false)
const attrs = {
is_authenticated: participant.is_authenticated ? 'true' : 'false',
suite_user_id: participant.suite_user_id || '',
}
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(attrs, roomData?.encryption_mode)
const badgeTooltip = tBadge(trustLevel)
return (
<>
<VStack gap="0" alignItems="start">
<Button
variant="greyscale"
size="sm"
tooltip={badgeTooltip}
aria-label={badgeTooltip}
onPress={() => setIsIdentityOpen(true)}
className={css({
padding: '0.1rem 0.25rem !important',
minWidth: 'auto !important',
height: 'auto !important',
gap: '0.15rem !important',
borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important',
color: 'white !important',
cursor: 'pointer',
'&[data-hovered]': {
backgroundColor: 'rgba(255, 255, 255, 0.15) !important',
},
})}
>
<EncryptionBadge isEncrypted={true} trustLevel={trustLevel} />
<Text
variant="sm"
margin={false}
className={css({
maxWidth: '8rem',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
})}
>
{participant.username}
</Text>
</Button>
{fingerprint && (
<Text
variant="sm"
margin={false}
className={css({
fontFamily: 'monospace',
fontSize: '0.6rem',
color: 'greyscale.100',
letterSpacing: '0.03em',
paddingLeft: '0.25rem',
})}
>
{formatFingerprint(fingerprint)}
</Text>
)}
<Text
variant="sm"
margin={false}
className={css({
fontSize: '0.7rem',
color: 'greyscale.200',
paddingLeft: '0.25rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '10rem',
})}
>
{participant.is_authenticated && participant.email
? participant.email
: tBadge('anonymous')}
</Text>
</VStack>
<EncryptionIdentityDialog
isOpen={isIdentityOpen}
onOpenChange={setIsIdentityOpen}
participantName={participant.username}
participantEmail={participant.email}
suiteUserId={participant.suite_user_id}
isAuthenticated={participant.is_authenticated}
encryptionMode={roomData?.encryption_mode}
preloadedFingerprint={fingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/>
</>
)
}
export const NOTIFICATION_DISPLAY_DURATION = 10000 export const NOTIFICATION_DISPLAY_DURATION = 10000
export const WaitingParticipantNotification = () => { export const WaitingParticipantNotification = () => {
const roomData = useRoomData()
const encrypted = isEncryptedRoom(roomData)
const { triggerNotificationSound } = useNotificationSound() const { triggerNotificationSound } = useNotificationSound()
const { t } = useTranslation('notifications', { const { t } = useTranslation('notifications', {
@@ -202,29 +100,25 @@ export const WaitingParticipantNotification = () => {
> >
{t('one')} {t('one')}
</Text> </Text>
<HStack gap="0.5rem"> <HStack gap="1rem">
<Avatar <Avatar
name={waitingParticipants[0].username} name={waitingParticipants[0].username}
bgColor={waitingParticipants[0].color} bgColor={waitingParticipants[0].color}
context="list" context="list"
notification notification
/> />
{encrypted ? ( <Text
<WaitingParticipantIdentity participant={waitingParticipants[0]} /> variant="sm"
) : ( margin={false}
<Text className={css({
variant="sm" maxWidth: '10rem',
margin={false} wordBreak: 'break-word',
className={css({ overflowWrap: 'break-word',
maxWidth: '10rem', whiteSpace: 'normal',
wordBreak: 'break-word', })}
overflowWrap: 'break-word', >
whiteSpace: 'normal', {waitingParticipants[0].username}
})} </Text>
>
{waitingParticipants[0].username}
</Text>
)}
</HStack> </HStack>
<HStack gap="0.25rem" marginLeft="auto"> <HStack gap="0.25rem" marginLeft="auto">
<Button <Button
@@ -72,8 +72,7 @@ export const RecordingDownload = () => {
if ( if (
data.status !== RecordingStatus.Saved && data.status !== RecordingStatus.Saved &&
data.status !== RecordingStatus.NotificationSucceed && data.status !== RecordingStatus.NotificationSucceed
data.status !== RecordingStatus.FailedToStop
) { ) {
return <ErrorScreen title={t('unsaved.title')} body={t('unsaved.body')} /> return <ErrorScreen title={t('unsaved.title')} body={t('unsaved.body')} />
} }
@@ -10,21 +10,6 @@ export enum ApiAccessLevel {
RESTRICTED = 'restricted', RESTRICTED = 'restricted',
} }
export enum ApiEncryptionMode {
NONE = 'none',
BASIC = 'basic',
ADVANCED = 'advanced',
}
export function isEncryptedRoom(room?: { encryption_mode?: ApiEncryptionMode; encryption_enabled?: boolean } | null): boolean {
if (!room) return false
// Support both new encryption_mode and legacy encryption_enabled
if (room.encryption_mode !== undefined) {
return room.encryption_mode !== ApiEncryptionMode.NONE
}
return !!room.encryption_enabled
}
export type ApiRoom = { export type ApiRoom = {
id: string id: string
name: string name: string
@@ -32,8 +17,6 @@ export type ApiRoom = {
pin_code: string pin_code: string
is_administrable: boolean is_administrable: boolean
access_level: ApiAccessLevel access_level: ApiAccessLevel
encryption_mode: ApiEncryptionMode
encrypted_symmetric_key?: string
livekit?: ApiLiveKit livekit?: ApiLiveKit
configuration?: { configuration?: {
[key: string]: string | number | boolean | string[] [key: string]: string | number | boolean | string[]
@@ -1,31 +1,24 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError' import { ApiError } from '@/api/ApiError'
import { ApiRoom, ApiEncryptionMode } from './ApiRoom' import { ApiRoom } from './ApiRoom'
export interface CreateRoomParams { export interface CreateRoomParams {
slug: string slug: string
callbackId?: string callbackId?: string
username?: string username?: string
encryptionMode?: ApiEncryptionMode
encryptedSymmetricKey?: string
} }
const createRoom = ({ const createRoom = ({
slug, slug,
callbackId, callbackId,
username = '', username = '',
encryptionMode = ApiEncryptionMode.NONE,
encryptedSymmetricKey = '',
}: CreateRoomParams): Promise<ApiRoom> => { }: CreateRoomParams): Promise<ApiRoom> => {
const queryParams = username ? `?username=${encodeURIComponent(username)}` : '' return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
return fetchApi(`rooms/${queryParams}`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
name: slug, name: slug,
callback_id: callbackId, callback_id: callbackId,
encryption_mode: encryptionMode,
encrypted_symmetric_key: encryptedSymmetricKey,
}), }),
}) })
} }
@@ -6,9 +6,6 @@ export interface EnterRoomParams {
roomId: string roomId: string
allowEntry: boolean allowEntry: boolean
participantId: string participantId: string
encryptedKey?: string
adminEphemeralPublicKey?: string
encryptedVaultKey?: string
} }
export interface EnterRoomResponse { export interface EnterRoomResponse {
@@ -19,18 +16,12 @@ export const enterRoom = async ({
roomId, roomId,
allowEntry, allowEntry,
participantId, participantId,
encryptedKey = '',
adminEphemeralPublicKey = '',
encryptedVaultKey = '',
}: EnterRoomParams): Promise<EnterRoomResponse> => { }: EnterRoomParams): Promise<EnterRoomResponse> => {
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, { return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
participant_id: participantId, participant_id: participantId,
allow_entry: allowEntry, allow_entry: allowEntry,
encrypted_key: encryptedKey,
admin_ephemeral_public_key: adminEphemeralPublicKey,
encrypted_vault_key: encryptedVaultKey,
}), }),
}) })
} }

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