mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fc70d4e92 |
+1
-1
@@ -4,7 +4,7 @@ __pycache__
|
|||||||
**/__pycache__
|
**/__pycache__
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
venv
|
venv
|
||||||
**/.venv
|
.venv
|
||||||
|
|
||||||
# System-specific files
|
# System-specific files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ MANIFEST
|
|||||||
|
|
||||||
# Translations # Translations
|
# Translations # Translations
|
||||||
*.pot
|
*.pot
|
||||||
*.mo
|
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
|
|||||||
+2
-50
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
|
||||||
|
|
||||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
We’re 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, France’s 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
|
||||||
|
|
||||||
We’re 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.
|
We’re 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. We’re 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).
|
||||||
|
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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": [
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ paths:
|
|||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/ForbiddenError'
|
$ref: '#/components/responses/ForbiddenError'
|
||||||
|
|
||||||
/rooms/:
|
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Rooms
|
- Rooms
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ paths:
|
|||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/ForbiddenError'
|
$ref: '#/components/responses/ForbiddenError'
|
||||||
|
|
||||||
/rooms/:
|
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Rooms
|
- Rooms
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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.
@@ -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.
@@ -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.
@@ -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 d’arrê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 d’arrê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 d’une salle"
|
msgstr "Afficher les détails d’une 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 l’application"
|
msgstr "Nom de l’application"
|
||||||
|
|
||||||
#: 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 l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||||
"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 d’application"
|
msgstr "Domaine d’application"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:820
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Domaines d’application"
|
msgstr "Domaines d’application"
|
||||||
|
|
||||||
#: 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.
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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 = [
|
||||||
|
|||||||
Generated
-2365
File diff suppressed because it is too large
Load Diff
Generated
+16
-889
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,4 @@ export const keys = {
|
|||||||
requestEntry: 'requestEntry',
|
requestEntry: 'requestEntry',
|
||||||
waitingParticipants: 'waitingParticipants',
|
waitingParticipants: 'waitingParticipants',
|
||||||
roomCreationCallback: 'roomCreationCallback',
|
roomCreationCallback: 'roomCreationCallback',
|
||||||
files: 'files',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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')}>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-119
@@ -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
Reference in New Issue
Block a user