Compare commits

..

1 Commits

Author SHA1 Message Date
Michel-Marie MAUDET 724a4ef3df 🔒️(backend) secure native app OIDC login with exchange codes
Add one-time exchange code mechanism for native app OIDC login.
Instead of exposing session ID in redirect URL, generates a
short-lived single-use code stored in Redis. Native apps exchange
this code for the session ID via a dedicated API endpoint.

Includes NativeAppRedirect for custom URL schemes, rate limiting,
logging, and whitelist of allowed schemes.

Closes #1153

Co-Authored-By: gigi206
2026-03-20 18:35:49 +01:00
152 changed files with 2117 additions and 5848 deletions
+33 -33
View File
@@ -48,12 +48,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -93,12 +93,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -139,12 +139,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -185,13 +185,13 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/summary/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
@@ -233,14 +233,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/agents/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
# docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
-40
View File
@@ -297,46 +297,6 @@ jobs:
- name: Run tests
run: uv run 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:
runs-on: ubuntu-latest
permissions:
+2 -53
View File
@@ -10,75 +10,25 @@ and this project adheres to
### Added
- ✨(encryption) opt-in end-to-end encryption for meetings, with the
passphrase carried in the URL hash and an explicit "Create an
encrypted meeting" entry in the home create menu (gated by a
per-user Security preference) #1337
- ✨(encryption) authenticated participant emails surfaced in the
participants list of encrypted rooms; anonymous participants get a
red badge with tooltip in the list, waiting room, and the floating
join-request notification
### 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
- ✨(helm) support celery with our Django backend #1124
- ✨(helm) support ingress for custom background image #1124
- ✨(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
- ♿️(frontend) Caption text size setting for accessibility #1062
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
-(frontend) improve chat toast a11y for screen readers #1109
-(frontend) improve ui and aria labels for help article links #1108
- ♿(frontend) improve chat toast a11y for screen readers #1109
- ♿(frontend) improve ui and aria labels for help article links #1108
- 🌐(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
@@ -86,7 +36,6 @@ and this project adheres to
- 🩹(backend) add page_size to pagination for room endpoints #1131
- 🐛(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
-6
View File
@@ -191,7 +191,6 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests
@$(MAKE) test-back-parallel
@$(MAKE) test-summary
.PHONY: test
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}}
.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.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
+21 -44
View File
@@ -2,6 +2,7 @@
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
</p>
<p align="center">
<a href="https://github.com/suitenumerique/meet/stargazers/">
<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"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
</a>
</p>
<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 align="center">
@@ -27,54 +28,25 @@
## 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/).
### Features
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- End-to-end encryption with passphrase-in-link key distribution
- End-to-end encryption (coming soon)
- Meeting recording
- Meeting transcription & Summary (currently in beta)
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit Advances features including :
- speaker detection
- simulcast
- end-to-end optimizations
- speaker detection
- simulcast
- end-to-end optimizations
- selective subscription
- SVC codecs (VP9, AV1)
### End-to-end encryption
La Suite Meet supports end-to-end encryption (E2EE) for meetings, so the media server (LiveKit SFU) cannot read audio, video or screen-share content.
#### How it works
- Each encrypted meeting carries a 48-character hex passphrase appended to the URL hash (`#…`) — 192 bits of entropy. The server never sees it; sharing the meeting link shares the key.
- Frames are encrypted in the browser via LiveKit's Worker + `crypto.subtle` (AES-GCM); only the media payload is encrypted, codec headers stay clear so the SFU can still packetize RTP.
- The runtime "is this call encrypted?" decision keys off the URL hash, not the database flag. The DB column (`Room.encryption_mode`) is only used as a sanity reference: if the URL hash and the server's claim disagree, the joining client surfaces an explicit mismatch screen instead of silently joining in clear or in a private encrypted bubble.
> **Threat model.** "Server doesn't see plaintext" — not "users are safe from a malicious server." A compromised server could still serve modified JavaScript to a participant, who would then leak their passphrase. The E2EE story protects the media path against a passive or compromised SFU, not against a fully compromised origin.
#### Encryption mode is set at creation, immutable after
`Room.encryption_mode` is a string enum (`none` / `basic`) chosen when the room is created and never mutated afterwards — changing it would change the link's semantics, since the passphrase lives in the URL hash. There is no mid-call "pause encryption" mechanism: while a meeting is encrypted, **recording and transcription endpoints reject requests with a 400** (`Recording is unavailable in encrypted rooms.` / `Subtitles are unavailable in encrypted rooms.`), the More-tools panel renders those items disabled with an explanatory banner, and the SIP gateway never gets a dispatch rule for encrypted rooms (so dial-in numbers and PINs aren't allocated). Encrypted rooms are also force-locked to `restricted` access level (lobby admission), since basic E2EE only meaningfully protects against passive eavesdropping if the host vets joiners before they receive the in-URL key.
#### Opt-in by user
End-to-end encryption is a per-user preference. In **Settings**, under the **Security** section, signed-in users can flip the **End-to-end encryption** toggle — once enabled, a third "Create an encrypted meeting" entry appears in the home-page create-menu (with its own confirmation modal that lists the disabled features and a "Treat this link like a password" connection-details dialog before the meeting starts). Joining is unaffected by the toggle: any participant clicking a meeting link that carries a valid hash joins encrypted, regardless of their own setting. Authenticated joiners of encrypted rooms cannot edit their displayed name — the server enforces the OIDC name on the JWT.
#### Configuration
```env
ENCRYPTION_ENABLED=true
```
Setting `ENCRYPTION_ENABLED=false` rejects encrypted-room creation at the API level. Existing encrypted rooms stay encrypted (the mode is immutable), but no new ones can be created.
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).
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
Were continuously adding new features to enhance your experience, with the latest updates coming soon!
@@ -91,6 +63,7 @@ On the 25th of January 2026, David Amiel, Frances Minister for Civil Service
- [Philosophy](#philosophy)
- [Open source](#open-source)
## Get started
## Docs
@@ -109,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.
#### 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🙏
| 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.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 |
| [mosa.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| 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.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 |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing
@@ -127,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))
- 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
Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
@@ -135,6 +109,7 @@ Our users come first. Were committed to making La Suite Meet as accessible an
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
## Open-source
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
@@ -146,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).
## Contributors 🧞
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
</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!
This project is tested with BrowserStack.
@@ -161,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).
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
app-summary-dev \
python -m pytest "$@"
+3 -3
View File
@@ -60,7 +60,7 @@
},
{
"username": "user-e2e-chromium",
"email": "user.test@chromium.test",
"email": "user@chromium.e2e",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": "true",
@@ -74,7 +74,7 @@
},
{
"username": "user-e2e-webkit",
"email": "user.test@webkit.test",
"email": "user@webkit.e2e",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": "true",
@@ -88,7 +88,7 @@
},
{
"username": "user-e2e-firefox",
"email": "user.test@firefox.test",
"email": "user@firefox.e2e",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": "true",
+4 -3
View File
@@ -61,10 +61,11 @@ services:
`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]
> 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
@@ -75,7 +76,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
3. Enter the name of the realm - `meet`.
4. Click "Create".
### Step 2: Create a New Client
#### Step 2: Create a New Client
1. Navigate to the "Clients" tab.
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. Click "Save".
### Step 3: Get Client Credentials
#### Step 3: Get Client Credentials
1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret.
-1
View File
@@ -190,7 +190,6 @@ paths:
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/:
post:
tags:
- Rooms
-1
View File
@@ -113,7 +113,6 @@ paths:
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/:
post:
tags:
- Rooms
+1 -1
View File
@@ -27,7 +27,7 @@ AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:3000
MEDIA_BASE_URL=http://localhost:8083
FILE_UPLOAD_ENABLED=True
# OIDC
-6
View File
@@ -36,12 +36,6 @@
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed brevo versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["brevo-python"],
"allowedVersions": "<3.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+2
View File
@@ -4,6 +4,8 @@ FROM python:3.13-slim AS base
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
+6 -6
View File
@@ -1,20 +1,20 @@
[project]
name = "agents"
version = "1.12.0"
version = "1.10.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.4.5",
"livekit-plugins-deepgram==1.4.5",
"livekit-plugins-silero==1.4.5",
"livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"python-dotenv==1.2.1",
"protobuf==6.33.5"
]
[project.optional-dependencies]
dev = [
"ruff==0.15.6",
"ruff==0.14.4",
]
[build-system]
-3
View File
@@ -73,8 +73,5 @@ def get_frontend_configuration(request):
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
}
frontend_configuration["encryption"] = {
"enabled": settings.ENCRYPTION_ENABLED,
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
+14 -89
View File
@@ -30,30 +30,9 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = [
"id",
"email",
"full_name",
"short_name",
"timezone",
"language",
"default_encryption_mode",
]
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
def validate_default_encryption_mode(self, value):
"""Reject a non-none default when the server has encryption disabled.
Keeps the user preference DB in sync with the deployment's posture:
if an operator flips ENCRYPTION_ENABLED off, no client should be able
to keep persisting `basic` as their default behind their back.
"""
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
raise serializers.ValidationError(
_("End-to-end encryption is disabled on this server.")
)
return value
class UserLightSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields."""
@@ -95,7 +74,6 @@ class ResourceAccessSerializerMixin:
raise PermissionDenied(
"Only owners of a room can assign other users as owners."
)
return data
def validate_resource(self, resource):
@@ -150,60 +128,9 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
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"]
def validate_encryption_mode(self, value):
"""Encryption mode is part of the link's semantics (the passphrase
lives in the URL hash for `basic` rooms) so it cannot be changed once
the room exists."""
instance = self.instance
if instance and instance.encryption_mode != value:
raise serializers.ValidationError(
"Encryption mode cannot be changed after room creation."
)
return value
def validate_access_level(self, value):
"""Encrypted rooms must stay restricted — the lobby is the only way
to enforce per-participant admission, and basic encryption relies on
the host vetting each joiner before they receive the in-URL key."""
instance = self.instance
if (
instance
and instance.encryption_mode != models.EncryptionMode.NONE
and value != models.RoomAccessLevel.RESTRICTED
):
raise serializers.ValidationError(
"Encrypted rooms require restricted access level."
)
return value
def validate(self, attrs):
"""Force encrypted rooms to RESTRICTED at creation time.
Doing this here (rather than in validate_access_level) lets the
client omit `access_level` entirely when creating an encrypted room
— we silently override whatever the default would have been.
"""
encryption_mode = attrs.get(
"encryption_mode",
self.instance.encryption_mode
if self.instance
else models.EncryptionMode.NONE,
)
if encryption_mode != models.EncryptionMode.NONE and not self.instance:
attrs["access_level"] = models.RoomAccessLevel.RESTRICTED
return super().validate(attrs)
def to_representation(self, instance):
"""
Add users only for administrator users.
@@ -245,21 +172,12 @@ class RoomSerializer(serializers.ModelSerializer):
if should_access_room:
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
# In encrypted rooms, authenticated users cannot pick an
# arbitrary display name — it must come from the OIDC profile.
# We enforce this server-side so a tampered client cannot
# override what other participants see.
if instance.is_encrypted and request.user.is_authenticated:
username = request.user.full_name or request.user.email
output["livekit"] = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
encryption_mode=instance.encryption_mode,
)
else:
del output["pin_code"]
@@ -347,7 +265,7 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True, allow_blank=True)
username = serializers.CharField(required=True)
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -385,9 +303,6 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf.
@@ -398,7 +313,9 @@ class ParticipantPermission(BaseModel):
can_subscribe: bool | None = None
can_publish: 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
recorder: bool | None = None
can_update_metadata: bool | None = None
@@ -449,6 +366,14 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
f"Setting the following participant permissions is not allowed: "
f"{', '.join(suspicious_fields)}."
)
if permission.can_subscribe_metrics is not None:
raise serializers.ValidationError(
{
"permission": {
"can_subscribe_metrics": "This permission is not implemented."
}
}
)
return permission
+6
View File
@@ -73,3 +73,9 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class SessionExchangeAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous requests to the session exchange endpoint."""
scope = "session_exchange"
+3 -28
View File
@@ -281,18 +281,6 @@ class RoomViewSet(
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
encryption_mode = serializer.validated_data.get(
"encryption_mode", models.EncryptionMode.NONE
)
if (
encryption_mode != models.EncryptionMode.NONE
and not settings.ENCRYPTION_ENABLED
):
raise drf_exceptions.ValidationError(
{"encryption_mode": "Encryption is not enabled on this server."}
)
room = serializer.save()
models.ResourceAccess.objects.create(
resource=room,
@@ -316,21 +304,16 @@ class RoomViewSet(
"""Start recording a room."""
serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
{"detail": "Invalid request."},
status=drf_status.HTTP_400_BAD_REQUEST,
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data.get("options")
room = self.get_object()
if room.is_encrypted:
raise drf_exceptions.ValidationError(
{"detail": "Recording is unavailable in encrypted rooms."}
)
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room,
@@ -413,14 +396,12 @@ class RoomViewSet(
serializer.is_valid(raise_exception=True)
room = self.get_object()
validated_data = serializer.validated_data
lobby_service = LobbyService()
participant, livekit = lobby_service.request_entry(
room=room,
request=request,
**validated_data,
**serializer.validated_data,
)
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id)
@@ -483,7 +464,6 @@ class RoomViewSet(
lobby_service = LobbyService()
participants = lobby_service.list_waiting_participants(room.id)
return drf_response.Response({"participants": participants})
@decorators.action(
@@ -586,11 +566,6 @@ class RoomViewSet(
room = self.get_object()
if room.is_encrypted:
raise drf_exceptions.ValidationError(
{"detail": "Subtitles are unavailable in encrypted rooms."}
)
try:
SubtitleService().start_subtitle(room)
except SubtitleException:
+61
View File
@@ -0,0 +1,61 @@
"""API endpoint for exchanging a one-time code for a session ID."""
import logging
from django.conf import settings
from django.core.cache import cache
from rest_framework import serializers, status
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from core.api.throttling import SessionExchangeAnonRateThrottle
from .views import EXCHANGE_CODE_PREFIX
logger = logging.getLogger(__name__)
class SessionExchangeSerializer(serializers.Serializer):
"""Validates the exchange code request."""
code = serializers.CharField(max_length=64, min_length=16)
@api_view(["POST"])
@permission_classes([AllowAny])
@throttle_classes([SessionExchangeAnonRateThrottle])
def session_exchange(
request,
): # NOSONAR (S3752) POST-only, AllowAny is intentional: single-use code with 30s TTL and rate limiting
"""Exchange a one-time code for a session ID.
The code was generated during the OIDC callback and stored in cache
with a short TTL. This endpoint retrieves the session ID, deletes the
code (single-use), and returns the session ID to the native app.
"""
serializer = SessionExchangeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
code = serializer.validated_data["code"]
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
session_key = cache.get(cache_key)
if session_key is None:
logger.warning(
"Session exchange failed: invalid or expired code from %s",
request.META.get("REMOTE_ADDR"),
)
return Response(
{"detail": "Invalid or expired code."},
status=status.HTTP_400_BAD_REQUEST,
)
# Delete immediately — single use
cache.delete(cache_key)
cookie_name = getattr(settings, "SESSION_COOKIE_NAME", "sessionid")
logger.info("Session exchange successful from %s", request.META.get("REMOTE_ADDR"))
return Response({cookie_name: session_key})
+103
View File
@@ -0,0 +1,103 @@
"""Custom OIDC authentication views for native app support.
When a native app (iOS, Android, Desktop) initiates OIDC login, it sets
`returnTo` to a custom URL scheme (e.g. `visio://auth-callback`). After
the OIDC flow completes, Django sets the session cookie and redirects to
that URL. However, native apps cannot read browser cookies — they need
the session ID passed explicitly.
Instead of exposing the session ID directly in the redirect URL, this
module generates a short-lived, single-use exchange code. The native app
then exchanges this code for the session ID via a dedicated API endpoint.
"""
import uuid
from urllib.parse import urlencode, urlparse
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponseRedirect
from lasuite.oidc_login.views import (
OIDCAuthenticationCallbackView as BaseCallbackView,
OIDCAuthenticationRequestView as BaseRequestView,
)
# Cache key prefix and TTL for exchange codes
EXCHANGE_CODE_PREFIX = "auth_exchange:"
EXCHANGE_CODE_TTL = 30 # seconds
class NativeAppRedirect(HttpResponseRedirect):
"""HttpResponseRedirect subclass that allows native app custom URL schemes.
Django's HttpResponseRedirect only allows http, https, and ftp schemes.
Native apps use custom schemes (e.g. visio://) for deep links, which
Django rejects with DisallowedRedirect. This subclass extends
allowed_schemes with the configured native app schemes.
"""
allowed_schemes = HttpResponseRedirect.allowed_schemes + list(
getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
)
class OIDCAuthenticationRequestView(BaseRequestView):
"""Custom authenticate view that preserves native app returnTo in session.
mozilla-django-oidc's get_next_url() rejects custom URL schemes
(e.g. visio://auth-callback) because url_has_allowed_host_and_scheme()
only allows http/https. We intercept the returnTo parameter and store
it directly in the session for whitelisted schemes, bypassing the
safety check (which is not relevant for native app deep links).
"""
def get(self, request):
redirect_field = getattr(settings, "OIDC_REDIRECT_FIELD_NAME", "returnTo")
return_to = request.GET.get(redirect_field, "")
parsed = urlparse(return_to)
allowed_schemes = getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
response = super().get(request)
# Override oidc_login_next AFTER super() which set it to None
# via get_next_url() rejecting the custom scheme.
if parsed.scheme in allowed_schemes:
request.session["oidc_login_next"] = return_to
request.session.save()
return response
class OIDCAuthenticationCallbackView(BaseCallbackView):
"""Callback view that generates an exchange code for native app deep links."""
def login_success(self):
"""After successful login, append exchange code for whitelisted scheme redirects."""
# Temporarily remove native redirect from session to prevent
# super().login_success() from raising DisallowedRedirect when
# it tries HttpResponseRedirect with a custom scheme.
native_redirect = self.request.session.pop("oidc_login_next", None)
allowed_schemes = getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
parsed = urlparse(native_redirect or "")
if native_redirect and parsed.scheme in allowed_schemes:
# Let super() redirect to the default URL (homepage)
super().login_success()
# Generate a short-lived, single-use exchange code
exchange_code = uuid.uuid4().hex
session_key = self.request.session.session_key
cache.set(
f"{EXCHANGE_CODE_PREFIX}{exchange_code}",
session_key,
EXCHANGE_CODE_TTL,
)
separator = "&" if parsed.query else "?"
new_url = (
f"{native_redirect}{separator}{urlencode({'code': exchange_code})}"
)
return NativeAppRedirect(new_url)
return super().login_success()
@@ -1,46 +0,0 @@
"""Add Room.encryption_mode and User.default_encryption_mode (enum-based).
We store the mode as an enum (CharField with choices) rather than a boolean
so a future "advanced" mode (per-user vault keys, etc.) can be added without
a schema migration.
"""
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_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Passphrase-in-URL encryption"),
],
default="none",
help_text="End-to-end encryption mode for this room.",
max_length=20,
verbose_name="Encryption mode",
),
),
migrations.AddField(
model_name="user",
name="default_encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Passphrase-in-URL encryption"),
],
default="none",
help_text="Encryption mode pre-selected when this user creates a new meeting.",
max_length=20,
verbose_name="Default encryption mode",
),
),
]
+2 -87
View File
@@ -98,17 +98,6 @@ class RoomAccessLevel(models.TextChoices):
RESTRICTED = "restricted", _("Restricted Access")
class EncryptionMode(models.TextChoices):
"""Encryption mode for a room.
Kept as an enum (not a boolean) so future modes — e.g. a vault-managed
per-user key flow — can be added without another schema migration.
"""
NONE = "none", _("No encryption")
BASIC = "basic", _("Passphrase-in-URL encryption")
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -211,15 +200,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts."
),
)
default_encryption_mode = models.CharField(
_("Default encryption mode"),
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
help_text=_(
"Encryption mode pre-selected when this user creates a new meeting."
),
)
objects = auth_models.UserManager()
@@ -408,15 +388,6 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
# Set at creation, immutable after (the URL hash carries the passphrase,
# so changing the mode would break every previously-shared link).
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(
blank=True,
default=dict,
@@ -442,64 +413,13 @@ class Room(Resource):
return capfirst(self.name)
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms.
Skip PIN allocation for encrypted rooms — the SIP gateway will
always reject calls to them (no way to derive the key), and the
PIN namespace is finite (10**length): no point burning slots that
can never be dialed.
Also run `clean()` so the encryption invariants are enforced on
every save path (ORM, admin, shell), not only via the DRF
serializer.
"""
self.clean()
if (
settings.ROOM_TELEPHONY_ENABLED
and not self.pk
and not self.pin_code
and self.encryption_mode == EncryptionMode.NONE
):
"""Generate a unique n-digit pin code for new rooms."""
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
super().save(*args, **kwargs)
def clean(self):
"""Enforce encryption-mode invariants outside DRF.
Two rules:
- `encryption_mode` is set at creation and never mutated afterwards
(the URL-hash passphrase encodes assumptions about it).
- An encrypted room must be at the RESTRICTED access level so the
host vets joiners before they ever see the in-URL key.
"""
super().clean()
if self.pk is not None:
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
if (
previous is not None
and previous.encryption_mode != self.encryption_mode
):
raise ValidationError(
{
"encryption_mode": _(
"Encryption mode cannot be changed after room creation."
)
}
)
if (
self.encryption_mode != EncryptionMode.NONE
and self.access_level != RoomAccessLevel.RESTRICTED
):
raise ValidationError(
{
"access_level": _(
"Encrypted rooms must use the 'restricted' access level."
)
}
)
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -522,11 +442,6 @@ class Room(Resource):
"""Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC
@property
def is_encrypted(self):
"""Convenience: any non-none encryption mode counts as encrypted."""
return self.encryption_mode != EncryptionMode.NONE
@staticmethod
def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code"""
+10 -13
View File
@@ -4,7 +4,7 @@ import smtplib
from logging import getLogger
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.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
@@ -45,18 +45,15 @@ class InvitationService:
)
) # 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:
email.send()
send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
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
+1 -10
View File
@@ -202,16 +202,7 @@ class LiveKitEventsService:
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
# Note: `encryption_mode` is stamped into the LK room's metadata at
# creation time via the access token's RoomConfiguration (see
# `utils.generate_token`), so we don't need to patch it here.
# Phone dial-in is incompatible with end-to-end encryption — a SIP
# caller has no way to derive the room key, and the SIP gateway will
# play "encryption_not_supported" and hang up on them anyway. Skip
# the dispatch rule for encrypted rooms so no metadata mentions a
# PIN that won't be reachable.
if settings.ROOM_TELEPHONY_ENABLED and not room.is_encrypted:
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.create_dispatch_rule(room)
except TelephonyException as e:
+5 -35
View File
@@ -46,18 +46,14 @@ class LobbyParticipant:
username: str
color: str
id: str
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
# they can decide whether to accept self-declared identities.
is_authenticated: bool = False
def to_dict(self) -> Dict[str, object]:
def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation."""
return {
"status": self.status.value,
"username": self.username,
"id": self.id,
"color": self.color,
"is_authenticated": self.is_authenticated,
}
@classmethod
@@ -72,7 +68,6 @@ class LobbyParticipant:
username=data["username"],
id=data["id"],
color=data["color"],
is_authenticated=bool(data.get("is_authenticated", False)),
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -104,7 +99,7 @@ class LobbyService:
key=settings.LOBBY_COOKIE_NAME,
value=participant_id,
httponly=True,
secure=not settings.DEBUG,
secure=True,
samesite="Lax",
)
@@ -145,19 +140,6 @@ class LobbyService:
5. If denied, do nothing.
"""
# In encrypted rooms, authenticated users cannot pick an arbitrary
# display name — server enforces the OIDC name so a tampered client
# can't impersonate someone else with their account. If both
# full_name and email are absent (degenerate OIDC payload), fall
# back to a server-controlled technical name rather than trusting
# whatever the client posted.
if room.is_encrypted and request.user.is_authenticated:
username = (
request.user.full_name
or request.user.email
or f"noname-{request.user.id}"
)
participant_id = self._get_or_create_participant_id(request)
participant = self._get_participant(room.id, participant_id)
@@ -170,7 +152,6 @@ class LobbyService:
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
is_authenticated=request.user.is_authenticated,
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
@@ -183,24 +164,19 @@ class LobbyService:
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
encryption_mode=room.encryption_mode,
)
return participant, livekit_config
livekit_config = None
if participant is None:
participant = self.enter(
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
)
participant = self.enter(room.id, participant_id, username)
elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
@@ -209,7 +185,6 @@ class LobbyService:
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
encryption_mode=room.encryption_mode,
)
return participant, livekit_config
@@ -226,11 +201,7 @@ class LobbyService:
)
def enter(
self,
room_id: UUID,
participant_id: str,
username: str,
is_authenticated: bool = False,
self, room_id: UUID, participant_id: str, username: str
) -> LobbyParticipant:
"""Add participant to waiting lobby.
@@ -245,7 +216,6 @@ class LobbyService:
username=username,
id=participant_id,
color=color,
is_authenticated=is_authenticated,
)
try:
@@ -0,0 +1,143 @@
"""
Tests for the session exchange API endpoint and OIDC callback view.
"""
import uuid
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from core.authentication.views import EXCHANGE_CODE_PREFIX, EXCHANGE_CODE_TTL
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def _clear_throttle_cache():
"""Clear cache before each test to reset throttle counters."""
cache.clear()
def test_session_exchange_valid_code():
"""A valid exchange code should return the session ID and be consumed."""
code = uuid.uuid4().hex
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
cache.set(cache_key, "test-session-key-123", EXCHANGE_CODE_TTL)
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": code},
format="json",
)
assert response.status_code == 200
data = response.json()
assert "test-session-key-123" in data.values()
# Code should be consumed (single-use)
assert cache.get(cache_key) is None
def test_session_exchange_invalid_code():
"""An invalid/unknown code should return 400."""
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": uuid.uuid4().hex},
format="json",
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid or expired code."
def test_session_exchange_expired_code():
"""An expired code should return 400."""
code = uuid.uuid4().hex
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
# Set with 0 TTL to simulate expiration
cache.set(cache_key, "expired-session", 0)
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": code},
format="json",
)
assert response.status_code == 400
def test_session_exchange_code_too_short():
"""A code that's too short should be rejected by validation."""
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": "short"},
format="json",
)
assert response.status_code == 400
def test_session_exchange_missing_code():
"""Missing code field should be rejected."""
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{},
format="json",
)
assert response.status_code == 400
def test_session_exchange_replay_attack():
"""Using the same code twice should fail the second time."""
code = uuid.uuid4().hex
cache.set(f"{EXCHANGE_CODE_PREFIX}{code}", "session-123", EXCHANGE_CODE_TTL)
client = APIClient()
# First use succeeds
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": code},
format="json",
)
assert response.status_code == 200
# Second use fails
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": code},
format="json",
)
assert response.status_code == 400
def test_session_exchange_returns_correct_cookie_name(settings):
"""The response key should match SESSION_COOKIE_NAME from settings."""
settings.SESSION_COOKIE_NAME = "meet_sessionid"
code = uuid.uuid4().hex
cache.set(f"{EXCHANGE_CODE_PREFIX}{code}", "my-session", EXCHANGE_CODE_TTL)
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": code},
format="json",
)
assert response.status_code == 200
assert response.json() == {"meet_sessionid": "my-session"}
def test_session_exchange_get_not_allowed():
"""GET method should not be allowed on the exchange endpoint."""
client = APIClient()
response = client.get("/api/v1.0/auth/session-exchange/")
assert response.status_code == 405
@@ -240,9 +240,10 @@ def test_api_rooms_invite_error(mock_invite_to_room):
mock_invite_to_room.assert_called_once()
@mock.patch("core.services.invitation.EmailMultiAlternatives")
def test_api_rooms_invite_success(mock_email_class, settings):
@mock.patch("core.services.invitation.send_mail")
def test_api_rooms_invite_success(mock_send_mail, settings):
"""Test privileged users should successfully send invitation emails."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_APP_BASE_URL = "https://acme.com"
@@ -254,6 +255,7 @@ def test_api_rooms_invite_success(mock_email_class, settings):
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
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.json() == {"status": "success", "message": "invitations sent"}
mock_email_class.assert_called_once()
mock_send_mail.assert_called_once()
# Check constructor arguments
call_kwargs = mock_email_class.call_args[1] # EmailMultiAlternatives(**kwargs)
subject, body, sender, recipients = mock_send_mail.call_args[0]
assert call_kwargs["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"]
assert (
subject == f"Video call in progress: {user.email} is waiting for you to connect"
)
# Check plain text body
plain_body = call_kwargs["body"]
# Verify email contains expected content
required_content = [
"ACME",
"https://acme.com/logo",
f"https://acme.com/{room.slug}",
f"acme.com/{room.slug}",
"ACME", # Brand name
"https://acme.com/logo", # Logo URL
f"https://acme.com/{room.slug}", # Room url
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:
assert content in html_body
assert content in body
# Check send was called
mock_instance.send.assert_called_once()
assert sender == "notifications@acme.com"
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
@@ -59,7 +59,6 @@ def test_request_entry_anonymous(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"is_authenticated": False,
"livekit": None,
}
@@ -109,7 +108,6 @@ def test_request_entry_authenticated_user(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"is_authenticated": True,
"livekit": None,
}
@@ -182,7 +180,6 @@ def test_request_entry_with_existing_participants(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"is_authenticated": False,
"livekit": None,
}
@@ -235,7 +232,6 @@ def test_request_entry_public_room(settings):
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
"is_authenticated": False,
"livekit": {"token": "test-token"},
}
@@ -288,7 +284,6 @@ def test_request_entry_authenticated_user_public_room(settings):
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
"is_authenticated": True,
"livekit": {"token": "test-token"},
}
@@ -343,7 +338,6 @@ def test_request_entry_waiting_participant_public_room(settings):
"username": "user1",
"status": "accepted",
"color": "#123456",
"is_authenticated": False,
"livekit": {"token": "test-token"},
}
@@ -607,14 +601,12 @@ def test_list_waiting_participants_success(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"is_authenticated": False,
},
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
"is_authenticated": False,
},
]
@@ -130,11 +130,10 @@ def test_update_participant_success(mock_livekit_client):
"can_publish": True,
"can_publish_data": True,
"can_publish_sources": [
"CAMERA",
"MICROPHONE",
],
1,
2,
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"can_update_metadata": True,
"can_subscribe_metrics": True,
},
"name": "John Doe",
}
@@ -156,14 +155,8 @@ def test_update_participant_success(mock_livekit_client):
{"can_subscribe": True},
{"can_publish": True},
{"can_publish_data": True},
{
"can_publish_sources": [
"CAMERA",
"MICROPHONE",
]
},
{"can_publish_sources": [1, 2]},
{"can_update_metadata": True},
{"can_subscribe_metrics": False},
],
)
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():
"""Test update participant returns 403 when user lacks room privileges."""
client = APIClient()
@@ -33,7 +33,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -53,7 +52,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -72,7 +70,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -89,7 +86,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -106,7 +102,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -216,7 +211,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once()
@@ -263,7 +257,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -274,7 +267,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
encryption_mode="none",
)
@@ -316,7 +308,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -327,7 +318,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
sources=None,
is_admin_or_owner=False,
participant_id=None,
encryption_mode="none",
)
@@ -353,7 +343,6 @@ def test_api_rooms_retrieve_authenticated():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -405,7 +394,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -416,7 +404,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
encryption_mode="none",
)
@@ -466,7 +453,6 @@ def test_api_rooms_retrieve_administrators(
"short_name": other_user_access.user.short_name,
"timezone": "UTC",
"language": other_user_access.user.language,
"default_encryption_mode": "none",
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -480,7 +466,6 @@ def test_api_rooms_retrieve_administrators(
"short_name": user_access.user.short_name,
"timezone": "UTC",
"language": user_access.user.language,
"default_encryption_mode": "none",
},
"resource": str(room.id),
"role": user_access.role,
@@ -502,7 +487,6 @@ def test_api_rooms_retrieve_administrators(
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -513,5 +497,4 @@ def test_api_rooms_retrieve_administrators(
sources=None,
is_admin_or_owner=True,
participant_id=None,
encryption_mode="none",
)
+1 -10
View File
@@ -268,7 +268,6 @@ def test_request_entry_public_room(
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
encryption_mode="none",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -308,7 +307,6 @@ def test_request_entry_trusted_room(
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
encryption_mode="none",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -339,12 +337,7 @@ def test_request_entry_new_participant(
assert participant == participant_data
assert livekit_config is None
mock_enter.assert_called_once_with(
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
)
mock_enter.assert_called_once_with(room.id, participant_id, username)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -409,7 +402,6 @@ def test_request_entry_accepted_participant(
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
encryption_mode="none",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -785,7 +777,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"username": "test-username",
"id": participant_id,
"color": "#123456",
"is_authenticated": False,
}
mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60
-1
View File
@@ -125,7 +125,6 @@ def test_api_users_retrieve_me_authenticated(settings):
"short_name": user.short_name,
"language": user.language,
"timezone": "UTC",
"default_encryption_mode": "none",
}
+4
View File
@@ -7,6 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets
from core.authentication.api import session_exchange
from core.external_api import viewsets as external_viewsets
# - Main endpoints
@@ -40,6 +41,9 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
path(
"auth/session-exchange/", session_exchange, name="session_exchange"
),
path("config/", get_frontend_configuration, name="config"),
]
),
+7 -54
View File
@@ -32,7 +32,6 @@ from livekit.api import ( # pylint: disable=E0611
UpdateRoomMetadataRequest,
VideoGrants,
)
from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611
logger = logging.getLogger(__name__)
@@ -67,7 +66,6 @@ def generate_token(
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
participant_id: Optional[str] = None,
encryption_mode: str = "none",
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -88,26 +86,17 @@ def generate_token(
str: The LiveKit JWT access token.
"""
# Local import: core.models loads core.utils mid-import (see models.py:28),
# so importing EncryptionMode at module top would deadlock the bootstrap.
from core.models import ( # noqa: PLC0415 pylint: disable=import-outside-toplevel
EncryptionMode,
)
if is_admin_or_owner or sources is None:
if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES
# In encrypted rooms, no one can change their name/attributes after the
# admin accepted them — otherwise a participant authenticated under one
# identity could rewrite their JWT-presented name to spoof someone else
# mid-meeting. In plain rooms, free naming is fine.
can_update_own_metadata = encryption_mode == EncryptionMode.NONE
if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=is_admin_or_owner,
can_update_own_metadata=can_update_own_metadata,
can_update_own_metadata=True,
can_publish=bool(sources),
can_publish_sources=sources,
can_subscribe=True,
@@ -123,27 +112,6 @@ def generate_token(
if color is None:
color = generate_color(identity)
attributes = {
"color": color,
"room_admin": "true" if is_admin_or_owner else "false",
"is_authenticated": "true" if not user.is_anonymous else "false",
}
# Emit the email only for authenticated participants of *encrypted*
# rooms. LK signaling broadcasts attributes to every peer in the room,
# so making this conditional on the encryption gate is what prevents
# an anonymous joiner of a public/trusted room from harvesting all
# authenticated users' emails. Frontend hiding (the
# `isLoggedIn`-gated render in ParticipantListItem) is only
# defense-in-depth — anyone with devtools can read attributes
# otherwise.
if (
not user.is_anonymous
and encryption_mode != EncryptionMode.NONE
and getattr(user, "email", None)
):
attributes["email"] = user.email
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -152,23 +120,10 @@ def generate_token(
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
.with_attributes(attributes)
)
# Encode the encryption mode into the room's metadata at LK-creation
# time (via the access token's room_config). LiveKit creates the room
# lazily when the first participant joins; the embedded config tells
# it to stamp `{"encryption_mode": "<mode>"}` into the metadata at
# that moment — no extra round-trip, no race window where a SIP
# caller could read empty metadata before the `room_started` webhook
# has time to push it.
if encryption_mode != EncryptionMode.NONE:
token = token.with_room_config(
RoomConfiguration(
name=room,
metadata=json.dumps({"encryption_mode": encryption_mode}),
)
.with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
)
return token.to_jwt()
@@ -181,7 +136,6 @@ def generate_livekit_config(
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
encryption_mode: str = "none",
) -> dict:
"""Generate LiveKit configuration for room access.
@@ -214,7 +168,6 @@ def generate_livekit_config(
sources=sources,
is_admin_or_owner=is_admin_or_owner,
participant_id=participant_id,
encryption_mode=encryption_mode,
),
}
+16 -14
View File
@@ -344,6 +344,11 @@ class Base(Configuration):
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
"session_exchange": values.Value(
default="5/minute",
environ_name="SESSION_EXCHANGE_THROTTLE_RATES",
environ_prefix=None,
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
@@ -452,19 +457,23 @@ class Base(Configuration):
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
# Session
SESSION_ENGINE = values.Value(
default="django.contrib.sessions.backends.cache",
environ_name="SESSION_ENGINE",
environ_prefix=None,
)
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
)
# OIDC - Authorization Code Flow
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
# Custom URL schemes allowed for native app OIDC redirects.
# Only these schemes will receive an exchange code in the callback.
NATIVE_APP_REDIRECT_SCHEMES = values.ListValue(
default=["visio"],
environ_name="NATIVE_APP_REDIRECT_SCHEMES",
environ_prefix=None,
)
OIDC_CREATE_USER = values.BooleanValue(
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
)
@@ -808,13 +817,6 @@ class Base(Configuration):
environ_prefix=None,
)
# End-to-end encryption (passphrase-in-URL-hash mode).
# When True, users may opt in (account preference) to have their meetings
# created as end-to-end encrypted by default.
ENCRYPTION_ENABLED = values.BooleanValue(
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
)
# External Applications
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
40,
+15 -20
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.12.0"
version = "1.10.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -24,16 +24,16 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.42.68",
"boto3==1.42.49",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.6.2",
"dj-database-url==3.1.2",
"dj-database-url==3.1.0",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.25",
"django-lasuite[all]==0.0.24",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==6.0.0",
@@ -43,21 +43,21 @@ dependencies = [
"django==5.2.12",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2026.3.4",
"dockerflow==2026.1.26",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==25.1.0",
"jsonschema==4.26.0",
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.3",
"pydantic==2.12.5",
"PyJWT==2.12.1",
"psycopg[binary]==3.3.2",
"pydantic==2.12.4",
"PyJWT==2.11.0",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.5",
"sentry-sdk==2.54.0",
"whitenoise==6.12.0",
"sentry-sdk==2.53.0",
"whitenoise==6.11.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.13.3",
@@ -72,11 +72,11 @@ dependencies = [
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.3.1",
"drf-spectacular-sidecar==2026.1.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.11.0",
"pyfakefs==6.1.5",
"ipython==9.10.0",
"pyfakefs==6.1.1",
"pylint-django==2.7.0",
"pylint<4.0.0",
"pytest-cov==7.0.0",
@@ -84,17 +84,12 @@ dev = [
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.26.0",
"ruff==0.15.6",
"responses==0.25.8",
"ruff==0.15.1",
"types-requests==2.32.4.20260107",
]
[tool.uv.build-backend]
module-name = [
"core",
"demo",
"meet"
]
module-root = ""
source-exclude = [
"**/tests/**",
+99 -99
View File
@@ -159,30 +159,30 @@ wheels = [
[[package]]
name = "boto3"
version = "1.42.68"
version = "1.42.49"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ae/60c642aa5413e560b671da825329f510b29a77274ed0f580bde77562294d/boto3-1.42.68.tar.gz", hash = "sha256:3f349f967ab38c23425626d130962bcb363e75f042734fe856ea8c5a00eef03c", size = 112761, upload-time = "2026-03-13T19:32:17.137Z" }
sdist = { url = "https://files.pythonhosted.org/packages/20/91/105aa17e0f3a566d33e2d8a3b32a70f553b1ad500d9756c6dd63991d8354/boto3-1.42.49.tar.gz", hash = "sha256:9cd252f640567b86e92b0a8ffdd4ade9a3018ee357c724bff6a21b8c8a41be0c", size = 112877, upload-time = "2026-02-13T20:29:57.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/f6/dc6e993479dbb597d68223fbf61cb026511737696b15bd7d2a33e9b2c24f/boto3-1.42.68-py3-none-any.whl", hash = "sha256:dbff353eb7dc93cbddd7926ed24793e0174c04adbe88860dfa639568442e4962", size = 140556, upload-time = "2026-03-13T19:32:14.951Z" },
{ url = "https://files.pythonhosted.org/packages/12/b1/1fa30cd7b26617d59efbe3a4f3660a5b8b397a4623bf1e67016c4cb6dd0e/boto3-1.42.49-py3-none-any.whl", hash = "sha256:99e1df4361c3f6ff6ade65803c043ea96314826134962dd3b385433b309eb819", size = 140606, upload-time = "2026-02-13T20:29:55.366Z" },
]
[[package]]
name = "botocore"
version = "1.42.73"
version = "1.42.64"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/3c/ac4bc939da695d2c648bf28f7b204ab741e4504e81749ccf943403cc07ca/botocore-1.42.64.tar.gz", hash = "sha256:4ee2aece227b9171ace8b749af694a77ab984fceab1639f2626bd0d6fb1aa69d", size = 14967869, upload-time = "2026-03-09T19:51:46.213Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" },
{ url = "https://files.pythonhosted.org/packages/33/0f/a0feb9a93da8f583217432dce71ce1940d6d8aa5884bad340872a504ba3f/botocore-1.42.64-py3-none-any.whl", hash = "sha256:f77c5cb76ed30576ed0bc73b591265d03dddffff02a9208d3ee0c790f43d3cd2", size = 14641339, upload-time = "2026-03-09T19:51:41.244Z" },
]
[[package]]
@@ -545,14 +545,14 @@ wheels = [
[[package]]
name = "dj-database-url"
version = "3.1.2"
version = "3.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/f6/00b625e9d371b980aa261011d0dc906a16444cb688f94215e0dc86996eb5/dj_database_url-3.1.2.tar.gz", hash = "sha256:63c20e4bbaa51690dfd4c8d189521f6bf6bc9da9fcdb23d95d2ee8ee87f9ec62", size = 11490, upload-time = "2026-02-19T15:30:23.638Z" }
sdist = { url = "https://files.pythonhosted.org/packages/95/c6/88676a7333fb7c668e626b55f8bfc8527dd863973eb1c40412b95d27747d/dj_database_url-3.1.0.tar.gz", hash = "sha256:d80218426b83f9302c8d27d4fccf52de5cf0cab179f0645fb2839f37605d1353", size = 7924, upload-time = "2026-01-04T09:18:32.693Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/a9/57c66006373381f1d3e5bd94216f1d371228a89f443d3030e010f73dd198/dj_database_url-3.1.2-py3-none-any.whl", hash = "sha256:544e015fee3efa5127a1eb1cca465f4ace578265b3671fe61d0ed7dbafb5ec8a", size = 8953, upload-time = "2026-02-19T15:30:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/68/1b/e84f7472ab0bdacc3fd09556eb4dd40d88246941d465cc103b36a8dabcd8/dj_database_url-3.1.0-py3-none-any.whl", hash = "sha256:155a56fbbecbaaf1348ccd73bf29138b4c9988363ba08261a0f0145e392e638c", size = 8849, upload-time = "2026-01-04T09:18:43.77Z" },
]
[[package]]
@@ -633,7 +633,7 @@ wheels = [
[[package]]
name = "django-lasuite"
version = "0.0.25"
version = "0.0.24"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
@@ -644,9 +644,9 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/bc/5826a3e5ada5dad1ff6af9a2e1eb598a0dc2cb9c4d2dbf011f1bbb310c9e/django_lasuite-0.0.25.tar.gz", hash = "sha256:ee44783942e6ead74a732f6d7280c5fca961b66581350ce01c0589c3e80684cf", size = 34819, upload-time = "2026-03-10T13:40:28.705Z" }
sdist = { url = "https://files.pythonhosted.org/packages/53/dc/4002ba20b964237a609628d53b950c5aecf6387119058831e0ee33176f59/django_lasuite-0.0.24.tar.gz", hash = "sha256:3231b0178a2187405c8faae447225c5dd069263a9a09e73e2a36427be4bf2388", size = 34293, upload-time = "2026-02-11T12:25:42.558Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/2f/819ebbc9d3a4b8233cef1e9b0b607724e4acf527988393b04a4036b9c235/django_lasuite-0.0.25-py3-none-any.whl", hash = "sha256:4c10f625005cd41d05e8d34269f1a3b58fea8be5296528bd58596227f50d9884", size = 54034, upload-time = "2026-03-10T13:40:26.903Z" },
{ url = "https://files.pythonhosted.org/packages/5d/90/9170b8f443ab5d34b32d7cd386ce3b38ca35baae966da4a1d77b4f4ab3d2/django_lasuite-0.0.24-py3-none-any.whl", hash = "sha256:c37e0b3606a23fd4094ca435fa4edf7a64d325be64b0310fa6577f75d515d856", size = 52620, upload-time = "2026-02-11T12:25:40.626Z" },
]
[package.optional-dependencies]
@@ -737,11 +737,11 @@ wheels = [
[[package]]
name = "dockerflow"
version = "2026.3.4"
version = "2026.1.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/03/935f8399094611981b37634a7ee078484b3995f744135339e51385638f6c/dockerflow-2026.3.4.tar.gz", hash = "sha256:6cc7fcab2a078d06aeca8b02e22fd7f6766dca322fb30aed6910684dbc08690c", size = 68701, upload-time = "2026-03-04T09:01:28.178Z" }
sdist = { url = "https://files.pythonhosted.org/packages/11/08/7abe8458ce6fa82e694ba9b792b4158596fd681782976699d3f15642a23a/dockerflow-2026.1.26.tar.gz", hash = "sha256:c45667ec078944a468b9a634c866587b4cf31559d0e6f369fbadd21edce66b38", size = 68042, upload-time = "2026-01-26T15:30:06.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/b9/2d7bf30eb0c24e60e61e4f6ae7574e123d3fa51b39a612e146c94158446a/dockerflow-2026.3.4-py2.py3-none-any.whl", hash = "sha256:38fe0ad30a4aedf68e589919695d502fc6f584a761b56a91f56e80cbadfbf61a", size = 36424, upload-time = "2026-03-04T09:01:26.631Z" },
{ url = "https://files.pythonhosted.org/packages/de/88/26cec1e8ce09b4022233f232962fba72650c22941d7a609d10b9eec9f748/dockerflow-2026.1.26-py2.py3-none-any.whl", hash = "sha256:26f02d8842672921b2a49f1e019fac54ed2ee69dc673ed80f7b237b67394c2be", size = 35992, upload-time = "2026-01-26T15:30:05.525Z" },
]
[[package]]
@@ -763,14 +763,14 @@ wheels = [
[[package]]
name = "drf-spectacular-sidecar"
version = "2026.3.1"
version = "2026.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/42/2f8c1b2846399d47094ec414bc0d6a7cce7ba95fd6545a97285eee89f7f1/drf_spectacular_sidecar-2026.3.1.tar.gz", hash = "sha256:5b7fedad66e3851f2f442480792c08115d79217959d01645b93d3d2258938be1", size = 2461501, upload-time = "2026-03-01T11:31:19.708Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/81/c7b0e3ccbd5a039c4f4fcfecf88391a666ca1406a953886e2f39295b1c90/drf_spectacular_sidecar-2026.1.1.tar.gz", hash = "sha256:6f7c173a8ddbbbdafc7a27e028614b65f07a89ca90f996a432d57460463b56be", size = 2468060, upload-time = "2026-01-01T11:27:12.682Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/28/2d5e64d101ebc5180674fcaf7b5a35e398e2f8d9688b2e8d52b0e1394e7d/drf_spectacular_sidecar-2026.3.1-py3-none-any.whl", hash = "sha256:864edb83e022e13e3941c325c3cc0c954c843fa2e1d0bc95e81887664b2d3dad", size = 2481725, upload-time = "2026-03-01T11:31:18.469Z" },
{ url = "https://files.pythonhosted.org/packages/db/96/38725edda526f3e9e597f531beeec94b0ef433d9494f06a13b7636eecb6e/drf_spectacular_sidecar-2026.1.1-py3-none-any.whl", hash = "sha256:af8df62f1b594ec280351336d837eaf2402ab25a6bc2a1fad7aee9935821070f", size = 2489520, upload-time = "2026-01-01T11:27:11.056Z" },
]
[[package]]
@@ -976,7 +976,7 @@ wheels = [
[[package]]
name = "ipython"
version = "9.11.0"
version = "9.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -990,9 +990,9 @@ dependencies = [
{ name = "stack-data" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" },
{ url = "https://files.pythonhosted.org/packages/3d/aa/898dec789a05731cd5a9f50605b7b44a72bd198fd0d4528e11fc610177cc/ipython-9.10.0-py3-none-any.whl", hash = "sha256:c6ab68cc23bba8c7e18e9b932797014cc61ea7fd6f19de180ab9ba73e65ee58d", size = 622774, upload-time = "2026-02-02T10:00:31.503Z" },
]
[[package]]
@@ -1157,7 +1157,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.12.0"
version = "1.10.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1222,24 +1222,24 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = "==3.13.3" },
{ name = "boto3", specifier = "==1.42.68" },
{ name = "boto3", specifier = "==1.42.49" },
{ name = "brevo-python", specifier = "==1.2.0" },
{ name = "brotli", specifier = "==1.2.0" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.2" },
{ name = "dj-database-url", specifier = "==3.1.2" },
{ name = "dj-database-url", specifier = "==3.1.0" },
{ name = "django", specifier = "==5.2.12" },
{ name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==8.2.0" },
{ name = "django-filter", specifier = "==25.2" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.25" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.24" },
{ name = "django-parler", specifier = "==2.3" },
{ name = "django-pydantic-field", specifier = "==0.5.4" },
{ name = "django-redis", specifier = "==6.0.0" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
{ name = "django-timezone-field", specifier = ">=5.1" },
{ name = "djangorestframework", specifier = "==3.16.1" },
{ name = "dockerflow", specifier = "==2026.3.4" },
{ name = "dockerflow", specifier = "==2026.1.26" },
{ name = "drf-spectacular", specifier = "==0.29.0" },
{ name = "easy-thumbnails", specifier = "==2.10.1" },
{ name = "factory-boy", specifier = "==3.3.3" },
@@ -1249,25 +1249,25 @@ requires-dist = [
{ name = "markdown", specifier = "==3.10.2" },
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.3" },
{ name = "pydantic", specifier = "==2.12.5" },
{ name = "pyjwt", specifier = "==2.12.1" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.2" },
{ name = "pydantic", specifier = "==2.12.4" },
{ name = "pyjwt", specifier = "==2.11.0" },
{ name = "python-frontmatter", specifier = "==1.1.0" },
{ name = "python-magic", specifier = "==0.4.27" },
{ name = "redis", specifier = "==5.2.1" },
{ name = "requests", specifier = "==2.32.5" },
{ name = "sentry-sdk", specifier = "==2.54.0" },
{ name = "whitenoise", specifier = "==6.12.0" },
{ name = "sentry-sdk", specifier = "==2.53.0" },
{ name = "whitenoise", specifier = "==6.11.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "django-extensions", specifier = "==4.1" },
{ name = "drf-spectacular-sidecar", specifier = "==2026.3.1" },
{ name = "drf-spectacular-sidecar", specifier = "==2026.1.1" },
{ name = "freezegun", specifier = "==1.5.5" },
{ name = "ipdb", specifier = "==0.13.13" },
{ name = "ipython", specifier = "==9.11.0" },
{ name = "pyfakefs", specifier = "==6.1.5" },
{ name = "ipython", specifier = "==9.10.0" },
{ name = "pyfakefs", specifier = "==6.1.1" },
{ name = "pylint", specifier = "<4.0.0" },
{ name = "pylint-django", specifier = "==2.7.0" },
{ name = "pytest", specifier = "==9.0.2" },
@@ -1275,8 +1275,8 @@ dev = [
{ name = "pytest-django", specifier = "==4.12.0" },
{ name = "pytest-icdiff", specifier = "==0.9" },
{ name = "pytest-xdist", specifier = "==3.8.0" },
{ name = "responses", specifier = "==0.26.0" },
{ name = "ruff", specifier = "==0.15.6" },
{ name = "responses", specifier = "==0.25.8" },
{ name = "ruff", specifier = "==0.15.1" },
{ name = "types-requests", specifier = "==2.32.4.20260107" },
]
@@ -1595,14 +1595,14 @@ wheels = [
[[package]]
name = "psycopg"
version = "3.3.3"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
{ url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" },
]
[package.optional-dependencies]
@@ -1612,31 +1612,31 @@ binary = [
[[package]]
name = "psycopg-binary"
version = "3.3.3"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
{ url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
{ url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
{ url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
{ url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
{ url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
{ url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
{ url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
{ url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068, upload-time = "2025-12-06T17:33:39.303Z" },
{ url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520, upload-time = "2025-12-06T17:33:42.536Z" },
{ url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086, upload-time = "2025-12-06T17:33:46.54Z" },
{ url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125, upload-time = "2025-12-06T17:33:50.413Z" },
{ url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914, upload-time = "2025-12-06T17:33:54.544Z" },
{ url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081, upload-time = "2025-12-06T17:33:58.477Z" },
{ url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332, upload-time = "2025-12-06T17:34:02.011Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781, upload-time = "2025-12-06T17:34:05.298Z" },
{ url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544, upload-time = "2025-12-06T17:34:08.251Z" },
{ url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070, upload-time = "2025-12-06T17:34:11.392Z" },
{ url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922, upload-time = "2025-12-06T17:34:14.88Z" },
{ url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" },
{ url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" },
{ url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" },
{ url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" },
{ url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" },
{ url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" },
{ url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" },
{ url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" },
{ url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" },
]
[[package]]
@@ -1668,7 +1668,7 @@ wheels = [
[[package]]
name = "pydantic"
version = "2.12.5"
version = "2.12.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -1676,9 +1676,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
{ url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" },
]
[[package]]
@@ -1736,11 +1736,11 @@ wheels = [
[[package]]
name = "pyfakefs"
version = "6.1.5"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/87/fe3f1fbbf18480b57e6b195d4d416e01d8e55bf88743982ccb3a8845c2a2/pyfakefs-6.1.5.tar.gz", hash = "sha256:d70fb1c622a1d5c729462d31bb58eead99c1830986bc049bf8975c480a49d19b", size = 226814, upload-time = "2026-03-15T19:05:18.025Z" }
sdist = { url = "https://files.pythonhosted.org/packages/43/84/843a50cfde48d6004cbf540b38b82a39737273cc76b51da6b456fea75de0/pyfakefs-6.1.1.tar.gz", hash = "sha256:1c166fdba85f3f288f89b2a410e05712914d1f7ea77b920e960fe0cbbea1294d", size = 226032, upload-time = "2026-02-09T19:44:19.539Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/d8/a60fb94ab2b5a9bf0e38d6e93667904d4e844d8b7e9cfb5ef0304d68da6b/pyfakefs-6.1.5-py3-none-any.whl", hash = "sha256:2c482f36bad7d1f7516d58d17818fb0dbe397a3372ec5274869386a9b7cd4883", size = 239885, upload-time = "2026-03-15T19:05:16.488Z" },
{ url = "https://files.pythonhosted.org/packages/a5/e2/1d74c7663aa38e405c25a30c26e8a1b92c06f8947118b9617042f4290ade/pyfakefs-6.1.1-py3-none-any.whl", hash = "sha256:e2c30fd0b7736ba03785bbc20b86de2312623cf7a11a7de334042ab0de23d46f", size = 239630, upload-time = "2026-02-09T19:44:18.093Z" },
]
[[package]]
@@ -1754,11 +1754,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.12.1"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
]
[[package]]
@@ -1992,16 +1992,16 @@ wheels = [
[[package]]
name = "responses"
version = "0.26.0"
version = "0.25.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0e/95/89c054ad70bfef6da605338b009b2e283485835351a9935c7bfbfaca7ffc/responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4", size = 79320, upload-time = "2025-08-08T19:01:46.709Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" },
{ url = "https://files.pythonhosted.org/packages/1c/4c/cc276ce57e572c102d9542d383b2cfd551276581dc60004cb94fe8774c11/responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c", size = 34769, upload-time = "2025-08-08T19:01:45.018Z" },
]
[[package]]
@@ -2072,27 +2072,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.6"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
]
[[package]]
@@ -2109,15 +2109,15 @@ wheels = [
[[package]]
name = "sentry-sdk"
version = "2.54.0"
version = "2.53.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" },
{ url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" },
]
[[package]]
@@ -2271,11 +2271,11 @@ wheels = [
[[package]]
name = "whitenoise"
version = "6.12.0"
version = "6.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
sdist = { url = "https://files.pythonhosted.org/packages/15/95/8c81ec6b6ebcbf8aca2de7603070ccf37dbb873b03f20708e0f7c1664bc6/whitenoise-6.11.0.tar.gz", hash = "sha256:0f5bfce6061ae6611cd9396a8231e088722e4fc67bc13a111be74c738d99375f", size = 26432, upload-time = "2025-09-18T09:16:10.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
{ url = "https://files.pythonhosted.org/packages/6c/e9/4366332f9295fe0647d7d3251ce18f5615fbcb12d02c79a26f8dba9221b3/whitenoise-6.11.0-py3-none-any.whl", hash = "sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258", size = 20197, upload-time = "2025-09-18T09:16:09.754Z" },
]
[[package]]
+16 -889
View File
File diff suppressed because it is too large Load Diff
+3 -7
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.12.0",
"version": "1.10.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -10,9 +10,7 @@
"preview": "vite preview",
"i18n:extract": "npx i18next -c i18next-parser.config.json",
"format": "prettier --write ./src",
"check": "prettier --check ./src",
"test": "vitest run",
"test:watch": "vitest"
"check": "prettier --check ./src"
},
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34",
@@ -61,12 +59,10 @@
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"jsdom": "^29.0.2",
"postcss": "8.5.6",
"prettier": "3.8.1",
"typescript": "5.8.3",
"vite": "7.3.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "^4.1.3"
"vite-tsconfig-paths": "6.1.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+1 -13
View File
@@ -15,19 +15,7 @@ export const fetchApi = async <T = Record<string, unknown>>(
...options?.headers,
},
})
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
}
}
const result = await response.json()
if (!response.ok) {
throw new ApiError(response.status, result)
}
-1
View File
@@ -5,5 +5,4 @@ export const keys = {
requestEntry: 'requestEntry',
waitingParticipants: 'waitingParticipants',
roomCreationCallback: 'roomCreationCallback',
files: 'files',
}
-11
View File
@@ -13,7 +13,6 @@ export interface ApiConfig {
help_article_transcript: string
help_article_recording: string
help_article_more_tools: string
help_article_encryption?: string
}
feedback: {
url: string
@@ -31,13 +30,6 @@ export interface ApiConfig {
expiration_days?: 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: {
enabled: boolean
}
@@ -53,9 +45,6 @@ export interface ApiConfig {
enable_firefox_proxy_workaround: boolean
default_sources: string[]
}
encryption?: {
enabled: boolean
}
transcription_destination?: string
}
+2 -2
View File
@@ -57,7 +57,7 @@ export const Avatar = ({
style,
...props
}: AvatarProps) => {
const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? ''
const initial = name?.trim()?.charAt(0) ?? ''
return (
<div
style={{
@@ -70,7 +70,7 @@ export const Avatar = ({
<span
aria-hidden="true"
className={css({
lineHeight: 1,
marginTop: '-0.3rem',
})}
>
{initial}
@@ -1,13 +1,10 @@
import { BackendLanguage } from '@/utils/languages'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export type ApiUser = {
id: string
email: string
full_name: string | null
short_name: string | null
full_name: string
last_name: string
language: BackendLanguage
timezone: string
default_encryption_mode: ApiEncryptionMode
}
@@ -1,18 +1,15 @@
import { type ApiUser } from './ApiUser'
import { fetchApi } from '@/api/fetchApi'
export type ApiUserPreferences = Partial<
Pick<ApiUser, 'timezone' | 'language' | 'default_encryption_mode'>
> & { id: string }
export type ApiUserPreferences = Pick<ApiUser, 'id' | 'timezone' | 'language'>
export const updateUserPreferences = async ({
user,
}: {
user: ApiUserPreferences
}): Promise<ApiUser> => {
const { id, ...payload } = user
return await fetchApi(`/users/${id}/`, {
method: 'PATCH',
body: JSON.stringify(payload),
return await fetchApi(`/users/${user.id}/`, {
method: 'PUT',
body: JSON.stringify({ timezone: user.timezone, language: user.language }),
})
}
@@ -1,113 +0,0 @@
/**
* Tile overlay shown when LiveKit raises an EncryptionError for a remote
* participant (a passphrase/key mismatch "you and they don't share the
* same encryption key"). Renders the participant's avatar placeholder
* over the broken video, plus a black banner at the bottom of the tile
* explaining the issue.
*
* Cleared automatically once frames decrypt again
* (ParticipantEncryptionStatusChanged with encrypted=true).
*/
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Participant, RoomEvent } from 'livekit-client'
import { useRoomContext } from '@livekit/components-react'
import { RiLockFill } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
interface Props {
participant: Participant
}
export function DecryptionFailedTileOverlay({ participant }: Props) {
const { t } = useTranslation('rooms', {
keyPrefix: 'encryption.decryptionFailed',
})
const room = useRoomContext()
const roomData = useRoomData()
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!isEncrypted) return
if (participant.isLocal) return
const identity = participant.identity
const onError = (_err: Error, p?: Participant) => {
if (p?.identity === identity) setFailed(true)
}
const onStatus = (encrypted: boolean, p?: Participant) => {
if (p?.identity === identity && encrypted) setFailed(false)
}
room.on(RoomEvent.EncryptionError, onError)
room.on(RoomEvent.ParticipantEncryptionStatusChanged, onStatus)
return () => {
room.off(RoomEvent.EncryptionError, onError)
room.off(RoomEvent.ParticipantEncryptionStatusChanged, onStatus)
}
}, [room, isEncrypted, participant])
if (!failed) return null
return (
<output
aria-label={t('title')}
className={css({
position: 'absolute',
inset: 0,
zIndex: 3,
pointerEvents: 'none',
})}
>
<ParticipantPlaceholder participant={participant} />
<div
style={{
position: 'absolute',
bottom: '2.5rem',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: '0.5rem',
padding: '0.6rem 1rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.3rem',
maxWidth: '85%',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
color: '#f87171',
fontSize: '0.85rem',
fontWeight: 600,
}}
>
<RiLockFill size={14} />
<span>{t('title')}</span>
</div>
<div
style={{
color: '#d1d5db',
fontSize: '0.75rem',
textAlign: 'center',
lineHeight: 1.4,
maxWidth: '22rem',
}}
>
{t('body')}
</div>
</div>
</output>
)
}
@@ -1,79 +0,0 @@
/**
* Shown when the URL hash and the room's encryption_mode disagree.
*
* - missingPassphrase: room is encrypted on the server, but the URL has no
* (or an invalid) passphrase. The user opened the wrong link.
* - unexpectedPassphrase: the URL has a passphrase, but the server says the
* room is not encrypted. Either the room was created differently or the
* link looks tampered with either way, joining as "encrypted" would
* leave the user alone in an encrypted bubble. Better to bail.
*/
import { css } from '@/styled-system/css'
import { Center } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { RiAlertLine, RiLockUnlockLine } from '@remixicon/react'
import { Button, Text } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { navigateTo } from '@/navigation/navigateTo'
interface Props {
reason: 'missingPassphrase' | 'unexpectedPassphrase'
}
export function EncryptionMismatchScreen({ reason }: Props) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.mismatch' })
return (
<Screen layout="centered">
<CenteredContent>
<Center>
<div
className={css({
maxWidth: '420px',
padding: '2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1rem',
textAlign: 'center',
})}
>
<div
className={css({
width: '3.5rem',
height: '3.5rem',
borderRadius: '50%',
backgroundColor: '#fffbeb',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
{reason === 'missingPassphrase' ? (
<RiLockUnlockLine size={28} color="#b45309" />
) : (
<RiAlertLine size={28} color="#b45309" />
)}
</div>
<Text
as="h2"
className={css({ fontWeight: 700, fontSize: '1.15rem' })}
>
{t(`${reason}.title`)}
</Text>
<Text
as="p"
className={css({ fontSize: '0.9rem', color: 'greyscale.700' })}
>
{t(`${reason}.body`)}
</Text>
<Button variant="primary" onPress={() => navigateTo('home')}>
{t('backHome')}
</Button>
</div>
</Center>
</CenteredContent>
</Screen>
)
}
@@ -1,37 +0,0 @@
/**
* Small pill used to surface a feature name with an icon, e.g. in the
* encrypted-room create dialog, the "Meeting information" panel and the
* floating share dialog the three places that list disabled features.
*/
import { css } from '@/styled-system/css'
import { ReactNode } from 'react'
interface Props {
icon: ReactNode
label: string
size?: 'sm' | 'md'
}
export const FeaturePill = ({ icon, label, size = 'md' }: Props) => {
const fontSize = size === 'sm' ? '0.8rem' : '0.85rem'
const padding = size === 'sm' ? '0.3rem 0.6rem' : '0.4rem 0.7rem'
return (
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.250',
color: 'greyscale.700',
backgroundColor: 'white',
whiteSpace: 'nowrap',
})}
style={{ fontSize, padding }}
>
{icon}
{label}
</span>
)
}
@@ -1,141 +0,0 @@
/**
* Top-left status banner shown during a meeting.
*
* Renders a horizontal stack of pills, one per active state:
* - "End-to-end encrypted"
* - "Recording in progress"
* - "Transcription in progress"
*
* Each pill auto-collapses to its icon a few seconds after appearing,
* and expands back on hover.
*/
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import {
RiFileTextFill,
RiRecordCircleFill,
RiShieldCheckLine,
} from '@remixicon/react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { RecordingMode, useRecordingStatuses } from '@/features/recording'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
const COLLAPSE_DELAY_MS = 4000
interface PillProps {
icon: React.ReactNode
label: string
background: string
pulse?: boolean
}
function StatusPill({ icon, label, background, pulse }: PillProps) {
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
const t = setTimeout(() => setCollapsed(true), COLLAPSE_DELAY_MS)
return () => clearTimeout(t)
}, [])
return (
<output
onMouseEnter={() => setCollapsed(false)}
onMouseLeave={() => setCollapsed(true)}
aria-label={label}
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.3rem 0.6rem',
borderRadius: '1rem',
border: '2px solid rgba(0, 0, 0, 0.3)',
cursor: 'default',
overflow: 'hidden',
transition: 'max-width 300ms ease, padding-right 200ms ease',
whiteSpace: 'nowrap',
})}
style={{
backgroundColor: background,
maxWidth: collapsed ? '2.2rem' : '20rem',
paddingRight: collapsed ? '0.3rem' : '0.6rem',
animation: pulse ? 'pulse_background 1.6s infinite' : undefined,
}}
>
<span className={css({ flexShrink: 0, display: 'inline-flex' })}>
{icon}
</span>
<span
className={css({
fontSize: '0.7rem',
fontWeight: 600,
color: 'white',
letterSpacing: '0.02em',
transition: 'opacity 200ms ease',
})}
style={{ opacity: collapsed ? 0 : 1 }}
>
{label}
</span>
</output>
)
}
export function RoomStatusBanner() {
const { t } = useTranslation('rooms', { keyPrefix: 'roomStatus' })
const roomData = useRoomData()
// Use the metadata-driven `isStarted` for both pills — it flips to
// false the moment the user clicks stop (recording_status moves to
// Saving), so the pill disappears immediately instead of lingering
// through LK's 1-2s post-stop callback delay.
const screenRec = useRecordingStatuses(RecordingMode.ScreenRecording)
const transcript = useRecordingStatuses(RecordingMode.Transcript)
const isRecording = screenRec.isStarted
const isTranscribing = transcript.isStarted
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
if (!isEncrypted && !isRecording && !isTranscribing) {
return null
}
return (
<HStack
gap="0.4rem"
className={css({
position: 'absolute',
top: '0.5rem',
left: '0.5rem',
zIndex: 10,
})}
>
{isEncrypted && (
<StatusPill
key="encrypted"
icon={<RiShieldCheckLine size={14} color="white" />}
label={t('encrypted')}
background="#1e3a5f"
/>
)}
{isTranscribing && (
<StatusPill
key="transcript"
icon={<RiFileTextFill size={13} color="white" />}
label={t('transcribing')}
background="#7c2d12"
/>
)}
{isRecording && (
<StatusPill
key="recording"
icon={<RiRecordCircleFill size={13} color="white" />}
label={t('recording')}
background="#b91c1c"
pulse
/>
)}
</HStack>
)
}
@@ -1,10 +0,0 @@
export {
generatePassphrase,
isValidPassphrase,
getPassphraseFromHash,
PASSPHRASE_LENGTH,
} from './passphrase'
export { RoomStatusBanner } from './RoomStatusBanner'
export { FeaturePill } from './FeaturePill'
export { EncryptionMismatchScreen } from './EncryptionMismatchScreen'
export { DecryptionFailedTileOverlay } from './DecryptionFailedTileOverlay'
@@ -1,33 +0,0 @@
/**
* Passphrase utilities for end-to-end encryption.
*
* The passphrase is appended to a room URL as the hash fragment
* (e.g. `https://meet.example.com/abc-defg-hij#<passphrase>`). The
* server never sees it; participants share it by sharing the link.
*
* Encoding is plain hex so that the validator's regex matches exactly
* what the generator produces: 48 lowercase hex characters = 192 bits
* of entropy, no overlap with looser "looks like a passphrase" inputs.
*/
const PASSPHRASE_BYTES = 24
/** Length, in characters, of a generated passphrase. */
export const PASSPHRASE_LENGTH = PASSPHRASE_BYTES * 2
/** Generate a random passphrase suitable for an encrypted room. */
export function generatePassphrase(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(PASSPHRASE_BYTES)))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/** Whether a string is exactly a generator-shaped passphrase. */
export function isValidPassphrase(value: string): boolean {
return value.length === PASSPHRASE_LENGTH && /^[0-9a-f]+$/.test(value)
}
/** Read the current URL hash (without the leading `#`). */
export function getPassphraseFromHash(): string {
return window.location.hash.replace(/^#/, '')
}
@@ -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,147 +0,0 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Dialog } from '@/primitives'
import { Checkbox } from '@/primitives/Checkbox'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { RiAlertFill, RiCheckLine, RiFileCopyLine } from '@remixicon/react'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
interface Props {
room: ApiRoom | null
hash: string
onOpenChange: (open: boolean) => void
onStart: () => void
}
export const ConnectionDetailsDialog = ({
room,
hash,
onOpenChange,
onStart,
}: Props) => {
const { t } = useTranslation('home', { keyPrefix: 'connectionDetailsDialog' })
const [acknowledged, setAcknowledged] = useState(false)
const [copied, setCopied] = useState(false)
if (!room) return null
const url = `${getRouteUrl('room', room.slug)}#${hash}`
const displayUrl = url.replace(/^https?:\/\//, '')
const copy = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('copy failed', err)
}
}
return (
<Dialog
isOpen={!!room}
onOpenChange={onOpenChange}
title={t('title')}
role="dialog"
>
<p
className={css({
fontSize: '0.9rem',
color: 'greyscale.700',
marginBottom: '1rem',
})}
>
{t('description')}
</p>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.6rem 0.9rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.250',
backgroundColor: 'white',
marginBottom: '1rem',
})}
>
<span
className={css({
flexGrow: 1,
fontFamily: 'monospace',
fontSize: '0.8rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
>
{displayUrl}
</span>
<Button
variant={copied ? 'success' : 'tertiaryText'}
square
size="sm"
onPress={copy}
aria-label={t('copy')}
tooltip={t('copy')}
>
{copied ? <RiCheckLine size={16} /> : <RiFileCopyLine size={16} />}
</Button>
</div>
<div
className={css({
display: 'flex',
gap: '0.5rem',
padding: '0.75rem 0.9rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
marginBottom: '1rem',
alignItems: 'flex-start',
})}
>
<RiAlertFill
size={18}
color="#b45309"
className={css({ flexShrink: 0 })}
/>
<div className={css({ flex: 1 })}>
<p
className={css({
fontSize: '0.85rem',
color: '#7c2d12',
lineHeight: 1.4,
marginBottom: '0.5rem',
})}
>
{t('warning')}
</p>
<Checkbox
isSelected={acknowledged}
onChange={setAcknowledged}
className={css({
fontSize: '0.9rem',
color: '#7c2d12',
})}
>
{t('iUnderstand')}
</Checkbox>
</div>
</div>
<HStack gap="0.5rem" justify="flex-end">
<Button
variant="primary"
isDisabled={!acknowledged}
onPress={onStart}
data-attr="encrypted-start"
>
{t('startMeeting')}
</Button>
</HStack>
</Dialog>
)
}
@@ -1,92 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Button, Dialog } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import {
RiPhoneLine,
RiComputerLine,
RiFileTextLine,
RiRecordCircleLine,
} from '@remixicon/react'
import { FeaturePill } from '@/features/encryption'
interface Props {
isOpen: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
export const CreateEncryptedMeetingDialog = ({
isOpen,
onOpenChange,
onConfirm,
}: Props) => {
const { t } = useTranslation('home', {
keyPrefix: 'createEncryptedMeetingDialog',
})
return (
<Dialog
isOpen={isOpen}
onOpenChange={onOpenChange}
title={t('title')}
role="dialog"
>
<p
className={css({
fontSize: '0.9rem',
color: 'greyscale.700',
marginBottom: '0.75rem',
})}
>
{t('description')}
</p>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginBottom: '1rem',
})}
>
<FeaturePill
icon={<RiPhoneLine size={14} />}
label={t('features.dialIn')}
/>
<FeaturePill
icon={<RiComputerLine size={14} />}
label={t('features.meetingRoom')}
/>
<FeaturePill
icon={<RiFileTextLine size={14} />}
label={t('features.transcription')}
/>
<FeaturePill
icon={<RiRecordCircleLine size={14} />}
label={t('features.recording')}
/>
</div>
<p
className={css({
fontSize: '0.85rem',
color: 'greyscale.700',
marginBottom: '1.25rem',
})}
>
{t('warning')}
</p>
<HStack gap="0.5rem" justify="flex-end">
<Button variant="tertiary" onPress={() => onOpenChange(false)}>
{t('cancel')}
</Button>
<Button
variant="primary"
onPress={onConfirm}
data-attr="create-encrypted-confirm"
>
{t('confirm')}
</Button>
</HStack>
</Dialog>
)
}
@@ -1,163 +1,35 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
import { css } from '@/styled-system/css'
import { navigateTo } from '@/navigation/navigateTo'
import { isRoomValid } from '@/features/rooms'
import { normalizeRoomId } from '@/features/rooms/utils/isRoomValid'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { isValidPassphrase } from '@/features/encryption'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export const JoinMeetingDialog = () => {
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 trimmed = input.trim()
try {
const url = new URL(trimmed)
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 (parsed.hash) {
navigateTo('room', parsed.roomId, { hash: parsed.hash })
return
}
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, { hash: passphrase })
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
const roomId = (data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
navigateTo('room', roomId)
}
const validateRoomId = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return null
const { roomId: id, hash } = parseInput(trimmed)
if (!isRoomValid(id))
return (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
<li>uioazerjkl</li>
</Ul>
</>
)
// If a hash is pasted in, refuse malformed passphrases now (instead of
// letting Conference render the mismatch screen after navigation).
if (hash && !isValidPassphrase(hash))
return <p>{t('joinPassphraseInvalidFormat')}</p>
return 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')}
validate={(value: string) => {
const v = (value || '').trim()
if (!v) return t('joinPassphraseError')
if (!isValidPassphrase(v)) return t('joinPassphraseInvalidFormat')
return null
}}
/>
<P
className={css({
fontSize: '0.8rem',
color: '#b45309',
marginTop: '0.5rem',
})}
>
{t('joinPassphraseWarning')}
</P>
</Form>
</Dialog>
)
return !isRoomValid(trimmed) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}
return (
<Dialog title={t('joinMeeting')}>
<Form
onSubmit={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 */}
<Field
type="text"
@@ -17,7 +17,7 @@ export const LaterMeetingDialog = ({
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
const roomUrl = room ? getRouteUrl('room', room.slug) : null
const roomUrl = room && getRouteUrl('room', room?.slug)
const telephony = useTelephony()
const [isHovered, setIsHovered] = useState(false)
+16 -95
View File
@@ -1,10 +1,5 @@
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 { styled } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
@@ -12,12 +7,8 @@ import { Screen } from '@/layout/Screen'
import { generateRoomId, useCreateRoom } from '@/features/rooms'
import { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { RiAddLine, RiLink, RiShieldCrossLine } from '@remixicon/react'
import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { CreateEncryptedMeetingDialog } from '@/features/home/components/CreateEncryptedMeetingDialog'
import { ConnectionDetailsDialog } from '@/features/home/components/ConnectionDetailsDialog'
import { generatePassphrase } from '@/features/encryption'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useEffect, useState } from 'react'
@@ -157,42 +148,17 @@ const IntroText = styled('div', {
export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn, user } = useUser()
const { isLoggedIn } = useUser()
const {
userChoices: { username },
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom }>(null)
const [encryptedRoom, setEncryptedRoom] = useState<null | {
room: ApiRoom
hash: string
}>(null)
const [showEncryptedConfirm, setShowEncryptedConfirm] = useState(false)
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const [redirectFailed, setRedirectFailed] = useState(false)
const { data } = useConfig()
// The encrypted dropdown entry is offered only when:
// - the server has encryption enabled (instance config), AND
// - the user opted into the feature in their preferences.
// "Instant meeting" and "Later date" stay plain regardless — encryption
// is always an explicit, opt-in flow with its own confirmation modal.
const encryptionAvailable =
!!data?.encryption?.enabled &&
user?.default_encryption_mode === ApiEncryptionMode.BASIC
const buildRoomBundle = async (
encryptionMode: ApiEncryptionMode = ApiEncryptionMode.NONE
) => {
const slug = generateRoomId()
const hash =
encryptionMode === ApiEncryptionMode.BASIC
? generatePassphrase()
: undefined
const room = await createRoom({ slug, username, encryptionMode })
return { room, hash }
}
useEffect(() => {
const checkSiteAndRedirect = async () => {
@@ -244,10 +210,12 @@ export const Home = () => {
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={async () => {
const { room } = await buildRoomBundle()
navigateTo('room', room.slug, {
state: { create: true, initialRoomData: room },
})
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
@@ -258,37 +226,17 @@ export const Home = () => {
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={async () => {
const { room } = await buildRoomBundle()
setLaterRoom({ room })
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoom(data)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
{encryptionAvailable && (
<>
<RACSeparator
className={css({
border: 'none',
height: '1px',
background: 'greyscale.250',
margin: '0.35rem 0',
})}
/>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => setShowEncryptedConfirm(true)}
data-attr="create-option-encrypted"
>
<RiShieldCrossLine size={18} />
{t('createMenu.encryptedOption')}
</MenuItem>
</>
)}
</RACMenu>
</Menu>
) : (
@@ -317,36 +265,9 @@ export const Home = () => {
</RightColumn>
</Columns>
<LaterMeetingDialog
room={laterRoom?.room ?? null}
room={laterRoom}
onOpenChange={() => setLaterRoom(null)}
/>
<CreateEncryptedMeetingDialog
isOpen={showEncryptedConfirm}
onOpenChange={setShowEncryptedConfirm}
onConfirm={async () => {
setShowEncryptedConfirm(false)
const { room, hash } = await buildRoomBundle(
ApiEncryptionMode.BASIC
)
if (hash) setEncryptedRoom({ room, hash })
}}
/>
<ConnectionDetailsDialog
room={encryptedRoom?.room ?? null}
hash={encryptedRoom?.hash ?? ''}
onOpenChange={(open) => {
if (!open) setEncryptedRoom(null)
}}
onStart={() => {
if (!encryptedRoom) return
const { room, hash } = encryptedRoom
setEncryptedRoom(null)
navigateTo('room', room.slug, {
state: { create: true, initialRoomData: room },
hash,
})
}}
/>
</Screen>
</UserAware>
)
@@ -3,8 +3,7 @@ import { HStack, VStack } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar'
import { Button, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { RiErrorWarningLine, RiInfinityLine } from '@remixicon/react'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { RiInfinityLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState } from 'react'
import { usePrevious } from '@/hooks/usePrevious'
@@ -22,8 +21,6 @@ export const WaitingParticipantNotification = () => {
const { t } = useTranslation('notifications', {
keyPrefix: 'waitingParticipants',
})
const { t: tRooms } = useTranslation('rooms', { keyPrefix: 'identity' })
const anonymousLabel = tRooms('anonymous.tooltip')
const timerRef = useRef<NodeJS.Timeout | null>(null)
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
@@ -37,6 +34,7 @@ export const WaitingParticipantNotification = () => {
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
useEffect(() => {
// Show notification when the first participant enters the waiting room
if (
!isParticipantListEmpty(waitingParticipants) &&
isParticipantListEmpty(prevWaitingParticipant) &&
@@ -51,9 +49,10 @@ export const WaitingParticipantNotification = () => {
}
timerRef.current = setTimeout(() => {
setShowQuickActionsMessage(false)
timerRef.current = null
timerRef.current = null // Clear the ref when timeout completes
}, NOTIFICATION_DISPLAY_DURATION)
} else if (waitingParticipants.length !== prevWaitingParticipant?.length) {
// Hide notification when the participant count changes
setShowQuickActionsMessage(false)
}
}, [
@@ -64,6 +63,7 @@ export const WaitingParticipantNotification = () => {
])
useEffect(() => {
// This cleanup function will only run when the component unmounts
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
@@ -72,6 +72,7 @@ export const WaitingParticipantNotification = () => {
}, [])
useEffect(() => {
// Hide notification when participants panel is opened
if (isParticipantsOpen) {
setShowQuickActionsMessage(false)
}
@@ -99,7 +100,7 @@ export const WaitingParticipantNotification = () => {
>
{t('one')}
</Text>
<HStack gap="0.5rem" alignItems="center">
<HStack gap="1rem">
<Avatar
name={waitingParticipants[0].username}
bgColor={waitingParticipants[0].color}
@@ -118,22 +119,6 @@ export const WaitingParticipantNotification = () => {
>
{waitingParticipants[0].username}
</Text>
{!waitingParticipants[0].is_authenticated && (
<VisualOnlyTooltip
tooltip={anonymousLabel}
ariaLabel={anonymousLabel}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
cursor: 'help',
})}
>
<RiErrorWarningLine size={16} color="#f87171" />
</span>
</VisualOnlyTooltip>
)}
</HStack>
<HStack gap="0.25rem" marginLeft="auto">
<Button
@@ -1,13 +1,11 @@
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
import { RecordingStateToast } from './RecordingStateToast'
import { ErrorAlertDialog } from './ErrorAlertDialog'
// RecordingStateToast removed — the RoomStatusBanner (top-left pill row)
// now shows "Recording in progress" and "Transcription in progress" in the
// same place, so the standalone toast was rendering behind the new pills.
export const RecordingProvider = () => {
return (
<>
<RecordingStateToast />
<LimitReachedAlertDialog />
<ErrorAlertDialog />
</>
@@ -10,11 +10,6 @@ export enum ApiAccessLevel {
RESTRICTED = 'restricted',
}
export enum ApiEncryptionMode {
NONE = 'none',
BASIC = 'basic',
}
export type ApiRoom = {
id: string
name: string
@@ -22,7 +17,6 @@ export type ApiRoom = {
pin_code: string
is_administrable: boolean
access_level: ApiAccessLevel
encryption_mode: ApiEncryptionMode
livekit?: ApiLiveKit
configuration?: {
[key: string]: string | number | boolean | string[]
@@ -1,30 +1,24 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiEncryptionMode, ApiRoom } from './ApiRoom'
import { ApiRoom } from './ApiRoom'
export interface CreateRoomParams {
slug: string
callbackId?: string
username?: string
encryptionMode?: ApiEncryptionMode
}
const createRoom = ({
slug,
callbackId,
username = '',
encryptionMode = ApiEncryptionMode.NONE,
}: CreateRoomParams): Promise<ApiRoom> => {
const queryParams = username
? `?username=${encodeURIComponent(username)}`
: ''
return fetchApi(`rooms/${queryParams}`, {
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
method: 'POST',
body: JSON.stringify({
name: slug,
callback_id: callbackId,
encryption_mode: encryptionMode,
}),
})
}
@@ -8,7 +8,6 @@ export type WaitingParticipant = {
status: string
username: string
color: string
is_authenticated: boolean
}
export type WaitingParticipantsResponse = {
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
@@ -7,24 +7,18 @@ import {
} from '@livekit/components-react'
import {
DisconnectReason,
ExternalE2EEKeyProvider,
MediaDeviceFailure,
Room,
RoomOptions,
VideoPresets,
} from 'livekit-client'
import {
getPassphraseFromHash,
isValidPassphrase,
EncryptionMismatchScreen,
} from '@/features/encryption'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen'
import { QueryAware } from '@/components/QueryAware'
import { ErrorScreen } from '@/components/ErrorScreen'
import { fetchRoom } from '../api/fetchRoom'
import { ApiEncryptionMode, ApiRoom } from '../api/ApiRoom'
import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference'
@@ -92,74 +86,12 @@ export const Conference = ({
retry: false,
})
// The URL hash is the *source of truth* for whether to encrypt: the
// server is never given the passphrase, so a compromised server can't
// fabricate or suppress encryption — it can only claim a status, and we
// use that claim only as a sanity reference for the mismatch screen.
//
// `hasValidHash` is synchronous (reads window.location.hash), so it's
// either true or false on every render — never "we don't know yet".
const hashPassphrase = getPassphraseFromHash()
const hasValidHash = isValidPassphrase(hashPassphrase)
const dbSaysEncrypted = data?.encryption_mode === ApiEncryptionMode.BASIC
type EncryptionMismatch =
| 'missingPassphrase'
| 'unexpectedPassphrase'
| null
let encryptionMismatch: EncryptionMismatch = null
if (data !== undefined) {
if (dbSaysEncrypted && !hasValidHash) {
encryptionMismatch = 'missingPassphrase'
} else if (!dbSaysEncrypted && hashPassphrase.length > 0) {
encryptionMismatch = 'unexpectedPassphrase'
}
}
// We treat the room as encrypted purely because we have a valid hash.
// No server condition. If the hash is valid we MUST run E2EE; if not,
// there's nothing to encrypt with.
const isEncrypted = hasValidHash
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
const workerRef = useRef<Worker | null>(null)
// `roomWithE2EE` is the actual `Room` instance for which we've already
// run `setKey + setE2EEEnabled(true)`. Comparing it by reference with the
// currently-memoised `room` lets us derive the "setup complete" status
// synchronously during render — no separate boolean, no useEffect-driven
// reset, no race window between a new Room appearing and a flag flipping.
//
// A device-pref change rebuilds `roomOptions` → a new `Room` instance is
// memoised; on that same render `roomWithE2EE !== room` so the gate
// below stays closed until the setup effect has stamped the new Room.
const [roomWithE2EE, setRoomWithE2EE] = useState<Room | null>(null)
const [encryptionSetupError, setEncryptionSetupError] =
useState<Error | null>(null)
const getKeyProvider = () => {
if (!keyProviderRef.current && isEncrypted) {
keyProviderRef.current = new ExternalE2EEKeyProvider()
}
return keyProviderRef.current
}
const getWorker = () => {
if (!workerRef.current && isEncrypted && typeof window !== 'undefined') {
workerRef.current = new Worker(
new URL('livekit-client/e2ee-worker', import.meta.url)
)
}
return workerRef.current
}
const roomOptions = useMemo((): RoomOptions => {
const baseOptions: RoomOptions = {
return {
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: isEncrypted ? undefined : 'vp9',
red: !isEncrypted,
videoCodec: 'vp9',
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
@@ -174,20 +106,8 @@ export const Conference = ({
deviceId: userConfig.audioOutputDeviceId ?? undefined,
},
}
if (isEncrypted) {
const worker = getWorker()
const keyProvider = getKeyProvider()
if (keyProvider && worker) {
baseOptions.encryption = { keyProvider, worker }
}
}
return baseOptions
// do not rely on the userConfig object directly as its reference may change on every render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isEncrypted,
userConfig.videoDeviceId,
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
@@ -196,14 +116,61 @@ export const Conference = ({
const room = useMemo(() => new Room(roomOptions), [roomOptions])
const encryptionSetupComplete = !isEncrypted || roomWithE2EE === room
// Never let LiveKitRoom connect in an indeterminate state:
// 1. `data` must have arrived from the server so we know whether to
// show the mismatch screen.
// 2. If the URL has a valid hash, the *current* Room must have already
// been armed with setKey + setE2EEEnabled — otherwise the camera
// goes out in clear.
const canConnectMediaWise = data !== undefined && encryptionSetupComplete
useEffect(() => {
/**
* Warm up connection to LiveKit server before joining room
* This prefetch helps reduce initial connection latency by establishing
* an early HTTP connection to the WebRTC signaling server
*
* It should cache DNS and TLS keys.
*/
const prepareConnection = async () => {
if (!apiConfig || isConnectionWarmedUp) return
await room.prepareConnection(apiConfig.livekit.url)
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
try {
const wssUrl =
apiConfig.livekit.url
.replace('https://', 'wss://')
.replace(/\/$/, '') + '/rtc'
/**
* FIREFOX + PROXY WORKAROUND:
*
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
*
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
*
* Note: This issue is reproducible on LiveKit's demo app.
* Reference: livekit-examples/meet/issues/466
*/
const ws = new WebSocket(wssUrl)
// 401 unauthorized response is expected
ws.onerror = () => ws.readyState <= 1 && ws.close()
} catch (e) {
console.debug('Firefox WebSocket workaround failed.', e)
}
}
setIsConnectionWarmedUp(true)
}
prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
}>({
error: null,
kind: null,
})
const isMobile = useIsMobile()
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
@@ -219,98 +186,9 @@ export const Conference = ({
return livekit_url
}, [apiConfig?.livekit])
useEffect(() => {
if (!isEncrypted || roomWithE2EE === room) return
const keyProvider = getKeyProvider()
if (!keyProvider) return
// `isEncrypted === hasValidHash`, so by the time we get here the URL
// already carries a valid passphrase. Hash generation happens upstream
// (in `Home.tsx` for new encrypted meetings); we just read it here.
const passphrase = getPassphraseFromHash()
if (!passphrase) return
// Must only stamp the room as "armed" after the chain has actually
// succeeded. If `setE2EEEnabled` rejects we surface the failure to
// the user via `encryptionSetupError` and stay disconnected.
let cancelled = false
keyProvider
.setKey(passphrase)
.then(() => room.setE2EEEnabled(true))
.then(() => {
if (!cancelled) setRoomWithE2EE(room)
})
.catch((err) => {
if (cancelled) return
console.error('[Encryption] setup failed:', err)
setEncryptionSetupError(
err instanceof Error ? err : new Error(String(err))
)
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, isEncrypted, roomWithE2EE])
useEffect(() => {
if (!data) return
let currentHash = getPassphraseFromHash()
const onHashChange = () => {
const next = getPassphraseFromHash()
if (next === currentHash) return
currentHash = next
window.location.reload()
}
window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange)
}, [data])
useEffect(() => {
/**
* Warm up connection to LiveKit server before joining room.
* Use the normalized `serverUrl` (the same value the LiveKitRoom
* will connect to after `force_wss_protocol`) so the warm-up matches
* the actual connection target.
*/
const prepareConnection = async () => {
if (!apiConfig || !serverUrl || isConnectionWarmedUp) return
await room.prepareConnection(serverUrl)
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
try {
const wssUrl =
serverUrl.replace('https://', 'wss://').replace(/\/$/, '') + '/rtc'
/**
* FIREFOX + PROXY WORKAROUND see livekit-examples/meet/issues/466
*/
const ws = new WebSocket(wssUrl)
ws.onerror = () => ws.readyState <= 1 && ws.close()
} catch (e) {
console.debug('Firefox WebSocket workaround failed.', e)
}
}
setIsConnectionWarmedUp(true)
}
prepareConnection()
}, [room, apiConfig, serverUrl, isConnectionWarmedUp])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
}>({
error: null,
kind: null,
})
const isMobile = useIsMobile()
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
return (
<ErrorScreen
title={t('error.createRoom.heading')}
@@ -319,23 +197,11 @@ export const Conference = ({
)
}
if (encryptionMismatch) {
return <EncryptionMismatchScreen reason={encryptionMismatch} />
}
if (encryptionSetupError) {
return (
<ErrorScreen
title={t('error.encryptionSetup.heading')}
body={t('error.encryptionSetup.body')}
/>
)
}
// Some clients (like DINUM) operate in bandwidth-constrained environments
// These settings help ensure successful connections in poor network conditions
const connectOptions = {
maxRetries: 5,
peerConnectionTimeout: 60000,
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
}
return (
@@ -345,12 +211,12 @@ export const Conference = ({
room={room}
serverUrl={serverUrl}
token={data?.livekit?.token}
connect={isConnectionWarmedUp && canConnectMediaWise}
connect={isConnectionWarmedUp}
audio={userConfig.audioEnabled}
video={
userConfig.videoEnabled && {
processor: BackgroundProcessorFactory.fromProcessorConfig(
userConfig.processorConfig
processor: BackgroundProcessorFactory.deserializeProcessor(
userConfig.processorSerialized
),
}
}
@@ -5,19 +5,15 @@ import { HStack, styled, VStack } from '@/styled-system/jsx'
import { Heading, Dialog } from 'react-aria-components'
import { Text, text } from '@/primitives/Text'
import {
RiAlertFill,
RiCheckLine,
RiCloseLine,
RiComputerLine,
RiFileCopyLine,
RiPhoneLine,
RiSpam2Fill,
} from '@remixicon/react'
import { useMemo } from 'react'
import { css } from '@/styled-system/css'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { FeaturePill } from '@/features/encryption'
import { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
@@ -45,30 +41,15 @@ const StyledRACDialog = styled(Dialog, {
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const { t: tHome } = useTranslation('home', {
keyPrefix: 'connectionDetailsDialog',
})
const { t: tFeatures } = useTranslation('home', {
keyPrefix: 'createEncryptedMeetingDialog',
})
const roomData = useRoomData()
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
const isAdminOrOwner = !!roomData?.is_administrable
const baseRoomUrl = getRouteUrl('room', roomData?.slug)
// Include the hash (passphrase) for basic encrypted rooms so the full link is visible
const roomUrl = window.location.hash
? `${baseRoomUrl}${window.location.hash}`
: baseRoomUrl
const roomUrl = getRouteUrl('room', roomData?.slug)
const telephony = useTelephony()
// Encrypted rooms never get a working PIN (backend skips both pin_code
// and dispatch_rule allocation), so the phone block must stay hidden
// even if a stale pin_code somehow slipped through.
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && roomData?.pin_code && !isEncrypted
}, [telephony?.enabled, roomData?.pin_code, isEncrypted])
return telephony?.enabled && roomData?.pin_code
}, [telephony?.enabled, roomData?.pin_code])
const {
isCopied,
@@ -87,7 +68,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
style={{ maxWidth: '100%', overflow: 'visible' }}
>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{isEncrypted ? t('encryptedHeading') : t('heading')}
{t('heading')}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
@@ -103,235 +84,113 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
<RiCloseLine />
</Button>
</Div>
{isEncrypted && !isAdminOrOwner ? (
<P>{t('encryptedGuestBody')}</P>
) : (
<P>{t('description')}</P>
)}
{(() => {
if (isEncrypted && !isAdminOrOwner) return null
if (isEncrypted) {
return (
<div
className={css({
width: '100%',
marginTop: '0.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<div
role="alert"
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
padding: '0.6rem 0.85rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
color: '#7c2d12',
})}
>
<RiAlertFill
size={18}
color="#b45309"
className={css({ flexShrink: 0 })}
/>
<Text
variant="sm"
margin={false}
className={css({
color: '#7c2d12',
fontSize: '0.85rem',
lineHeight: 1.4,
})}
>
{tHome('warning')}
</Text>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.250',
})}
>
<span
className={css({
flex: 1,
fontFamily: 'monospace',
fontSize: '0.8rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
>
{roomUrl?.replace(/^https?:\/\//, '')}
</span>
<Button
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size="sm"
onPress={copyRoomUrlToClipboard}
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
>
{isRoomUrlCopied ? (
<RiCheckLine size={16} />
) : (
<RiFileCopyLine size={16} />
)}
</Button>
</div>
<Text
margin={false}
className={css({
fontSize: '12px',
fontWeight: 400,
color: 'greyscale.500',
})}
>
{t('encryptedDisabledHeading')}
</Text>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.4rem',
})}
>
<FeaturePill
size="sm"
icon={<RiPhoneLine size={13} />}
label={tFeatures('features.dialIn')}
/>
<FeaturePill
size="sm"
icon={<RiComputerLine size={13} />}
label={tFeatures('features.meetingRoom')}
/>
</div>
</div>
)
}
if (isTelephonyReadyForUse) {
return (
<div
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'visible',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<Text as="p" wrap="pretty">
{roomUrl?.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && roomUrl && (
<Button
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={
isRoomUrlCopied ? t('copied') : t('copyUrl')
}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
>
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
) : (
<RiFileCopyLine aria-hidden="true" />
)}
</Button>
)}
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" wrap="pretty">
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
{telephony?.internationalPhoneNumber}
</Text>
<Text as="p" wrap="pretty">
<Bold>{t('phone.pinCode')}</Bold>{' '}
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
<P>{t('description')}</P>
{isTelephonyReadyForUse ? (
<div
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'visible',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<Text as="p" wrap="pretty">
{roomUrl?.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && roomUrl && (
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
>
{isCopied ? (
<>
<RiCheckLine
size={18}
style={{ marginRight: '8px' }}
aria-hidden="true"
/>
{t('copied')}
</>
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
aria-hidden="true"
/>
{t('copy')}
</>
<RiFileCopyLine aria-hidden="true" />
)}
</Button>
</div>
)
}
return (
)}
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" wrap="pretty">
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
{telephony?.internationalPhoneNumber}
</Text>
<Text as="p" wrap="pretty">
<Bold>{t('phone.pinCode')}</Bold>{' '}
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'tertiary'}
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
<RiCheckLine
size={18}
style={{ marginRight: '8px' }}
aria-hidden="true"
/>
{t('copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('copyUrl')}
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
aria-hidden="true"
/>
{t('copy')}
</>
)}
</Button>
)
})()}
</div>
) : (
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('copyUrl')}
</>
)}
</Button>
)}
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
<HStack>
<div
@@ -4,15 +4,15 @@ import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
createLocalAudioTrack,
createLocalVideoTrack,
createLocalAudioTrack,
LocalAudioTrack,
LocalVideoTrack,
Track,
} from 'livekit-client'
import { H } from '@/primitives/H'
import { Field } from '@/primitives/Field'
import { Button, Dialog, Form, Text } from '@/primitives'
import { Button, Dialog, Text, Form } from '@/primitives'
import { VStack } from '@/styled-system/jsx'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
@@ -32,15 +32,8 @@ import { useQuery } from '@tanstack/react-query'
import { queryClient } from '@/api/queryClient'
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel, ApiEncryptionMode } from '../api/ApiRoom'
import {
isValidPassphrase,
getPassphraseFromHash,
EncryptionMismatchScreen,
} from '@/features/encryption'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { RiInformationLine, RiLockLine } from '@remixicon/react'
import { useUser } from '@/features/auth'
import { openPermissionsDialog } from '@/stores/permissions'
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
import { isSafari } from '@/utils/livekit'
@@ -51,7 +44,8 @@ const onError = (e: Error) => console.error('ERROR', e)
const Effects = ({
videoTrack,
}: Pick<EffectsConfigurationProps, 'videoTrack'>) => {
onSubmit,
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
const [isDialogOpen, setIsDialogOpen] = useState(false)
const openDialog = () => setIsDialogOpen(true)
@@ -87,7 +81,7 @@ const Effects = ({
>
{t('subTitle')}
</Text>
<EffectsConfiguration videoTrack={videoTrack} />
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
</Dialog>
<Button
variant="whiteCircle"
@@ -110,39 +104,6 @@ export const Join = ({
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
// Early fetch to inspect the room (encrypted? requires passphrase?)
const { data: roomInfo } = useQuery({
queryKey: [keys.room, roomId, 'info'],
queryFn: () => fetchRoom({ roomId }),
staleTime: 6 * 60 * 60 * 1000,
retry: false,
})
const isEncryptedRoom = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC
const { user, isLoggedIn } = useUser()
// Authenticated joiners of an encrypted room can't pick an arbitrary
// display name — it must match the OIDC profile, and the backend
// re-enforces this when minting the JWT.
const isNameLocked = isEncryptedRoom && !!isLoggedIn
const lockedName = user?.full_name || user?.email || ''
// Keep the passphrase in state and refresh on `hashchange` so the
// mismatch screen recovers immediately when the user pastes the
// correct hash into the address bar.
const [passphrase, setPassphrase] = useState(getPassphraseFromHash)
useEffect(() => {
const onHashChange = () => setPassphrase(getPassphraseFromHash())
window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange)
}, [])
const hasValidPassphrase = isEncryptedRoom
? isValidPassphrase(passphrase)
: true
// If the URL has a passphrase but the room itself is not encrypted, the
// link looks tampered with — refuse to join and offer a fresh room.
const unexpectedPassphrase =
!!roomInfo && !isEncryptedRoom && passphrase.length > 0
const {
userChoices: {
audioEnabled,
@@ -150,7 +111,7 @@ export const Join = ({
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorConfig,
processorSerialized,
username,
},
saveAudioInputEnabled,
@@ -159,6 +120,7 @@ export const Join = ({
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
} = usePersistentUserChoices()
const initialUserChoices = useRef<LocalUserChoices | null>(null)
@@ -170,7 +132,7 @@ export const Join = ({
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorConfig,
processorSerialized,
username,
}
}
@@ -184,8 +146,8 @@ export const Join = ({
video: !!initialUserChoices.current &&
initialUserChoices.current?.videoEnabled && {
deviceId: initialUserChoices.current.videoDeviceId,
processor: BackgroundProcessorFactory.fromProcessorConfig(
initialUserChoices.current.processorConfig
processor: BackgroundProcessorFactory.deserializeProcessor(
initialUserChoices.current.processorSerialized
),
},
},
@@ -213,13 +175,20 @@ export const Join = ({
[tracks]
)
/*
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
*/
useEffect(() => {
const createVideoTrack = async () => {
try {
const track = await createLocalVideoTrack({
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
})
setDynamicVideoTrack(track)
} catch (error) {
@@ -238,7 +207,7 @@ export const Join = ({
}, [
videoEnabled,
videoDeviceId,
processorConfig,
processorSerialized,
previewVideoTrack,
dynamicVideoTrack,
])
@@ -280,6 +249,8 @@ export const Join = ({
const videoTrack = dynamicVideoTrack || previewVideoTrack
const audioTrack = dynamicAudioTrack || previewAudioTrack
// LiveKit by default populates device choices with "default" value.
// Instead, use the current device id used by the preview track as a default
useResolveInitiallyDefaultDeviceId(
audioDeviceId,
audioTrack,
@@ -319,6 +290,12 @@ export const Join = ({
}
}, [videoTrack, videoEnabled])
// Room data strategy:
// 1. Initial fetch is performed to check access and get LiveKit configuration
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
// 3. State is manually updated via queryClient when a waiting participant is accepted
// 4. No automatic refetching or revalidation occurs during this period
// todo - refactor in a hook
const {
data: roomData,
error,
@@ -360,6 +337,7 @@ export const Join = ({
const { data } = await refetchRoom()
if (!data?.livekit) {
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
if (data?.access_level == ApiAccessLevel.TRUSTED) {
openLoginHint()
}
@@ -452,12 +430,6 @@ export const Join = ({
)
default:
if (unexpectedPassphrase) {
return <EncryptionMismatchScreen reason="unexpectedPassphrase" />
}
if (isEncryptedRoom && !hasValidPassphrase) {
return <EncryptionMismatchScreen reason="missingPassphrase" />
}
return (
<Form
onSubmit={handleSubmit}
@@ -470,75 +442,20 @@ export const Join = ({
<H lvl={1} margin="sm" centered>
{t('heading')}
</H>
{isNameLocked ? (
<div
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})}
>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('usernameLabel')}
</Text>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
backgroundColor: 'greyscale.100',
borderRadius: '0.375rem',
border: '1px solid',
borderColor: 'greyscale.200',
})}
>
<RiLockLine size={14} color="#6b7280" />
<Text variant="sm" margin={false}>
{lockedName}
</Text>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
})}
>
<RiInformationLine
size={18}
color="#6b7280"
className={css({ flexShrink: 0 })}
/>
<Text
variant="note"
margin={false}
className={css({
fontSize: '0.8rem',
color: 'greyscale.500',
})}
>
{t('encryptedNameLocked')}
</Text>
</div>
</div>
) : (
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
)}
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
</VStack>
</Form>
)
@@ -773,7 +690,12 @@ export const Join = ({
zIndex: '1',
})}
>
<Effects videoTrack={videoTrack} />
<Effects
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
</div>
</div>
@@ -21,6 +21,7 @@ export const useLobby = ({
}) => {
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const clearWaitingTimeout = useCallback(() => {
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
@@ -39,7 +40,10 @@ export const useLobby = ({
/* eslint-disable @tanstack/query/exhaustive-deps */
queryKey: [keys.requestEntry, roomId],
queryFn: async () => {
const response = await requestEntry({ roomId, username })
const response = await requestEntry({
roomId,
username,
})
if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED)
@@ -62,7 +62,7 @@ export const useWaitingParticipants = () => {
allowEntry: boolean
) => {
await enterRoom({
roomId,
roomId: roomId,
allowEntry,
participantId: participant.id,
})
@@ -78,7 +78,7 @@ export const useWaitingParticipants = () => {
await Promise.all(
waitingParticipants.map((participant) =>
enterRoom({
roomId,
roomId: roomId,
allowEntry,
participantId: participant.id,
})
@@ -1,15 +1,15 @@
import { Div, Field, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Separator as RACSeparator } from 'react-aria-components'
import { RiAlertFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -22,7 +22,12 @@ export const Admin = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
const readOnlyData = useRoomData()
const { data: readOnlyData } = useQuery({
queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId }),
retry: false,
enabled: false,
})
const {
toggleMicrophone,
@@ -161,99 +166,45 @@ export const Admin = () => {
>
{t('access.description')}
</Text>
{(() => {
const isEncrypted =
readOnlyData?.encryption_mode === ApiEncryptionMode.BASIC
return (
<>
{isEncrypted && (
<div
role="alert"
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
padding: '0.6rem 0.85rem',
marginBottom: '0.75rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
color: '#7c2d12',
})}
>
<RiAlertFill
size={18}
color="#b45309"
className={css({ flexShrink: 0 })}
/>
<Text
margin={false}
className={css({
color: '#7c2d12',
fontSize: '0.85rem',
lineHeight: 1.4,
})}
>
{t('access.encryptedLocked')}
</Text>
</div>
)}
<div
className={css({
opacity: isEncrypted ? 0.7 : 1,
pointerEvents: isEncrypted ? 'none' : undefined,
transition: 'opacity 200ms ease',
})}
aria-disabled={isEncrypted || undefined}
>
<Field
type="radioGroup"
label={t('access.type')}
aria-label={t('access.type')}
labelProps={{
className: css({
fontSize: '1rem',
paddingBottom: '1rem',
}),
}}
isDisabled={isEncrypted}
value={
isEncrypted
? ApiAccessLevel.RESTRICTED
: readOnlyData?.access_level
}
onChange={(value) =>
patchRoom({
roomId,
room: { access_level: value as ApiAccessLevel },
})
.then((room) => {
queryClient.setQueryData([keys.room, roomId], room)
})
.catch((e) => console.error(e))
}
items={[
{
value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'),
description: t('access.levels.public.description'),
},
{
value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'),
},
{
value: ApiAccessLevel.RESTRICTED,
label: t('access.levels.restricted.label'),
description: t('access.levels.restricted.description'),
},
]}
/>
</div>
</>
)
})()}
<Field
type="radioGroup"
label={t('access.type')}
aria-label={t('access.type')}
labelProps={{
className: css({
fontSize: '1rem',
paddingBottom: '1rem',
}),
}}
value={readOnlyData?.access_level}
onChange={(value) =>
patchRoom({
roomId,
room: { access_level: value as ApiAccessLevel },
})
.then((room) => {
queryClient.setQueryData([keys.room, roomId], room)
})
.catch((e) => console.error(e))
}
items={[
{
value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'),
description: t('access.levels.public.description'),
},
{
value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'),
},
{
value: ApiAccessLevel.RESTRICTED,
label: t('access.levels.restricted.label'),
description: t('access.levels.restricted.description'),
},
]}
/>
</div>
</Div>
)
@@ -2,186 +2,28 @@ import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import {
RiAlertFill,
RiCheckLine,
RiComputerLine,
RiFileCopyLine,
RiPhoneLine,
} from '@remixicon/react'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
import { Bold, Button, Div, Text } from '@/primitives'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { useRoomData } from '../hooks/useRoomData'
import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
import { FeaturePill } from '@/features/encryption'
import { ApiEncryptionMode } from '../../api/ApiRoom'
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const { t: tHome } = useTranslation('home', {
keyPrefix: 'connectionDetailsDialog',
})
const data = useRoomData()
const baseRoomUrl = getRouteUrl('room', data?.slug)
const roomUrl = window.location.hash
? `${baseRoomUrl}${window.location.hash}`
: baseRoomUrl
const roomUrl = getRouteUrl('room', data?.slug)
const telephony = useTelephony()
const isEncrypted = data?.encryption_mode === ApiEncryptionMode.BASIC
const isAdminOrOwner = !!data?.is_administrable
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && data?.pin_code && !isEncrypted
}, [telephony?.enabled, data?.pin_code, isEncrypted])
return telephony?.enabled && data?.pin_code
}, [telephony?.enabled, data?.pin_code])
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
if (isEncrypted && !isAdminOrOwner) {
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="start"
>
<Text as="p" variant="note" wrap="pretty">
{t('encrypted.guestBody')}
</Text>
</Div>
)
}
if (isEncrypted) {
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="start"
>
<VStack
alignItems="stretch"
gap="0.75rem"
className={css({ width: '100%' })}
>
<div
role="alert"
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
padding: '0.6rem 0.85rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
color: '#7c2d12',
})}
>
<RiAlertFill
size={18}
color="#b45309"
className={css({ flexShrink: 0 })}
/>
<Text
variant="sm"
margin={false}
className={css({
color: '#7c2d12',
fontSize: '0.85rem',
lineHeight: 1.4,
})}
>
{tHome('warning')}
</Text>
</div>
<Text as="p" variant="note" margin={false}>
<Bold>{t('encrypted.linkLabel')}</Bold>
</Text>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.250',
})}
>
<span
className={css({
flex: 1,
fontFamily: 'monospace',
fontSize: '0.8rem',
wordBreak: 'break-all',
})}
>
{roomUrl.replace(/^https?:\/\//, '')}
</span>
<Button
square
size="sm"
variant={isCopied ? 'success' : 'tertiaryText'}
onPress={copyRoomToClipboard}
aria-label={
isCopied
? t('roomInformation.button.copied')
: t('roomInformation.button.copy')
}
tooltip={
isCopied
? t('roomInformation.button.copied')
: t('roomInformation.button.copy')
}
data-attr="copy-info-sidepannel"
>
{isCopied ? (
<RiCheckLine size={16} />
) : (
<RiFileCopyLine size={16} />
)}
</Button>
</div>
<Text
as="p"
margin={false}
className={css({
fontSize: '12px',
fontWeight: 400,
color: 'greyscale.500',
})}
>
{t('encrypted.disabledHeading')}
</Text>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
})}
>
<FeaturePill
icon={<RiPhoneLine size={14} />}
label={t('encrypted.features.dialIn')}
/>
<FeaturePill
icon={<RiComputerLine size={14} />}
label={t('encrypted.features.meetingRoom')}
/>
</div>
</VStack>
</Div>
)
}
return (
<Div
display="flex"
@@ -208,14 +50,7 @@ export const Info = () => {
flexDirection: 'column',
})}
>
<Text
as="p"
variant="xsNote"
className={css({
wordBreak: 'break-all',
whiteSpace: 'normal',
})}
>
<Text as="p" variant="xsNote" wrap="pretty">
{roomUrl.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && (
@@ -1,10 +1,12 @@
import {
AudioTrack,
ConnectionQualityIndicator,
LockLockedIcon,
ParticipantTileProps,
ScreenShareIcon,
useEnsureTrackRef,
useFeatureContext,
useIsEncrypted,
useMaybeLayoutContext,
useMaybeTrackRefContext,
useParticipantTile,
@@ -22,7 +24,6 @@ import { Track } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { HStack } from '@/styled-system/jsx'
import { DecryptionFailedTileOverlay } from '@/features/encryption'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
@@ -30,8 +31,6 @@ import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
export function TrackRefContextIfNeeded(
@@ -77,6 +76,7 @@ export const ParticipantTile: (
onParticipantClick,
trackRef: trackReference,
})
const isEncrypted = useIsEncrypted(trackReference.participant)
const layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -112,6 +112,7 @@ export const ParticipantTile: (
const interactiveProps = {
...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
@@ -212,6 +213,9 @@ export const ParticipantTile: (
}}
/>
)}
{isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
@@ -231,20 +235,9 @@ export const ParticipantTile: (
hasKeyboardFocus={hasKeyboardFocus}
/>
)}
{!isScreenShare && (
<DecryptionFailedTileOverlay
participant={trackReference.participant}
/>
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
<KeyboardShortcutHint>
{t('toolbarHint', {
shortcut: formatShortcutLabel(
getShortcutDescriptorById('open-shortcuts')?.shortcut
),
})}
</KeyboardShortcutHint>
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
</div>
)
})
@@ -149,12 +149,11 @@ export const SidePanel = () => {
activeSubPanelId,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const title = t(`heading.${activeSubPanelId || activePanelId}`)
return (
<StyledSidePanel
title={title}
ariaLabel={t('ariaLabel', { title })}
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
@@ -3,7 +3,6 @@ import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { RiAlertFill } from '@remixicon/react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import {
@@ -13,15 +12,12 @@ import {
ScreenRecordingSidePanel,
} from '@/features/recording'
import { useConfig } from '@/api/useConfig'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export interface ToolsButtonProps {
icon: ReactNode
title: string
description: string
onPress: () => void
isDisabled?: boolean
}
const ToolButton = ({
@@ -29,11 +25,9 @@ const ToolButton = ({
title,
description,
onPress,
isDisabled,
}: ToolsButtonProps) => {
return (
<RACButton
isDisabled={isDisabled}
className={css({
display: 'flex',
flexDirection: 'row',
@@ -45,14 +39,10 @@ const ToolButton = ({
width: 'full',
backgroundColor: 'gray.50',
textAlign: 'start',
'&[data-hovered]:not([data-disabled])': {
'&[data-hovered]': {
backgroundColor: 'primary.50',
cursor: 'pointer',
},
'&[data-disabled]': {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
onPress={onPress}
>
@@ -75,7 +65,7 @@ const ToolButton = ({
<div>
<Text
margin={false}
as="h2"
as="h3"
className={css({
display: 'flex',
gap: 0.25,
@@ -108,15 +98,17 @@ export const Tools = () => {
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const roomData = useRoomData()
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
useRestoreFocus(isToolsOpen, {
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
@@ -174,42 +166,12 @@ export const Tools = () => {
</A>
)}
</Text>
{isEncrypted && (
<div
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
padding: '0.6rem 0.85rem',
margin: '0 0.75rem 0.75rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
color: '#7c2d12',
})}
role="alert"
>
<RiAlertFill size={18} color="#b45309" />
<Text
variant="sm"
margin={false}
className={css({
color: '#7c2d12',
fontSize: '0.85rem',
lineHeight: 1.4,
})}
>
{t('encryptedBlock')}
</Text>
</div>
)}
{isTranscriptEnabled && (
<ToolButton
icon={<Icon type="symbols" name="speech_to_text" />}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={openTranscript}
isDisabled={isEncrypted}
onPress={() => openTranscript()}
/>
)}
{isScreenRecordingEnabled && (
@@ -217,8 +179,7 @@ export const Tools = () => {
icon={<Icon type="symbols" name="mode_standby" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={openScreenRecording}
isDisabled={isEncrypted}
onPress={() => openScreenRecording()}
/>
)}
</Div>
@@ -11,7 +11,11 @@ import {
TIMEOUT_TICK,
timerWorkerScript,
} from './TimerWorker'
import { BackgroundProcessorInterface, ProcessorConfig, ProcessorType } from '.'
import {
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144
@@ -29,7 +33,7 @@ const DEFAULT_BLUR = '10'
* MediaStreamTrackProcessor.
*/
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
options: ProcessorConfig
options: BackgroundOptions
name: string
processedTrack?: MediaStreamTrack | undefined
@@ -60,10 +64,15 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
type: ProcessorType
virtualBackgroundImage?: HTMLImageElement
constructor(opts: ProcessorConfig) {
constructor(opts: BackgroundOptions) {
this.name = 'blur'
this.options = opts
this.type = opts.type
if (this.options.blurRadius) {
this.type = ProcessorType.BLUR
} else {
this.type = ProcessorType.VIRTUAL
}
}
static get isSupported() {
@@ -98,12 +107,6 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
}
_initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') {
throw new Error(
'Virtual background is only supported for virtual background'
)
}
const needsUpdate =
this.options.imagePath &&
this.virtualBackgroundImage &&
@@ -115,7 +118,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
}
}
async update(opts: ProcessorConfig): Promise<void> {
async update(opts: BackgroundOptions): Promise<void> {
this.options = opts
this._initVirtualBackgroundImage()
}
@@ -184,7 +187,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT
PROCESSING_WIDTH
)
}
@@ -209,9 +212,6 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
* TODO: future improvement with WebGL.
*/
async blur() {
if (this.options.type !== 'blur') {
throw new Error('Blurring is only supported for blur background')
}
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
@@ -293,7 +293,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
await this.sizeSource()
await this.segment()
if (this.options.type === 'blur') {
if (this.options.blurRadius) {
await this.blur()
} else {
await this.drawVirtualBackground()
@@ -354,4 +354,15 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.timerWorker?.terminate()
this.imageSegmenter?.close()
}
clone() {
return new BackgroundCustomProcessor(this.options)
}
serialize() {
return {
type: this.type,
options: this.options,
}
}
}
@@ -332,4 +332,15 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
this.timerWorker?.terminate()
this.faceLandmarker?.close()
}
clone() {
return new FaceLandmarksProcessor(this.options)
}
serialize() {
return {
type: this.type,
options: this.options,
}
}
}
@@ -1,23 +1,28 @@
import { ProcessorOptions, Track } from 'livekit-client'
import {
BackgroundBlur,
BackgroundTransformer,
ProcessorWrapper,
VirtualBackground,
} from '@livekit/track-processors'
import { ProcessorConfig, BackgroundProcessorInterface, ProcessorType } from '.'
import {
BackgroundOptions,
BackgroundProcessorInterface,
ProcessorType,
} from '.'
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
processor: ProcessorWrapper<{ imagePath?: string; blurRadius?: number }>
opts: ProcessorConfig
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
processorType: ProcessorType
constructor(opts: ProcessorConfig) {
constructor(opts: BackgroundOptions) {
this.opts = opts
if (opts.type === 'virtual') {
if (opts.imagePath) {
this.processorType = ProcessorType.VIRTUAL
this.processor = VirtualBackground(opts.imagePath)
} else if (opts.type === 'blur') {
} else if (opts.blurRadius !== undefined) {
this.processorType = ProcessorType.BLUR
this.processor = BackgroundBlur(opts.blurRadius)
} else {
@@ -39,23 +44,24 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
return this.processor.destroy()
}
async update(opts: ProcessorConfig): Promise<void> {
this.opts = opts
const newProcessorType =
opts.type === 'virtual' ? ProcessorType.VIRTUAL : ProcessorType.BLUR
async update(opts: BackgroundOptions): Promise<void> {
const newProcessorType = opts.imagePath
? ProcessorType.VIRTUAL
: ProcessorType.BLUR
let processedOpts = opts
if (newProcessorType !== this.processorType) {
this.processorType = newProcessorType
if (newProcessorType === ProcessorType.VIRTUAL) {
this.processor.name = 'virtual-background'
processedOpts = { ...opts, blurRadius: undefined }
} else {
this.processor.name = 'background-blur'
processedOpts = { ...opts, imagePath: undefined }
}
}
await this.processor.updateTransformerOptions(
opts as { imagePath?: string; blurRadius?: number }
)
await this.processor.updateTransformerOptions(processedOpts)
this.opts = processedOpts
}
get name() {
@@ -67,6 +73,17 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
}
get options() {
return this.opts
return (this.processor.transformer as BackgroundTransformer).options
}
clone() {
return new UnifiedBackgroundTrackProcessor(this.options || this.opts)
}
serialize() {
return {
type: this.processorType,
options: this.options,
}
}
}
@@ -2,7 +2,23 @@ import { ProcessorWrapper } from '@livekit/track-processors'
import { Track, TrackProcessor } from 'livekit-client'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
export type BackgroundOptions = {
blurRadius?: number
imagePath?: string
}
export interface ProcessorSerialized {
type: ProcessorType
options: BackgroundOptions
}
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): Promise<void>
options: BackgroundOptions
clone(): BackgroundProcessorInterface
serialize(): ProcessorSerialized
}
export enum ProcessorType {
BLUR = 'blur',
@@ -10,16 +26,6 @@ export enum ProcessorType {
FACE_LANDMARKS = 'faceLandmarks',
}
export type ProcessorConfig =
| { type: ProcessorType.BLUR; blurRadius: number }
| { type: ProcessorType.VIRTUAL; imagePath: string; fileId?: string }
| ({ type: ProcessorType.FACE_LANDMARKS } & FaceLandmarksOptions)
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
update(opts: ProcessorConfig): Promise<void>
options: ProcessorConfig
}
export class BackgroundProcessorFactory {
static hasModernApiSupport() {
return ProcessorWrapper.hasModernApiSupport
@@ -30,27 +36,28 @@ export class BackgroundProcessorFactory {
}
static getProcessor(
config: ProcessorConfig
type: ProcessorType,
opts: BackgroundOptions
): BackgroundProcessorInterface | undefined {
const isBlur = config.type === ProcessorType.BLUR
const isVirtual = config.type === ProcessorType.VIRTUAL
const isBlur = type === ProcessorType.BLUR
const isVirtual = type === ProcessorType.VIRTUAL
if (!isBlur && !isVirtual) return undefined
if (ProcessorWrapper.isSupported) {
return new UnifiedBackgroundTrackProcessor(config)
return new UnifiedBackgroundTrackProcessor(opts)
}
if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(config)
return new BackgroundCustomProcessor(opts)
}
return undefined
}
static fromProcessorConfig(data?: ProcessorConfig) {
if (data) {
return BackgroundProcessorFactory.getProcessor(data)
static deserializeProcessor(data?: ProcessorSerialized) {
if (data?.type) {
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
}
return undefined
}
@@ -69,10 +69,10 @@ const SelectDevicePermissions = <T extends string | number>({
iconComponent={iconComponent}
placeholder={items.length === 0 ? t('loading') : t('select')}
selectedKey={selectedKey}
onSelectionChange={async (key) => {
onSelectionChange={(key) => {
if (key === selectedKey) return
await setActiveMediaDevice(key as string)
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
{...props}
/>
@@ -82,8 +82,8 @@ export const VideoDeviceControl = ({
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.fromProcessorConfig(
userChoices.processorConfig
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
)
const toggle = trackProps.toggle as (
@@ -5,15 +5,11 @@ import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export const ScreenRecordingMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { isScreenRecordingOpen, openScreenRecording, toggleTools } =
useSidePanel()
const roomData = useRoomData()
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
@@ -25,12 +21,9 @@ export const ScreenRecordingMenuItem = () => {
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
isDisabled={isEncrypted}
onAction={() => {
if (isEncrypted) return
if (!isScreenRecordingOpen) openScreenRecording()
else toggleTools()
}}
onAction={() =>
!isScreenRecordingOpen ? openScreenRecording() : toggleTools()
}
>
<RiRecordCircleLine size={20} />
{t('screenRecording')}
@@ -5,14 +5,10 @@ import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export const TranscriptMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel()
const roomData = useRoomData()
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
@@ -24,12 +20,7 @@ export const TranscriptMenuItem = () => {
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
isDisabled={isEncrypted}
onAction={() => {
if (isEncrypted) return
if (!isTranscriptOpen) openTranscript()
else toggleTools()
}}
onAction={() => (!isTranscriptOpen ? openTranscript() : toggleTools())}
>
<RiFileTextLine size={20} />
{t('transcript')}
@@ -13,7 +13,7 @@ import {
useTrackMutedIndicator,
} from '@livekit/components-react'
import Source = Track.Source
import { RiErrorWarningLine, RiMicFill, RiMicOffFill } from '@remixicon/react'
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button } from '@/primitives'
import { useState } from 'react'
import { MuteAlertDialog } from '../../MuteAlertDialog'
@@ -21,8 +21,6 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
import { PinBadge } from './PinBadge'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useUser } from '@/features/auth'
type MicIndicatorProps = {
participant: Participant
@@ -99,16 +97,7 @@ export const ParticipantListItem = ({
participant,
}: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const { isLoggedIn } = useUser()
const name = participant.name || participant.identity
const isParticipantAuthenticated =
participant.attributes?.is_authenticated === 'true'
const anonymousLabel = t('identity.anonymous.tooltip')
// Email is only displayed to authenticated viewers (defense-in-depth on
// top of the JWT-level guarantee that it's only emitted for authenticated
// participants). The LK signaling channel broadcasts attributes to every
// peer, so the UI is what protects anonymous viewers from seeing it.
const email = isLoggedIn ? participant.attributes?.email : undefined
return (
<HStack
role="listitem"
@@ -129,55 +118,39 @@ export const ParticipantListItem = ({
<PinBadge participant={participant} />
</div>
<VStack gap={0} alignItems="start">
<HStack gap="0.2rem" alignItems="center">
<Text
variant="sm"
margin={false}
<Text
variant="sm"
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '150px',
lineHeight: 1.2,
maxWidth: '120px',
display: 'block',
})}
>
{name}
{isLocal(participant) && ` (${t('participants.you')})`}
</Text>
{!isParticipantAuthenticated && (
<VisualOnlyTooltip
tooltip={anonymousLabel}
ariaLabel={anonymousLabel}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
cursor: 'help',
})}
>
<RiErrorWarningLine size={14} color="#dc2626" />
</span>
</VisualOnlyTooltip>
({t('participants.you')})
</span>
)}
</HStack>
</Text>
{getParticipantIsRoomAdmin(participant) && (
<Text variant="xsNote">{t('participants.host')}</Text>
)}
{email && (
<Text
variant="xsNote"
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '180px',
})}
>
{email}
</Text>
)}
</VStack>
</HStack>
<HStack>
@@ -1,11 +1,10 @@
import { Button, Text } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Avatar } from '@/components/Avatar'
import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { RiCloseLine, RiErrorWarningLine } from '@remixicon/react'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { RiCloseLine } from '@remixicon/react'
export const WaitingParticipantListItem = ({
participant,
@@ -15,7 +14,6 @@ export const WaitingParticipantListItem = ({
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
}) => {
const { t } = useTranslation('rooms')
const anonymousLabel = t('identity.anonymous.tooltip')
return (
<HStack
@@ -32,50 +30,38 @@ export const WaitingParticipantListItem = ({
className={css({
flex: '1',
minWidth: '0',
gap: '0.35rem',
})}
>
<Avatar name={participant.username} bgColor={participant.color} />
<VStack
gap={0}
alignItems="start"
className={css({ flex: 1, minWidth: 0 })}
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
flex: '1',
minWidth: '0',
})}
>
<HStack gap="0.2rem" alignItems="center">
<Text
variant="sm"
margin={false}
className={css({
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: '0.1rem 0.25rem',
lineHeight: 1.2,
})}
>
{participant.username}
</Text>
{!participant.is_authenticated && (
<VisualOnlyTooltip
tooltip={anonymousLabel}
ariaLabel={anonymousLabel}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
cursor: 'help',
})}
>
<RiErrorWarningLine size={14} color="#dc2626" />
</span>
</VisualOnlyTooltip>
)}
</HStack>
</VStack>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
display: 'block',
})}
>
{participant.username}
</span>
</Text>
</HStack>
<HStack gap="0.25rem" className={css({ flexShrink: '0' })}>
<HStack
gap="0.25rem"
className={css({
flexShrink: '0',
})}
>
<Button
size="sm"
variant="tertiary"
@@ -2,12 +2,14 @@ import { useLocalParticipant } from '@livekit/components-react'
import { LocalVideoTrack } from 'livekit-client'
import { css } from '@/styled-system/css'
import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
import { TrackSource } from '@livekit/protocol'
export const Effects = () => {
const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices()
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
@@ -22,6 +24,9 @@ export const Effects = () => {
isDisabled={!canPublishCamera}
videoTrack={localCameraTrack}
layout="vertical"
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
)
File diff suppressed because it is too large Load Diff
@@ -27,7 +27,7 @@ export const FunnyEffects = ({
showFrench: false,
}
}
return { ...processor.options }
return processor.serialize().options
}
const options = getOptions()
@@ -2,15 +2,12 @@ import { useTelephony } from './useTelephony'
import { useTranslation } from 'react-i18next'
import { useEffect, useMemo, useState } from 'react'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { ApiRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { getRouteUrl } from '@/navigation/getRouteUrl'
const COPY_SUCCESS_TIMEOUT = 3000
export const useCopyRoomToClipboard = (
room: ApiRoom | undefined,
hashOverride?: string
) => {
export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
const telephony = useTelephony()
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
@@ -35,23 +32,12 @@ export const useCopyRoomToClipboard = (
}, [isRoomUrlCopied])
const roomUrl = useMemo(() => {
if (!room?.slug) return ''
const base = getRouteUrl('room', room.slug)
// In basic encrypted mode, the passphrase is in the URL hash
const hash = hashOverride ? `#${hashOverride}` : window.location.hash
return hash ? `${base}${hash}` : base
}, [room?.slug, hashOverride])
return room?.slug ? getRouteUrl('room', room.slug) : ''
}, [room?.slug])
// Encrypted rooms never get a dispatch rule on the SIP gateway side
// (the backend skips it because no PIN-driven join can ever decrypt),
// so make sure we don't paste a non-functional phone+PIN snippet either.
const hasTelephonyInfo = useMemo(() => {
return (
telephony.enabled &&
room?.pin_code &&
room?.encryption_mode !== ApiEncryptionMode.BASIC
)
}, [telephony.enabled, room?.pin_code, room?.encryption_mode])
return telephony.enabled && room?.pin_code
}, [telephony.enabled, room?.pin_code])
const content = useMemo(() => {
if (!roomUrl || !room) return ''
@@ -1,7 +1,7 @@
import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices'
import type { VideoResolution } from '@/stores/userChoices'
import { ProcessorConfig } from '@/features/rooms/livekit/components/blur'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
import type { VideoQuality } from 'livekit-client'
export function usePersistentUserChoices() {
@@ -36,8 +36,10 @@ export function usePersistentUserChoices() {
saveNoiseReductionEnabled: (enabled: boolean) => {
userChoicesStore.noiseReductionEnabled = enabled
},
saveProcessorConfig: (processorConfig: ProcessorConfig | undefined) => {
userChoicesStore.processorConfig = processorConfig
saveProcessorSerialized: (
processorSerialized: ProcessorSerialized | undefined
) => {
userChoicesStore.processorSerialized = processorSerialized
},
}
}
@@ -42,7 +42,6 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
import { CarouselLayout } from '../components/layout/CarouselLayout'
import { GridLayout } from '../components/layout/GridLayout'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { RoomStatusBanner } from '@/features/encryption'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
@@ -277,7 +276,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<RoomStatusBanner />
<div
// todo - extract these magic values into constant
style={{
@@ -5,16 +5,10 @@ export const flexibleRoomIdPattern =
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
const roomRegex = new RegExp(`^${roomIdPattern}$`)
const roomWithoutHyphensRegex = /^[a-z]{10}$/
export const isRoomValid = (roomIdOrUrl: string) => {
const lower = roomIdOrUrl.toLowerCase()
return (
roomRegex.test(lower) ||
roomWithoutHyphensRegex.test(lower) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}`).test(roomIdOrUrl)
)
}
export const isRoomValid = (roomIdOrUrl: string) =>
roomRegex.test(roomIdOrUrl) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
export const normalizeRoomId = (roomId: string) => {
const cleanId = roomId.toLowerCase().replace(/-/g, '')
@@ -39,24 +39,18 @@ export const CreateMeetingButton = () => {
const { data } = useRoomCreationCallback({ callbackId })
const [basicHash, setBasicHash] = useState<string | undefined>(undefined)
const roomUrl = useMemo(() => {
if (!room?.slug) return undefined
const base = getRouteUrl('room', room.slug)
return basicHash ? `${base}#${basicHash}` : base
}, [room, basicHash])
if (room?.slug) return getRouteUrl('room', room.slug)
}, [room])
useEffect(() => {
if (!data?.room?.slug) return
setRoom(data.room)
setCallbackId(undefined)
setIsPending(false)
const url = getRouteUrl('room', data.room.slug)
popupManager.sendRoomData({
room: {
url,
url: getRouteUrl('room', data.room.slug),
...data.room,
},
})
@@ -67,7 +61,6 @@ export const CreateMeetingButton = () => {
(id) => setCallbackId(id),
(data) => {
setRoom(data)
if (data.hash) setBasicHash(data.hash)
setIsPending(false)
}
)
@@ -75,25 +68,6 @@ export const CreateMeetingButton = () => {
return () => popupManager.cleanup()
}, [])
// Communicate iframe height to parent for proper sizing
useEffect(() => {
const targetOrigin = (() => {
try {
return document.referrer ? new URL(document.referrer).origin : '/'
} catch {
return '/'
}
})()
const observer = new ResizeObserver(() => {
window.parent.postMessage(
{ type: 'RESIZE', data: { height: document.body.scrollHeight } },
targetOrigin
)
})
observer.observe(document.body)
return () => observer.disconnect()
}, [])
const resetState = () => {
setRoom(undefined)
setCallbackId(undefined)
@@ -1,77 +1,64 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEffect, useMemo } from 'react'
import { css } from '@/styled-system/css'
import { generateRoomId, useCreateRoom } from '../../rooms'
import { useUser } from '@/features/auth'
import { Spinner } from '@/primitives/Spinner'
import { Button, Text } from '@/primitives'
import { VStack } from '@/styled-system/jsx'
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
import { PopupWindow } from '../utils/PopupWindow'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { RiVideoOnLine } from '@remixicon/react'
const callbackIdHandler = new CallbackIdHandler()
const popupWindow = new PopupWindow()
export const CreatePopup = () => {
const { isLoggedIn } = useUser({
fetchUserOptions: { attemptSilent: false },
})
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
const { mutateAsync: createRoom } = useCreateRoom()
const { t } = useTranslation('sdk', { keyPrefix: 'createPopup' })
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
const [isCreating, setIsCreating] = useState(false)
/**
* Handle unauthenticated users by redirecting to login
*
* When redirecting to authentication, the window.location change breaks the connection
* between this popup and its parent window. We need to send the callbackId to the parent
* before redirecting so it can re-establish connection after authentication completes.
* This prevents the popup from becoming orphaned and ensures state consistency.
*/
useEffect(() => {
if (isLoggedIn === false) {
popupWindow.navigateToAuthentication()
}
}, [isLoggedIn])
const handleCreate = async () => {
setIsCreating(true)
try {
const slug = generateRoomId()
const roomData = await createRoom({
slug,
encryptionMode: ApiEncryptionMode.NONE,
// redirection loses the connection to the manager
// prevent it passing an async callback id
popupWindow.sendCallbackId(callbackId, () => {
popupWindow.navigateToAuthentication()
})
popupWindow.sendRoomData({ slug: roomData.slug }, () => {
callbackIdHandler.clear()
popupWindow.close()
})
} catch (error) {
console.error('Failed to create meeting room:', error)
setIsCreating(false)
}
}
}, [isLoggedIn, callbackId])
/**
* Automatically create meeting room once user is authenticated
* This effect will trigger either immediately if the user is already logged in,
* or after successful authentication and return to this popup
*/
useEffect(() => {
if (isLoggedIn && !isCreating && callbackId) {
void handleCreate()
const createMeetingRoom = async () => {
try {
const slug = generateRoomId()
const roomData = await createRoom({
slug,
callbackId,
})
// Send room data back to parent window and clean up resources
popupWindow.sendRoomData(roomData, () => {
callbackIdHandler.clear()
popupWindow.close()
})
} catch (error) {
console.error('Failed to create meeting room:', error)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoggedIn])
if (!isLoggedIn || isCreating) {
return (
<div
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
width: '100%',
})}
>
<Spinner />
</div>
)
}
if (isLoggedIn && callbackId) {
createMeetingRoom()
}
}, [isLoggedIn, callbackId, createRoom])
return (
<div
@@ -81,26 +68,9 @@ export const CreatePopup = () => {
alignItems: 'center',
height: '100%',
width: '100%',
padding: '2rem',
})}
>
<VStack gap="0.75rem" alignItems="stretch" maxWidth="22rem" width="100%">
<Text
variant="sm"
bold
className={css({
textAlign: 'center',
fontSize: '1.1rem',
marginBottom: '0.5rem',
})}
>
{t('title')}
</Text>
<Button variant="primary" fullWidth onPress={handleCreate}>
<RiVideoOnLine size={18} />
{t('create')}
</Button>
</VStack>
<Spinner />
</div>
)
}
@@ -56,21 +56,16 @@ export class PopupManager {
case PopupMessageType.CALLBACK_ID:
onCallbackId(data.callbackId as string)
return
case PopupMessageType.ROOM_DATA: {
case PopupMessageType.ROOM_DATA:
if (!data?.room) return
onRoomData(data.room)
const baseUrl = getRouteUrl('room', data.room.slug)
const roomUrl = data.room.hash
? `${baseUrl}#${data.room.hash}`
: baseUrl
this.sendRoomData({
room: {
url: roomUrl,
url: getRouteUrl('room', data.room.slug),
...data.room,
},
})
return
}
}
}
window.addEventListener('message', this.messageHandler)
@@ -27,7 +27,7 @@ export class PopupWindow {
public sendRoomData(data: CallbackCreationRoomData, callback?: () => void) {
this.sendMessageToManager(
PopupMessageType.ROOM_DATA,
{ room: { slug: data.slug, hash: data.hash } },
{ room: { slug: data.slug } },
callback
)
}
@@ -1,12 +1,10 @@
export type CallbackCreationRoomData = {
slug: string
hash?: string
}
export enum ClientMessageType {
ROOM_CREATED = 'ROOM_CREATED',
STATE_CLEAR = 'STATE_CLEAR',
RESIZE = 'RESIZE',
}
export interface PopupMessageData {
@@ -1,95 +0,0 @@
/**
* "Encrypt new meetings by default" toggle used both in the in-meeting
* Security tab and the home-page SettingsDialog. Flipping it directly
* patches the user preference the per-meeting CreateEncryptedMeeting
* dialog already surfaces the disabled-features warning when needed.
*/
import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import { A, Field, Text } from '@/primitives'
import { useUser } from '@/features/auth'
import { updateUserPreferences } from '@/features/auth/api/updateUserPreferences'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export const EncryptionDefaultField = () => {
const { t } = useTranslation('settings', { keyPrefix: 'security' })
const { user, isLoggedIn } = useUser()
const { data: config } = useConfig()
const isFeatureEnabled = !!config?.encryption?.enabled
const { mutateAsync, isPending } = useMutation({
mutationFn: updateUserPreferences,
onSuccess: (updatedUser) => {
queryClient.setQueryData([keys.user], updatedUser)
},
})
const isOn = user?.default_encryption_mode === ApiEncryptionMode.BASIC
const handleToggle = async (next: boolean) => {
if (!user) return
try {
await mutateAsync({
user: {
id: user.id,
default_encryption_mode: next
? ApiEncryptionMode.BASIC
: ApiEncryptionMode.NONE,
},
})
} catch {
// react-query stores the error on the mutation; the UI shows
// isPending while in flight and reverts the toggle on failure.
}
}
if (!isLoggedIn) {
return (
<>
<Text variant="note" margin={false}>
{t('signInRequired')}
</Text>
<LoginButton />
</>
)
}
if (!isFeatureEnabled) {
return (
<Text variant="note" margin={false}>
{t('featureDisabled')}
</Text>
)
}
return (
<Field
type="switch"
label={t('encryption.label')}
description={
<>
{t('encryption.description')}{' '}
{config?.support?.help_article_encryption && (
<A
href={config.support.help_article_encryption}
target="_blank"
rel="noopener noreferrer"
externalIcon
color="note"
>
{t('encryption.learnMore')}
</A>
)}
</>
}
isSelected={isOn}
isDisabled={isPending}
onChange={handleToggle}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
)
}
@@ -1,18 +1,8 @@
import { Trans, useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import {
A,
Badge,
Dialog,
type DialogProps,
Field,
H,
P,
Text,
} from '@/primitives'
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
import { useUser } from '@/features/auth'
import { LoginButton } from '@/components/LoginButton'
import { EncryptionDefaultField } from './EncryptionDefaultField'
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
@@ -56,13 +46,6 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
i18n.changeLanguage(lang as string)
}}
/>
<H lvl={2} margin={false}>
{t('security.heading')}
</H>
<Text variant="note" margin="md">
{t('security.subtitle')}
</Text>
<EncryptionDefaultField />
</Dialog>
)
}

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