mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70e021965a | |||
| deea80864a | |||
| 8fb4954c7d | |||
| b60193065c | |||
| 6410665c5d | |||
| b1be05dddb | |||
| ef8eda66d6 | |||
| 32ecc3836e | |||
| cddd9e3cd5 | |||
| 9abb560d3c | |||
| 0c21978ca4 | |||
| 7105c6bc70 | |||
| 267b39a975 | |||
| d7ce25b1b5 | |||
| 316008016c | |||
| 191adc0499 | |||
| 854792e2ef | |||
| 547e93b9f1 | |||
| d919cd8097 | |||
| 768b5d7680 | |||
| 5376d39111 | |||
| 58bc6398eb |
@@ -223,6 +223,8 @@ jobs:
|
|||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
REDIS_URL: redis://localhost:6379/1
|
REDIS_URL: redis://localhost:6379/1
|
||||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
|
LIVEKIT_API_SECRET: secret
|
||||||
|
LIVEKIT_API_KEY: devkey
|
||||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||||
AWS_S3_ACCESS_KEY_ID: meet
|
AWS_S3_ACCESS_KEY_ID: meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
|
|||||||
@@ -8,14 +8,9 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [1.13.0] - 2026-03-31
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ⬆️(dependencies) update python dependencies
|
- ⬆️(dependencies) update python dependencies
|
||||||
- ♿️(frontend) add explicit region for call controls #1216
|
|
||||||
- ♿️(frontend) improve accessibility of the reaction toolbar #1216
|
|
||||||
- ♿️(frontend) enhance sidepanel navigation accessibility #1216
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ create-env-files: \
|
|||||||
env.d/development/crowdin \
|
env.d/development/crowdin \
|
||||||
env.d/development/postgresql \
|
env.d/development/postgresql \
|
||||||
env.d/development/kc_postgresql \
|
env.d/development/kc_postgresql \
|
||||||
env.d/development/summary \
|
env.d/development/summary
|
||||||
env.d/development/kube-secret
|
|
||||||
.PHONY: create-env-files
|
.PHONY: create-env-files
|
||||||
|
|
||||||
bootstrap: ## Prepare Docker images for the project
|
bootstrap: ## Prepare Docker images for the project
|
||||||
@@ -266,9 +265,6 @@ env.d/development/kc_postgresql:
|
|||||||
env.d/development/summary:
|
env.d/development/summary:
|
||||||
cp -n env.d/development/summary.dist env.d/development/summary
|
cp -n env.d/development/summary.dist env.d/development/summary
|
||||||
|
|
||||||
env.d/development/kube-secret:
|
|
||||||
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
|
|
||||||
|
|
||||||
# -- Internationalization
|
# -- Internationalization
|
||||||
|
|
||||||
env.d/development/crowdin:
|
env.d/development/crowdin:
|
||||||
@@ -363,6 +359,10 @@ install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
|||||||
./bin/install-external-secrets.sh
|
./bin/install-external-secrets.sh
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
|
start-tilt: ## start the kubernetes cluster using kind
|
||||||
|
tilt up --namespace=meet -f ./bin/Tiltfile
|
||||||
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||||
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
|
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/suitenumerique/meet/stargazers/">
|
<a href="https://github.com/suitenumerique/meet/stargazers/">
|
||||||
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
|
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
|
||||||
@@ -12,11 +11,11 @@
|
|||||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
||||||
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
||||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -28,25 +27,75 @@
|
|||||||
## La Suite Meet: Simple Video Conferencing
|
## La Suite Meet: Simple Video Conferencing
|
||||||
|
|
||||||
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Optimized for stability in large meetings (+100 p.)
|
- Optimized for stability in large meetings (+100 p.)
|
||||||
- Support for multiple screen sharing streams
|
- Support for multiple screen sharing streams
|
||||||
- Non-persistent, secure chat
|
- Non-persistent, secure chat
|
||||||
- End-to-end encryption (coming soon)
|
- End-to-end encryption with hybrid key distribution
|
||||||
- Meeting recording
|
- Meeting recording
|
||||||
- Meeting transcription & Summary (currently in beta)
|
- Meeting transcription & Summary (currently in beta)
|
||||||
- Telephony integration
|
- Telephony integration
|
||||||
- Secure participation with robust authentication and access control
|
- Secure participation with robust authentication and access control
|
||||||
- Customizable frontend style
|
- Customizable frontend style
|
||||||
- LiveKit Advances features including :
|
- LiveKit Advances features including :
|
||||||
- speaker detection
|
- speaker detection
|
||||||
- simulcast
|
- simulcast
|
||||||
- end-to-end optimizations
|
- end-to-end optimizations
|
||||||
- selective subscription
|
- selective subscription
|
||||||
- SVC codecs (VP9, AV1)
|
- SVC codecs (VP9, AV1)
|
||||||
|
|
||||||
|
### End-to-end encryption
|
||||||
|
|
||||||
La Suite Meet 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 supports end-to-end encryption (E2EE) for meetings, ensuring that the media server (LiveKit SFU) cannot access audio/video content. Two encryption modes are available:
|
||||||
|
|
||||||
|
#### Basic encryption
|
||||||
|
|
||||||
|
- Passphrase-based — the encryption key is embedded in the meeting URL hash (`#passphrase`)
|
||||||
|
- Uses LiveKit's built-in Worker + `crypto.subtle` (AES-GCM) for frame encryption
|
||||||
|
- Sharing the meeting link shares the encryption key
|
||||||
|
- No account or onboarding required
|
||||||
|
- Security depends on keeping the link private
|
||||||
|
|
||||||
|
#### Advanced encryption
|
||||||
|
|
||||||
|
- Key managed by [La Suite Encryption](https://github.com/suitenumerique/encryption) — the symmetric key never leaves the vault iframe
|
||||||
|
- Uses XChaCha20-Poly1305 (libsodium) via the VaultClient iframe for frame encryption
|
||||||
|
- Key distribution uses `vaultClient.shareKeys()` (hybrid PKI with X25519 + post-quantum slot)
|
||||||
|
- All participants must complete encryption onboarding (key generation + backup) before joining
|
||||||
|
- Requires a Chromium-based browser (Chrome, Edge, Brave) — uses the Insertable Streams API
|
||||||
|
|
||||||
|
**Frame encryption (both modes):**
|
||||||
|
|
||||||
|
- Codec header bytes (VP8 payload descriptor) are preserved unencrypted — required for proper RTP packetization
|
||||||
|
- Only the media payload is encrypted, with a per-frame random nonce
|
||||||
|
- The server (LiveKit SFU) only forwards encrypted data it cannot read
|
||||||
|
|
||||||
|
**Trust levels (advanced mode):**
|
||||||
|
| Badge | Level | Description |
|
||||||
|
|-------|-------|-------------|
|
||||||
|
| 🟢 Green shield | Verified | User completed encryption onboarding (public key registered). Identity cryptographically verified. |
|
||||||
|
| 🔵 Blue shield | Authenticated | User signed in via ProConnect/OIDC. Identity server-verified. |
|
||||||
|
| 🟡 Orange warning | Anonymous | User not signed in. Self-declared name. Admin should verify identity before accepting. |
|
||||||
|
|
||||||
|
**Security guarantees:**
|
||||||
|
|
||||||
|
- Encrypted rooms enforce restricted access (lobby approval required)
|
||||||
|
- Trust information (`is_authenticated`, `email`) comes from server-signed JWT tokens — cannot be spoofed
|
||||||
|
- Recording and transcription are not available in encrypted rooms (server cannot decrypt media)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENCRYPTION_ENABLED=true
|
||||||
|
ENCRYPTION_VAULT_URL=https://data.encryption.example.fr
|
||||||
|
ENCRYPTION_INTERFACE_URL=https://encryption.example.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
When the encryption service is deployed and configured, rooms can use advanced encryption. Without it, only basic (passphrase) encryption is available.
|
||||||
|
|
||||||
|
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||||
|
|
||||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||||
|
|
||||||
@@ -63,7 +112,6 @@ On the 25th of January 2026, David Amiel, France’s Minister for Civil Service
|
|||||||
- [Philosophy](#philosophy)
|
- [Philosophy](#philosophy)
|
||||||
- [Open source](#open-source)
|
- [Open source](#open-source)
|
||||||
|
|
||||||
|
|
||||||
## Get started
|
## Get started
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
@@ -82,15 +130,15 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
|
|||||||
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
|
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
|
||||||
|
|
||||||
#### Known instances
|
#### Known instances
|
||||||
|
|
||||||
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
|
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
|
||||||
|
|
||||||
| Url | Org | Access |
|
| Url | Org | Access |
|
||||||
|---------------------------------------------------------------| --- | ------- |
|
| ------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -100,7 +148,6 @@ We <3 contributions of any kind, big and small:
|
|||||||
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
|
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
|
||||||
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
||||||
|
|
||||||
|
|
||||||
## Philosophy
|
## Philosophy
|
||||||
|
|
||||||
We’re relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
|
We’re relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
|
||||||
@@ -109,7 +156,6 @@ Our users come first. We’re committed to making La Suite Meet as accessible an
|
|||||||
|
|
||||||
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
|
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
|
||||||
|
|
||||||
|
|
||||||
## Open-source
|
## Open-source
|
||||||
|
|
||||||
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
|
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
|
||||||
@@ -121,14 +167,13 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
|
|||||||
|
|
||||||
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
|
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
|
||||||
|
|
||||||
|
|
||||||
## Contributors 🧞
|
## Contributors 🧞
|
||||||
|
|
||||||
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
|
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
|
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
|
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
|
||||||
This project is tested with BrowserStack.
|
This project is tested with BrowserStack.
|
||||||
@@ -137,4 +182,3 @@ This project is tested with BrowserStack.
|
|||||||
|
|
||||||
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
|
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
|
||||||
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
|
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
|
||||||
|
|
||||||
|
|||||||
+2
-8
@@ -2,7 +2,7 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
|||||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||||
namespace_create('meet')
|
namespace_create('meet')
|
||||||
|
|
||||||
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
DEV_ENV = os.getenv('DEV_ENV', 'dev')
|
||||||
|
|
||||||
if DEV_ENV == 'dev-dinum':
|
if DEV_ENV == 'dev-dinum':
|
||||||
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
|
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
|
||||||
@@ -95,13 +95,7 @@ docker_build(
|
|||||||
)
|
)
|
||||||
clean_old_images('localhost:5001/meet-livekit')
|
clean_old_images('localhost:5001/meet-livekit')
|
||||||
|
|
||||||
load('ext://secret', 'secret_yaml_generic')
|
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||||
k8s_yaml(secret_yaml_generic(
|
|
||||||
name="secret-dev",
|
|
||||||
from_env_file="../env.d/development/kube-secret"
|
|
||||||
))
|
|
||||||
|
|
||||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
|
||||||
|
|
||||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||||
|
|||||||
+20
-3
@@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-chromium",
|
"username": "user-e2e-chromium",
|
||||||
"email": "user@chromium.e2e",
|
"email": "user.test@chromium.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Chromium",
|
"lastName": "Chromium",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-webkit",
|
"username": "user-e2e-webkit",
|
||||||
"email": "user@webkit.e2e",
|
"email": "user.test@webkit.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Webkit",
|
"lastName": "Webkit",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-firefox",
|
"username": "user-e2e-firefox",
|
||||||
"email": "user@firefox.e2e",
|
"email": "user.test@firefox.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Firefox",
|
"lastName": "Firefox",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -845,6 +845,23 @@
|
|||||||
"offline_access",
|
"offline_access",
|
||||||
"microprofile-jwt"
|
"microprofile-jwt"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "encryption",
|
||||||
|
"name": "Encryption Service",
|
||||||
|
"enabled": true,
|
||||||
|
"publicClient": true,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"redirectUris": [
|
||||||
|
"http://encryption.localhost:7200/auth/callback"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"http://encryption.localhost:7200",
|
||||||
|
"http://data.encryption.localhost:7200"
|
||||||
|
],
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"fullScopeAllowed": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"clientScopes": [
|
"clientScopes": [
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
WHISPERX_BASE_URL=https://configure-your-url.com
|
|
||||||
WHISPERX_API_KEY=<key>
|
|
||||||
LLM_BASE_URL=https://configure-your-url.com
|
|
||||||
LLM_API_KEY=<key>
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.13.0"
|
version = "1.12.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.4.5",
|
"livekit-agents==1.4.5",
|
||||||
|
|||||||
@@ -73,5 +73,11 @@ def get_frontend_configuration(request):
|
|||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if settings.ENCRYPTION_ENABLED and settings.ENCRYPTION_VAULT_URL:
|
||||||
|
frontend_configuration["encryption"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"vault_url": settings.ENCRYPTION_VAULT_URL,
|
||||||
|
"interface_url": settings.ENCRYPTION_INTERFACE_URL,
|
||||||
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
fields = ["id", "sub", "email", "full_name", "short_name", "timezone", "language"]
|
||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["id", "sub", "email", "full_name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
class UserLightSerializer(serializers.ModelSerializer):
|
class UserLightSerializer(serializers.ModelSerializer):
|
||||||
@@ -74,6 +74,23 @@ class ResourceAccessSerializerMixin:
|
|||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
"Only owners of a room can assign other users as owners."
|
"Only owners of a room can assign other users as owners."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# In advanced encrypted rooms, new accesses require an encrypted_symmetric_key
|
||||||
|
# so the new member can decrypt the room's streams. Without it, they'd have
|
||||||
|
# access but no key — which is useless and confusing.
|
||||||
|
# Future: a sharing UI (like Docs) could provide the key via vault shareKeys.
|
||||||
|
if not self.instance and "resource" in data:
|
||||||
|
resource = data["resource"]
|
||||||
|
if (
|
||||||
|
hasattr(resource, 'encryption_mode')
|
||||||
|
and resource.encryption_mode == models.EncryptionMode.ADVANCED
|
||||||
|
and not data.get("encrypted_symmetric_key")
|
||||||
|
):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Adding members to advanced encrypted rooms requires "
|
||||||
|
"an encrypted_symmetric_key for the new user."
|
||||||
|
)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def validate_resource(self, resource):
|
def validate_resource(self, resource):
|
||||||
@@ -98,7 +115,7 @@ class ResourceAccessSerializer(
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.ResourceAccess
|
model = models.ResourceAccess
|
||||||
fields = ["id", "user", "resource", "role"]
|
fields = ["id", "user", "resource", "role", "encrypted_symmetric_key"]
|
||||||
read_only_fields = ["id"]
|
read_only_fields = ["id"]
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
@@ -128,9 +145,27 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Room
|
model = models.Room
|
||||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_mode"]
|
||||||
read_only_fields = ["id", "slug", "pin_code"]
|
read_only_fields = ["id", "slug", "pin_code"]
|
||||||
|
|
||||||
|
def validate_access_level(self, value):
|
||||||
|
"""Encrypted rooms must stay restricted — prevent downgrading access level."""
|
||||||
|
instance = self.instance
|
||||||
|
if instance and instance.encryption_enabled and value != models.RoomAccessLevel.RESTRICTED:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Encrypted rooms require restricted access level to enforce lobby approval."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_encryption_mode(self, value):
|
||||||
|
"""Once encryption is enabled on a room, it cannot be disabled or downgraded."""
|
||||||
|
instance = self.instance
|
||||||
|
if instance and instance.encryption_enabled and value == models.EncryptionMode.NONE:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Encryption cannot be disabled once enabled on a room."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
"""
|
"""
|
||||||
Add users only for administrator users.
|
Add users only for administrator users.
|
||||||
@@ -172,18 +207,34 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
if should_access_room:
|
if should_access_room:
|
||||||
room_id = f"{instance.id!s}"
|
room_id = f"{instance.id!s}"
|
||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
|
|
||||||
|
# In encrypted rooms, authenticated users must use their real name from
|
||||||
|
# the OIDC profile (ProConnect) — they cannot choose an arbitrary name.
|
||||||
|
if instance.encryption_enabled and request.user.is_authenticated:
|
||||||
|
username = request.user.full_name or request.user.email
|
||||||
|
|
||||||
output["livekit"] = utils.generate_livekit_config(
|
output["livekit"] = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
username=username,
|
username=username,
|
||||||
configuration=configuration,
|
configuration=configuration,
|
||||||
is_admin_or_owner=is_admin_or_owner,
|
is_admin_or_owner=is_admin_or_owner,
|
||||||
|
encryption_mode=instance.encryption_mode,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
del output["pin_code"]
|
del output["pin_code"]
|
||||||
|
|
||||||
output["is_administrable"] = is_admin_or_owner
|
output["is_administrable"] = is_admin_or_owner
|
||||||
|
|
||||||
|
# Include the current user's encrypted symmetric key for advanced E2EE
|
||||||
|
if request.user.is_authenticated and instance.encryption_mode == models.EncryptionMode.ADVANCED:
|
||||||
|
try:
|
||||||
|
access = instance.accesses.get(user=request.user)
|
||||||
|
if access.encrypted_symmetric_key:
|
||||||
|
output["encrypted_symmetric_key"] = access.encrypted_symmetric_key
|
||||||
|
except models.ResourceAccess.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
@@ -265,7 +316,8 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
|||||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||||
"""Validate request entry data."""
|
"""Validate request entry data."""
|
||||||
|
|
||||||
username = serializers.CharField(required=True)
|
username = serializers.CharField(required=True, allow_blank=True)
|
||||||
|
ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
|
|
||||||
|
|
||||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||||
@@ -273,6 +325,9 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
|||||||
|
|
||||||
participant_id = serializers.UUIDField(required=True)
|
participant_id = serializers.UUIDField(required=True)
|
||||||
allow_entry = serializers.BooleanField(required=True)
|
allow_entry = serializers.BooleanField(required=True)
|
||||||
|
encrypted_key = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
|
admin_ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
|
encrypted_vault_key = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
|
|
||||||
|
|
||||||
class CreationCallbackSerializer(BaseValidationOnlySerializer):
|
class CreationCallbackSerializer(BaseValidationOnlySerializer):
|
||||||
|
|||||||
@@ -281,11 +281,32 @@ class RoomViewSet(
|
|||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Set the current user as owner of the newly created room."""
|
"""Set the current user as owner of the newly created room."""
|
||||||
|
encryption_mode = serializer.validated_data.get("encryption_mode", models.EncryptionMode.NONE)
|
||||||
|
|
||||||
|
# Block encrypted room creation if encryption is not enabled on this instance
|
||||||
|
if encryption_mode != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
|
||||||
|
raise drf_exceptions.ValidationError(
|
||||||
|
{"encryption_mode": "Encryption is not enabled on this server."}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Advanced encryption requires the vault service to be configured
|
||||||
|
if encryption_mode == models.EncryptionMode.ADVANCED and not getattr(settings, 'ENCRYPTION_VAULT_URL', ''):
|
||||||
|
raise drf_exceptions.ValidationError(
|
||||||
|
{"encryption_mode": "Advanced encryption requires the encryption service to be configured."}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Encrypted rooms must use restricted access to enforce lobby approval
|
||||||
|
# before the encryption key is shared with participants.
|
||||||
|
if encryption_mode != models.EncryptionMode.NONE:
|
||||||
|
serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED
|
||||||
|
|
||||||
room = serializer.save()
|
room = serializer.save()
|
||||||
|
encrypted_symmetric_key = self.request.data.get("encrypted_symmetric_key", "")
|
||||||
models.ResourceAccess.objects.create(
|
models.ResourceAccess.objects.create(
|
||||||
resource=room,
|
resource=room,
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
role=models.RoleChoices.OWNER,
|
role=models.RoleChoices.OWNER,
|
||||||
|
encrypted_symmetric_key=encrypted_symmetric_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
if callback_id := self.request.data.get("callback_id"):
|
if callback_id := self.request.data.get("callback_id"):
|
||||||
@@ -314,6 +335,12 @@ class RoomViewSet(
|
|||||||
options = serializer.validated_data.get("options")
|
options = serializer.validated_data.get("options")
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
if room.encryption_enabled:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"detail": "Recording is not available in encrypted rooms."},
|
||||||
|
status=drf_status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
# May raise exception if an active or initiated recording already exist for the room
|
# May raise exception if an active or initiated recording already exist for the room
|
||||||
recording = models.Recording.objects.create(
|
recording = models.Recording.objects.create(
|
||||||
room=room,
|
room=room,
|
||||||
@@ -396,12 +423,28 @@ class RoomViewSet(
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
|
||||||
|
# Advanced encrypted rooms require authentication
|
||||||
|
if room.encryption_mode == models.EncryptionMode.ADVANCED and not request.user.is_authenticated:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"detail": "This meeting requires authentication to join."},
|
||||||
|
status=drf_status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# In encrypted rooms, authenticated users must use their real name
|
||||||
|
# from the OIDC profile — they cannot choose an arbitrary name.
|
||||||
|
if room.encryption_enabled and request.user.is_authenticated:
|
||||||
|
validated_data["username"] = (
|
||||||
|
request.user.full_name or request.user.email
|
||||||
|
)
|
||||||
|
|
||||||
lobby_service = LobbyService()
|
lobby_service = LobbyService()
|
||||||
|
|
||||||
participant, livekit = lobby_service.request_entry(
|
participant, livekit = lobby_service.request_entry(
|
||||||
room=room,
|
room=room,
|
||||||
request=request,
|
request=request,
|
||||||
**serializer.validated_data,
|
**validated_data,
|
||||||
)
|
)
|
||||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||||
lobby_service.prepare_response(response, participant.id)
|
lobby_service.prepare_response(response, participant.id)
|
||||||
@@ -437,6 +480,9 @@ class RoomViewSet(
|
|||||||
room_id=room.id,
|
room_id=room.id,
|
||||||
participant_id=str(serializer.validated_data.get("participant_id")),
|
participant_id=str(serializer.validated_data.get("participant_id")),
|
||||||
allow_entry=serializer.validated_data.get("allow_entry"),
|
allow_entry=serializer.validated_data.get("allow_entry"),
|
||||||
|
encrypted_key=serializer.validated_data.get("encrypted_key", ''),
|
||||||
|
admin_ephemeral_public_key=serializer.validated_data.get("admin_ephemeral_public_key", ''),
|
||||||
|
encrypted_vault_key=serializer.validated_data.get("encrypted_vault_key", ''),
|
||||||
)
|
)
|
||||||
return drf_response.Response({"message": "Participant was updated."})
|
return drf_response.Response({"message": "Participant was updated."})
|
||||||
|
|
||||||
@@ -464,6 +510,14 @@ class RoomViewSet(
|
|||||||
lobby_service = LobbyService()
|
lobby_service = LobbyService()
|
||||||
|
|
||||||
participants = lobby_service.list_waiting_participants(room.id)
|
participants = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
|
# Only expose email and ephemeral keys in encrypted rooms.
|
||||||
|
# Strip them otherwise to avoid leaking personal data.
|
||||||
|
if not room.encryption_enabled:
|
||||||
|
for p in participants:
|
||||||
|
p.pop("email", None)
|
||||||
|
p.pop("ephemeral_public_key", None)
|
||||||
|
|
||||||
return drf_response.Response({"participants": participants})
|
return drf_response.Response({"participants": participants})
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
@@ -566,6 +620,12 @@ class RoomViewSet(
|
|||||||
|
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
if room.encryption_enabled:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Transcription is not available in encrypted rooms."},
|
||||||
|
status=drf_status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
SubtitleService().start_subtitle(room)
|
SubtitleService().start_subtitle(room)
|
||||||
except SubtitleException:
|
except SubtitleException:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0018_rename_active_application_is_active"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="room",
|
||||||
|
name="encryption_enabled",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether end-to-end encryption is enabled for this room.",
|
||||||
|
verbose_name="Encryption enabled",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Replace encryption_enabled boolean with encryption_mode enum."""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_encryption_enabled_to_mode(apps, schema_editor):
|
||||||
|
"""Convert existing encryption_enabled=True rooms to encryption_mode='basic'."""
|
||||||
|
Room = apps.get_model("core", "Room")
|
||||||
|
Room.objects.filter(encryption_enabled=True).update(encryption_mode="basic")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_mode_to_encryption_enabled(apps, schema_editor):
|
||||||
|
"""Reverse: set encryption_enabled=True for any non-'none' encryption_mode."""
|
||||||
|
Room = apps.get_model("core", "Room")
|
||||||
|
Room.objects.exclude(encryption_mode="none").update(encryption_enabled=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0019_room_encryption_enabled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# 1. Add the new encryption_mode field
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="room",
|
||||||
|
name="encryption_mode",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("none", "No encryption"),
|
||||||
|
("basic", "Basic encryption"),
|
||||||
|
("advanced", "Advanced encryption"),
|
||||||
|
],
|
||||||
|
default="none",
|
||||||
|
help_text="End-to-end encryption mode for this room.",
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="Encryption mode",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# 2. Migrate existing data
|
||||||
|
migrations.RunPython(
|
||||||
|
migrate_encryption_enabled_to_mode,
|
||||||
|
migrate_mode_to_encryption_enabled,
|
||||||
|
),
|
||||||
|
# 3. Remove the old boolean field
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="room",
|
||||||
|
name="encryption_enabled",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Add encrypted_symmetric_key to ResourceAccess for advanced E2EE mode."""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0020_room_encryption_mode"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="resourceaccess",
|
||||||
|
name="encrypted_symmetric_key",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Vault-wrapped symmetric encryption key for advanced E2EE mode. Each user's copy is encrypted for their own vault public key.",
|
||||||
|
verbose_name="Encrypted symmetric key",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -98,6 +98,14 @@ class RoomAccessLevel(models.TextChoices):
|
|||||||
RESTRICTED = "restricted", _("Restricted Access")
|
RESTRICTED = "restricted", _("Restricted Access")
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptionMode(models.TextChoices):
|
||||||
|
"""Encryption mode choices for rooms."""
|
||||||
|
|
||||||
|
NONE = "none", _("No encryption")
|
||||||
|
BASIC = "basic", _("Basic encryption")
|
||||||
|
ADVANCED = "advanced", _("Advanced encryption")
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(models.Model):
|
class BaseModel(models.Model):
|
||||||
"""
|
"""
|
||||||
Serves as an abstract base model for other models, ensuring that records are validated
|
Serves as an abstract base model for other models, ensuring that records are validated
|
||||||
@@ -324,6 +332,15 @@ class ResourceAccess(BaseModel):
|
|||||||
role = models.CharField(
|
role = models.CharField(
|
||||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||||
)
|
)
|
||||||
|
encrypted_symmetric_key = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
verbose_name=_("Encrypted symmetric key"),
|
||||||
|
help_text=_(
|
||||||
|
"Vault-wrapped symmetric encryption key for advanced E2EE mode. "
|
||||||
|
"Each user's copy is encrypted for their own vault public key."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "meet_resource_access"
|
db_table = "meet_resource_access"
|
||||||
@@ -388,6 +405,13 @@ class Room(Resource):
|
|||||||
choices=RoomAccessLevel.choices,
|
choices=RoomAccessLevel.choices,
|
||||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||||
)
|
)
|
||||||
|
encryption_mode = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=EncryptionMode.choices,
|
||||||
|
default=EncryptionMode.NONE,
|
||||||
|
verbose_name=_("Encryption mode"),
|
||||||
|
help_text=_("End-to-end encryption mode for this room."),
|
||||||
|
)
|
||||||
configuration = models.JSONField(
|
configuration = models.JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=dict,
|
default=dict,
|
||||||
@@ -442,6 +466,11 @@ class Room(Resource):
|
|||||||
"""Check if a room is public"""
|
"""Check if a room is public"""
|
||||||
return self.access_level == RoomAccessLevel.PUBLIC
|
return self.access_level == RoomAccessLevel.PUBLIC
|
||||||
|
|
||||||
|
@property
|
||||||
|
def encryption_enabled(self):
|
||||||
|
"""Check if any encryption mode is active."""
|
||||||
|
return self.encryption_mode != EncryptionMode.NONE
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_unique_pin_code(length):
|
def generate_unique_pin_code(length):
|
||||||
"""Generate a unique n-digit PIN code"""
|
"""Generate a unique n-digit PIN code"""
|
||||||
|
|||||||
@@ -46,15 +46,36 @@ class LobbyParticipant:
|
|||||||
username: str
|
username: str
|
||||||
color: str
|
color: str
|
||||||
id: str
|
id: str
|
||||||
|
is_authenticated: bool = False
|
||||||
|
email: Optional[str] = None
|
||||||
|
suite_user_id: Optional[str] = None
|
||||||
|
ephemeral_public_key: str = ''
|
||||||
|
encrypted_key: str = ''
|
||||||
|
admin_ephemeral_public_key: str = ''
|
||||||
|
encrypted_vault_key: str = ''
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, str]:
|
def to_dict(self) -> Dict[str, str]:
|
||||||
"""Serialize the participant object to a dict representation."""
|
"""Serialize the participant object to a dict representation."""
|
||||||
return {
|
result = {
|
||||||
"status": self.status.value,
|
"status": self.status.value,
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
|
"is_authenticated": self.is_authenticated,
|
||||||
}
|
}
|
||||||
|
if self.email:
|
||||||
|
result["email"] = self.email
|
||||||
|
if self.suite_user_id:
|
||||||
|
result["suite_user_id"] = self.suite_user_id
|
||||||
|
if self.ephemeral_public_key:
|
||||||
|
result["ephemeral_public_key"] = self.ephemeral_public_key
|
||||||
|
if self.encrypted_key:
|
||||||
|
result["encrypted_key"] = self.encrypted_key
|
||||||
|
if self.admin_ephemeral_public_key:
|
||||||
|
result["admin_ephemeral_public_key"] = self.admin_ephemeral_public_key
|
||||||
|
if self.encrypted_vault_key:
|
||||||
|
result["encrypted_vault_key"] = self.encrypted_vault_key
|
||||||
|
return result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "LobbyParticipant":
|
def from_dict(cls, data: dict) -> "LobbyParticipant":
|
||||||
@@ -68,6 +89,13 @@ class LobbyParticipant:
|
|||||||
username=data["username"],
|
username=data["username"],
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
color=data["color"],
|
color=data["color"],
|
||||||
|
is_authenticated=data.get("is_authenticated", False),
|
||||||
|
email=data.get("email"),
|
||||||
|
suite_user_id=data.get("suite_user_id"),
|
||||||
|
ephemeral_public_key=data.get("ephemeral_public_key", ''),
|
||||||
|
encrypted_key=data.get("encrypted_key", ''),
|
||||||
|
admin_ephemeral_public_key=data.get("admin_ephemeral_public_key", ''),
|
||||||
|
encrypted_vault_key=data.get("encrypted_vault_key", ''),
|
||||||
)
|
)
|
||||||
except (KeyError, ValueError) as e:
|
except (KeyError, ValueError) as e:
|
||||||
logger.exception("Error creating Participant from dict:")
|
logger.exception("Error creating Participant from dict:")
|
||||||
@@ -99,7 +127,7 @@ class LobbyService:
|
|||||||
key=settings.LOBBY_COOKIE_NAME,
|
key=settings.LOBBY_COOKIE_NAME,
|
||||||
value=participant_id,
|
value=participant_id,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=True,
|
secure=not settings.DEBUG,
|
||||||
samesite="Lax",
|
samesite="Lax",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -111,11 +139,16 @@ class LobbyService:
|
|||||||
1. The room is public (open to everyone)
|
1. The room is public (open to everyone)
|
||||||
2. The room has TRUSTED access level and the user is authenticated
|
2. The room has TRUSTED access level and the user is authenticated
|
||||||
|
|
||||||
|
Encrypted rooms never bypass the lobby — participants must go through
|
||||||
|
the lobby key exchange to receive the encryption key.
|
||||||
|
|
||||||
Note: Room access levels can change while participants are waiting in the lobby.
|
Note: Room access levels can change while participants are waiting in the lobby.
|
||||||
This function only checks the current state and should be called each time
|
This function only checks the current state and should be called each time
|
||||||
a participant requests entry to ensure consistent access control, even for
|
a participant requests entry to ensure consistent access control, even for
|
||||||
participants who have already begun waiting.
|
participants who have already begun waiting.
|
||||||
"""
|
"""
|
||||||
|
if hasattr(room, 'encryption_mode') and room.encryption_mode != 'none':
|
||||||
|
return False
|
||||||
return room.is_public or (
|
return room.is_public or (
|
||||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||||
and user.is_authenticated
|
and user.is_authenticated
|
||||||
@@ -126,6 +159,7 @@ class LobbyService:
|
|||||||
room,
|
room,
|
||||||
request,
|
request,
|
||||||
username: str,
|
username: str,
|
||||||
|
ephemeral_public_key: str = '',
|
||||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||||
"""Request entry to a room for a participant.
|
"""Request entry to a room for a participant.
|
||||||
|
|
||||||
@@ -164,19 +198,42 @@ class LobbyService:
|
|||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
is_admin_or_owner=False,
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
|
encryption_mode=room.encryption_mode,
|
||||||
)
|
)
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
|
|
||||||
livekit_config = None
|
livekit_config = None
|
||||||
|
|
||||||
if participant is None:
|
if participant is None:
|
||||||
participant = self.enter(room.id, participant_id, username)
|
participant = self.enter(
|
||||||
|
room.id, participant_id, username,
|
||||||
|
is_authenticated=request.user.is_authenticated,
|
||||||
|
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
|
||||||
|
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
|
||||||
|
ephemeral_public_key=ephemeral_public_key,
|
||||||
|
)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||||
self.refresh_waiting_status(room.id, participant_id)
|
self.refresh_waiting_status(room.id, participant_id)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||||
# wrongly named, contains access token to join a room
|
# If the joiner comes back with a different ephemeral key (e.g. browser
|
||||||
|
# closed and reopened), they can no longer decrypt the encrypted symmetric
|
||||||
|
# key. Reset them to WAITING so the admin re-accepts with the new key.
|
||||||
|
if (
|
||||||
|
ephemeral_public_key
|
||||||
|
and participant.ephemeral_public_key
|
||||||
|
and ephemeral_public_key != participant.ephemeral_public_key
|
||||||
|
):
|
||||||
|
participant = self.enter(
|
||||||
|
room.id, participant_id, username,
|
||||||
|
is_authenticated=request.user.is_authenticated,
|
||||||
|
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
|
||||||
|
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
|
||||||
|
ephemeral_public_key=ephemeral_public_key,
|
||||||
|
)
|
||||||
|
return participant, None
|
||||||
|
|
||||||
livekit_config = utils.generate_livekit_config(
|
livekit_config = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
@@ -185,6 +242,7 @@ class LobbyService:
|
|||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
is_admin_or_owner=False,
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
|
encryption_mode=room.encryption_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
@@ -201,7 +259,11 @@ class LobbyService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def enter(
|
def enter(
|
||||||
self, room_id: UUID, participant_id: str, username: str
|
self, room_id: UUID, participant_id: str, username: str,
|
||||||
|
is_authenticated: bool = False,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
suite_user_id: Optional[str] = None,
|
||||||
|
ephemeral_public_key: str = '',
|
||||||
) -> LobbyParticipant:
|
) -> LobbyParticipant:
|
||||||
"""Add participant to waiting lobby.
|
"""Add participant to waiting lobby.
|
||||||
|
|
||||||
@@ -216,6 +278,10 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=color,
|
color=color,
|
||||||
|
is_authenticated=is_authenticated,
|
||||||
|
email=email,
|
||||||
|
suite_user_id=suite_user_id,
|
||||||
|
ephemeral_public_key=ephemeral_public_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -284,6 +350,9 @@ class LobbyService:
|
|||||||
room_id: UUID,
|
room_id: UUID,
|
||||||
participant_id: str,
|
participant_id: str,
|
||||||
allow_entry: bool,
|
allow_entry: bool,
|
||||||
|
encrypted_key: str = '',
|
||||||
|
admin_ephemeral_public_key: str = '',
|
||||||
|
encrypted_vault_key: str = '',
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle decision on participant entry.
|
"""Handle decision on participant entry.
|
||||||
|
|
||||||
@@ -302,7 +371,13 @@ class LobbyService:
|
|||||||
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
||||||
}
|
}
|
||||||
|
|
||||||
self._update_participant_status(room_id, participant_id, **decision)
|
self._update_participant_status(
|
||||||
|
room_id, participant_id,
|
||||||
|
encrypted_key=encrypted_key,
|
||||||
|
admin_ephemeral_public_key=admin_ephemeral_public_key,
|
||||||
|
encrypted_vault_key=encrypted_vault_key,
|
||||||
|
**decision,
|
||||||
|
)
|
||||||
|
|
||||||
def _update_participant_status(
|
def _update_participant_status(
|
||||||
self,
|
self,
|
||||||
@@ -310,6 +385,9 @@ class LobbyService:
|
|||||||
participant_id: str,
|
participant_id: str,
|
||||||
status: LobbyParticipantStatus,
|
status: LobbyParticipantStatus,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
|
encrypted_key: str = '',
|
||||||
|
admin_ephemeral_public_key: str = '',
|
||||||
|
encrypted_vault_key: str = '',
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update participant status with appropriate timeout."""
|
"""Update participant status with appropriate timeout."""
|
||||||
|
|
||||||
@@ -330,6 +408,12 @@ class LobbyService:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
participant.status = status
|
participant.status = status
|
||||||
|
if encrypted_key:
|
||||||
|
participant.encrypted_key = encrypted_key
|
||||||
|
if admin_ephemeral_public_key:
|
||||||
|
participant.admin_ephemeral_public_key = admin_ephemeral_public_key
|
||||||
|
if encrypted_vault_key:
|
||||||
|
participant.encrypted_vault_key = encrypted_vault_key
|
||||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||||
|
|
||||||
def clear_room_cache(self, room_id: UUID) -> None:
|
def clear_room_cache(self, room_id: UUID) -> None:
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
|||||||
"""Test that tokens signed with incorrect signature are rejected."""
|
"""Test that tokens signed with incorrect signature are rejected."""
|
||||||
|
|
||||||
settings.ROOM_SUBTITLE_ENABLED = True
|
settings.ROOM_SUBTITLE_ENABLED = True
|
||||||
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret-padded-to-32-bytes!!"
|
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def mock_livekit_config(settings):
|
|||||||
"""Mock LiveKit configuration."""
|
"""Mock LiveKit configuration."""
|
||||||
settings.LIVEKIT_CONFIGURATION = {
|
settings.LIVEKIT_CONFIGURATION = {
|
||||||
"api_key": "test_api_key",
|
"api_key": "test_api_key",
|
||||||
"api_secret": "test_api_secret_padded_to_32bytes!",
|
"api_secret": "test_api_secret",
|
||||||
"url": "https://test-livekit.example.com/",
|
"url": "https://test-livekit.example.com/",
|
||||||
}
|
}
|
||||||
return settings.LIVEKIT_CONFIGURATION
|
return settings.LIVEKIT_CONFIGURATION
|
||||||
|
|||||||
@@ -713,7 +713,7 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
|||||||
}
|
}
|
||||||
token = jwt.encode(
|
token = jwt.encode(
|
||||||
payload,
|
payload,
|
||||||
"invalid-private-key-padded-to-32b!",
|
"invalid-private-key",
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
algorithm=settings.APPLICATION_JWT_ALG,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ def generate_token(
|
|||||||
sources: Optional[List[str]] = None,
|
sources: Optional[List[str]] = None,
|
||||||
is_admin_or_owner: bool = False,
|
is_admin_or_owner: bool = False,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
|
encryption_mode: str = 'none',
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a LiveKit access token for a user in a specific room.
|
"""Generate a LiveKit access token for a user in a specific room.
|
||||||
|
|
||||||
@@ -92,11 +93,15 @@ def generate_token(
|
|||||||
if sources is None:
|
if sources is None:
|
||||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||||
|
|
||||||
|
# In encrypted rooms, no one can change their name/metadata to prevent
|
||||||
|
# identity spoofing — the admin accepted them based on their declared identity.
|
||||||
|
can_update_metadata = encryption_mode == 'none'
|
||||||
|
|
||||||
video_grants = VideoGrants(
|
video_grants = VideoGrants(
|
||||||
room=room,
|
room=room,
|
||||||
room_join=True,
|
room_join=True,
|
||||||
room_admin=is_admin_or_owner,
|
room_admin=is_admin_or_owner,
|
||||||
can_update_own_metadata=True,
|
can_update_own_metadata=can_update_metadata,
|
||||||
can_publish=bool(sources),
|
can_publish=bool(sources),
|
||||||
can_publish_sources=sources,
|
can_publish_sources=sources,
|
||||||
can_subscribe=True,
|
can_subscribe=True,
|
||||||
@@ -112,6 +117,42 @@ def generate_token(
|
|||||||
if color is None:
|
if color is None:
|
||||||
color = generate_color(identity)
|
color = generate_color(identity)
|
||||||
|
|
||||||
|
# Build participant attributes — these are server-signed in the JWT
|
||||||
|
# and visible to all participants in the room.
|
||||||
|
attributes = {
|
||||||
|
"color": color,
|
||||||
|
"room_admin": "true" if is_admin_or_owner else "false",
|
||||||
|
"is_authenticated": "true" if not user.is_anonymous else "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add identity info for authenticated users in encrypted rooms only.
|
||||||
|
#
|
||||||
|
# Email and suite_user_id are included in the JWT attributes for encrypted
|
||||||
|
# rooms because:
|
||||||
|
# - Email: allows admins to verify participant identity in the lobby and
|
||||||
|
# participant list (important for trust decisions in encrypted meetings)
|
||||||
|
# - suite_user_id: required for vault key exchange in advanced encryption
|
||||||
|
# (vaultClient.shareKeys needs the recipient's user ID)
|
||||||
|
#
|
||||||
|
# These attributes are NOT included in non-encrypted rooms because:
|
||||||
|
# - Non-encrypted rooms have no waiting room, so anonymous users can join
|
||||||
|
# freely and would see everyone's email via LiveKit signaling
|
||||||
|
# - LiveKit JWT attributes are immutable and broadcast to ALL participants
|
||||||
|
# equally — there is no way to show them only to authenticated users
|
||||||
|
# at the protocol level
|
||||||
|
# - The frontend additionally hides email from anonymous users in the UI,
|
||||||
|
# but this is defense-in-depth, not the primary protection
|
||||||
|
#
|
||||||
|
# Future improvement: serve email via a Django API endpoint that checks
|
||||||
|
# the requester's authentication, removing it from the JWT entirely.
|
||||||
|
# This would require the backend to call LiveKit's ListParticipants API
|
||||||
|
# to cross-reference identities with the user database.
|
||||||
|
if not user.is_anonymous and encryption_mode != 'none':
|
||||||
|
if user.email:
|
||||||
|
attributes["email"] = user.email
|
||||||
|
if user.sub:
|
||||||
|
attributes["suite_user_id"] = str(user.sub)
|
||||||
|
|
||||||
token = (
|
token = (
|
||||||
AccessToken(
|
AccessToken(
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
@@ -120,9 +161,7 @@ def generate_token(
|
|||||||
.with_grants(video_grants)
|
.with_grants(video_grants)
|
||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(username or default_username)
|
.with_name(username or default_username)
|
||||||
.with_attributes(
|
.with_attributes(attributes)
|
||||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
@@ -136,6 +175,7 @@ def generate_livekit_config(
|
|||||||
color: Optional[str] = None,
|
color: Optional[str] = None,
|
||||||
configuration: Optional[dict] = None,
|
configuration: Optional[dict] = None,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
|
encryption_mode: str = 'none',
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Generate LiveKit configuration for room access.
|
"""Generate LiveKit configuration for room access.
|
||||||
|
|
||||||
@@ -168,6 +208,7 @@ def generate_livekit_config(
|
|||||||
sources=sources,
|
sources=sources,
|
||||||
is_admin_or_owner=is_admin_or_owner,
|
is_admin_or_owner=is_admin_or_owner,
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
|
encryption_mode=encryption_mode,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -561,12 +561,12 @@ class Base(Configuration):
|
|||||||
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
||||||
)
|
)
|
||||||
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
||||||
default=["given_name", "usual_name"],
|
default=["first_name", "last_name"],
|
||||||
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
||||||
default="given_name",
|
default="first_name",
|
||||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
@@ -808,6 +808,17 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# End-to-end encryption settings
|
||||||
|
ENCRYPTION_ENABLED = values.BooleanValue(
|
||||||
|
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
|
||||||
|
)
|
||||||
|
ENCRYPTION_VAULT_URL = values.Value(
|
||||||
|
None, environ_name="ENCRYPTION_VAULT_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
ENCRYPTION_INTERFACE_URL = values.Value(
|
||||||
|
None, environ_name="ENCRYPTION_INTERFACE_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# External Applications
|
# External Applications
|
||||||
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
|
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
|
||||||
40,
|
40,
|
||||||
@@ -981,13 +992,7 @@ class Test(Base):
|
|||||||
USE_SWAGGER = True
|
USE_SWAGGER = True
|
||||||
EXTERNAL_API_ENABLED = True
|
EXTERNAL_API_ENABLED = True
|
||||||
|
|
||||||
LIVEKIT_CONFIGURATION = {
|
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
|
||||||
"api_key": "devkey-padded-for-minimum-len!-livekit",
|
|
||||||
"api_secret": "secret-key-padded-for-minimum-len!-livekit",
|
|
||||||
"url": "http://127.0.0.1.nip.io:7880",
|
|
||||||
}
|
|
||||||
|
|
||||||
APPLICATION_JWT_SECRET_KEY = "secret-key-padded-for-minimum-len!-application" # noqa:S105
|
|
||||||
APPLICATION_JWT_AUDIENCE = "Test inc."
|
APPLICATION_JWT_AUDIENCE = "Test inc."
|
||||||
|
|
||||||
CELERY_TASK_ALWAYS_EAGER = True
|
CELERY_TASK_ALWAYS_EAGER = True
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.13.0"
|
version = "1.12.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -55,7 +55,7 @@ dependencies = [
|
|||||||
"PyJWT==2.12.1",
|
"PyJWT==2.12.1",
|
||||||
"python-frontmatter==1.1.0",
|
"python-frontmatter==1.1.0",
|
||||||
"python-magic==0.4.27",
|
"python-magic==0.4.27",
|
||||||
"requests==2.33.0",
|
"requests==2.32.5",
|
||||||
"sentry-sdk==2.54.0",
|
"sentry-sdk==2.54.0",
|
||||||
"whitenoise==6.12.0",
|
"whitenoise==6.12.0",
|
||||||
"mozilla-django-oidc==5.0.2",
|
"mozilla-django-oidc==5.0.2",
|
||||||
|
|||||||
Generated
+49
-49
@@ -474,55 +474,55 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "46.0.6"
|
version = "46.0.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
|
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
|
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1157,7 +1157,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.13.0"
|
version = "1.12.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -1255,7 +1255,7 @@ requires-dist = [
|
|||||||
{ name = "python-frontmatter", specifier = "==1.1.0" },
|
{ name = "python-frontmatter", specifier = "==1.1.0" },
|
||||||
{ name = "python-magic", specifier = "==0.4.27" },
|
{ name = "python-magic", specifier = "==0.4.27" },
|
||||||
{ name = "redis", specifier = "==5.2.1" },
|
{ name = "redis", specifier = "==5.2.1" },
|
||||||
{ name = "requests", specifier = "==2.33.0" },
|
{ name = "requests", specifier = "==2.32.5" },
|
||||||
{ name = "sentry-sdk", specifier = "==2.54.0" },
|
{ name = "sentry-sdk", specifier = "==2.54.0" },
|
||||||
{ name = "whitenoise", specifier = "==6.12.0" },
|
{ name = "whitenoise", specifier = "==6.12.0" },
|
||||||
]
|
]
|
||||||
@@ -1965,7 +1965,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.33.0"
|
version = "2.32.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
@@ -1973,9 +1973,9 @@ dependencies = [
|
|||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Generated
+872
-3
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.13.0",
|
"version": "1.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
||||||
"format": "prettier --write ./src",
|
"format": "prettier --write ./src",
|
||||||
"check": "prettier --check ./src"
|
"check": "prettier --check ./src",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||||
@@ -59,10 +61,12 @@
|
|||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "5.2.0",
|
||||||
"eslint-plugin-react-refresh": "0.4.20",
|
"eslint-plugin-react-refresh": "0.4.20",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.1",
|
||||||
"vite-tsconfig-paths": "6.1.1"
|
"vite-tsconfig-paths": "6.1.1",
|
||||||
|
"vitest": "^4.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,11 +274,6 @@ const config: Config = {
|
|||||||
min: { value: 'min-content' },
|
min: { value: 'min-content' },
|
||||||
max: { value: 'max-content' },
|
max: { value: 'max-content' },
|
||||||
fit: { value: 'fit-content' },
|
fit: { value: 'fit-content' },
|
||||||
// room layout
|
|
||||||
'room-side-panel': { value: '360px' },
|
|
||||||
'room-side-panel-margin': { value: '1.5rem' },
|
|
||||||
'room-control-bar': { value: '80px' },
|
|
||||||
'room-reaction-toolbar-height': { value: '42px' },
|
|
||||||
},
|
},
|
||||||
spacing,
|
spacing,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import './i18n/init'
|
|||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { AppInitialization } from '@/components/AppInitialization'
|
import { AppInitialization } from '@/components/AppInitialization'
|
||||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||||
|
import { VaultClientProvider } from '@/features/encryption'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
@@ -25,20 +26,22 @@ function App() {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{!isSDKContext && <AppInitialization />}
|
{!isSDKContext && <AppInitialization />}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<I18nProvider locale={i18n.language}>
|
<VaultClientProvider>
|
||||||
<Layout>
|
<I18nProvider locale={i18n.language}>
|
||||||
|
<Layout>
|
||||||
<Switch>
|
<Switch>
|
||||||
{Object.entries(routes).map(([, route], i) => (
|
{Object.entries(routes).map(([, route], i) => (
|
||||||
<Route key={i} path={route.path} component={route.Component} />
|
<Route key={i} path={route.path} component={route.Component} />
|
||||||
))}
|
))}
|
||||||
<Route component={NotFoundScreen} />
|
<Route component={NotFoundScreen} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</Layout>
|
</Layout>
|
||||||
<ReactQueryDevtools
|
<ReactQueryDevtools
|
||||||
initialIsOpen={false}
|
initialIsOpen={false}
|
||||||
buttonPosition="bottom-left"
|
buttonPosition="bottom-left"
|
||||||
/>
|
/>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
</VaultClientProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export interface ApiConfig {
|
|||||||
enable_firefox_proxy_workaround: boolean
|
enable_firefox_proxy_workaround: boolean
|
||||||
default_sources: string[]
|
default_sources: string[]
|
||||||
}
|
}
|
||||||
|
encryption?: {
|
||||||
|
enabled: boolean
|
||||||
|
vault_url: string
|
||||||
|
interface_url: string
|
||||||
|
}
|
||||||
transcription_destination?: string
|
transcription_destination?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const Avatar = ({
|
|||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
const initial = name?.trim()?.charAt(0) ?? ''
|
const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? ''
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -70,7 +70,7 @@ export const Avatar = ({
|
|||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={css({
|
className={css({
|
||||||
marginTop: '-0.3rem',
|
lineHeight: 1,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{initial}
|
{initial}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { BackendLanguage } from '@/utils/languages'
|
|||||||
export type ApiUser = {
|
export type ApiUser = {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
full_name: string
|
full_name: string | null
|
||||||
|
short_name: string | null
|
||||||
last_name: string
|
last_name: string
|
||||||
language: BackendLanguage
|
language: BackendLanguage
|
||||||
timezone: string
|
timezone: string
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Indicator shown at the top-left of an encrypted meeting.
|
||||||
|
*
|
||||||
|
* Initially shows the full label "End-to-end encrypted" with a lock icon.
|
||||||
|
* After a few seconds, collapses to just the lock icon.
|
||||||
|
* On hover, expands back with a smooth animation.
|
||||||
|
* Clicking opens a modal explaining what E2EE means and its limitations.
|
||||||
|
*/
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { VStack } from '@/styled-system/jsx'
|
||||||
|
import { RiLockFill, RiShieldCheckFill } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { isEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Dialog, Text } from '@/primitives'
|
||||||
|
|
||||||
|
const COLLAPSE_DELAY = 4000
|
||||||
|
|
||||||
|
export function EncryptedMeetingBanner() {
|
||||||
|
const roomData = useRoomData()
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const isStrongEncryption = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setIsCollapsed(true), COLLAPSE_DELAY)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!isEncryptedRoom(roomData)) return null
|
||||||
|
|
||||||
|
const bgColor = isStrongEncryption ? '#166534' : '#1e3a5f'
|
||||||
|
const hoverBgColor = isStrongEncryption ? '#15803d' : '#2563eb'
|
||||||
|
const icon = isStrongEncryption
|
||||||
|
? <RiShieldCheckFill size={13} color="white" className={css({ flexShrink: 0 })} />
|
||||||
|
: <RiLockFill size={13} color="white" className={css({ flexShrink: 0 })} />
|
||||||
|
const label = isStrongEncryption ? t('bannerStrong') : t('banner')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setIsCollapsed(false)}
|
||||||
|
onMouseLeave={() => setIsCollapsed(true)}
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && setIsModalOpen(true)}
|
||||||
|
aria-label={label}
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0.5rem',
|
||||||
|
left: '0.5rem',
|
||||||
|
zIndex: 10,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.35rem',
|
||||||
|
padding: '0.3rem 0.6rem',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
border: '2px solid rgba(0, 0, 0, 0.3)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'all 300ms ease',
|
||||||
|
maxWidth: isCollapsed ? '2.2rem' : '16rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
paddingRight: isCollapsed ? '0.3rem' : '0.6rem',
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = hoverBgColor }}
|
||||||
|
onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = bgColor }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'white',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
transition: 'opacity 200ms ease',
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
opacity: isCollapsed ? 0 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onOpenChange={setIsModalOpen}
|
||||||
|
role="dialog"
|
||||||
|
type="flex"
|
||||||
|
title={t('bannerModal.title')}
|
||||||
|
>
|
||||||
|
<VStack
|
||||||
|
gap="1rem"
|
||||||
|
alignItems="start"
|
||||||
|
className={css({ maxWidth: '24rem' })}
|
||||||
|
>
|
||||||
|
<Text variant="sm">
|
||||||
|
{isStrongEncryption
|
||||||
|
? t('bannerModal.descriptionAdvanced')
|
||||||
|
: t('bannerModal.descriptionBasic')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<VStack gap="0.5rem" alignItems="start" className={css({ width: '100%' })}>
|
||||||
|
<Text variant="sm" className={css({ fontWeight: 600 })}>
|
||||||
|
{t('bannerModal.guarantees')}
|
||||||
|
</Text>
|
||||||
|
<ul
|
||||||
|
className={css({
|
||||||
|
paddingLeft: '1.5rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
listStyleType: 'disc',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.4rem',
|
||||||
|
'& li': {
|
||||||
|
paddingLeft: '0.25rem',
|
||||||
|
},
|
||||||
|
'& li::marker': {
|
||||||
|
color: '#22c55e',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<li>{t('bannerModal.guarantee1')}</li>
|
||||||
|
<li>{t('bannerModal.guarantee2')}</li>
|
||||||
|
<li>{t('bannerModal.guarantee3')}</li>
|
||||||
|
</ul>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack gap="0.5rem" alignItems="start" className={css({ width: '100%' })}>
|
||||||
|
<Text variant="sm" className={css({ fontWeight: 600 })}>
|
||||||
|
{t('bannerModal.limitations')}
|
||||||
|
</Text>
|
||||||
|
<ul
|
||||||
|
className={css({
|
||||||
|
paddingLeft: '1.5rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
listStyleType: 'disc',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.4rem',
|
||||||
|
'& li': {
|
||||||
|
paddingLeft: '0.25rem',
|
||||||
|
},
|
||||||
|
'& li::marker': {
|
||||||
|
color: '#f59e0b',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<li>{t('bannerModal.limitation1')}</li>
|
||||||
|
<li>{isStrongEncryption
|
||||||
|
? t('bannerModal.limitation2Advanced')
|
||||||
|
: t('bannerModal.limitation2Basic')}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
variant="note"
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'greyscale.200',
|
||||||
|
paddingTop: '0.75rem',
|
||||||
|
width: '100%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('bannerModal.note')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Per-participant encryption trust badge.
|
||||||
|
*
|
||||||
|
* In advanced mode:
|
||||||
|
* - "verified": Green shield — fingerprint explicitly trusted
|
||||||
|
* - "unknown": Grey shield — has public key, not yet verified
|
||||||
|
* - "refused": Red shield — fingerprint previously refused
|
||||||
|
* - "authenticated": Blue shield — ProConnect, no vault keys
|
||||||
|
* - "anonymous": Orange warning — not signed in
|
||||||
|
*
|
||||||
|
* In basic mode:
|
||||||
|
* - "authenticated": Blue shield — ProConnect
|
||||||
|
* - "anonymous": Orange warning — not signed in
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
RiShieldCheckFill,
|
||||||
|
RiShieldFill,
|
||||||
|
RiShieldCrossFill,
|
||||||
|
RiErrorWarningFill,
|
||||||
|
RiLockFill,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import type { TrustLevel } from './types'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface EncryptionBadgeProps {
|
||||||
|
trustLevel: TrustLevel | null
|
||||||
|
isEncrypted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EncryptionBadge({
|
||||||
|
trustLevel,
|
||||||
|
isEncrypted,
|
||||||
|
}: EncryptionBadgeProps) {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
|
||||||
|
|
||||||
|
if (!isEncrypted) return null
|
||||||
|
|
||||||
|
let icon: React.ReactNode
|
||||||
|
let label: string
|
||||||
|
|
||||||
|
switch (trustLevel) {
|
||||||
|
case 'verified':
|
||||||
|
icon = <RiShieldCheckFill size={14} color="#22c55e" />
|
||||||
|
label = t('verified')
|
||||||
|
break
|
||||||
|
case 'unknown':
|
||||||
|
icon = <RiShieldFill size={14} color="#9ca3af" />
|
||||||
|
label = t('unknown')
|
||||||
|
break
|
||||||
|
case 'refused':
|
||||||
|
icon = <RiShieldCrossFill size={14} color="#ef4444" />
|
||||||
|
label = t('refused')
|
||||||
|
break
|
||||||
|
case 'authenticated':
|
||||||
|
icon = <RiShieldCheckFill size={14} color="#3b82f6" />
|
||||||
|
label = t('authenticated')
|
||||||
|
break
|
||||||
|
case 'anonymous':
|
||||||
|
icon = <RiErrorWarningFill size={15} color="#d97706" />
|
||||||
|
label = t('anonymous')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
icon = <RiLockFill size={14} />
|
||||||
|
label = t('default')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={label}
|
||||||
|
className={css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: '0.15rem',
|
||||||
|
cursor: 'inherit',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
interface EncryptionContextValue {
|
||||||
|
symmetricKey?: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
const EncryptionContext = createContext<EncryptionContextValue>({})
|
||||||
|
|
||||||
|
export const EncryptionProvider = EncryptionContext.Provider
|
||||||
|
export const useEncryptionContext = () => useContext(EncryptionContext)
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* Dialog showing a participant's encryption fingerprint.
|
||||||
|
* Allows the admin to verify, accept, or refuse the fingerprint.
|
||||||
|
*
|
||||||
|
* This connects to the encryption library's VaultClient to check/accept/refuse
|
||||||
|
* fingerprints from the TOFU (Trust On First Use) registry.
|
||||||
|
*/
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { VStack, HStack } from '@/styled-system/jsx'
|
||||||
|
import { Dialog, Text, Button } from '@/primitives'
|
||||||
|
import { Avatar } from '@/components/Avatar'
|
||||||
|
import { useUser } from '@/features/auth'
|
||||||
|
import {
|
||||||
|
RiShieldCheckFill,
|
||||||
|
RiShieldCheckLine,
|
||||||
|
RiAlertLine,
|
||||||
|
RiCheckLine,
|
||||||
|
RiCloseLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useVaultClient } from './VaultClientProvider'
|
||||||
|
import { formatFingerprint } from './useParticipantTrustLevel'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface EncryptionIdentityDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
participantName: string
|
||||||
|
participantEmail?: string
|
||||||
|
suiteUserId?: string
|
||||||
|
isAuthenticated: boolean
|
||||||
|
encryptionMode?: 'basic' | 'advanced' | 'none'
|
||||||
|
isSelf?: boolean
|
||||||
|
preloadedFingerprint?: string | null
|
||||||
|
preloadedFingerprintStatus?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error'
|
||||||
|
|
||||||
|
export function EncryptionIdentityDialog({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
participantName,
|
||||||
|
participantEmail,
|
||||||
|
suiteUserId,
|
||||||
|
isAuthenticated,
|
||||||
|
encryptionMode,
|
||||||
|
isSelf,
|
||||||
|
preloadedFingerprint,
|
||||||
|
preloadedFingerprintStatus,
|
||||||
|
}: EncryptionIdentityDialogProps) {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' })
|
||||||
|
const { client: vaultClient } = useVaultClient()
|
||||||
|
const { isLoggedIn } = useUser()
|
||||||
|
const [status, setStatus] = useState<FingerprintStatus>(
|
||||||
|
(preloadedFingerprintStatus as FingerprintStatus) || 'loading'
|
||||||
|
)
|
||||||
|
const [fingerprint, setFingerprint] = useState<string | null>(preloadedFingerprint || null)
|
||||||
|
|
||||||
|
// Sync preloaded data when it becomes available (hook resolves after mount)
|
||||||
|
useEffect(() => {
|
||||||
|
if (preloadedFingerprintStatus) setStatus(preloadedFingerprintStatus as FingerprintStatus)
|
||||||
|
if (preloadedFingerprint) setFingerprint(preloadedFingerprint)
|
||||||
|
}, [preloadedFingerprint, preloadedFingerprintStatus])
|
||||||
|
|
||||||
|
const isBasicMode = encryptionMode !== 'advanced'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
// In basic mode, no fingerprint check — identity is from ProConnect only
|
||||||
|
if (isBasicMode) {
|
||||||
|
setStatus(isAuthenticated ? 'no-key' : 'no-key')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!vaultClient) {
|
||||||
|
setStatus('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!suiteUserId) {
|
||||||
|
setStatus(isAuthenticated ? 'no-key' : 'no-key')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function checkFingerprint() {
|
||||||
|
try {
|
||||||
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('timeout')), 3000)
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchResult = await Promise.race([
|
||||||
|
vaultClient!.fetchPublicKeys([suiteUserId!]),
|
||||||
|
timeout,
|
||||||
|
])
|
||||||
|
|
||||||
|
const publicKey = fetchResult.publicKeys[suiteUserId!]
|
||||||
|
|
||||||
|
if (!publicKey || cancelled) {
|
||||||
|
setStatus('no-key')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute fingerprint from the public key (SHA-256, first 16 hex chars)
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', publicKey)
|
||||||
|
const fp = Array.from(new Uint8Array(hash))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
.slice(0, 16)
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
setFingerprint(fp)
|
||||||
|
|
||||||
|
// Check local registry without triggering TOFU auto-trust
|
||||||
|
const { fingerprints: known } = await Promise.race([
|
||||||
|
vaultClient!.getKnownFingerprints(),
|
||||||
|
timeout,
|
||||||
|
])
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const knownEntry = known[suiteUserId!]
|
||||||
|
if (!knownEntry) {
|
||||||
|
setStatus('unknown')
|
||||||
|
} else if (knownEntry.fingerprint === fp) {
|
||||||
|
setStatus(knownEntry.status)
|
||||||
|
} else {
|
||||||
|
// Fingerprint changed — needs re-verification
|
||||||
|
setStatus('unknown')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkFingerprint()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [isOpen, vaultClient, suiteUserId, isAuthenticated])
|
||||||
|
|
||||||
|
const handleAccept = async () => {
|
||||||
|
if (!vaultClient || !suiteUserId || !fingerprint) return
|
||||||
|
try {
|
||||||
|
await vaultClient.acceptFingerprint(suiteUserId, fingerprint)
|
||||||
|
setStatus('trusted')
|
||||||
|
} catch {
|
||||||
|
// Failed to accept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefuse = async () => {
|
||||||
|
if (!vaultClient || !suiteUserId || !fingerprint) return
|
||||||
|
try {
|
||||||
|
await vaultClient.refuseFingerprint(suiteUserId, fingerprint)
|
||||||
|
setStatus('refused')
|
||||||
|
} catch {
|
||||||
|
// Failed to refuse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
role="dialog"
|
||||||
|
type="flex"
|
||||||
|
title={t('title')}
|
||||||
|
>
|
||||||
|
<VStack
|
||||||
|
gap="0.75rem"
|
||||||
|
alignItems="start"
|
||||||
|
className={css({ maxWidth: '22rem' })}
|
||||||
|
>
|
||||||
|
<HStack gap="0.65rem" className={css({ width: '100%' })}>
|
||||||
|
<div className={css({ flexShrink: 0, transform: 'scale(0.85)' })}>
|
||||||
|
<Avatar name={participantName} bgColor="rgb(87, 44, 216)" />
|
||||||
|
</div>
|
||||||
|
<VStack gap="0" alignItems="start">
|
||||||
|
<Text className={css({ fontWeight: 600, fontSize: '0.9rem' })}>{participantName}</Text>
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}>
|
||||||
|
{isLoggedIn && participantEmail ? participantEmail : (!isAuthenticated ? t('anonymous') : '')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||||
|
{isSelf
|
||||||
|
? (isAuthenticated ? t('descriptionSelf') : t('descriptionSelfAnonymous'))
|
||||||
|
: t('description')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<Text variant="note">{t('loading')}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'no-key' && isBasicMode && isAuthenticated && (
|
||||||
|
<HStack
|
||||||
|
gap="0.5rem"
|
||||||
|
className={css({
|
||||||
|
backgroundColor: '#eff6ff',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid #bfdbfe',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiShieldCheckLine size={20} color="#3b82f6" className={css({ flexShrink: 0 })} />
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||||
|
{t('noKeyBasicAuthenticated')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'no-key' && !(isBasicMode && isAuthenticated) && !isSelf && (
|
||||||
|
<HStack
|
||||||
|
gap="0.5rem"
|
||||||
|
className={css({
|
||||||
|
backgroundColor: '#fffbeb',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid #fde68a',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiAlertLine size={20} color="#f59e0b" className={css({ flexShrink: 0 })} />
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||||
|
{isAuthenticated ? t('noKey') : t('noKeyAnonymous')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<Text variant="note" className={css({ color: '#ef4444' })}>
|
||||||
|
{t('error')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(status === 'trusted' || status === 'refused' || status === 'unknown') && fingerprint && (
|
||||||
|
<>
|
||||||
|
<VStack
|
||||||
|
gap="0.25rem"
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'greyscale.50',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
width: '100%',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.7rem', fontFamily: 'inherit' })}>
|
||||||
|
{t('fingerprintLabel')}
|
||||||
|
</Text>
|
||||||
|
{formatFingerprint(fingerprint)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{status === 'trusted' && (
|
||||||
|
<VStack gap="0.25rem" alignItems="start">
|
||||||
|
<HStack gap="0.5rem" className={css({ color: '#22c55e' })}>
|
||||||
|
<RiShieldCheckFill size={18} />
|
||||||
|
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
|
||||||
|
{t('trusted')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||||
|
{isSelf ? t('descriptionSelf') : t('trustedDescription')}
|
||||||
|
</Text>
|
||||||
|
{!isSelf && (
|
||||||
|
<Text
|
||||||
|
variant="note"
|
||||||
|
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
|
||||||
|
onClick={() => setStatus('unknown')}
|
||||||
|
>
|
||||||
|
{t('changeDecision')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'refused' && (
|
||||||
|
<VStack gap="0.25rem" alignItems="start">
|
||||||
|
<HStack gap="0.5rem" className={css({ color: '#ef4444' })}>
|
||||||
|
<RiCloseLine size={18} />
|
||||||
|
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
|
||||||
|
{t('refused')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||||
|
{t('refusedDescription')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="note"
|
||||||
|
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
|
||||||
|
onClick={() => setStatus('unknown')}
|
||||||
|
>
|
||||||
|
{t('changeDecision')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'unknown' && !isSelf && (
|
||||||
|
<VStack gap="0.5rem" className={css({ width: '100%' })}>
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||||
|
{t('unknownDescription')}
|
||||||
|
</Text>
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.75rem', fontStyle: 'italic' })}>
|
||||||
|
{t('fingerprintHint')}
|
||||||
|
</Text>
|
||||||
|
<HStack gap="0.5rem">
|
||||||
|
<Button size="sm" variant="primary" onPress={handleAccept}>
|
||||||
|
<RiCheckLine size={16} />
|
||||||
|
{t('accept')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondaryText" onPress={handleRefuse}>
|
||||||
|
<RiCloseLine size={16} />
|
||||||
|
{t('refuse')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Overlay shown during encryption key exchange.
|
||||||
|
*
|
||||||
|
* When a participant joins an encrypted room, there's a brief period
|
||||||
|
* between connection and receiving the symmetric key where media
|
||||||
|
* cannot be decrypted. This overlay provides feedback during that time.
|
||||||
|
*
|
||||||
|
* After 20 seconds without the key, shows an error with a refresh button.
|
||||||
|
*/
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { VStack } from '@/styled-system/jsx'
|
||||||
|
import { Text, Button } from '@/primitives'
|
||||||
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
|
import { RiLockFill, RiAlertFill, RiRefreshLine } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const KEY_EXCHANGE_TIMEOUT = 20000
|
||||||
|
|
||||||
|
export function EncryptionSetupOverlay({
|
||||||
|
isSettingUp,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
isSettingUp: boolean
|
||||||
|
error: string | null
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
|
||||||
|
const [timedOut, setTimedOut] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSettingUp) {
|
||||||
|
setTimedOut(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setTimedOut(true), KEY_EXCHANGE_TIMEOUT)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [isSettingUp])
|
||||||
|
|
||||||
|
if (!isSettingUp && !error) return null
|
||||||
|
|
||||||
|
const showError = error || timedOut
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<VStack gap="1rem" alignItems="center">
|
||||||
|
{showError ? (
|
||||||
|
<>
|
||||||
|
<RiAlertFill size={36} color="#f87171" />
|
||||||
|
<Text
|
||||||
|
className={css({
|
||||||
|
color: '#f87171',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{timedOut ? t('error.timeout') : t('error.title')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className={css({
|
||||||
|
color: 'greyscale.300',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '20rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{error || t('error.timeoutHint')}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
<RiRefreshLine size={16} />
|
||||||
|
{t('error.refresh')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiLockFill size={32} color="white" />
|
||||||
|
<Text
|
||||||
|
className={css({
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('settingUp.title')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className={css({
|
||||||
|
color: 'greyscale.300',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '20rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('settingUp.description')}
|
||||||
|
</Text>
|
||||||
|
<Spinner />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Modal explaining encryption trust levels.
|
||||||
|
* Shown when admin clicks the trust badge in the waiting room.
|
||||||
|
*/
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { VStack, HStack } from '@/styled-system/jsx'
|
||||||
|
import { Dialog, Text } from '@/primitives'
|
||||||
|
import { RiShieldCheckFill, RiShieldCheckLine, RiAlertLine } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface EncryptionTrustModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
participantName: string
|
||||||
|
isAuthenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EncryptionTrustModal({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
participantName,
|
||||||
|
isAuthenticated,
|
||||||
|
}: EncryptionTrustModalProps) {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.trustModal' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
role="dialog"
|
||||||
|
type="flex"
|
||||||
|
title={t('title')}
|
||||||
|
>
|
||||||
|
<VStack
|
||||||
|
gap="1rem"
|
||||||
|
alignItems="start"
|
||||||
|
className={css({ maxWidth: '22rem' })}
|
||||||
|
>
|
||||||
|
<Text variant="sm">{t('intro', { name: participantName })}</Text>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<HStack
|
||||||
|
gap="0.75rem"
|
||||||
|
className={css({
|
||||||
|
backgroundColor: '#eff6ff',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid #bfdbfe',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiShieldCheckLine
|
||||||
|
size={24}
|
||||||
|
color="#3b82f6"
|
||||||
|
className={css({ flexShrink: 0 })}
|
||||||
|
/>
|
||||||
|
<VStack gap="0.25rem" alignItems="start">
|
||||||
|
<Text className={css({ fontWeight: 600, fontSize: '0.85rem' })}>
|
||||||
|
{t('authenticated.title')}
|
||||||
|
</Text>
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||||
|
{t('authenticated.description')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
) : (
|
||||||
|
<HStack
|
||||||
|
gap="0.75rem"
|
||||||
|
className={css({
|
||||||
|
backgroundColor: '#fffbeb',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid #fde68a',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiAlertLine
|
||||||
|
size={24}
|
||||||
|
color="#f59e0b"
|
||||||
|
className={css({ flexShrink: 0 })}
|
||||||
|
/>
|
||||||
|
<VStack gap="0.25rem" alignItems="start">
|
||||||
|
<Text className={css({ fontWeight: 600, fontSize: '0.85rem' })}>
|
||||||
|
{t('anonymous.title')}
|
||||||
|
</Text>
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||||
|
{t('anonymous.description')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VStack
|
||||||
|
gap="0.5rem"
|
||||||
|
alignItems="start"
|
||||||
|
className={css({
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'greyscale.200',
|
||||||
|
paddingTop: '0.75rem',
|
||||||
|
width: '100%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="note"
|
||||||
|
className={css({ fontWeight: 600, fontSize: '0.8rem' })}
|
||||||
|
>
|
||||||
|
{t('levels.title')}
|
||||||
|
</Text>
|
||||||
|
<HStack gap="0.5rem" alignItems="start">
|
||||||
|
<RiShieldCheckFill
|
||||||
|
size={16}
|
||||||
|
color="#22c55e"
|
||||||
|
className={css({ flexShrink: 0, marginTop: '2px' })}
|
||||||
|
/>
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
|
||||||
|
{t('levels.verified')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap="0.5rem" alignItems="start">
|
||||||
|
<RiShieldCheckLine
|
||||||
|
size={16}
|
||||||
|
color="#3b82f6"
|
||||||
|
className={css({ flexShrink: 0, marginTop: '2px' })}
|
||||||
|
/>
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
|
||||||
|
{t('levels.authenticated')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap="0.5rem" alignItems="start">
|
||||||
|
<RiAlertLine
|
||||||
|
size={16}
|
||||||
|
color="#f59e0b"
|
||||||
|
className={css({ flexShrink: 0, marginTop: '2px' })}
|
||||||
|
/>
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
|
||||||
|
{t('levels.anonymous')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Hybrid key distributor: determines the best key distribution method per participant.
|
||||||
|
*
|
||||||
|
* For each participant joining an encrypted call:
|
||||||
|
* 1. Check if they have a registered public key (via VaultClient/encryption library)
|
||||||
|
* → If YES: wrap symmetric key with their public key (PKI path) → trust level "verified"
|
||||||
|
* 2. Check if they are authenticated via ProConnect
|
||||||
|
* → If YES but no public key: use ephemeral DH → trust level "authenticated"
|
||||||
|
* 3. Otherwise: use ephemeral DH → trust level "anonymous"
|
||||||
|
*
|
||||||
|
* The symmetric key is always the same for everyone — only the distribution channel varies.
|
||||||
|
*/
|
||||||
|
import type { TrustLevel } from './types'
|
||||||
|
import { PARTICIPANT_TRUST_ATTR } from './types'
|
||||||
|
|
||||||
|
export interface ParticipantEncryptionInfo {
|
||||||
|
identity: string
|
||||||
|
trustLevel: TrustLevel
|
||||||
|
hasPublicKey: boolean
|
||||||
|
isAuthenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the trust level for a participant based on their encryption capabilities.
|
||||||
|
*/
|
||||||
|
export function determineTrustLevel(
|
||||||
|
hasPublicKey: boolean,
|
||||||
|
isAuthenticated: boolean
|
||||||
|
): TrustLevel {
|
||||||
|
if (hasPublicKey) return 'verified'
|
||||||
|
if (isAuthenticated) return 'authenticated'
|
||||||
|
return 'anonymous'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive trust level from participant's server-signed attributes.
|
||||||
|
*
|
||||||
|
* The `is_authenticated` attribute is set by the backend in the LiveKit JWT token
|
||||||
|
* and cannot be spoofed by clients. It indicates whether the participant
|
||||||
|
* authenticated via OIDC (ProConnect/Keycloak).
|
||||||
|
*
|
||||||
|
* In basic encryption mode, the "verified" level is never returned because
|
||||||
|
* PKI keys are not used — encryption relies on a shared passphrase, not on
|
||||||
|
* per-user public keys. The green shield would be misleading.
|
||||||
|
*
|
||||||
|
* In advanced encryption mode, "verified" means the participant has completed
|
||||||
|
* encryption onboarding and their public key is used to encrypt the symmetric key.
|
||||||
|
*/
|
||||||
|
export function getTrustLevelFromAttributes(
|
||||||
|
attributes: Record<string, string> | undefined,
|
||||||
|
encryptionMode?: 'basic' | 'advanced' | 'none',
|
||||||
|
): TrustLevel | null {
|
||||||
|
if (!attributes) return null
|
||||||
|
|
||||||
|
const isAdvanced = encryptionMode === 'advanced'
|
||||||
|
|
||||||
|
// Check for explicit trust level (set by PKI integration)
|
||||||
|
const explicitLevel = attributes[PARTICIPANT_TRUST_ATTR]
|
||||||
|
if (explicitLevel === 'verified' && isAdvanced) {
|
||||||
|
return 'verified'
|
||||||
|
}
|
||||||
|
if (explicitLevel === 'authenticated' || explicitLevel === 'anonymous') {
|
||||||
|
return explicitLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive from server-signed is_authenticated attribute
|
||||||
|
if (attributes.is_authenticated === 'true') {
|
||||||
|
return 'authenticated'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'anonymous'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to distribute the symmetric key via PKI (encryption library).
|
||||||
|
* Returns true if successful, false if the participant doesn't have a public key.
|
||||||
|
*/
|
||||||
|
export async function distributeKeyViaPKI(
|
||||||
|
vaultClient: VaultClient,
|
||||||
|
symmetricKey: Uint8Array,
|
||||||
|
participantUserId: string
|
||||||
|
): Promise<{ success: boolean; encryptedKey?: ArrayBuffer }> {
|
||||||
|
try {
|
||||||
|
const { publicKeys } = await vaultClient.fetchPublicKeys([
|
||||||
|
participantUserId,
|
||||||
|
])
|
||||||
|
const publicKey = publicKeys[participantUserId]
|
||||||
|
|
||||||
|
if (!publicKey) {
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use encryptWithoutKey to wrap the symmetric key for this user
|
||||||
|
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||||
|
symmetricKey.buffer as ArrayBuffer,
|
||||||
|
{ [participantUserId]: publicKey }
|
||||||
|
)
|
||||||
|
|
||||||
|
const encryptedKey = encryptedKeys[participantUserId]
|
||||||
|
if (!encryptedKey) {
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, encryptedKey }
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
'[Encryption] PKI key distribution failed for participant:',
|
||||||
|
participantUserId,
|
||||||
|
err
|
||||||
|
)
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode trust level into participant attributes for badge display.
|
||||||
|
*/
|
||||||
|
export function encodeTrustLevelAttribute(
|
||||||
|
trustLevel: TrustLevel
|
||||||
|
): Record<string, string> {
|
||||||
|
return { [PARTICIPANT_TRUST_ATTR]: trustLevel }
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Encryption Security Architecture
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
### What E2EE protects against
|
||||||
|
- **Server-side data access**: The LiveKit SFU and Meet backend cannot read audio/video content
|
||||||
|
- **Network interception**: Media frames are encrypted before leaving the client
|
||||||
|
- **Unauthorized participants**: Restricted access + lobby ensures only admin-approved users join
|
||||||
|
|
||||||
|
### Known limitations and mitigations
|
||||||
|
|
||||||
|
#### Compromised LiveKit server (MITM on key exchange)
|
||||||
|
|
||||||
|
**Threat**: If the LiveKit server is compromised, it could perform a Man-in-the-Middle attack on the ephemeral DH key exchange, intercepting the symmetric key.
|
||||||
|
|
||||||
|
**Current mitigation**: KEY_RESPONSE is only accepted from participants with `room_admin: "true"` in their server-signed JWT attributes. This prevents non-admin participants from injecting fake keys, but does not protect against a compromised server that can forge JWT attributes.
|
||||||
|
|
||||||
|
**Planned mitigations (3 levels):**
|
||||||
|
|
||||||
|
##### Level 1 — Signed key exchange (requires encryption onboarding)
|
||||||
|
|
||||||
|
When the admin has completed encryption onboarding via `data.encryption`:
|
||||||
|
1. Admin signs the KEY_RESPONSE with their permanent private key (stored in IndexedDB)
|
||||||
|
2. Receiving participant fetches admin's public key from `data.encryption` registry
|
||||||
|
3. Verifies the signature before accepting the symmetric key
|
||||||
|
4. If signature is invalid → **reject the key, show error, cut video**
|
||||||
|
|
||||||
|
This protects against server compromise because the server cannot forge the admin's private key signature.
|
||||||
|
|
||||||
|
**Requirement**: Admin must have completed encryption onboarding. If not, falls back to Level 2.
|
||||||
|
|
||||||
|
##### Level 2 — SAS (Short Authentication String) verification
|
||||||
|
|
||||||
|
After the ephemeral DH key exchange:
|
||||||
|
1. Both parties compute SAS = hash(DH_shared_secret) → displayed as 4 emojis or a 6-digit code
|
||||||
|
2. Each participant sees the SAS on their own screen (local rendering)
|
||||||
|
3. They read it aloud to each other during the call
|
||||||
|
4. If the SAS matches → the key exchange was not intercepted
|
||||||
|
5. If the SAS doesn't match → MITM detected → reject the key
|
||||||
|
|
||||||
|
This works because:
|
||||||
|
- A MITM results in different DH shared secrets → different SAS codes
|
||||||
|
- The SAS is rendered locally — the server cannot change what appears on screen
|
||||||
|
- Real-time audio manipulation to fake the spoken SAS is extremely difficult
|
||||||
|
|
||||||
|
**Requirement**: Participants must verbally compare the SAS. Optional but recommended.
|
||||||
|
|
||||||
|
##### Level 3 — Trust the server (current default)
|
||||||
|
|
||||||
|
Relies on the LiveKit server's integrity (JWT-signed attributes). Suitable when:
|
||||||
|
- The server infrastructure is self-hosted and trusted
|
||||||
|
- The threat model does not include server compromise
|
||||||
|
- Quick, frictionless meetings are prioritized over maximum security
|
||||||
|
|
||||||
|
#### Key propagation without admin
|
||||||
|
|
||||||
|
**Current behavior**: Any participant who has the symmetric key can relay it to new joiners.
|
||||||
|
|
||||||
|
**Risk**: If the server is compromised, it could inject a fake participant who relays a compromised key.
|
||||||
|
|
||||||
|
**Planned fix**: Only accept KEY_RESPONSE from participants whose identity can be:
|
||||||
|
- Cryptographically verified (Level 1 — signature from registered public key), or
|
||||||
|
- Manually verified (Level 2 — SAS comparison)
|
||||||
|
|
||||||
|
Non-verified key relays should show a clear warning.
|
||||||
|
|
||||||
|
## Trust levels
|
||||||
|
|
||||||
|
| Level | Badge | Identity verification | Key exchange | Server compromise protection |
|
||||||
|
|-------|-------|----------------------|-------------|------------------------------|
|
||||||
|
| Verified | 🟢 Green shield | Public key registered in `data.encryption` | Signed with permanent private key | Yes — signature cannot be forged |
|
||||||
|
| Authenticated | 🔵 Blue shield | OIDC/ProConnect login | Ephemeral DH (unsigned) | No — relies on server integrity |
|
||||||
|
| Anonymous | 🟡 Orange warning | None (self-declared name) | Ephemeral DH (unsigned) | No — relies on server integrity |
|
||||||
|
|
||||||
|
#### Basic mode: unencrypted frame window on connection
|
||||||
|
|
||||||
|
**Behavior**: LiveKit's built-in Worker passes frames through unencrypted when `!isEnabled()`.
|
||||||
|
|
||||||
|
**Mitigation**: `setE2EEEnabled(true)` is called BEFORE the room connects (in Conference.tsx),
|
||||||
|
ensuring the 'enable' message reaches the Worker before any frames flow. This eliminates the
|
||||||
|
unencrypted window in normal operation. However, edge cases (Worker message queue delays,
|
||||||
|
race conditions during reconnection) could theoretically still allow a few unencrypted frames.
|
||||||
|
|
||||||
|
**Advanced mode**: VaultE2EEManager drops frames when the key isn't ready — no pass-through.
|
||||||
|
|
||||||
|
#### Basic mode: "Decryption failed" overlay may not appear with wrong passphrase
|
||||||
|
|
||||||
|
**Behavior**: When a participant joins with a wrong passphrase, the receiver may not show the
|
||||||
|
"Decryption failed" overlay. The LiveKit Worker's error throttling (`MAX_ERRORS_PER_MINUTE = 5`)
|
||||||
|
stops emitting `EncryptionError` events after 5 failures. Additionally, when a participant
|
||||||
|
reconnects, the new `ParticipantTile` mounts fresh and may not receive errors referencing
|
||||||
|
the new participant identity.
|
||||||
|
|
||||||
|
**Impact**: The user sees a black tile but no error message explaining why.
|
||||||
|
|
||||||
|
**Advanced mode**: VaultE2EEManager emits `EncryptionError` for each failure and signals
|
||||||
|
`ParticipantEncryptionStatusChanged(true)` on first successful decrypt, ensuring the overlay
|
||||||
|
appears and clears correctly.
|
||||||
|
|
||||||
|
## Implementation status
|
||||||
|
|
||||||
|
- [x] Basic E2EE with LiveKit Worker + passphrase in URL hash
|
||||||
|
- [x] Advanced E2EE with VaultClient iframe (XChaCha20-Poly1305)
|
||||||
|
- [x] Preserved codec header bytes for RTP compatibility
|
||||||
|
- [x] Admin as key authority
|
||||||
|
- [x] Server-signed trust attributes in JWT
|
||||||
|
- [x] Trust badges (verified/unknown/refused/authenticated/anonymous)
|
||||||
|
- [x] Encryption identity dialog with fingerprint verification
|
||||||
|
- [x] Encryption settings in account menu (VaultClient onboarding)
|
||||||
|
- [x] Fingerprint accept/refuse with `fingerprint-changed` event
|
||||||
|
- [x] Disable recording/transcription in encrypted rooms (backend + frontend)
|
||||||
|
- [x] Lobby bypass disabled for encrypted rooms
|
||||||
|
- [x] Backend blocks encrypted room creation when `ENCRYPTION_ENABLED=false`
|
||||||
|
- [ ] Signed KEY_RESPONSE (Level 1)
|
||||||
|
- [ ] SAS verification (Level 2)
|
||||||
|
- [ ] Restrict key propagation to verified participants only
|
||||||
|
- [x] Mitigate unencrypted frame window (setE2EEEnabled before connection)
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* React context provider for the centralized encryption VaultClient SDK.
|
||||||
|
*
|
||||||
|
* The client SDK is loaded at runtime via a <script> tag from the vault domain
|
||||||
|
* (data.encryption). This provider:
|
||||||
|
* - Loads the client.js script from the vault URL
|
||||||
|
* - Creates and initializes the VaultClient instance
|
||||||
|
* - Sets auth context when the user logs in
|
||||||
|
* - Tracks key state (hasKeys, publicKey)
|
||||||
|
* - Provides the client to all downstream components
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useUser } from '@/features/auth'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
|
export interface VaultClientContextValue {
|
||||||
|
client: VaultClient | null
|
||||||
|
isReady: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
hasKeys: boolean | null
|
||||||
|
publicKey: ArrayBuffer | null
|
||||||
|
refreshKeyState: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const VaultClientContext = createContext<VaultClientContextValue>({
|
||||||
|
client: null,
|
||||||
|
isReady: false,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
hasKeys: null,
|
||||||
|
publicKey: null,
|
||||||
|
refreshKeyState: async () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadClientScript(vaultUrl: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (window.EncryptionClient?.VaultClient) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptSrc = `${vaultUrl}/client.js`
|
||||||
|
const existing = document.querySelector(`script[src="${scriptSrc}"]`)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener('load', () => resolve())
|
||||||
|
existing.addEventListener('error', () =>
|
||||||
|
reject(new Error('Failed to load encryption client SDK'))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = scriptSrc
|
||||||
|
script.async = true
|
||||||
|
script.onload = () => resolve()
|
||||||
|
script.onerror = () =>
|
||||||
|
reject(new Error('Failed to load encryption client SDK'))
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VaultClientProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { data: config } = useConfig()
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const { user } = useUser()
|
||||||
|
const clientRef = useRef<VaultClient | null>(null)
|
||||||
|
const [clientInitialized, setClientInitialized] = useState(false)
|
||||||
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [hasKeys, setHasKeys] = useState<boolean | null>(null)
|
||||||
|
const [publicKey, setPublicKey] = useState<ArrayBuffer | null>(null)
|
||||||
|
const initRef = useRef(false)
|
||||||
|
|
||||||
|
const vaultUrl = config?.encryption?.vault_url
|
||||||
|
const interfaceUrl = config?.encryption?.interface_url
|
||||||
|
|
||||||
|
// Load script + initialize VaultClient once
|
||||||
|
useEffect(() => {
|
||||||
|
if (initRef.current || !vaultUrl || !interfaceUrl) return
|
||||||
|
initRef.current = true
|
||||||
|
|
||||||
|
let destroyed = false
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
await loadClientScript(vaultUrl!)
|
||||||
|
|
||||||
|
if (destroyed) return
|
||||||
|
|
||||||
|
const client = new window.EncryptionClient.VaultClient({
|
||||||
|
vaultUrl: vaultUrl!,
|
||||||
|
interfaceUrl: interfaceUrl!,
|
||||||
|
lang: i18n.language,
|
||||||
|
})
|
||||||
|
|
||||||
|
clientRef.current = client
|
||||||
|
|
||||||
|
client.on('onboarding:complete', () => {
|
||||||
|
setHasKeys(true)
|
||||||
|
client
|
||||||
|
.getPublicKey()
|
||||||
|
.then(({ publicKey: pk }) => setPublicKey(pk))
|
||||||
|
.catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('keys-changed', () => {
|
||||||
|
client
|
||||||
|
.hasKeys()
|
||||||
|
.then(({ hasKeys: exists }) => {
|
||||||
|
setHasKeys(exists)
|
||||||
|
if (exists) {
|
||||||
|
client
|
||||||
|
.getPublicKey()
|
||||||
|
.then(({ publicKey: pk }) => setPublicKey(pk))
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('keys-destroyed', () => {
|
||||||
|
setHasKeys(false)
|
||||||
|
setPublicKey(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.init()
|
||||||
|
|
||||||
|
if (destroyed) {
|
||||||
|
client.destroy()
|
||||||
|
} else {
|
||||||
|
setClientInitialized(true)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!destroyed) {
|
||||||
|
setError((err as Error).message)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void init()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
destroyed = true
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.destroy()
|
||||||
|
clientRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [vaultUrl, interfaceUrl, i18n.language])
|
||||||
|
|
||||||
|
// Set auth context when user is available
|
||||||
|
// Note: Meet may have anonymous users — VaultClient only works for authenticated users
|
||||||
|
// with a suite_user_id. For anonymous users, isReady stays false.
|
||||||
|
useEffect(() => {
|
||||||
|
const client = clientRef.current
|
||||||
|
if (!client || !clientInitialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const suiteUserId = (user as Record<string, unknown>)?.sub as string | undefined
|
||||||
|
if (suiteUserId) {
|
||||||
|
client.setAuthContext({ suiteUserId })
|
||||||
|
setIsReady(true)
|
||||||
|
// Check key state now that auth context is set
|
||||||
|
client.hasKeys()
|
||||||
|
.then(({ hasKeys: exists }) => {
|
||||||
|
setHasKeys(exists)
|
||||||
|
if (exists) {
|
||||||
|
client.getPublicKey()
|
||||||
|
.then(({ publicKey: pk }) => setPublicKey(pk))
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}, [clientInitialized, (user as Record<string, unknown>)?.sub])
|
||||||
|
|
||||||
|
const refreshKeyState = useCallback(async () => {
|
||||||
|
const client = clientRef.current
|
||||||
|
if (!client) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { hasKeys: exists } = await client.hasKeys()
|
||||||
|
setHasKeys(exists)
|
||||||
|
if (exists) {
|
||||||
|
const { publicKey: pk } = await client.getPublicKey()
|
||||||
|
setPublicKey(pk)
|
||||||
|
} else {
|
||||||
|
setPublicKey(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Vault not available
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VaultClientContext.Provider
|
||||||
|
value={{
|
||||||
|
client: clientInitialized ? clientRef.current : null,
|
||||||
|
isReady,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
hasKeys,
|
||||||
|
publicKey,
|
||||||
|
refreshKeyState,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</VaultClientContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVaultClient = (): VaultClientContextValue =>
|
||||||
|
useContext(VaultClientContext)
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import {
|
||||||
|
VaultE2EEManager,
|
||||||
|
getUnencryptedBytes,
|
||||||
|
UNENCRYPTED_BYTES,
|
||||||
|
} from './VaultE2EEManager'
|
||||||
|
|
||||||
|
// ── getUnencryptedBytes ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getUnencryptedBytes', () => {
|
||||||
|
it('returns 10 for VP8 keyframes', () => {
|
||||||
|
const frame = { type: 'key', data: new ArrayBuffer(100) }
|
||||||
|
expect(getUnencryptedBytes(frame as unknown as RTCEncodedVideoFrame)).toBe(
|
||||||
|
UNENCRYPTED_BYTES.key
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 3 for VP8 delta frames', () => {
|
||||||
|
const frame = { type: 'delta', data: new ArrayBuffer(100) }
|
||||||
|
expect(getUnencryptedBytes(frame as unknown as RTCEncodedVideoFrame)).toBe(
|
||||||
|
UNENCRYPTED_BYTES.delta
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 1 for audio frames (no type property)', () => {
|
||||||
|
const frame = { data: new ArrayBuffer(100) }
|
||||||
|
expect(getUnencryptedBytes(frame as unknown as RTCEncodedAudioFrame)).toBe(
|
||||||
|
UNENCRYPTED_BYTES.audio
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Mock VaultClient ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createMockVaultClient() {
|
||||||
|
// Simulates vault crypto: prepends 24-byte nonce + appends 16-byte MAC
|
||||||
|
const NONCE_LEN = 24
|
||||||
|
const MAC_LEN = 16
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptWithKey: vi.fn(async (data: ArrayBuffer, _key: ArrayBuffer) => {
|
||||||
|
const input = new Uint8Array(data)
|
||||||
|
const nonce = new Uint8Array(NONCE_LEN).fill(0xaa) // deterministic for tests
|
||||||
|
const ciphertext = new Uint8Array(input.length + MAC_LEN)
|
||||||
|
ciphertext.set(input) // "encrypt" = copy (for testing)
|
||||||
|
ciphertext.set(new Uint8Array(MAC_LEN).fill(0xbb), input.length) // fake MAC
|
||||||
|
|
||||||
|
const result = new Uint8Array(NONCE_LEN + ciphertext.length)
|
||||||
|
result.set(nonce)
|
||||||
|
result.set(ciphertext, NONCE_LEN)
|
||||||
|
return { encryptedData: result.buffer }
|
||||||
|
}),
|
||||||
|
|
||||||
|
decryptWithKey: vi.fn(
|
||||||
|
async (encryptedData: ArrayBuffer, _key: ArrayBuffer) => {
|
||||||
|
const input = new Uint8Array(encryptedData)
|
||||||
|
// Strip nonce (24B) and MAC (16B)
|
||||||
|
const plaintext = input.slice(NONCE_LEN, input.length - MAC_LEN)
|
||||||
|
return { data: plaintext.buffer }
|
||||||
|
}
|
||||||
|
),
|
||||||
|
} as unknown as VaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('VaultE2EEManager key management', () => {
|
||||||
|
it('stores an independent copy of the key', () => {
|
||||||
|
const vaultClient = createMockVaultClient()
|
||||||
|
const manager = new VaultE2EEManager(vaultClient)
|
||||||
|
|
||||||
|
const original = new Uint8Array([1, 2, 3, 4])
|
||||||
|
manager.setEncryptedSymmetricKey(original.buffer)
|
||||||
|
|
||||||
|
// Mutate original — should not affect stored key
|
||||||
|
original[0] = 99
|
||||||
|
|
||||||
|
// Access internal state via encryptData (which uses freshKeyBuffer)
|
||||||
|
// If the key was a view on the original, this would reflect the mutation
|
||||||
|
expect(manager.isDataChannelEncryptionEnabled).toBe(false) // _isDataChannelEncryptionEnabled not set
|
||||||
|
manager.isDataChannelEncryptionEnabled = true
|
||||||
|
expect(manager.isDataChannelEncryptionEnabled).toBe(true) // key is set
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isDataChannelEncryptionEnabled is false without key', () => {
|
||||||
|
const manager = new VaultE2EEManager(createMockVaultClient())
|
||||||
|
manager.isDataChannelEncryptionEnabled = true
|
||||||
|
expect(manager.isDataChannelEncryptionEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isDataChannelEncryptionEnabled is true with key + flag', () => {
|
||||||
|
const manager = new VaultE2EEManager(createMockVaultClient())
|
||||||
|
manager.setEncryptedSymmetricKey(new ArrayBuffer(32))
|
||||||
|
manager.isDataChannelEncryptionEnabled = true
|
||||||
|
expect(manager.isDataChannelEncryptionEnabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Data channel encrypt/decrypt round-trip ───────────────────────────
|
||||||
|
|
||||||
|
describe('VaultE2EEManager data channel encryption', () => {
|
||||||
|
let manager: VaultE2EEManager
|
||||||
|
let vaultClient: ReturnType<typeof createMockVaultClient>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vaultClient = createMockVaultClient()
|
||||||
|
manager = new VaultE2EEManager(vaultClient as unknown as VaultClient)
|
||||||
|
manager.setEncryptedSymmetricKey(new ArrayBuffer(32))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encryptData calls vaultClient.encryptWithKey', async () => {
|
||||||
|
const data = new Uint8Array([10, 20, 30])
|
||||||
|
const result = await manager.encryptData(data)
|
||||||
|
|
||||||
|
expect(vaultClient.encryptWithKey).toHaveBeenCalledOnce()
|
||||||
|
expect(result.payload).toBeInstanceOf(Uint8Array)
|
||||||
|
expect(result.payload.length).toBeGreaterThan(data.length) // overhead from nonce+MAC
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleEncryptedData calls vaultClient.decryptWithKey', async () => {
|
||||||
|
const data = new Uint8Array([10, 20, 30])
|
||||||
|
const encrypted = await manager.encryptData(data)
|
||||||
|
const decrypted = await manager.handleEncryptedData(
|
||||||
|
encrypted.payload,
|
||||||
|
new Uint8Array(0),
|
||||||
|
'participant-1',
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(vaultClient.decryptWithKey).toHaveBeenCalledOnce()
|
||||||
|
expect(new Uint8Array(decrypted.payload)).toEqual(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encryptData throws without key', async () => {
|
||||||
|
const noKeyManager = new VaultE2EEManager(
|
||||||
|
vaultClient as unknown as VaultClient
|
||||||
|
)
|
||||||
|
await expect(noKeyManager.encryptData(new Uint8Array([1]))).rejects.toThrow(
|
||||||
|
'No encrypted symmetric key set'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleEncryptedData throws without key', async () => {
|
||||||
|
const noKeyManager = new VaultE2EEManager(
|
||||||
|
vaultClient as unknown as VaultClient
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
noKeyManager.handleEncryptedData(
|
||||||
|
new Uint8Array([1]),
|
||||||
|
new Uint8Array(0),
|
||||||
|
'p',
|
||||||
|
0
|
||||||
|
)
|
||||||
|
).rejects.toThrow('No encrypted symmetric key set')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Frame format (header preservation) ────────────────────────────────
|
||||||
|
|
||||||
|
describe('Frame format — header preservation', () => {
|
||||||
|
let vaultClient: ReturnType<typeof createMockVaultClient>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vaultClient = createMockVaultClient()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encrypt preserves VP8 keyframe header (10 bytes)', async () => {
|
||||||
|
// Simulate what the sender transform does
|
||||||
|
const frameData = new Uint8Array(100)
|
||||||
|
// Fill with recognizable pattern: header = 0x01-0x0A, payload = 0xFF
|
||||||
|
for (let i = 0; i < 10; i++) frameData[i] = i + 1
|
||||||
|
frameData.fill(0xff, 10)
|
||||||
|
|
||||||
|
const unencryptedBytes = UNENCRYPTED_BYTES.key // 10
|
||||||
|
const header = frameData.slice(0, unencryptedBytes)
|
||||||
|
const payload = frameData.slice(unencryptedBytes)
|
||||||
|
|
||||||
|
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||||
|
payload.buffer,
|
||||||
|
new ArrayBuffer(32)
|
||||||
|
)
|
||||||
|
const encrypted = new Uint8Array(encryptedData)
|
||||||
|
|
||||||
|
// Reconstruct frame: [header][encrypted payload]
|
||||||
|
const newFrame = new Uint8Array(header.length + encrypted.length)
|
||||||
|
newFrame.set(header)
|
||||||
|
newFrame.set(encrypted, header.length)
|
||||||
|
|
||||||
|
// Verify header is preserved unencrypted
|
||||||
|
expect(newFrame.slice(0, 10)).toEqual(header)
|
||||||
|
// Verify the rest is different (encrypted)
|
||||||
|
expect(newFrame.length).toBeGreaterThan(frameData.length) // overhead
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encrypt + decrypt round-trip preserves original frame', async () => {
|
||||||
|
const frameData = new Uint8Array(50)
|
||||||
|
for (let i = 0; i < 50; i++) frameData[i] = i
|
||||||
|
|
||||||
|
const unencryptedBytes = UNENCRYPTED_BYTES.delta // 3
|
||||||
|
const header = frameData.slice(0, unencryptedBytes)
|
||||||
|
const payload = frameData.slice(unencryptedBytes)
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||||
|
payload.slice().buffer,
|
||||||
|
new ArrayBuffer(32)
|
||||||
|
)
|
||||||
|
const encrypted = new Uint8Array(encryptedData)
|
||||||
|
const encryptedFrame = new Uint8Array(header.length + encrypted.length)
|
||||||
|
encryptedFrame.set(header)
|
||||||
|
encryptedFrame.set(encrypted, header.length)
|
||||||
|
|
||||||
|
// Decrypt (receiver side)
|
||||||
|
const rxHeader = encryptedFrame.slice(0, unencryptedBytes)
|
||||||
|
const rxEncrypted = encryptedFrame.slice(unencryptedBytes)
|
||||||
|
const { data } = await vaultClient.decryptWithKey(
|
||||||
|
rxEncrypted.slice().buffer,
|
||||||
|
new ArrayBuffer(32)
|
||||||
|
)
|
||||||
|
const plaintext = new Uint8Array(data)
|
||||||
|
const decryptedFrame = new Uint8Array(rxHeader.length + plaintext.length)
|
||||||
|
decryptedFrame.set(rxHeader)
|
||||||
|
decryptedFrame.set(plaintext, rxHeader.length)
|
||||||
|
|
||||||
|
// Original frame should be recovered exactly
|
||||||
|
expect(decryptedFrame).toEqual(frameData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('audio frames preserve 1 byte header', async () => {
|
||||||
|
const frameData = new Uint8Array(20)
|
||||||
|
frameData[0] = 0xfc // Opus TOC byte
|
||||||
|
frameData.fill(0xab, 1)
|
||||||
|
|
||||||
|
const unencryptedBytes = UNENCRYPTED_BYTES.audio // 1
|
||||||
|
const header = frameData.slice(0, unencryptedBytes)
|
||||||
|
const payload = frameData.slice(unencryptedBytes)
|
||||||
|
|
||||||
|
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||||
|
payload.slice().buffer,
|
||||||
|
new ArrayBuffer(32)
|
||||||
|
)
|
||||||
|
const encrypted = new Uint8Array(encryptedData)
|
||||||
|
const encryptedFrame = new Uint8Array(header.length + encrypted.length)
|
||||||
|
encryptedFrame.set(header)
|
||||||
|
encryptedFrame.set(encrypted, header.length)
|
||||||
|
|
||||||
|
// First byte (Opus TOC) must be preserved
|
||||||
|
expect(encryptedFrame[0]).toBe(0xfc)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Proof: data transiting through LiveKit SFU is not decipherable ────
|
||||||
|
|
||||||
|
describe('SFU sees only encrypted data', () => {
|
||||||
|
let vaultClient: ReturnType<typeof createMockVaultClient>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vaultClient = createMockVaultClient()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encrypted frame payload does NOT match original payload', async () => {
|
||||||
|
// Simulate a VP8 keyframe with recognizable pixel data
|
||||||
|
const frameSize = 5000 // typical small video frame
|
||||||
|
const originalFrame = new Uint8Array(frameSize)
|
||||||
|
for (let i = 0; i < frameSize; i++) originalFrame[i] = i % 256
|
||||||
|
|
||||||
|
const headerSize = UNENCRYPTED_BYTES.key // 10
|
||||||
|
const header = originalFrame.slice(0, headerSize)
|
||||||
|
const payload = originalFrame.slice(headerSize)
|
||||||
|
|
||||||
|
// Encrypt (what the sender does before sending to SFU)
|
||||||
|
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||||
|
payload.slice().buffer,
|
||||||
|
new ArrayBuffer(32)
|
||||||
|
)
|
||||||
|
const encrypted = new Uint8Array(encryptedData)
|
||||||
|
|
||||||
|
// This is what the SFU sees: [header][encrypted payload]
|
||||||
|
const sfuFrame = new Uint8Array(header.length + encrypted.length)
|
||||||
|
sfuFrame.set(header)
|
||||||
|
sfuFrame.set(encrypted, header.length)
|
||||||
|
|
||||||
|
// The SFU frame is LARGER than original (nonce + MAC overhead)
|
||||||
|
expect(sfuFrame.length).toBe(originalFrame.length + 24 + 16) // +40B
|
||||||
|
|
||||||
|
// The header bytes are the same (unencrypted, needed for RTP)
|
||||||
|
expect(sfuFrame.slice(0, headerSize)).toEqual(header)
|
||||||
|
|
||||||
|
// The payload bytes are COMPLETELY DIFFERENT from the original
|
||||||
|
const sfuPayload = sfuFrame.slice(headerSize)
|
||||||
|
const originalPayload = originalFrame.slice(headerSize)
|
||||||
|
expect(sfuPayload.length).not.toBe(originalPayload.length)
|
||||||
|
expect(sfuPayload).not.toEqual(originalPayload)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encrypted payload cannot be reversed without vault decryption', async () => {
|
||||||
|
const originalPayload = new Uint8Array([72, 101, 108, 108, 111]) // "Hello"
|
||||||
|
|
||||||
|
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||||
|
originalPayload.slice().buffer,
|
||||||
|
new ArrayBuffer(32)
|
||||||
|
)
|
||||||
|
const encrypted = new Uint8Array(encryptedData)
|
||||||
|
|
||||||
|
// The encrypted data is 40 bytes larger (24B nonce + 16B MAC)
|
||||||
|
expect(encrypted.length).toBe(originalPayload.length + 24 + 16)
|
||||||
|
|
||||||
|
// No substring of the encrypted data matches the original payload
|
||||||
|
// (the nonce prepended and MAC appended obscure everything)
|
||||||
|
for (let i = 0; i <= encrypted.length - originalPayload.length; i++) {
|
||||||
|
const slice = encrypted.slice(i, i + originalPayload.length)
|
||||||
|
if (i === 24) {
|
||||||
|
// At offset 24 (after nonce), our mock "encrypts" by copying,
|
||||||
|
// so in a real vault this would NOT match. Skip this offset for
|
||||||
|
// the mock — the real test is the overhead structure.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expect(slice).not.toEqual(originalPayload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overhead is exactly 40 bytes (24B nonce + 16B MAC) per frame', async () => {
|
||||||
|
const testSizes = [10, 100, 1000, 5000, 20000]
|
||||||
|
|
||||||
|
for (const size of testSizes) {
|
||||||
|
const payload = new Uint8Array(size)
|
||||||
|
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||||
|
payload.buffer,
|
||||||
|
new ArrayBuffer(32)
|
||||||
|
)
|
||||||
|
const overhead = new Uint8Array(encryptedData).length - size
|
||||||
|
expect(overhead).toBe(40) // 24B nonce + 16B MAC = XChaCha20-Poly1305
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only codec header bytes leak — they contain no media content', () => {
|
||||||
|
// VP8 keyframe header is 10 bytes of codec metadata (not pixels)
|
||||||
|
// VP8 delta header is 3 bytes
|
||||||
|
// Opus audio header is 1 byte (TOC byte = codec config, not audio samples)
|
||||||
|
//
|
||||||
|
// These bytes tell the RTP packetizer how to split the frame into packets.
|
||||||
|
// They do NOT contain visual or audio content.
|
||||||
|
|
||||||
|
expect(UNENCRYPTED_BYTES.key).toBe(10) // VP8 payload descriptor
|
||||||
|
expect(UNENCRYPTED_BYTES.delta).toBe(3) // VP8 payload descriptor
|
||||||
|
expect(UNENCRYPTED_BYTES.audio).toBe(1) // Opus TOC byte
|
||||||
|
|
||||||
|
// Maximum leak per frame is 10 bytes out of typically 1000-50000 byte frames
|
||||||
|
// = 0.02% to 1% of frame data, and it's codec metadata, not content
|
||||||
|
const typicalKeyframeSize = 50000
|
||||||
|
const leakRatio = UNENCRYPTED_BYTES.key / typicalKeyframeSize
|
||||||
|
expect(leakRatio).toBeLessThan(0.001) // less than 0.1%
|
||||||
|
})
|
||||||
|
|
||||||
|
it('full sender→SFU→receiver pipeline: receiver recovers original, SFU cannot', async () => {
|
||||||
|
// Original video frame (sender side)
|
||||||
|
const originalFrame = new Uint8Array(200)
|
||||||
|
for (let i = 0; i < 200; i++) originalFrame[i] = (i * 7 + 13) % 256
|
||||||
|
const headerSize = UNENCRYPTED_BYTES.delta // 3
|
||||||
|
|
||||||
|
// ── SENDER: encrypt and send ──
|
||||||
|
const header = originalFrame.slice(0, headerSize)
|
||||||
|
const payload = originalFrame.slice(headerSize)
|
||||||
|
|
||||||
|
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||||
|
payload.slice().buffer,
|
||||||
|
new ArrayBuffer(32)
|
||||||
|
)
|
||||||
|
const encrypted = new Uint8Array(encryptedData)
|
||||||
|
const wireFrame = new Uint8Array(header.length + encrypted.length)
|
||||||
|
wireFrame.set(header)
|
||||||
|
wireFrame.set(encrypted, header.length)
|
||||||
|
|
||||||
|
// ── SFU: can only see wireFrame — cannot recover original ──
|
||||||
|
// The SFU would need to strip the nonce and decrypt the ciphertext,
|
||||||
|
// but it doesn't have the symmetric key (it's in the vault iframe).
|
||||||
|
expect(wireFrame).not.toEqual(originalFrame)
|
||||||
|
expect(wireFrame.length).not.toBe(originalFrame.length)
|
||||||
|
|
||||||
|
// ── RECEIVER: decrypt and recover ──
|
||||||
|
const rxHeader = wireFrame.slice(0, headerSize)
|
||||||
|
const rxEncrypted = wireFrame.slice(headerSize)
|
||||||
|
|
||||||
|
const { data } = await vaultClient.decryptWithKey(
|
||||||
|
rxEncrypted.slice().buffer,
|
||||||
|
new ArrayBuffer(32)
|
||||||
|
)
|
||||||
|
const decryptedPayload = new Uint8Array(data)
|
||||||
|
const recoveredFrame = new Uint8Array(rxHeader.length + decryptedPayload.length)
|
||||||
|
recoveredFrame.set(rxHeader)
|
||||||
|
recoveredFrame.set(decryptedPayload, rxHeader.length)
|
||||||
|
|
||||||
|
// Receiver gets the EXACT original frame
|
||||||
|
expect(recoveredFrame).toEqual(originalFrame)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* Custom E2EE Manager that delegates crypto to the VaultClient iframe.
|
||||||
|
*
|
||||||
|
* Uses XChaCha20-Poly1305 (libsodium) via the vault — the symmetric key
|
||||||
|
* never leaves the iframe. Preserves codec header bytes unencrypted so
|
||||||
|
* the WebRTC RTP packetizer can construct valid packets.
|
||||||
|
*
|
||||||
|
* Frame format (sender output / receiver input):
|
||||||
|
* [unencrypted codec header][vault-encrypted payload]
|
||||||
|
*
|
||||||
|
* Where vault-encrypted payload = [24B nonce][ciphertext + 16B Poly1305 MAC]
|
||||||
|
*
|
||||||
|
* Unencrypted header sizes (VP8):
|
||||||
|
* - keyframe: 10 bytes (VP8 payload descriptor)
|
||||||
|
* - delta: 3 bytes
|
||||||
|
* - audio: 1 byte (Opus TOC)
|
||||||
|
*/
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { Encryption_Type } from '@livekit/protocol'
|
||||||
|
import type { Room, RemoteTrack, Track } from 'livekit-client'
|
||||||
|
import { RoomEvent, ParticipantEvent, ConnectionState } from 'livekit-client'
|
||||||
|
import type { RTCEngine } from 'livekit-client/src/room/RTCEngine'
|
||||||
|
|
||||||
|
const E2EE_FLAG = Symbol('e2ee')
|
||||||
|
|
||||||
|
enum EncryptionEvent {
|
||||||
|
ParticipantEncryptionStatusChanged = 'participantEncryptionStatusChanged',
|
||||||
|
EncryptionError = 'encryptionError',
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsertableStreamSupported(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window.RTCRtpSender !== 'undefined' &&
|
||||||
|
// @ts-expect-error — createEncodedStreams not in TS types
|
||||||
|
typeof window.RTCRtpSender.prototype.createEncodedStreams !== 'undefined'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UNENCRYPTED_BYTES = { key: 10, delta: 3, audio: 1 }
|
||||||
|
|
||||||
|
export function getUnencryptedBytes(
|
||||||
|
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame
|
||||||
|
): number {
|
||||||
|
if (!('type' in frame)) return UNENCRYPTED_BYTES.audio
|
||||||
|
return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VaultE2EEManager extends EventEmitter {
|
||||||
|
private vaultClient: VaultClient
|
||||||
|
private room?: Room
|
||||||
|
private encryptionEnabled = false
|
||||||
|
private _isDataChannelEncryptionEnabled = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted symmetric key (wrapped for the user's vault public key).
|
||||||
|
* Stored as an independent copy so the original ArrayBuffer can't be detached.
|
||||||
|
*/
|
||||||
|
private encryptedKeyBytes: Uint8Array | null = null
|
||||||
|
|
||||||
|
constructor(vaultClient: VaultClient) {
|
||||||
|
super()
|
||||||
|
this.vaultClient = vaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
get isEnabled() {
|
||||||
|
return this.encryptionEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDataChannelEncryptionEnabled() {
|
||||||
|
return this._isDataChannelEncryptionEnabled && !!this.encryptedKeyBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
set isDataChannelEncryptionEnabled(enabled: boolean) {
|
||||||
|
this._isDataChannelEncryptionEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh ArrayBuffer copy of the key for each vault call (avoids postMessage detachment). */
|
||||||
|
private freshKeyBuffer(): ArrayBuffer {
|
||||||
|
return new Uint8Array(this.encryptedKeyBytes!).buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
setEncryptedSymmetricKey(key: ArrayBuffer): void {
|
||||||
|
this.encryptedKeyBytes = new Uint8Array(new Uint8Array(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle (mirrors built-in E2EEManager) ────────────────────────
|
||||||
|
|
||||||
|
setup(room: Room): void {
|
||||||
|
if (!isInsertableStreamSupported()) {
|
||||||
|
throw new Error(
|
||||||
|
'End-to-end encryption is not supported in this browser. ' +
|
||||||
|
'Please use a Chromium-based browser (Chrome, Edge, Brave).'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (room !== this.room) {
|
||||||
|
this.room = room
|
||||||
|
this.setupEventListeners(room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEngine(_engine: RTCEngine): void {}
|
||||||
|
|
||||||
|
setParticipantCryptorEnabled(
|
||||||
|
enabled: boolean,
|
||||||
|
participantIdentity: string
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
participantIdentity === this.room?.localParticipant.identity &&
|
||||||
|
this.encryptionEnabled !== enabled
|
||||||
|
) {
|
||||||
|
this.encryptionEnabled = enabled
|
||||||
|
this.emit(
|
||||||
|
EncryptionEvent.ParticipantEncryptionStatusChanged,
|
||||||
|
enabled,
|
||||||
|
this.room!.localParticipant
|
||||||
|
)
|
||||||
|
} else if (participantIdentity !== this.room?.localParticipant.identity) {
|
||||||
|
const p = this.room?.getParticipantByIdentity(participantIdentity)
|
||||||
|
if (p)
|
||||||
|
this.emit(
|
||||||
|
EncryptionEvent.ParticipantEncryptionStatusChanged,
|
||||||
|
enabled,
|
||||||
|
p
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSifTrailer(_trailer: Uint8Array): void {}
|
||||||
|
|
||||||
|
async encryptData(data: Uint8Array) {
|
||||||
|
if (!this.encryptedKeyBytes)
|
||||||
|
throw new Error('No encrypted symmetric key set')
|
||||||
|
const r = await this.vaultClient.encryptWithKey(
|
||||||
|
data.slice().buffer,
|
||||||
|
this.freshKeyBuffer()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
payload: new Uint8Array(r.encryptedData).slice(),
|
||||||
|
iv: new Uint8Array(0),
|
||||||
|
keyIndex: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEncryptedData(
|
||||||
|
payload: Uint8Array,
|
||||||
|
_iv: Uint8Array,
|
||||||
|
_participantIdentity: string,
|
||||||
|
_keyIndex: number
|
||||||
|
) {
|
||||||
|
if (!this.encryptedKeyBytes)
|
||||||
|
throw new Error('No encrypted symmetric key set')
|
||||||
|
const r = await this.vaultClient.decryptWithKey(
|
||||||
|
payload.slice().buffer,
|
||||||
|
this.freshKeyBuffer()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
payload: new Uint8Array(r.data).slice(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event listeners ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private setupEventListeners(room: Room): void {
|
||||||
|
room.on(RoomEvent.TrackPublished, (pub, participant) => {
|
||||||
|
this.setParticipantCryptorEnabled(
|
||||||
|
pub.trackInfo!.encryption !== Encryption_Type.NONE,
|
||||||
|
participant.identity
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||||
|
if (state === ConnectionState.Connected) {
|
||||||
|
room.remoteParticipants.forEach((p) => {
|
||||||
|
p.trackPublications.forEach((pub) => {
|
||||||
|
this.setParticipantCryptorEnabled(
|
||||||
|
pub.trackInfo!.encryption !== Encryption_Type.NONE,
|
||||||
|
p.identity
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
|
||||||
|
this.setupReceiver(track, participant.identity)
|
||||||
|
})
|
||||||
|
|
||||||
|
room.on(RoomEvent.SignalConnected, () => {
|
||||||
|
this.setParticipantCryptorEnabled(
|
||||||
|
room.localParticipant.isE2EEEnabled,
|
||||||
|
room.localParticipant.identity
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
room.localParticipant.on(
|
||||||
|
ParticipantEvent.LocalSenderCreated,
|
||||||
|
(sender: RTCRtpSender, track: Track) => {
|
||||||
|
this.setupSender(sender, track.mediaStreamID)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sender (encrypt outgoing frames) ────────────────────────────────
|
||||||
|
|
||||||
|
private setupSender(sender: RTCRtpSender, _trackId: string): void {
|
||||||
|
if (E2EE_FLAG in sender) return
|
||||||
|
if (!this.room?.localParticipant.identity) return
|
||||||
|
|
||||||
|
// @ts-expect-error — createEncodedStreams not in TS types
|
||||||
|
const streams = sender.createEncodedStreams()
|
||||||
|
|
||||||
|
const transformStream = new TransformStream({
|
||||||
|
transform: async (
|
||||||
|
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
|
||||||
|
controller: TransformStreamDefaultController
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!this.encryptedKeyBytes) return // drop — never send unencrypted
|
||||||
|
if (!frame.data || frame.data.byteLength === 0)
|
||||||
|
return controller.enqueue(frame)
|
||||||
|
|
||||||
|
const unencryptedBytes = getUnencryptedBytes(frame)
|
||||||
|
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
|
||||||
|
const payload = new Uint8Array(frame.data, unencryptedBytes)
|
||||||
|
|
||||||
|
const { encryptedData } = await this.vaultClient.encryptWithKey(
|
||||||
|
payload.slice().buffer,
|
||||||
|
this.freshKeyBuffer()
|
||||||
|
)
|
||||||
|
|
||||||
|
const encrypted = new Uint8Array(encryptedData)
|
||||||
|
const newData = new Uint8Array(
|
||||||
|
header.byteLength + encrypted.byteLength
|
||||||
|
)
|
||||||
|
newData.set(header)
|
||||||
|
newData.set(encrypted, header.byteLength)
|
||||||
|
frame.data = newData.buffer
|
||||||
|
controller.enqueue(frame)
|
||||||
|
} catch {
|
||||||
|
// Drop frame on error — never send unencrypted
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
streams.readable.pipeThrough(transformStream).pipeTo(streams.writable)
|
||||||
|
// @ts-expect-error
|
||||||
|
sender[E2EE_FLAG] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Receiver (decrypt incoming frames) ──────────────────────────────
|
||||||
|
|
||||||
|
private setupReceiver(track: RemoteTrack, participantIdentity: string): void {
|
||||||
|
if (!track.receiver) return
|
||||||
|
const receiver = track.receiver
|
||||||
|
if (E2EE_FLAG in receiver) return
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
let writable: WritableStream = receiver.writableStream
|
||||||
|
// @ts-expect-error
|
||||||
|
let readable: ReadableStream = receiver.readableStream
|
||||||
|
|
||||||
|
if (!writable || !readable) {
|
||||||
|
// @ts-expect-error
|
||||||
|
const streams = receiver.createEncodedStreams()
|
||||||
|
// @ts-expect-error
|
||||||
|
receiver.writableStream = streams.writable
|
||||||
|
writable = streams.writable
|
||||||
|
// @ts-expect-error
|
||||||
|
receiver.readableStream = streams.readable
|
||||||
|
readable = streams.readable
|
||||||
|
}
|
||||||
|
|
||||||
|
let successEmitted = false
|
||||||
|
|
||||||
|
const transformStream = new TransformStream({
|
||||||
|
transform: async (
|
||||||
|
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
|
||||||
|
controller: TransformStreamDefaultController
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!this.encryptedKeyBytes) return // drop — can't decrypt without key
|
||||||
|
if (!frame.data || frame.data.byteLength === 0)
|
||||||
|
return controller.enqueue(frame)
|
||||||
|
|
||||||
|
const unencryptedBytes = getUnencryptedBytes(frame)
|
||||||
|
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
|
||||||
|
const encryptedPayload = new Uint8Array(frame.data, unencryptedBytes)
|
||||||
|
|
||||||
|
const { data } = await this.vaultClient.decryptWithKey(
|
||||||
|
encryptedPayload.slice().buffer,
|
||||||
|
this.freshKeyBuffer()
|
||||||
|
)
|
||||||
|
|
||||||
|
const plaintext = new Uint8Array(data)
|
||||||
|
const newData = new Uint8Array(
|
||||||
|
header.byteLength + plaintext.byteLength
|
||||||
|
)
|
||||||
|
newData.set(header)
|
||||||
|
newData.set(plaintext, header.byteLength)
|
||||||
|
frame.data = newData.buffer
|
||||||
|
controller.enqueue(frame)
|
||||||
|
|
||||||
|
if (!successEmitted) {
|
||||||
|
successEmitted = true
|
||||||
|
const p = this.room?.getParticipantByIdentity(participantIdentity)
|
||||||
|
if (p)
|
||||||
|
this.emit(
|
||||||
|
EncryptionEvent.ParticipantEncryptionStatusChanged,
|
||||||
|
true,
|
||||||
|
p
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Drop frame — keeps pipe alive, avoids sending corrupt data to decoder
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
readable
|
||||||
|
.pipeThrough(transformStream)
|
||||||
|
.pipeTo(writable)
|
||||||
|
.catch(() => {})
|
||||||
|
// @ts-expect-error
|
||||||
|
receiver[E2EE_FLAG] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface VaultClient {
|
||||||
|
init(): Promise<void>
|
||||||
|
destroy(): void
|
||||||
|
setTheme(theme: string): void
|
||||||
|
setAuthContext(context: { suiteUserId: string }): void
|
||||||
|
hasKeys(): Promise<{ hasKeys: boolean }>
|
||||||
|
getPublicKey(): Promise<{ publicKey: ArrayBuffer }>
|
||||||
|
encryptWithoutKey(
|
||||||
|
data: ArrayBuffer,
|
||||||
|
userPublicKeys: Record<string, ArrayBuffer>,
|
||||||
|
options?: { optimizeMemory?: boolean }
|
||||||
|
): Promise<{
|
||||||
|
encryptedContent: ArrayBuffer
|
||||||
|
encryptedKeys: Record<string, ArrayBuffer>
|
||||||
|
}>
|
||||||
|
encryptWithKey(
|
||||||
|
data: ArrayBuffer,
|
||||||
|
encryptedSymmetricKey: ArrayBuffer,
|
||||||
|
encryptedKeyChain?: ArrayBuffer[],
|
||||||
|
options?: { optimizeMemory?: boolean }
|
||||||
|
): Promise<{ encryptedData: ArrayBuffer }>
|
||||||
|
decryptWithKey(
|
||||||
|
encryptedData: ArrayBuffer,
|
||||||
|
encryptedSymmetricKey: ArrayBuffer,
|
||||||
|
encryptedKeyChain?: ArrayBuffer[],
|
||||||
|
options?: { optimizeMemory?: boolean }
|
||||||
|
): Promise<{ data: ArrayBuffer }>
|
||||||
|
shareKeys(
|
||||||
|
encryptedSymmetricKey: ArrayBuffer,
|
||||||
|
userPublicKeys: Record<string, ArrayBuffer>
|
||||||
|
): Promise<{ encryptedKeys: Record<string, ArrayBuffer> }>
|
||||||
|
fetchPublicKeys(
|
||||||
|
userIds: string[]
|
||||||
|
): Promise<{ publicKeys: Record<string, ArrayBuffer> }>
|
||||||
|
checkFingerprints(
|
||||||
|
userFingerprints: Record<string, string>,
|
||||||
|
currentUserId?: string
|
||||||
|
): Promise<{
|
||||||
|
results: Array<{
|
||||||
|
userId: string
|
||||||
|
knownFingerprint: string | null
|
||||||
|
providedFingerprint: string
|
||||||
|
status: 'trusted' | 'refused' | 'unknown'
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
acceptFingerprint(userId: string, fingerprint: string): Promise<void>
|
||||||
|
refuseFingerprint(userId: string, fingerprint: string): Promise<void>
|
||||||
|
getKnownFingerprints(): Promise<{
|
||||||
|
fingerprints: Record<
|
||||||
|
string,
|
||||||
|
{ fingerprint: string; status: 'trusted' | 'refused' | 'unknown' }
|
||||||
|
>
|
||||||
|
}>
|
||||||
|
openOnboarding(container: HTMLElement): void
|
||||||
|
openBackup(container: HTMLElement): void
|
||||||
|
openRestore(container: HTMLElement): void
|
||||||
|
openDeviceTransfer(container: HTMLElement): void
|
||||||
|
openSettings(container: HTMLElement): void
|
||||||
|
closeInterface(): void
|
||||||
|
on<K extends string>(event: K, listener: (data: unknown) => void): void
|
||||||
|
off<K extends string>(event: K, listener: (data: unknown) => void): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable error codes carried by `VaultError`. Sourced from the
|
||||||
|
* encryption SDK (re-exported on `window.EncryptionClient.VaultErrorCode`)
|
||||||
|
* — meet consumers match on these via `(err as VaultError).code` rather
|
||||||
|
* than regexing message text. Keep in sync with the SDK definition.
|
||||||
|
*/
|
||||||
|
type VaultErrorCode =
|
||||||
|
| 'MISSING_KEYS'
|
||||||
|
| 'WRONG_SECRET_KEY'
|
||||||
|
| 'INVALID_BACKUP'
|
||||||
|
| 'INVALID_MNEMONIC'
|
||||||
|
| 'NOT_INITIALIZED'
|
||||||
|
| 'AUTH_REQUIRED'
|
||||||
|
| 'PRIVILEGED_ORIGIN_REQUIRED'
|
||||||
|
| 'TIMEOUT'
|
||||||
|
| 'IFRAME_REQUIRED'
|
||||||
|
| 'CIPHERTEXT_TOO_SHORT'
|
||||||
|
| 'UNKNOWN'
|
||||||
|
|
||||||
|
interface VaultError extends Error {
|
||||||
|
readonly code: VaultErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
EncryptionClient: {
|
||||||
|
VaultClient: new (options: {
|
||||||
|
vaultUrl: string
|
||||||
|
interfaceUrl: string
|
||||||
|
timeout?: number
|
||||||
|
theme?: string
|
||||||
|
lang?: string
|
||||||
|
}) => VaultClient
|
||||||
|
VaultError: new (code: VaultErrorCode, message: string) => VaultError
|
||||||
|
VaultErrorCode: { readonly [K in VaultErrorCode]: K }
|
||||||
|
isVaultError: (err: unknown) => err is VaultError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export { VaultClientProvider, useVaultClient } from './VaultClientProvider'
|
||||||
|
export type { VaultClientContextValue } from './VaultClientProvider'
|
||||||
|
export {
|
||||||
|
determineTrustLevel,
|
||||||
|
getTrustLevelFromAttributes,
|
||||||
|
distributeKeyViaPKI,
|
||||||
|
encodeTrustLevelAttribute,
|
||||||
|
} from './HybridKeyDistributor'
|
||||||
|
export type { ParticipantEncryptionInfo } from './HybridKeyDistributor'
|
||||||
|
export { EncryptionBadge } from './EncryptionBadge'
|
||||||
|
export { EncryptedMeetingBanner } from './EncryptedMeetingBanner'
|
||||||
|
export { EncryptionTrustModal } from './EncryptionTrustModal'
|
||||||
|
export { EncryptionIdentityDialog } from './EncryptionIdentityDialog'
|
||||||
|
export { useParticipantTrustLevel } from './useParticipantTrustLevel'
|
||||||
|
|
||||||
|
export { PARTICIPANT_TRUST_ATTR } from './types'
|
||||||
|
export type { TrustLevel } from './types'
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Key storage and passphrase utilities for E2EE lobby flow.
|
||||||
|
*
|
||||||
|
* Basic mode: passphrase is in the URL hash — shared by sharing the link.
|
||||||
|
* Advanced mode: vault-wrapped symmetric key exchanged via lobby REST API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Module-level symmetric key (basic mode) ───────────────────────────
|
||||||
|
|
||||||
|
let _symmetricKey: Uint8Array | null = null
|
||||||
|
|
||||||
|
export function setSymmetricKey(key: Uint8Array): void {
|
||||||
|
_symmetricKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSymmetricKey(): Uint8Array | null {
|
||||||
|
return _symmetricKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSymmetricKey(): void {
|
||||||
|
_symmetricKey = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Module-level encrypted vault key (advanced mode) ──────────────────
|
||||||
|
|
||||||
|
let _encryptedVaultKey: ArrayBuffer | null = null
|
||||||
|
|
||||||
|
export function setEncryptedVaultKey(key: ArrayBuffer): void {
|
||||||
|
_encryptedVaultKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEncryptedVaultKey(): ArrayBuffer | null {
|
||||||
|
return _encryptedVaultKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passphrase generation (basic mode) ────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random passphrase for basic mode encryption.
|
||||||
|
* 24 random bytes encoded in base36 = 48 alphanumeric characters.
|
||||||
|
*/
|
||||||
|
export function generatePassphrase(): string {
|
||||||
|
return Array.from(crypto.getRandomValues(new Uint8Array(24)))
|
||||||
|
.map((b) => b.toString(36).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expected length of a basic mode passphrase */
|
||||||
|
export const BASIC_KEY_LENGTH = 48
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Trust level for a participant's encryption key distribution.
|
||||||
|
*
|
||||||
|
* - 'verified': Key was distributed via PKI (public key registered in encryption library).
|
||||||
|
* Identity is cryptographically verified.
|
||||||
|
* - 'authenticated': Key was distributed via ephemeral DH, but participant is authenticated
|
||||||
|
* via ProConnect. Identity is server-verified, not cryptographically.
|
||||||
|
* - 'anonymous': Key was distributed via ephemeral DH, participant is not authenticated.
|
||||||
|
* Identity is self-declared.
|
||||||
|
*/
|
||||||
|
export type TrustLevel = 'verified' | 'authenticated' | 'anonymous' | 'refused' | 'unknown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata attached to participant attributes for encryption trust level.
|
||||||
|
*/
|
||||||
|
export const PARTICIPANT_TRUST_ATTR = 'encryption.trustLevel'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data channel topic for encryption key exchange protocol.
|
||||||
|
*/
|
||||||
|
export const KEY_EXCHANGE_TOPIC = 'encryption-key-exchange'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message types for the in-call key exchange protocol.
|
||||||
|
*/
|
||||||
|
export enum KeyExchangeMessageType {
|
||||||
|
/** New participant sends their ephemeral public key to request the symmetric key */
|
||||||
|
KEY_REQUEST = 'KEY_REQUEST',
|
||||||
|
/** Existing participant responds with the symmetric key encrypted for the requester */
|
||||||
|
KEY_RESPONSE = 'KEY_RESPONSE',
|
||||||
|
/** Requester confirms receipt of the key */
|
||||||
|
KEY_ACK = 'KEY_ACK',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyExchangeMessage {
|
||||||
|
type: KeyExchangeMessageType
|
||||||
|
/** Sender's participant identity */
|
||||||
|
senderIdentity: string
|
||||||
|
/** Target participant identity (for directed messages) */
|
||||||
|
targetIdentity?: string
|
||||||
|
/** Base64-encoded payload */
|
||||||
|
payload: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Hook that determines a participant's trust level and fingerprint status
|
||||||
|
* by checking the vault (encryption library) via VaultClient.
|
||||||
|
*
|
||||||
|
* In advanced mode:
|
||||||
|
* - Checks if the participant has a registered public key
|
||||||
|
* - Checks the fingerprint status (trusted/refused/unknown)
|
||||||
|
* - Returns "verified" only if they have a public key
|
||||||
|
*
|
||||||
|
* In basic mode:
|
||||||
|
* - Only uses authentication status (no vault check)
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useVaultClient } from './VaultClientProvider'
|
||||||
|
import type { TrustLevel } from './types'
|
||||||
|
|
||||||
|
/** Compute a fingerprint from a public key (same as encryption repo: SHA-256, first 16 hex chars) */
|
||||||
|
async function computeFingerprint(publicKey: ArrayBuffer): Promise<string> {
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', publicKey)
|
||||||
|
return Array.from(new Uint8Array(hash))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
.slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format for display: "a1b2c3d4e5f67890" → "A1B2 C3D4 E5F6 7890" */
|
||||||
|
export function formatFingerprint(fp: string): string {
|
||||||
|
return fp.replace(/(.{4})/g, '$1 ').trim().toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FingerprintStatus = 'loading' | 'trusted' | 'refused' | 'unknown' | 'no-key' | 'error'
|
||||||
|
|
||||||
|
export function useParticipantTrustLevel(
|
||||||
|
attributes: Record<string, string> | undefined,
|
||||||
|
encryptionMode?: string,
|
||||||
|
isSelf?: boolean,
|
||||||
|
): { trustLevel: TrustLevel; fingerprintStatus: FingerprintStatus; fingerprint: string | null } {
|
||||||
|
const { client: vaultClient } = useVaultClient()
|
||||||
|
const [fingerprintStatus, setFingerprintStatus] = useState<FingerprintStatus>('loading')
|
||||||
|
const [fingerprint, setFingerprint] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const isAuthenticated = attributes?.is_authenticated === 'true'
|
||||||
|
const suiteUserId = attributes?.suite_user_id
|
||||||
|
const isAdvanced = encryptionMode === 'advanced'
|
||||||
|
|
||||||
|
// Re-check when a fingerprint is accepted/refused via VaultClient
|
||||||
|
const [revision, setRevision] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vaultClient) return
|
||||||
|
const handler = () => setRevision((r) => r + 1)
|
||||||
|
vaultClient.on('fingerprint-changed', handler)
|
||||||
|
return () => { vaultClient.off('fingerprint-changed', handler) }
|
||||||
|
}, [vaultClient])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdvanced || !isAuthenticated) {
|
||||||
|
setFingerprintStatus('no-key')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!vaultClient || !suiteUserId) {
|
||||||
|
setFingerprintStatus(vaultClient ? 'no-key' : 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
const { publicKeys } = await vaultClient!.fetchPublicKeys([suiteUserId!])
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const publicKey = publicKeys[suiteUserId!]
|
||||||
|
if (!publicKey) {
|
||||||
|
setFingerprintStatus('no-key')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the fingerprint from the public key (SHA-256, first 16 hex chars)
|
||||||
|
const fp = await computeFingerprint(publicKey)
|
||||||
|
if (cancelled) return
|
||||||
|
setFingerprint(fp)
|
||||||
|
|
||||||
|
// Own fingerprint is always trusted — we hold the private key
|
||||||
|
if (isSelf) {
|
||||||
|
setFingerprintStatus('trusted')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have a known fingerprint in the local registry
|
||||||
|
const { fingerprints: known } = await vaultClient!.getKnownFingerprints()
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const knownEntry = known[suiteUserId!]
|
||||||
|
if (!knownEntry) {
|
||||||
|
// Never seen — unknown, needs explicit acceptance
|
||||||
|
setFingerprintStatus('unknown')
|
||||||
|
} else if (knownEntry.fingerprint === fp) {
|
||||||
|
// Same fingerprint — use stored status
|
||||||
|
setFingerprintStatus(knownEntry.status as FingerprintStatus)
|
||||||
|
} else {
|
||||||
|
// Different fingerprint — key changed, needs re-verification
|
||||||
|
setFingerprintStatus('unknown')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setFingerprintStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [vaultClient, suiteUserId, isAuthenticated, isAdvanced, isSelf, revision])
|
||||||
|
|
||||||
|
// Derive trust level from fingerprint status
|
||||||
|
let trustLevel: TrustLevel
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
trustLevel = 'anonymous'
|
||||||
|
} else if (!isAdvanced) {
|
||||||
|
// Basic mode: only authentication matters
|
||||||
|
trustLevel = 'authenticated'
|
||||||
|
} else if (fingerprintStatus === 'trusted') {
|
||||||
|
trustLevel = 'verified'
|
||||||
|
} else if (fingerprintStatus === 'refused') {
|
||||||
|
trustLevel = 'refused'
|
||||||
|
} else if (fingerprintStatus === 'no-key' || fingerprintStatus === 'error') {
|
||||||
|
// Authenticated but no vault keys — show as authenticated (blue)
|
||||||
|
trustLevel = 'authenticated'
|
||||||
|
} else {
|
||||||
|
// 'unknown' or 'loading' — has key but not yet verified
|
||||||
|
trustLevel = 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { trustLevel, fingerprintStatus, fingerprint }
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { Button, Dialog, type DialogProps, Text } from '@/primitives'
|
||||||
|
import { VStack, HStack } from '@/styled-system/jsx'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { RiLockFill, RiShieldCheckFill, RiAlertLine } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { useVaultClient } from '@/features/encryption'
|
||||||
|
|
||||||
|
export const EncryptionModeDialog = ({
|
||||||
|
onSelect,
|
||||||
|
isForLater = false,
|
||||||
|
...dialogProps
|
||||||
|
}: {
|
||||||
|
onSelect: (mode: ApiEncryptionMode) => void
|
||||||
|
isForLater?: boolean
|
||||||
|
} & Omit<DialogProps, 'title'>) => {
|
||||||
|
const { t } = useTranslation('home', { keyPrefix: 'encryptionModeDialog' })
|
||||||
|
const { hasKeys, client: vaultClient, error: vaultError, isLoading: vaultLoading } = useVaultClient()
|
||||||
|
const vaultUnavailable = !vaultClient && !vaultLoading
|
||||||
|
const canUseAdvanced = !!hasKeys && !vaultUnavailable
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={t('title')} isOpen {...dialogProps}>
|
||||||
|
<VStack gap="1rem" alignItems="stretch">
|
||||||
|
<Text variant="sm" className={css({ color: 'greyscale.700' })}>
|
||||||
|
{t('description')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.75rem',
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.200',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'border-color 150ms ease, background-color 150ms ease',
|
||||||
|
_hover: {
|
||||||
|
borderColor: 'primary.500',
|
||||||
|
backgroundColor: 'primary.50',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
onClick={() => onSelect(ApiEncryptionMode.BASIC)}
|
||||||
|
>
|
||||||
|
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
|
||||||
|
<RiLockFill size={20} color="#2563eb" />
|
||||||
|
</div>
|
||||||
|
<VStack gap="0.25rem" alignItems="flex-start">
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
bold
|
||||||
|
className={css({ color: 'greyscale.900' })}
|
||||||
|
>
|
||||||
|
{t('basic.title')}
|
||||||
|
</Text>
|
||||||
|
<Text variant="sm" className={css({ color: 'greyscale.600' })}>
|
||||||
|
{t('basic.description')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.75rem',
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.200',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
cursor: canUseAdvanced ? 'pointer' : 'not-allowed',
|
||||||
|
textAlign: 'left',
|
||||||
|
opacity: canUseAdvanced ? 1 : 0.5,
|
||||||
|
transition:
|
||||||
|
'border-color 150ms ease, background-color 150ms ease',
|
||||||
|
_hover: canUseAdvanced
|
||||||
|
? {
|
||||||
|
borderColor: 'green.500',
|
||||||
|
backgroundColor: 'green.50',
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
})}
|
||||||
|
onClick={() => canUseAdvanced && onSelect(ApiEncryptionMode.ADVANCED)}
|
||||||
|
disabled={!canUseAdvanced}
|
||||||
|
>
|
||||||
|
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
|
||||||
|
<RiShieldCheckFill
|
||||||
|
size={20}
|
||||||
|
color={canUseAdvanced ? '#166534' : '#9ca3af'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<VStack gap="0.25rem" alignItems="flex-start">
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
bold
|
||||||
|
className={css({
|
||||||
|
color: canUseAdvanced ? 'greyscale.900' : 'greyscale.400',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('advanced.title')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
className={css({
|
||||||
|
color: canUseAdvanced ? 'greyscale.600' : 'greyscale.400',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('advanced.description')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</button>
|
||||||
|
{!canUseAdvanced && (
|
||||||
|
<HStack
|
||||||
|
gap="0.4rem"
|
||||||
|
className={css({
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
backgroundColor: vaultUnavailable ? 'red.50' : 'orange.50',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiAlertLine
|
||||||
|
size={14}
|
||||||
|
color={vaultUnavailable ? '#dc2626' : '#d97706'}
|
||||||
|
className={css({ flexShrink: 0 })}
|
||||||
|
/>
|
||||||
|
<Text variant="note" className={css({ color: vaultUnavailable ? 'red.800' : 'orange.800' })}>
|
||||||
|
{vaultUnavailable
|
||||||
|
? t('advanced.serviceUnavailable')
|
||||||
|
: t('advanced.onboardingRequired')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</VStack>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,35 +1,147 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
|
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { isRoomValid } from '@/features/rooms'
|
import { isRoomValid } from '@/features/rooms'
|
||||||
|
import { normalizeRoomId } from '@/features/rooms/utils/isRoomValid'
|
||||||
|
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||||
|
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
export const JoinMeetingDialog = () => {
|
export const JoinMeetingDialog = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
|
const [step, setStep] = useState<'room' | 'passphrase'>('room')
|
||||||
|
const [roomId, setRoomId] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
|
const parseInput = (input: string): { roomId: string; hash: string } => {
|
||||||
const roomId = (data.roomId as string)
|
const trimmed = input.trim()
|
||||||
.trim()
|
try {
|
||||||
.replace(`${window.location.origin}/`, '')
|
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 URL already has a hash, navigate directly with it
|
||||||
|
if (parsed.hash) {
|
||||||
|
navigateTo('room', parsed.roomId)
|
||||||
|
window.location.hash = parsed.hash
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the room uses basic encryption (needs passphrase)
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const room = await fetchRoom({ roomId: parsed.roomId })
|
||||||
|
if (room.encryption_mode === ApiEncryptionMode.BASIC) {
|
||||||
|
setRoomId(parsed.roomId)
|
||||||
|
setStep('passphrase')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigateTo('room', parsed.roomId)
|
||||||
|
} catch {
|
||||||
|
// Room doesn't exist yet or error — navigate anyway
|
||||||
|
navigateTo('room', parsed.roomId)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePassphraseSubmit = (data: { passphrase?: FormDataEntryValue }) => {
|
||||||
|
const passphrase = (data.passphrase as string).trim()
|
||||||
navigateTo('room', roomId)
|
navigateTo('room', roomId)
|
||||||
|
window.location.hash = passphrase
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateRoomId = (value: string) => {
|
const validateRoomId = (value: string) => {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
return !isRoomValid(trimmed) ? (
|
const { roomId: id } = parseInput(trimmed)
|
||||||
|
return !isRoomValid(id) ? (
|
||||||
<>
|
<>
|
||||||
<p>{t('joinInputError')}</p>
|
<p>{t('joinInputError')}</p>
|
||||||
<Ul>
|
<Ul>
|
||||||
<li>{window.location.origin}/uio-azer-jkl</li>
|
<li>{window.location.origin}/uio-azer-jkl</li>
|
||||||
<li>uio-azer-jkl</li>
|
<li>uio-azer-jkl</li>
|
||||||
|
<li>uioazerjkl</li>
|
||||||
</Ul>
|
</Ul>
|
||||||
</>
|
</>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (step === 'passphrase') {
|
||||||
|
return (
|
||||||
|
<Dialog title={t('joinMeeting')}>
|
||||||
|
<Form onSubmit={handlePassphraseSubmit} submitLabel={t('joinPassphraseSubmit')}>
|
||||||
|
<P
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: t('joinPassphraseDescription', {
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'greyscale.100',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.200',
|
||||||
|
'& strong': {
|
||||||
|
color: '#16a34a',
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: t('joinPassphraseExample', {
|
||||||
|
origin: window.location.origin,
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* eslint-disable jsx-a11y/no-autofocus */}
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
isRequired
|
||||||
|
name="passphrase"
|
||||||
|
label={t('joinPassphraseLabel')}
|
||||||
|
errorMessage={t('joinPassphraseError')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<P
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: '#b45309',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('joinPassphraseWarning')}
|
||||||
|
</P>
|
||||||
|
</Form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={t('joinMeeting')}>
|
<Dialog title={t('joinMeeting')}>
|
||||||
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
|
<Form onSubmit={handleRoomSubmit} submitLabel={isLoading ? '...' : t('joinInputSubmit')}>
|
||||||
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
|
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
|
||||||
<Field
|
<Field
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRo
|
|||||||
// fixme - duplication with the InviteDialog
|
// fixme - duplication with the InviteDialog
|
||||||
export const LaterMeetingDialog = ({
|
export const LaterMeetingDialog = ({
|
||||||
room,
|
room,
|
||||||
|
hash,
|
||||||
...dialogProps
|
...dialogProps
|
||||||
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
|
}: { room: null | ApiRoom; hash?: string } & Omit<DialogProps, 'title'>) => {
|
||||||
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
||||||
|
|
||||||
const roomUrl = room && getRouteUrl('room', room?.slug)
|
const roomUrl = room ? `${getRouteUrl('room', room.slug)}${hash ? `#${hash}` : ''}` : null
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
@@ -31,7 +32,7 @@ export const LaterMeetingDialog = ({
|
|||||||
copyRoomToClipboard,
|
copyRoomToClipboard,
|
||||||
isRoomUrlCopied,
|
isRoomUrlCopied,
|
||||||
copyRoomUrlToClipboard,
|
copyRoomUrlToClipboard,
|
||||||
} = useCopyRoomToClipboard(room || undefined)
|
} = useCopyRoomToClipboard(room || undefined, hash)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
|
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
import { DialogTrigger, MenuItem, Menu as RACMenu, Separator as RACSeparator } from 'react-aria-components'
|
||||||
import { Button, Menu } from '@/primitives'
|
import { Button, Menu } from '@/primitives'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
@@ -7,8 +7,12 @@ import { Screen } from '@/layout/Screen'
|
|||||||
import { generateRoomId, useCreateRoom } from '@/features/rooms'
|
import { generateRoomId, useCreateRoom } from '@/features/rooms'
|
||||||
import { useUser, UserAware } from '@/features/auth'
|
import { useUser, UserAware } from '@/features/auth'
|
||||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
import { RiAddLine, RiLink, RiLockLine, RiShieldKeyholeLine } from '@remixicon/react'
|
||||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||||
|
import { EncryptionModeDialog } from '@/features/home/components/EncryptionModeDialog'
|
||||||
|
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
|
||||||
|
import { useVaultClient } from '@/features/encryption'
|
||||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||||
import { ReactNode, useEffect, useState } from 'react'
|
import { ReactNode, useEffect, useState } from 'react'
|
||||||
@@ -155,7 +159,9 @@ export const Home = () => {
|
|||||||
} = usePersistentUserChoices()
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
const { client: vaultClient } = useVaultClient()
|
||||||
|
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom; hash?: string }>(null)
|
||||||
|
const [encryptionDialogMode, setEncryptionDialogMode] = useState<null | 'instant' | 'later'>(null)
|
||||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -229,7 +235,7 @@ export const Home = () => {
|
|||||||
onAction={() => {
|
onAction={() => {
|
||||||
const slug = generateRoomId()
|
const slug = generateRoomId()
|
||||||
createRoom({ slug, username }).then((data) =>
|
createRoom({ slug, username }).then((data) =>
|
||||||
setLaterRoom(data)
|
setLaterRoom({ room: data })
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
data-attr="create-option-later"
|
data-attr="create-option-later"
|
||||||
@@ -237,6 +243,37 @@ export const Home = () => {
|
|||||||
<RiLink size={18} />
|
<RiLink size={18} />
|
||||||
{t('createMenu.laterOption')}
|
{t('createMenu.laterOption')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{data?.encryption?.enabled && (
|
||||||
|
<>
|
||||||
|
<RACSeparator
|
||||||
|
className={css({
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'greyscale.200',
|
||||||
|
margin: '0.25rem 0',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
className={
|
||||||
|
menuRecipe({ icon: true, variant: 'light' }).item
|
||||||
|
}
|
||||||
|
onAction={() => setEncryptionDialogMode('instant')}
|
||||||
|
data-attr="create-option-encrypted-instant"
|
||||||
|
>
|
||||||
|
<RiLockLine size={18} />
|
||||||
|
{t('createMenu.encryptedInstantOption')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
className={
|
||||||
|
menuRecipe({ icon: true, variant: 'light' }).item
|
||||||
|
}
|
||||||
|
onAction={() => setEncryptionDialogMode('later')}
|
||||||
|
data-attr="create-option-encrypted-later"
|
||||||
|
>
|
||||||
|
<RiShieldKeyholeLine size={18} />
|
||||||
|
{t('createMenu.encryptedLaterOption')}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</RACMenu>
|
</RACMenu>
|
||||||
</Menu>
|
</Menu>
|
||||||
) : (
|
) : (
|
||||||
@@ -265,9 +302,58 @@ export const Home = () => {
|
|||||||
</RightColumn>
|
</RightColumn>
|
||||||
</Columns>
|
</Columns>
|
||||||
<LaterMeetingDialog
|
<LaterMeetingDialog
|
||||||
room={laterRoom}
|
room={laterRoom?.room ?? null}
|
||||||
|
hash={laterRoom?.hash}
|
||||||
onOpenChange={() => setLaterRoom(null)}
|
onOpenChange={() => setLaterRoom(null)}
|
||||||
/>
|
/>
|
||||||
|
{encryptionDialogMode && (
|
||||||
|
<EncryptionModeDialog
|
||||||
|
onSelect={async (mode) => {
|
||||||
|
const dialogMode = encryptionDialogMode
|
||||||
|
setEncryptionDialogMode(null)
|
||||||
|
const slug = generateRoomId()
|
||||||
|
const hash = mode === ApiEncryptionMode.BASIC ? generatePassphrase() : undefined
|
||||||
|
|
||||||
|
let encryptedSymmetricKey = ''
|
||||||
|
if (mode === ApiEncryptionMode.ADVANCED && vaultClient) {
|
||||||
|
// encryptWithoutKey requires data to encrypt, but we only care about
|
||||||
|
// the generated symmetric key (encryptedKeys), not the encrypted content.
|
||||||
|
// The same symmetric key will be used for all streams (video/audio/chat).
|
||||||
|
const dummyData = new Uint8Array(32).buffer
|
||||||
|
const { publicKey } = await vaultClient.getPublicKey()
|
||||||
|
const { encryptedKeys } = await vaultClient.encryptWithoutKey(
|
||||||
|
dummyData,
|
||||||
|
{ self: publicKey }
|
||||||
|
)
|
||||||
|
const keyBytes = new Uint8Array(encryptedKeys['self'])
|
||||||
|
encryptedSymmetricKey = btoa(String.fromCharCode(...keyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoom({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
encryptionMode: mode,
|
||||||
|
encryptedSymmetricKey,
|
||||||
|
}).then((data) => {
|
||||||
|
if (dialogMode === 'instant') {
|
||||||
|
navigateTo('room', data.slug, {
|
||||||
|
state: { create: true, initialRoomData: data },
|
||||||
|
})
|
||||||
|
if (hash) {
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
'',
|
||||||
|
`${window.location.pathname}#${hash}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setLaterRoom({ room: data, hash })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onOpenChange={() => setEncryptionDialogMode(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Screen>
|
</Screen>
|
||||||
</UserAware>
|
</UserAware>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { cva, RecipeVariantProps } from '@/styled-system/css'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const controlBarRegion = cva({
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flex: '1 1 33%',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '0.65rem',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
mobile: {
|
|
||||||
true: {
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
width: '330px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
mobile: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ControlBarRegionProps = React.HTMLAttributes<HTMLDivElement> &
|
|
||||||
RecipeVariantProps<typeof controlBarRegion>
|
|
||||||
|
|
||||||
export function ControlBarRegion({
|
|
||||||
children,
|
|
||||||
mobile,
|
|
||||||
...props
|
|
||||||
}: ControlBarRegionProps) {
|
|
||||||
const { t } = useTranslation('rooms')
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="region"
|
|
||||||
aria-label={t('controls.region')}
|
|
||||||
className={controlBarRegion({ mobile })}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// RoomContentArea.tsx
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { cva } from '@/styled-system/css'
|
|
||||||
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|
||||||
import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
|
||||||
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
|
||||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
|
||||||
|
|
||||||
const RoomViewport = styled(
|
|
||||||
'div',
|
|
||||||
cva({
|
|
||||||
base: {
|
|
||||||
position: 'absolute',
|
|
||||||
maxHeight: '100%',
|
|
||||||
transition:
|
|
||||||
'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms, padding .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
isSidePanelOpen: {
|
|
||||||
true: {
|
|
||||||
inset: `var(--lk-grid-gap) calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin) * 2) calc(var(--sizes-room-control-bar)) 16px`,
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
inset: `var(--lk-grid-gap) var(--lk-grid-gap) calc(var(--sizes-room-control-bar))`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
isReactionToolbarOpen: {
|
|
||||||
true: {
|
|
||||||
paddingBottom:
|
|
||||||
'calc(var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))',
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
paddingBottom: '0',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const TrackAreaContainer = styled(
|
|
||||||
'div',
|
|
||||||
cva({
|
|
||||||
base: {
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
width: '100%',
|
|
||||||
transition: 'height .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
areSubtitlesOpen: {
|
|
||||||
true: {
|
|
||||||
height: 'calc(100% - 12rem)',
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
interface RoomContentAreaProps {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoomContentArea({ children }: RoomContentAreaProps) {
|
|
||||||
const { isSidePanelOpen } = useSidePanel()
|
|
||||||
const { areSubtitlesOpen } = useSubtitles()
|
|
||||||
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RoomViewport
|
|
||||||
isSidePanelOpen={isSidePanelOpen}
|
|
||||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
|
||||||
>
|
|
||||||
<TrackAreaContainer areSubtitlesOpen={areSubtitlesOpen}>
|
|
||||||
{children}
|
|
||||||
</TrackAreaContainer>
|
|
||||||
<Subtitles />
|
|
||||||
<MainNotificationToast />
|
|
||||||
</RoomViewport>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||||
@@ -10,11 +10,17 @@ import { decodeNotificationDataReceived } from './utils'
|
|||||||
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||||
|
import {
|
||||||
|
Emoji,
|
||||||
|
Reaction,
|
||||||
|
} from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||||
|
import {
|
||||||
|
ANIMATION_DURATION,
|
||||||
|
ReactionPortals,
|
||||||
|
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||||
import { layoutStore } from '@/stores/layout'
|
import { layoutStore } from '@/stores/layout'
|
||||||
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { Emoji } from '@/features/reactions/types'
|
|
||||||
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
|
||||||
|
|
||||||
export const MainNotificationToast = () => {
|
export const MainNotificationToast = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
@@ -22,7 +28,8 @@ export const MainNotificationToast = () => {
|
|||||||
const { t } = useTranslation('notifications')
|
const { t } = useTranslation('notifications')
|
||||||
const announce = useScreenReaderAnnounce()
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const { appendReaction } = useReactions()
|
const [reactions, setReactions] = useState<Reaction[]>([])
|
||||||
|
const instanceIdRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChatMessage = (
|
const handleChatMessage = (
|
||||||
@@ -55,13 +62,21 @@ export const MainNotificationToast = () => {
|
|||||||
}
|
}
|
||||||
}, [room, triggerNotificationSound, announce, t])
|
}, [room, triggerNotificationSound, announce, t])
|
||||||
|
|
||||||
const handleEmoji = useCallback(
|
const handleEmoji = (emoji: string, participant: Participant) => {
|
||||||
(emoji: string, participant: Participant) => {
|
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
|
||||||
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
|
const id = instanceIdRef.current++
|
||||||
appendReaction(emoji as Emoji, participant)
|
setReactions((prev) => [
|
||||||
},
|
...prev,
|
||||||
[appendReaction]
|
{
|
||||||
)
|
id,
|
||||||
|
emoji,
|
||||||
|
participant,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setTimeout(() => {
|
||||||
|
setReactions((prev) => prev.filter((instance) => instance.id !== id))
|
||||||
|
}, ANIMATION_DURATION)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDataReceived = (
|
const handleDataReceived = (
|
||||||
@@ -134,7 +149,7 @@ export const MainNotificationToast = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||||
}
|
}
|
||||||
}, [room, handleEmoji])
|
}, [room])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const showJoinNotification = (participant: Participant) => {
|
const showJoinNotification = (participant: Participant) => {
|
||||||
@@ -237,6 +252,7 @@ export const MainNotificationToast = () => {
|
|||||||
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
<WaitingParticipantNotification />
|
<WaitingParticipantNotification />
|
||||||
|
<ReactionPortals reactions={reactions} />
|
||||||
</Div>
|
</Div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-13
@@ -12,10 +12,112 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
|
|||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||||
import { NotificationType } from '@/features/notifications'
|
import { NotificationType } from '@/features/notifications'
|
||||||
|
import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
|
||||||
|
import { useParticipantTrustLevel, formatFingerprint } from '@/features/encryption/useParticipantTrustLevel'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
|
const WaitingParticipantIdentity = ({ participant }: { participant: WaitingParticipant }) => {
|
||||||
|
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
|
||||||
|
const roomData = useRoomData()
|
||||||
|
const [isIdentityOpen, setIsIdentityOpen] = useState(false)
|
||||||
|
const attrs = {
|
||||||
|
is_authenticated: participant.is_authenticated ? 'true' : 'false',
|
||||||
|
suite_user_id: participant.suite_user_id || '',
|
||||||
|
}
|
||||||
|
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(attrs, roomData?.encryption_mode)
|
||||||
|
const badgeTooltip = tBadge(trustLevel)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VStack gap="0" alignItems="start">
|
||||||
|
<Button
|
||||||
|
variant="greyscale"
|
||||||
|
size="sm"
|
||||||
|
tooltip={badgeTooltip}
|
||||||
|
aria-label={badgeTooltip}
|
||||||
|
onPress={() => setIsIdentityOpen(true)}
|
||||||
|
className={css({
|
||||||
|
padding: '0.1rem 0.25rem !important',
|
||||||
|
minWidth: 'auto !important',
|
||||||
|
height: 'auto !important',
|
||||||
|
gap: '0.15rem !important',
|
||||||
|
borderRadius: '0.25rem !important',
|
||||||
|
backgroundColor: 'transparent !important',
|
||||||
|
color: 'white !important',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.15) !important',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<EncryptionBadge isEncrypted={true} trustLevel={trustLevel} />
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
maxWidth: '8rem',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{participant.username}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
{fingerprint && (
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'greyscale.100',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
paddingLeft: '0.25rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formatFingerprint(fingerprint)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: 'greyscale.200',
|
||||||
|
paddingLeft: '0.25rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '10rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{participant.is_authenticated && participant.email
|
||||||
|
? participant.email
|
||||||
|
: tBadge('anonymous')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<EncryptionIdentityDialog
|
||||||
|
isOpen={isIdentityOpen}
|
||||||
|
onOpenChange={setIsIdentityOpen}
|
||||||
|
participantName={participant.username}
|
||||||
|
participantEmail={participant.email}
|
||||||
|
suiteUserId={participant.suite_user_id}
|
||||||
|
isAuthenticated={participant.is_authenticated}
|
||||||
|
encryptionMode={roomData?.encryption_mode}
|
||||||
|
preloadedFingerprint={fingerprint}
|
||||||
|
preloadedFingerprintStatus={fingerprintStatus}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const NOTIFICATION_DISPLAY_DURATION = 10000
|
export const NOTIFICATION_DISPLAY_DURATION = 10000
|
||||||
|
|
||||||
export const WaitingParticipantNotification = () => {
|
export const WaitingParticipantNotification = () => {
|
||||||
|
const roomData = useRoomData()
|
||||||
|
const encrypted = isEncryptedRoom(roomData)
|
||||||
const { triggerNotificationSound } = useNotificationSound()
|
const { triggerNotificationSound } = useNotificationSound()
|
||||||
|
|
||||||
const { t } = useTranslation('notifications', {
|
const { t } = useTranslation('notifications', {
|
||||||
@@ -100,25 +202,29 @@ export const WaitingParticipantNotification = () => {
|
|||||||
>
|
>
|
||||||
{t('one')}
|
{t('one')}
|
||||||
</Text>
|
</Text>
|
||||||
<HStack gap="1rem">
|
<HStack gap="0.5rem">
|
||||||
<Avatar
|
<Avatar
|
||||||
name={waitingParticipants[0].username}
|
name={waitingParticipants[0].username}
|
||||||
bgColor={waitingParticipants[0].color}
|
bgColor={waitingParticipants[0].color}
|
||||||
context="list"
|
context="list"
|
||||||
notification
|
notification
|
||||||
/>
|
/>
|
||||||
<Text
|
{encrypted ? (
|
||||||
variant="sm"
|
<WaitingParticipantIdentity participant={waitingParticipants[0]} />
|
||||||
margin={false}
|
) : (
|
||||||
className={css({
|
<Text
|
||||||
maxWidth: '10rem',
|
variant="sm"
|
||||||
wordBreak: 'break-word',
|
margin={false}
|
||||||
overflowWrap: 'break-word',
|
className={css({
|
||||||
whiteSpace: 'normal',
|
maxWidth: '10rem',
|
||||||
})}
|
wordBreak: 'break-word',
|
||||||
>
|
overflowWrap: 'break-word',
|
||||||
{waitingParticipants[0].username}
|
whiteSpace: 'normal',
|
||||||
</Text>
|
})}
|
||||||
|
>
|
||||||
|
{waitingParticipants[0].username}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack gap="0.25rem" marginLeft="auto">
|
<HStack gap="0.25rem" marginLeft="auto">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { RiEmotionLine } from '@remixicon/react'
|
|
||||||
import { ToggleButton } from '@/primitives'
|
|
||||||
|
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
|
||||||
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
|
||||||
|
|
||||||
export const ReactionsToggle = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
|
|
||||||
const { isOpen, toggle } = useReactionsToolbar()
|
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
|
||||||
id: 'reaction',
|
|
||||||
handler: toggle,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToggleButton
|
|
||||||
id="reactions-toggle"
|
|
||||||
data-attr="reactions-toggle"
|
|
||||||
square
|
|
||||||
variant="primaryDark"
|
|
||||||
aria-label={t('button')}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
tooltip={t('button')}
|
|
||||||
isSelected={isOpen}
|
|
||||||
onChange={toggle}
|
|
||||||
>
|
|
||||||
<RiEmotionLine />
|
|
||||||
</ToggleButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Emoji } from '../../types'
|
|
||||||
import { useReactions } from '../../hooks/useReactions'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
|
|
||||||
export const ReactionButton = ({ emoji }: { emoji: Emoji }) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
const { sendReaction } = useReactions()
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onPress={() => sendReaction(emoji)}
|
|
||||||
aria-label={t(`emojis.${emoji}`)}
|
|
||||||
variant="primaryTextDark"
|
|
||||||
size="sm"
|
|
||||||
round
|
|
||||||
data-attr={`send-reaction-${emoji}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`/assets/reactions/${emoji}.png`}
|
|
||||||
alt=""
|
|
||||||
className={css({
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
userSelect: 'none',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { FocusScope, useFocusManager } from '@react-aria/focus'
|
|
||||||
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
|
||||||
import { ReactionButton } from './ReactionButton'
|
|
||||||
import { Emoji } from '../../types'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
import { getFirstControlBarFocusable } from '@/utils/dom'
|
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const Container = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 'var(--sizes-room-control-bar)',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const StyledStrip = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.2rem',
|
|
||||||
borderRadius: '21px',
|
|
||||||
padding: '0.15rem',
|
|
||||||
backgroundColor: 'primaryDark.100',
|
|
||||||
opacity: 0,
|
|
||||||
transform: 'translateY(3.25rem)',
|
|
||||||
transition: 'opacity, transform',
|
|
||||||
transitionDuration: '0.5s',
|
|
||||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
isVisible: {
|
|
||||||
true: {
|
|
||||||
opacity: 1,
|
|
||||||
transform: 'translateY(0)',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
desktopOffset: {
|
|
||||||
true: {
|
|
||||||
// Ideally this value should be calculated dynamically in JavaScript to keep
|
|
||||||
// the reaction toolbar perfectly centered relative to the reaction toggle.
|
|
||||||
// However, for simplicity and to follow a pragmatic 80/20 approach,
|
|
||||||
// this value is currently hardcoded in CSS.
|
|
||||||
marginRight: '30px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Strip = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const { isOpen } = useReactionsToolbar()
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
// defer one frame so the browser paints opacity:0 first
|
|
||||||
const id = requestAnimationFrame(() => setIsVisible(true))
|
|
||||||
return () => cancelAnimationFrame(id)
|
|
||||||
} else {
|
|
||||||
setIsVisible(false)
|
|
||||||
}
|
|
||||||
}, [isOpen])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledStrip
|
|
||||||
ref={ref}
|
|
||||||
aria-hidden={!isOpen}
|
|
||||||
isVisible={isVisible}
|
|
||||||
desktopOffset={!isMobile}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</StyledStrip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
const focusManager = useFocusManager()
|
|
||||||
|
|
||||||
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
|
|
||||||
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
|
|
||||||
if (comingFromOutside) {
|
|
||||||
focusManager?.focusFirst()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowRight':
|
|
||||||
focusManager?.focusNext({ wrap: true })
|
|
||||||
break
|
|
||||||
case 'ArrowLeft':
|
|
||||||
focusManager?.focusPrevious({ wrap: true })
|
|
||||||
break
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault()
|
|
||||||
document.getElementById('reactions-toggle')?.focus()
|
|
||||||
layoutStore.showReactionsToolbar = false
|
|
||||||
break
|
|
||||||
case 'Tab':
|
|
||||||
if (!e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
getFirstControlBarFocusable('control-bar')?.focus()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="toolbar"
|
|
||||||
aria-label={t('toolbar')}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onFocus={onFocus}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReactionsToolbar = () => {
|
|
||||||
const { isOpen } = useReactionsToolbar()
|
|
||||||
const shouldMount = useDelayUnmount(isOpen, 300)
|
|
||||||
|
|
||||||
if (!shouldMount) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
|
|
||||||
<FocusScope autoFocus>
|
|
||||||
<KeyboardNavigation>
|
|
||||||
<Strip>
|
|
||||||
{Object.values(Emoji).map((emoji) => (
|
|
||||||
<ReactionButton key={emoji} emoji={emoji} />
|
|
||||||
))}
|
|
||||||
</Strip>
|
|
||||||
</KeyboardNavigation>
|
|
||||||
</FocusScope>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export const ANIMATION_DURATION = 3000
|
|
||||||
export const ANIMATION_DISTANCE = 300
|
|
||||||
export const FADE_OUT_THRESHOLD = 0.7
|
|
||||||
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
|
|
||||||
export const INITIAL_POSITION = 200
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { accessibilityStore } from '@/stores/accessibility'
|
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
|
|
||||||
import { getEmojiLabel } from '../utils'
|
|
||||||
import { Reaction } from '../types'
|
|
||||||
|
|
||||||
export const useAnnounceReaction = (latestReaction: Reaction | undefined) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
const { announceReactions } = useSnapshot(accessibilityStore)
|
|
||||||
const [lastAnnouncedId, setLastAnnouncedId] = useState<string | null>(null)
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!announceReactions || !latestReaction) return
|
|
||||||
if (latestReaction.id === lastAnnouncedId) return
|
|
||||||
|
|
||||||
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
|
|
||||||
const participantName = latestReaction.participantName
|
|
||||||
|
|
||||||
announce(t('announce', { name: participantName, emoji: emojiLabel }))
|
|
||||||
setLastAnnouncedId(latestReaction.id)
|
|
||||||
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { useCallback } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { reactionsStore } from '@/stores/reactions'
|
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
|
||||||
import { useNotifyParticipants } from '@/features/notifications'
|
|
||||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { Emoji } from '../types'
|
|
||||||
import { ANIMATION_DURATION } from '../constants'
|
|
||||||
|
|
||||||
export const useReactions = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
const { notifyParticipants } = useNotifyParticipants()
|
|
||||||
|
|
||||||
const appendReaction = useCallback(
|
|
||||||
(emoji: Emoji, participant?: Participant) => {
|
|
||||||
const newReaction = {
|
|
||||||
id: `${emoji}-${Date.now()}-${Math.random()}`,
|
|
||||||
emoji,
|
|
||||||
participantName: participant
|
|
||||||
? participant.name || participant.identity
|
|
||||||
: t('you'),
|
|
||||||
isLocal: !participant,
|
|
||||||
}
|
|
||||||
|
|
||||||
reactionsStore.reactions.push(newReaction)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const index = reactionsStore.reactions.findIndex(
|
|
||||||
(r) => r.id === newReaction.id
|
|
||||||
)
|
|
||||||
if (index !== -1) reactionsStore.reactions.splice(index, 1)
|
|
||||||
}, ANIMATION_DURATION)
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const sendReaction = async (emoji: Emoji) => {
|
|
||||||
appendReaction(emoji)
|
|
||||||
await notifyParticipants({
|
|
||||||
type: NotificationType.ReactionReceived,
|
|
||||||
additionalData: { data: { emoji } },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const debouncedSendReaction = useRateLimiter({
|
|
||||||
callback: sendReaction,
|
|
||||||
maxCalls: 10,
|
|
||||||
windowMs: 1000,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
sendReaction: debouncedSendReaction,
|
|
||||||
appendReaction,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
|
|
||||||
export const useReactionsToolbar = () => {
|
|
||||||
const layoutSnap = useSnapshot(layoutStore)
|
|
||||||
|
|
||||||
return {
|
|
||||||
isOpen: layoutSnap.showReactionsToolbar,
|
|
||||||
toggle: () => {
|
|
||||||
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export enum Emoji {
|
|
||||||
THUMBS_UP = 'thumbs-up',
|
|
||||||
THUMBS_DOWN = 'thumbs-down',
|
|
||||||
CLAP = 'clapping-hands',
|
|
||||||
HEART = 'red-heart',
|
|
||||||
LAUGHING = 'face-with-tears-of-joy',
|
|
||||||
SURPRISED = 'face-with-open-mouth',
|
|
||||||
CELEBRATION = 'party-popper',
|
|
||||||
PLEASE = 'folded-hands',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Reaction {
|
|
||||||
id: string
|
|
||||||
emoji: Emoji
|
|
||||||
participantName: string
|
|
||||||
isLocal: boolean
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,21 @@ export enum ApiAccessLevel {
|
|||||||
RESTRICTED = 'restricted',
|
RESTRICTED = 'restricted',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ApiEncryptionMode {
|
||||||
|
NONE = 'none',
|
||||||
|
BASIC = 'basic',
|
||||||
|
ADVANCED = 'advanced',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEncryptedRoom(room?: { encryption_mode?: ApiEncryptionMode; encryption_enabled?: boolean } | null): boolean {
|
||||||
|
if (!room) return false
|
||||||
|
// Support both new encryption_mode and legacy encryption_enabled
|
||||||
|
if (room.encryption_mode !== undefined) {
|
||||||
|
return room.encryption_mode !== ApiEncryptionMode.NONE
|
||||||
|
}
|
||||||
|
return !!room.encryption_enabled
|
||||||
|
}
|
||||||
|
|
||||||
export type ApiRoom = {
|
export type ApiRoom = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -17,6 +32,8 @@ export type ApiRoom = {
|
|||||||
pin_code: string
|
pin_code: string
|
||||||
is_administrable: boolean
|
is_administrable: boolean
|
||||||
access_level: ApiAccessLevel
|
access_level: ApiAccessLevel
|
||||||
|
encryption_mode: ApiEncryptionMode
|
||||||
|
encrypted_symmetric_key?: string
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
configuration?: {
|
configuration?: {
|
||||||
[key: string]: string | number | boolean | string[]
|
[key: string]: string | number | boolean | string[]
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { ApiError } from '@/api/ApiError'
|
import { ApiError } from '@/api/ApiError'
|
||||||
import { ApiRoom } from './ApiRoom'
|
import { ApiRoom, ApiEncryptionMode } from './ApiRoom'
|
||||||
|
|
||||||
export interface CreateRoomParams {
|
export interface CreateRoomParams {
|
||||||
slug: string
|
slug: string
|
||||||
callbackId?: string
|
callbackId?: string
|
||||||
username?: string
|
username?: string
|
||||||
|
encryptionMode?: ApiEncryptionMode
|
||||||
|
encryptedSymmetricKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const createRoom = ({
|
const createRoom = ({
|
||||||
slug,
|
slug,
|
||||||
callbackId,
|
callbackId,
|
||||||
username = '',
|
username = '',
|
||||||
|
encryptionMode = ApiEncryptionMode.NONE,
|
||||||
|
encryptedSymmetricKey = '',
|
||||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||||
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
|
const queryParams = username ? `?username=${encodeURIComponent(username)}` : ''
|
||||||
|
return fetchApi(`rooms/${queryParams}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: slug,
|
name: slug,
|
||||||
callback_id: callbackId,
|
callback_id: callbackId,
|
||||||
|
encryption_mode: encryptionMode,
|
||||||
|
encrypted_symmetric_key: encryptedSymmetricKey,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export interface EnterRoomParams {
|
|||||||
roomId: string
|
roomId: string
|
||||||
allowEntry: boolean
|
allowEntry: boolean
|
||||||
participantId: string
|
participantId: string
|
||||||
|
encryptedKey?: string
|
||||||
|
adminEphemeralPublicKey?: string
|
||||||
|
encryptedVaultKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnterRoomResponse {
|
export interface EnterRoomResponse {
|
||||||
@@ -16,12 +19,18 @@ export const enterRoom = async ({
|
|||||||
roomId,
|
roomId,
|
||||||
allowEntry,
|
allowEntry,
|
||||||
participantId,
|
participantId,
|
||||||
|
encryptedKey = '',
|
||||||
|
adminEphemeralPublicKey = '',
|
||||||
|
encryptedVaultKey = '',
|
||||||
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
||||||
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
participant_id: participantId,
|
participant_id: participantId,
|
||||||
allow_entry: allowEntry,
|
allow_entry: allowEntry,
|
||||||
|
encrypted_key: encryptedKey,
|
||||||
|
admin_ephemeral_public_key: adminEphemeralPublicKey,
|
||||||
|
encrypted_vault_key: encryptedVaultKey,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export type WaitingParticipant = {
|
|||||||
status: string
|
status: string
|
||||||
username: string
|
username: string
|
||||||
color: string
|
color: string
|
||||||
|
is_authenticated: boolean
|
||||||
|
email?: string
|
||||||
|
suite_user_id?: string
|
||||||
|
ephemeral_public_key?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WaitingParticipantsResponse = {
|
export type WaitingParticipantsResponse = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
|||||||
export interface RequestEntryParams {
|
export interface RequestEntryParams {
|
||||||
roomId: string
|
roomId: string
|
||||||
username?: string
|
username?: string
|
||||||
|
ephemeralPublicKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ApiLobbyStatus {
|
export enum ApiLobbyStatus {
|
||||||
@@ -17,16 +18,21 @@ export enum ApiLobbyStatus {
|
|||||||
export interface ApiRequestEntry {
|
export interface ApiRequestEntry {
|
||||||
status: ApiLobbyStatus
|
status: ApiLobbyStatus
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
|
encrypted_key?: string
|
||||||
|
admin_ephemeral_public_key?: string
|
||||||
|
encrypted_vault_key?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const requestEntry = async ({
|
export const requestEntry = async ({
|
||||||
roomId,
|
roomId,
|
||||||
username = '',
|
username = '',
|
||||||
|
ephemeralPublicKey = '',
|
||||||
}: RequestEntryParams) => {
|
}: RequestEntryParams) => {
|
||||||
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
|
ephemeral_public_key: ephemeralPublicKey,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
@@ -7,14 +7,23 @@ import {
|
|||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
|
ExternalE2EEKeyProvider,
|
||||||
MediaDeviceFailure,
|
MediaDeviceFailure,
|
||||||
Room,
|
Room,
|
||||||
RoomOptions,
|
RoomOptions,
|
||||||
VideoPresets,
|
VideoPresets,
|
||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
|
import { setSymmetricKey, getSymmetricKey, getEncryptedVaultKey, generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
|
||||||
|
import { isEncryptedRoom, ApiEncryptionMode } from '../api/ApiRoom'
|
||||||
|
import { VaultE2EEManager } from '@/features/encryption/VaultE2EEManager'
|
||||||
|
import { useVaultClient } from '@/features/encryption'
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
|
import { RiLockLine } from '@remixicon/react'
|
||||||
|
import { Center } from '@/styled-system/jsx'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
import { QueryAware } from '@/components/QueryAware'
|
import { QueryAware } from '@/components/QueryAware'
|
||||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||||
import { fetchRoom } from '../api/fetchRoom'
|
import { fetchRoom } from '../api/fetchRoom'
|
||||||
@@ -86,12 +95,49 @@ export const Conference = ({
|
|||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const encryptionEnabled = isEncryptedRoom(data)
|
||||||
|
const { client: vaultClient, hasKeys: vaultHasKeys, error: vaultError, isLoading: vaultLoading } = useVaultClient()
|
||||||
|
|
||||||
|
// Determine which E2EE backend to use based solely on the room's encryption_mode.
|
||||||
|
// Advanced mode always uses VaultClient, basic mode always uses LiveKit Worker+KeyProvider.
|
||||||
|
const useVaultE2EE = data?.encryption_mode === ApiEncryptionMode.ADVANCED
|
||||||
|
|
||||||
|
// Refs for both approaches (only one is used per session)
|
||||||
|
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
|
||||||
|
const workerRef = useRef<Worker | null>(null)
|
||||||
|
const vaultManagerRef = useRef<VaultE2EEManager | null>(null)
|
||||||
|
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled)
|
||||||
|
|
||||||
|
const getKeyProvider = () => {
|
||||||
|
if (!keyProviderRef.current && encryptionEnabled && !useVaultE2EE) {
|
||||||
|
keyProviderRef.current = new ExternalE2EEKeyProvider()
|
||||||
|
}
|
||||||
|
return keyProviderRef.current
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWorker = () => {
|
||||||
|
if (!workerRef.current && encryptionEnabled && !useVaultE2EE && typeof window !== 'undefined') {
|
||||||
|
workerRef.current = new Worker(
|
||||||
|
new URL('livekit-client/e2ee-worker', import.meta.url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return workerRef.current
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVaultManager = () => {
|
||||||
|
if (!vaultManagerRef.current && useVaultE2EE && vaultClient) {
|
||||||
|
vaultManagerRef.current = new VaultE2EEManager(vaultClient)
|
||||||
|
}
|
||||||
|
return vaultManagerRef.current
|
||||||
|
}
|
||||||
|
|
||||||
const roomOptions = useMemo((): RoomOptions => {
|
const roomOptions = useMemo((): RoomOptions => {
|
||||||
return {
|
const baseOptions: RoomOptions = {
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
videoCodec: 'vp9',
|
videoCodec: encryptionEnabled ? undefined : 'vp9',
|
||||||
|
red: !encryptionEnabled,
|
||||||
},
|
},
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||||
@@ -106,8 +152,25 @@ export const Conference = ({
|
|||||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useVaultE2EE) {
|
||||||
|
const vaultManager = getVaultManager()
|
||||||
|
if (vaultManager) {
|
||||||
|
baseOptions.encryption = { e2eeManager: vaultManager }
|
||||||
|
}
|
||||||
|
} else if (encryptionEnabled) {
|
||||||
|
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
|
// do not rely on the userConfig object directly as its reference may change on every render
|
||||||
}, [
|
}, [
|
||||||
|
encryptionEnabled,
|
||||||
|
useVaultE2EE,
|
||||||
userConfig.videoDeviceId,
|
userConfig.videoDeviceId,
|
||||||
userConfig.videoPublishResolution,
|
userConfig.videoPublishResolution,
|
||||||
userConfig.audioDeviceId,
|
userConfig.audioDeviceId,
|
||||||
@@ -116,6 +179,132 @@ export const Conference = ({
|
|||||||
|
|
||||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||||
|
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
||||||
|
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
|
||||||
|
*/
|
||||||
|
const serverUrl = useMemo(() => {
|
||||||
|
const livekit_url = apiConfig?.livekit.url
|
||||||
|
if (!livekit_url) return
|
||||||
|
if (apiConfig?.livekit.force_wss_protocol) {
|
||||||
|
return livekit_url.replace('https://', 'wss://')
|
||||||
|
}
|
||||||
|
return livekit_url
|
||||||
|
}, [apiConfig?.livekit])
|
||||||
|
|
||||||
|
// Encryption key setup:
|
||||||
|
// VaultE2EE: admin generates key via vaultClient.encryptWithoutKey(), joiner receives wrapped key
|
||||||
|
// Fallback: admin generates passphrase, joiner receives via lobby DH exchange
|
||||||
|
const isAdmin = mode === 'create' || data?.is_administrable === true
|
||||||
|
const adminPassphraseRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!encryptionEnabled || encryptionSetupComplete) return
|
||||||
|
|
||||||
|
if (useVaultE2EE) {
|
||||||
|
// Advanced mode: VaultE2EEManager delegates crypto to VaultClient iframe
|
||||||
|
const vaultManager = getVaultManager()
|
||||||
|
if (!vaultManager || !vaultClient) return
|
||||||
|
if (isAdmin) {
|
||||||
|
const existingKey = data?.encrypted_symmetric_key
|
||||||
|
if (existingKey) {
|
||||||
|
const binaryStr = atob(existingKey)
|
||||||
|
const bytes = new Uint8Array(binaryStr.length)
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
|
||||||
|
vaultManager.setEncryptedSymmetricKey(bytes.buffer)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const vaultKey = getEncryptedVaultKey()
|
||||||
|
if (vaultKey) {
|
||||||
|
vaultManager.setEncryptedSymmetricKey(vaultKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable E2EE BEFORE connecting — no tracks exist yet so
|
||||||
|
// republishAllTracks() is a no-op. Calling after connection
|
||||||
|
// triggers republish which times out.
|
||||||
|
room.setE2EEEnabled(true).catch((err) => {
|
||||||
|
console.error('[VaultE2EE] E2EE enable failed:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
setEncryptionSetupComplete(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic mode: LiveKit Worker+KeyProvider with passphrase
|
||||||
|
const keyProvider = getKeyProvider()
|
||||||
|
if (!keyProvider) return
|
||||||
|
|
||||||
|
let passphrase: string | null = null
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
if (!adminPassphraseRef.current) {
|
||||||
|
const existingHash = window.location.hash.slice(1)
|
||||||
|
if (existingHash) {
|
||||||
|
adminPassphraseRef.current = existingHash
|
||||||
|
} else {
|
||||||
|
adminPassphraseRef.current = generatePassphrase()
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
'',
|
||||||
|
`${window.location.pathname}${window.location.search}#${adminPassphraseRef.current}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
passphrase = adminPassphraseRef.current
|
||||||
|
setSymmetricKey(new TextEncoder().encode(passphrase))
|
||||||
|
} else {
|
||||||
|
const hashKey = window.location.hash.slice(1)
|
||||||
|
if (hashKey) {
|
||||||
|
passphrase = hashKey
|
||||||
|
setSymmetricKey(new TextEncoder().encode(passphrase))
|
||||||
|
} else {
|
||||||
|
const preExchangedKey = getSymmetricKey()
|
||||||
|
if (preExchangedKey) {
|
||||||
|
passphrase = new TextDecoder().decode(preExchangedKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passphrase) {
|
||||||
|
console.error('[Encryption] No passphrase available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keyProvider
|
||||||
|
.setKey(passphrase)
|
||||||
|
.then(async () => {
|
||||||
|
// Enable E2EE BEFORE connecting — sets encryptionType=GCM so tracks
|
||||||
|
// are published with encryption metadata from the start.
|
||||||
|
// Also sends 'enable' to the Worker before any frames flow,
|
||||||
|
// eliminating the unencrypted frame window.
|
||||||
|
try {
|
||||||
|
await room.setE2EEEnabled(true)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Encryption] E2EE enable failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
setEncryptionSetupComplete(true)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[Encryption] Key setup failed:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
}, [room, encryptionEnabled, encryptionSetupComplete, isAdmin, useVaultE2EE])
|
||||||
|
|
||||||
|
// In basic encrypted rooms, the passphrase is in the URL hash.
|
||||||
|
// If the user changes the hash (e.g. corrects a typo), reload the page
|
||||||
|
// so the new passphrase is picked up by the encryption setup.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!encryptionEnabled || useVaultE2EE) return
|
||||||
|
const handleHashChange = () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', handleHashChange)
|
||||||
|
return () => window.removeEventListener('hashchange', handleHashChange)
|
||||||
|
}, [encryptionEnabled, useVaultE2EE])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/**
|
/**
|
||||||
* Warm up connection to LiveKit server before joining room
|
* Warm up connection to LiveKit server before joining room
|
||||||
@@ -172,20 +361,6 @@ export const Conference = ({
|
|||||||
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
/*
|
|
||||||
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
|
||||||
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
|
||||||
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
|
|
||||||
*/
|
|
||||||
const serverUrl = useMemo(() => {
|
|
||||||
const livekit_url = apiConfig?.livekit.url
|
|
||||||
if (!livekit_url) return
|
|
||||||
if (apiConfig?.livekit.force_wss_protocol) {
|
|
||||||
return livekit_url.replace('https://', 'wss://')
|
|
||||||
}
|
|
||||||
return livekit_url
|
|
||||||
}, [apiConfig?.livekit])
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
if (isCreateError) {
|
if (isCreateError) {
|
||||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
// this error screen should be replaced by a proper waiting room for anonymous user.
|
||||||
@@ -197,6 +372,67 @@ export const Conference = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block entry to advanced encrypted rooms when vault service is unavailable
|
||||||
|
if (useVaultE2EE && !vaultLoading && !vaultClient) {
|
||||||
|
return (
|
||||||
|
<Screen layout="centered">
|
||||||
|
<CenteredContent withBackButton>
|
||||||
|
<Center>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
maxWidth: '400px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
padding: '2rem',
|
||||||
|
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.08)',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.200',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: '3.5rem',
|
||||||
|
height: '3.5rem',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#fef2f2',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiLockLine size={28} color="#dc2626" />
|
||||||
|
</div>
|
||||||
|
<Text as="h2" className={css({ fontWeight: 700, fontSize: '1.15rem' })}>
|
||||||
|
{t('encryption.error.title')}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" className={css({ fontSize: '0.9rem', color: 'greyscale.700' })}>
|
||||||
|
{t('encryption.error.vaultUnavailable')}
|
||||||
|
</Text>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: '#fffbeb',
|
||||||
|
border: '1px solid #fde68a',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
width: '100%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text as="p" className={css({ fontSize: '0.8rem', color: '#92400e' })}>
|
||||||
|
{t('encryption.error.vaultUnavailableHint')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Center>
|
||||||
|
</CenteredContent>
|
||||||
|
</Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
||||||
// These settings help ensure successful connections in poor network conditions
|
// These settings help ensure successful connections in poor network conditions
|
||||||
const connectOptions = {
|
const connectOptions = {
|
||||||
@@ -211,7 +447,7 @@ export const Conference = ({
|
|||||||
room={room}
|
room={room}
|
||||||
serverUrl={serverUrl}
|
serverUrl={serverUrl}
|
||||||
token={data?.livekit?.token}
|
token={data?.livekit?.token}
|
||||||
connect={isConnectionWarmedUp}
|
connect={isConnectionWarmedUp && encryptionSetupComplete}
|
||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={
|
video={
|
||||||
userConfig.videoEnabled && {
|
userConfig.videoEnabled && {
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
const roomUrl = getRouteUrl('room', roomData?.slug)
|
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 telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,122 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
||||||
import { Spinner } from '@/primitives/Spinner'
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
import { ApiAccessLevel, ApiEncryptionMode, isEncryptedRoom as checkEncryptedRoom } from '../api/ApiRoom'
|
||||||
|
import { useVaultClient } from '@/features/encryption'
|
||||||
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
|
|
||||||
|
const AdvancedOnboardingScreen = ({
|
||||||
|
modalOpen,
|
||||||
|
onModalOpenChange,
|
||||||
|
}: {
|
||||||
|
modalOpen: boolean
|
||||||
|
onModalOpenChange: (open: boolean) => void
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||||
|
const { client: vaultClient } = useVaultClient()
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modalOpen || !vaultClient) return
|
||||||
|
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
el.innerHTML = ''
|
||||||
|
vaultClient.openOnboarding(el)
|
||||||
|
|
||||||
|
const handleClosed = () => {
|
||||||
|
onModalOpenChange(false)
|
||||||
|
vaultClient.off('interface:closed', handleClosed)
|
||||||
|
}
|
||||||
|
vaultClient.on('interface:closed', handleClosed)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
vaultClient.off('interface:closed', handleClosed)
|
||||||
|
}
|
||||||
|
}, [modalOpen, vaultClient, onModalOpenChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VStack alignItems="center" textAlign="center" gap="0.75rem">
|
||||||
|
<RiLockLine size={32} color="#d97706" />
|
||||||
|
<H lvl={1} margin={false} centered>
|
||||||
|
{t('advancedOnboarding.title')}
|
||||||
|
</H>
|
||||||
|
<Text as="p" variant="note">
|
||||||
|
{t('advancedOnboarding.body')}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onPress={() => onModalOpenChange(true)}
|
||||||
|
>
|
||||||
|
{t('advancedOnboarding.button')}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
{modalOpen && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
})}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onModalOpenChange(false)
|
||||||
|
vaultClient?.closeInterface()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
width: '90%',
|
||||||
|
maxWidth: '550px',
|
||||||
|
maxHeight: '85vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
position: 'relative',
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onModalOpenChange(false)
|
||||||
|
vaultClient?.closeInterface()
|
||||||
|
}}
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0.75rem',
|
||||||
|
right: '0.75rem',
|
||||||
|
zIndex: 1,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
color: 'greyscale.500',
|
||||||
|
_hover: { color: 'greyscale.900' },
|
||||||
|
})}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={css({ minHeight: '300px' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||||
|
import { useUser } from '@/features/auth'
|
||||||
|
import { RiInformationLine, RiLockLine } from '@remixicon/react'
|
||||||
import { openPermissionsDialog } from '@/stores/permissions'
|
import { openPermissionsDialog } from '@/stores/permissions'
|
||||||
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
|
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
|
||||||
import { isSafari } from '@/utils/livekit'
|
import { isSafari } from '@/utils/livekit'
|
||||||
@@ -102,6 +216,31 @@ export const Join = ({
|
|||||||
roomId: string
|
roomId: string
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||||
|
const { isLoggedIn, user } = useUser()
|
||||||
|
|
||||||
|
// Early fetch to check if the room is encrypted (needed before form submission)
|
||||||
|
const { data: roomInfo } = useQuery({
|
||||||
|
queryKey: [keys.room, roomId, 'info'],
|
||||||
|
queryFn: () => fetchRoom({ roomId }),
|
||||||
|
staleTime: 6 * 60 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const isEncryptedRoom = checkEncryptedRoom(roomInfo)
|
||||||
|
const isBasicEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
const isAdvancedEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.ADVANCED
|
||||||
|
|
||||||
|
// Basic mode: validate the passphrase in the URL hash
|
||||||
|
const hashKey = window.location.hash.slice(1)
|
||||||
|
const hasValidBasicKey = isBasicEncrypted ? (hashKey.length === 48 && /^[a-z0-9]+$/.test(hashKey)) : true
|
||||||
|
|
||||||
|
// Advanced mode: require auth + vault onboarding
|
||||||
|
const { hasKeys: vaultHasKeys, isReady: vaultReady } = useVaultClient()
|
||||||
|
const advancedRequiresLogin = isAdvancedEncrypted && !isLoggedIn
|
||||||
|
const advancedRequiresOnboarding = isAdvancedEncrypted && isLoggedIn && vaultReady && !vaultHasKeys
|
||||||
|
|
||||||
|
// In encrypted rooms, authenticated users must use their OIDC name
|
||||||
|
const isNameLocked = isEncryptedRoom && !!isLoggedIn
|
||||||
|
const lockedName = user?.full_name || user?.email || ''
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userChoices: {
|
userChoices: {
|
||||||
@@ -325,8 +464,10 @@ export const Join = ({
|
|||||||
roomId,
|
roomId,
|
||||||
username,
|
username,
|
||||||
onAccepted: handleAccepted,
|
onAccepted: handleAccepted,
|
||||||
|
encryptionEnabled: isEncryptedRoom,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [advancedOnboardingOpen, setAdvancedOnboardingOpen] = useState(false)
|
||||||
const { openLoginHint } = useLoginHint()
|
const { openLoginHint } = useLoginHint()
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -426,6 +567,41 @@ export const Join = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
if (advancedRequiresLogin) {
|
||||||
|
return (
|
||||||
|
<VStack alignItems="center" textAlign="center" gap="0.75rem">
|
||||||
|
<RiLockLine size={32} color="#2563eb" />
|
||||||
|
<H lvl={1} margin={false} centered>
|
||||||
|
{t('advancedAuth.title')}
|
||||||
|
</H>
|
||||||
|
<Text as="p" variant="note">
|
||||||
|
{t('advancedAuth.body')}
|
||||||
|
</Text>
|
||||||
|
<LoginButton proConnectHint={false} />
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (advancedRequiresOnboarding || advancedOnboardingOpen) {
|
||||||
|
return (
|
||||||
|
<AdvancedOnboardingScreen
|
||||||
|
modalOpen={advancedOnboardingOpen}
|
||||||
|
onModalOpenChange={setAdvancedOnboardingOpen}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isBasicEncrypted && !hasValidBasicKey) {
|
||||||
|
return (
|
||||||
|
<VStack alignItems="center" textAlign="center" gap="0.75rem">
|
||||||
|
<RiLockLine size={32} color="#dc2626" />
|
||||||
|
<H lvl={1} margin={false} centered>
|
||||||
|
{t('invalidKey.title')}
|
||||||
|
</H>
|
||||||
|
<Text as="p" variant="note">
|
||||||
|
{t('invalidKey.body')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@@ -438,20 +614,81 @@ export const Join = ({
|
|||||||
<H lvl={1} margin="sm" centered>
|
<H lvl={1} margin="sm" centered>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</H>
|
</H>
|
||||||
<Field
|
{isNameLocked ? (
|
||||||
type="text"
|
<div
|
||||||
onChange={saveUsername}
|
className={css({
|
||||||
label={t('usernameLabel')}
|
display: 'flex',
|
||||||
id="input-name"
|
flexDirection: 'column',
|
||||||
defaultValue={username}
|
gap: '0.25rem',
|
||||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
width: '100%',
|
||||||
wrapperProps={{
|
})}
|
||||||
noMargin: true,
|
>
|
||||||
fullWidth: true,
|
<Text
|
||||||
}}
|
variant="sm"
|
||||||
autoComplete="name"
|
className={css({
|
||||||
maxLength={50}
|
color: 'greyscale.500',
|
||||||
/>
|
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"
|
||||||
|
className={css({
|
||||||
|
fontWeight: 500,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{lockedName}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiInformationLine size={12} color="#9ca3af" />
|
||||||
|
<Text
|
||||||
|
variant="note"
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: 'greyscale.400',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
ApiLobbyStatus,
|
ApiLobbyStatus,
|
||||||
ApiRequestEntry,
|
ApiRequestEntry,
|
||||||
} from '../api/requestEntry'
|
} from '../api/requestEntry'
|
||||||
|
import {
|
||||||
|
setSymmetricKey,
|
||||||
|
setEncryptedVaultKey,
|
||||||
|
} from '@/features/encryption/lobbyKeyExchange'
|
||||||
|
|
||||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 1000
|
||||||
@@ -14,14 +18,15 @@ export const useLobby = ({
|
|||||||
roomId,
|
roomId,
|
||||||
username,
|
username,
|
||||||
onAccepted,
|
onAccepted,
|
||||||
|
encryptionEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
roomId: string
|
roomId: string
|
||||||
username: string
|
username: string
|
||||||
onAccepted: (e: ApiRequestEntry) => void
|
onAccepted: (e: ApiRequestEntry) => void
|
||||||
|
encryptionEnabled?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
|
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
|
||||||
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
const clearWaitingTimeout = useCallback(() => {
|
const clearWaitingTimeout = useCallback(() => {
|
||||||
if (waitingTimeoutRef.current) {
|
if (waitingTimeoutRef.current) {
|
||||||
clearTimeout(waitingTimeoutRef.current)
|
clearTimeout(waitingTimeoutRef.current)
|
||||||
@@ -47,6 +52,20 @@ export const useLobby = ({
|
|||||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||||
clearWaitingTimeout()
|
clearWaitingTimeout()
|
||||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||||
|
|
||||||
|
// Advanced mode: vault-wrapped key
|
||||||
|
if (encryptionEnabled && response.encrypted_vault_key) {
|
||||||
|
console.info('[VaultE2EE] Joiner: received encrypted_vault_key from lobby, length:', response.encrypted_vault_key.length)
|
||||||
|
const binaryStr = atob(response.encrypted_vault_key)
|
||||||
|
const bytes = new Uint8Array(binaryStr.length)
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
|
bytes[i] = binaryStr.charCodeAt(i)
|
||||||
|
}
|
||||||
|
setEncryptedVaultKey(bytes.buffer)
|
||||||
|
} else if (encryptionEnabled) {
|
||||||
|
console.warn('[VaultE2EE] Joiner: ACCEPTED but no encrypted_vault_key in response', response)
|
||||||
|
}
|
||||||
|
|
||||||
onAccepted(response)
|
onAccepted(response)
|
||||||
} else if (response.status === ApiLobbyStatus.DENIED) {
|
} else if (response.status === ApiLobbyStatus.DENIED) {
|
||||||
clearWaitingTimeout()
|
clearWaitingTimeout()
|
||||||
@@ -60,7 +79,7 @@ export const useLobby = ({
|
|||||||
enabled: status === ApiLobbyStatus.WAITING,
|
enabled: status === ApiLobbyStatus.WAITING,
|
||||||
})
|
})
|
||||||
|
|
||||||
const startWaiting = useCallback(() => {
|
const startWaiting = useCallback(async () => {
|
||||||
setStatus(ApiLobbyStatus.WAITING)
|
setStatus(ApiLobbyStatus.WAITING)
|
||||||
startWaitingTimeout()
|
startWaitingTimeout()
|
||||||
}, [startWaitingTimeout])
|
}, [startWaitingTimeout])
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { RoomEvent } from 'livekit-client'
|
import { RoomEvent } from 'livekit-client'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { isEncryptedRoom as checkEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { useEnterRoom } from '../api/enterRoom'
|
import { useEnterRoom } from '../api/enterRoom'
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,8 @@ import {
|
|||||||
} from '../api/listWaitingParticipants'
|
} from '../api/listWaitingParticipants'
|
||||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
import { useVaultClient } from '@/features/encryption'
|
||||||
|
import { toastQueue } from '@/features/notifications/components/ToastProvider'
|
||||||
|
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 1000
|
||||||
|
|
||||||
@@ -18,9 +21,12 @@ export const useWaitingParticipants = () => {
|
|||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||||
|
const encrypted = checkEncryptedRoom(roomData)
|
||||||
|
const isAdvancedMode = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
|
||||||
|
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
const isAdminOrOwner = useIsAdminOrOwner()
|
||||||
|
const { client: vaultClient } = useVaultClient()
|
||||||
|
|
||||||
const handleDataReceived = useCallback((payload: Uint8Array) => {
|
const handleDataReceived = useCallback((payload: Uint8Array) => {
|
||||||
const notification = decodeNotificationDataReceived(payload)
|
const notification = decodeNotificationDataReceived(payload)
|
||||||
@@ -57,14 +63,91 @@ export const useWaitingParticipants = () => {
|
|||||||
|
|
||||||
const { mutateAsync: enterRoom } = useEnterRoom()
|
const { mutateAsync: enterRoom } = useEnterRoom()
|
||||||
|
|
||||||
|
const encryptKeyForAccept = async (participant: WaitingParticipant) => {
|
||||||
|
let encryptedKey = ''
|
||||||
|
let adminEphemeralPublicKey = ''
|
||||||
|
let encryptedVaultKey = ''
|
||||||
|
|
||||||
|
if (isAdvancedMode) {
|
||||||
|
// Advanced mode: re-wrap the existing symmetric key for the joiner.
|
||||||
|
// All steps are mandatory — if any fails, the participant must NOT be accepted
|
||||||
|
// (they would join without a key and see nothing).
|
||||||
|
if (!vaultClient) {
|
||||||
|
throw new Error('Encryption service is not available')
|
||||||
|
}
|
||||||
|
if (!participant.suite_user_id) {
|
||||||
|
throw new Error('Participant has no vault identity — they may not be authenticated')
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminKeyBase64 = roomData?.encrypted_symmetric_key
|
||||||
|
if (!adminKeyBase64) {
|
||||||
|
throw new Error('Admin has no encrypted symmetric key for this room')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info('[VaultE2EE] Admin: wrapping key for joiner', participant.suite_user_id)
|
||||||
|
const adminKeyBinary = atob(adminKeyBase64)
|
||||||
|
const adminKeyBytes = new Uint8Array(adminKeyBinary.length)
|
||||||
|
for (let i = 0; i < adminKeyBinary.length; i++) adminKeyBytes[i] = adminKeyBinary.charCodeAt(i)
|
||||||
|
|
||||||
|
// Fetch joiner's vault public key
|
||||||
|
const { publicKeys } = await vaultClient.fetchPublicKeys([participant.suite_user_id])
|
||||||
|
const joinerPubKey = publicKeys[participant.suite_user_id]
|
||||||
|
if (!joinerPubKey) {
|
||||||
|
throw new Error(`Could not find encryption public key for participant "${participant.username}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-wrap the symmetric key for the joiner using shareKeys
|
||||||
|
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||||
|
adminKeyBytes.buffer,
|
||||||
|
{ [participant.suite_user_id]: joinerPubKey }
|
||||||
|
)
|
||||||
|
const joinerKey = encryptedKeys[participant.suite_user_id]
|
||||||
|
if (!joinerKey) {
|
||||||
|
throw new Error('Key wrapping returned no result — shareKeys failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(joinerKey)
|
||||||
|
encryptedVaultKey = btoa(String.fromCharCode(...bytes))
|
||||||
|
console.info('[VaultE2EE] Admin: key wrapped successfully, length:', encryptedVaultKey.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { encryptedKey, adminEphemeralPublicKey, encryptedVaultKey }
|
||||||
|
}
|
||||||
|
|
||||||
const handleParticipantEntry = async (
|
const handleParticipantEntry = async (
|
||||||
participant: WaitingParticipant,
|
participant: WaitingParticipant,
|
||||||
allowEntry: boolean
|
allowEntry: boolean
|
||||||
) => {
|
) => {
|
||||||
|
let encryptedKey = ''
|
||||||
|
let adminEphemeralPublicKey = ''
|
||||||
|
let encryptedVaultKey = ''
|
||||||
|
|
||||||
|
if (allowEntry) {
|
||||||
|
try {
|
||||||
|
const keys = await encryptKeyForAccept(participant)
|
||||||
|
encryptedKey = keys.encryptedKey
|
||||||
|
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
|
||||||
|
encryptedVaultKey = keys.encryptedVaultKey
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[VaultE2EE] Cannot accept participant:', err)
|
||||||
|
toastQueue.add(
|
||||||
|
{
|
||||||
|
type: 'encryptionError' as NotificationType,
|
||||||
|
message: `Cannot accept ${participant.username}: ${(err as Error).message}`,
|
||||||
|
},
|
||||||
|
{ timeout: 8000 }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await enterRoom({
|
await enterRoom({
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
allowEntry,
|
allowEntry,
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
|
encryptedKey,
|
||||||
|
adminEphemeralPublicKey,
|
||||||
|
encryptedVaultKey,
|
||||||
})
|
})
|
||||||
await refetchWaiting()
|
await refetchWaiting()
|
||||||
}
|
}
|
||||||
@@ -76,13 +159,39 @@ export const useWaitingParticipants = () => {
|
|||||||
setListEnabled(false)
|
setListEnabled(false)
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
waitingParticipants.map((participant) =>
|
waitingParticipants.map(async (participant) => {
|
||||||
enterRoom({
|
let encryptedKey = ''
|
||||||
|
let adminEphemeralPublicKey = ''
|
||||||
|
let encryptedVaultKey = ''
|
||||||
|
|
||||||
|
if (allowEntry) {
|
||||||
|
try {
|
||||||
|
const keys = await encryptKeyForAccept(participant)
|
||||||
|
encryptedKey = keys.encryptedKey
|
||||||
|
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
|
||||||
|
encryptedVaultKey = keys.encryptedVaultKey
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[VaultE2EE] Cannot accept participant:', err)
|
||||||
|
toastQueue.add(
|
||||||
|
{
|
||||||
|
type: 'encryptionError' as NotificationType,
|
||||||
|
message: `Cannot accept ${participant.username}: ${(err as Error).message}`,
|
||||||
|
},
|
||||||
|
{ timeout: 8000 }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return enterRoom({
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
allowEntry,
|
allowEntry,
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
|
encryptedKey,
|
||||||
|
adminEphemeralPublicKey,
|
||||||
|
encryptedVaultKey,
|
||||||
})
|
})
|
||||||
)
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
await refetchWaiting()
|
await refetchWaiting()
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { Separator as RACSeparator } from 'react-aria-components'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import { ApiAccessLevel, isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useParams } from 'wouter'
|
import { useParams } from 'wouter'
|
||||||
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
||||||
|
import { RiLockFill } from '@remixicon/react'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
|
||||||
export const Admin = () => {
|
export const Admin = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||||
@@ -166,6 +168,25 @@ export const Admin = () => {
|
|||||||
>
|
>
|
||||||
{t('access.description')}
|
{t('access.description')}
|
||||||
</Text>
|
</Text>
|
||||||
|
{isEncryptedRoom(readOnlyData) && (
|
||||||
|
<HStack
|
||||||
|
gap="0.5rem"
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'primary.100',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.75rem',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiLockFill size={16} className={css({ flexShrink: 0 })} />
|
||||||
|
<Text
|
||||||
|
variant="note"
|
||||||
|
className={css({ textStyle: 'sm' })}
|
||||||
|
>
|
||||||
|
{t('access.encryptionWarning')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
<Field
|
<Field
|
||||||
type="radioGroup"
|
type="radioGroup"
|
||||||
label={t('access.type')}
|
label={t('access.type')}
|
||||||
@@ -192,11 +213,13 @@ export const Admin = () => {
|
|||||||
value: ApiAccessLevel.PUBLIC,
|
value: ApiAccessLevel.PUBLIC,
|
||||||
label: t('access.levels.public.label'),
|
label: t('access.levels.public.label'),
|
||||||
description: t('access.levels.public.description'),
|
description: t('access.levels.public.description'),
|
||||||
|
isDisabled: isEncryptedRoom(readOnlyData),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: ApiAccessLevel.TRUSTED,
|
value: ApiAccessLevel.TRUSTED,
|
||||||
label: t('access.levels.trusted.label'),
|
label: t('access.levels.trusted.label'),
|
||||||
description: t('access.levels.trusted.description'),
|
description: t('access.levels.trusted.description'),
|
||||||
|
isDisabled: isEncryptedRoom(readOnlyData),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: ApiAccessLevel.RESTRICTED,
|
value: ApiAccessLevel.RESTRICTED,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export const AdminToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isAdminOpen}
|
isSelected={isAdminOpen}
|
||||||
aria-expanded={isAdminOpen}
|
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleAdmin()
|
toggleAdmin()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export const Info = () => {
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||||
|
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
const roomUrl = getRouteUrl('room', data?.slug)
|
const baseRoomUrl = getRouteUrl('room', data?.slug)
|
||||||
|
const roomUrl = window.location.hash
|
||||||
|
? `${baseRoomUrl}${window.location.hash}`
|
||||||
|
: baseRoomUrl
|
||||||
|
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
@@ -50,7 +53,14 @@ export const Info = () => {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Text as="p" variant="xsNote" wrap="pretty">
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="xsNote"
|
||||||
|
className={css({
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
})}
|
||||||
|
>
|
||||||
{roomUrl.replace(/^https?:\/\//, '')}
|
{roomUrl.replace(/^https?:\/\//, '')}
|
||||||
</Text>
|
</Text>
|
||||||
{isTelephonyReadyForUse && (
|
{isTelephonyReadyForUse && (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
ConnectionQualityIndicator,
|
ConnectionQualityIndicator,
|
||||||
LockLockedIcon,
|
|
||||||
ParticipantTileProps,
|
ParticipantTileProps,
|
||||||
ScreenShareIcon,
|
ScreenShareIcon,
|
||||||
useEnsureTrackRef,
|
useEnsureTrackRef,
|
||||||
@@ -20,10 +19,23 @@ import {
|
|||||||
isTrackReferencePinned,
|
isTrackReferencePinned,
|
||||||
TrackReferenceOrPlaceholder,
|
TrackReferenceOrPlaceholder,
|
||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { Track } from 'livekit-client'
|
import { Track, RoomEvent } from 'livekit-client'
|
||||||
|
import type { Participant } from 'livekit-client'
|
||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||||
|
import {
|
||||||
|
EncryptionBadge,
|
||||||
|
EncryptionIdentityDialog,
|
||||||
|
} from '@/features/encryption'
|
||||||
|
import { useParticipantTrustLevel } from '@/features/encryption/useParticipantTrustLevel'
|
||||||
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
|
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||||
|
import { RiLockFill } from '@remixicon/react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||||
@@ -79,6 +91,51 @@ export const ParticipantTile: (
|
|||||||
trackRef: trackReference,
|
trackRef: trackReference,
|
||||||
})
|
})
|
||||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||||
|
const roomData = useRoomData()
|
||||||
|
const isEncryptedRoom = checkEncryptedRoom(roomData)
|
||||||
|
const isAdmin = useIsAdminOrOwner()
|
||||||
|
const [isIdentityOpen, setIsFingerprintOpen] = React.useState(false)
|
||||||
|
const participantAttrs = trackReference.participant.attributes as Record<string, string> | undefined
|
||||||
|
const { trustLevel, fingerprintStatus, fingerprint: participantFingerprint } = useParticipantTrustLevel(participantAttrs, roomData?.encryption_mode, trackReference.participant.isLocal)
|
||||||
|
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
|
||||||
|
const badgeTooltip = tBadge(trustLevel)
|
||||||
|
|
||||||
|
// Track decryption failures via EncryptionError events from LiveKit.
|
||||||
|
// useIsEncrypted returns true when E2EE is enabled, NOT when frames decrypt successfully.
|
||||||
|
// So we listen for actual decryption errors to know when to show the overlay.
|
||||||
|
const room = useRoomContext()
|
||||||
|
const [decryptionFailed, setDecryptionFailed] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isEncryptedRoom || trackReference.participant.isLocal) return
|
||||||
|
|
||||||
|
const participantIdentity = trackReference.participant.identity
|
||||||
|
|
||||||
|
const handleEncryptionError = (_error: Error, participant?: Participant) => {
|
||||||
|
if (participant?.identity === participantIdentity) {
|
||||||
|
setDecryptionFailed(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEncryptionStatusChanged = (encrypted: boolean, participant?: Participant) => {
|
||||||
|
// Clear the error when encryption status confirms frames are decrypting
|
||||||
|
if (participant?.identity === participantIdentity && encrypted) {
|
||||||
|
setDecryptionFailed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
room.on(RoomEvent.EncryptionError, handleEncryptionError)
|
||||||
|
room.on(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.EncryptionError, handleEncryptionError)
|
||||||
|
room.off(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged)
|
||||||
|
}
|
||||||
|
}, [room, isEncryptedRoom, trackReference.participant])
|
||||||
|
|
||||||
|
const showDecryptionError =
|
||||||
|
!trackReference.participant.isLocal &&
|
||||||
|
isEncryptedRoom &&
|
||||||
|
decryptionFailed
|
||||||
const layoutContext = useMaybeLayoutContext()
|
const layoutContext = useMaybeLayoutContext()
|
||||||
|
|
||||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||||
@@ -161,6 +218,65 @@ export const ParticipantTile: (
|
|||||||
participant={trackReference.participant}
|
participant={trackReference.participant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{showDecryptionError && !isScreenShare && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: '0 !important',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ParticipantPlaceholder
|
||||||
|
participant={trackReference.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>Decryption failed</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#d1d5db',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Check that you and this person are using the correct
|
||||||
|
meeting link. If they are the only one you can't see,
|
||||||
|
the issue is likely on their side.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!disableMetadata && (
|
{!disableMetadata && (
|
||||||
<div className="lk-participant-metadata">
|
<div className="lk-participant-metadata">
|
||||||
<HStack gap={0.25}>
|
<HStack gap={0.25}>
|
||||||
@@ -215,15 +331,59 @@ export const ParticipantTile: (
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isEncrypted && !isScreenShare && (
|
{isEncryptedRoom && !isScreenShare ? (
|
||||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
<Button
|
||||||
|
variant="greyscale"
|
||||||
|
size="sm"
|
||||||
|
tooltip={badgeTooltip}
|
||||||
|
aria-label={badgeTooltip}
|
||||||
|
onPress={() => setIsFingerprintOpen(true)}
|
||||||
|
className={css({
|
||||||
|
display: 'inline-flex !important',
|
||||||
|
alignItems: 'center !important',
|
||||||
|
gap: '0.15rem !important',
|
||||||
|
padding: '0.1rem 0.15rem !important',
|
||||||
|
minWidth: 'auto !important',
|
||||||
|
height: 'auto !important',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10,
|
||||||
|
borderRadius: '0.25rem !important',
|
||||||
|
backgroundColor: 'transparent !important',
|
||||||
|
color: 'inherit !important',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.15) !important',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{(isEncrypted || isEncryptedRoom) && (
|
||||||
|
<EncryptionBadge
|
||||||
|
isEncrypted={true}
|
||||||
|
trustLevel={trustLevel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="lk-participant-name-wrapper">
|
||||||
|
<ParticipantName
|
||||||
|
isScreenShare={isScreenShare}
|
||||||
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(isEncrypted || isEncryptedRoom) && !isScreenShare && (
|
||||||
|
<EncryptionBadge
|
||||||
|
isEncrypted={true}
|
||||||
|
trustLevel={trustLevel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="lk-participant-name-wrapper">
|
||||||
|
<ParticipantName
|
||||||
|
isScreenShare={isScreenShare}
|
||||||
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="lk-participant-name-wrapper">
|
|
||||||
<ParticipantName
|
|
||||||
isScreenShare={isScreenShare}
|
|
||||||
participant={trackReference.participant}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||||
@@ -246,6 +406,20 @@ export const ParticipantTile: (
|
|||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
</KeyboardShortcutHint>
|
</KeyboardShortcutHint>
|
||||||
|
{isEncryptedRoom && (
|
||||||
|
<EncryptionIdentityDialog
|
||||||
|
isOpen={isIdentityOpen}
|
||||||
|
onOpenChange={setIsFingerprintOpen}
|
||||||
|
participantName={trackReference.participant.name || trackReference.participant.identity}
|
||||||
|
participantEmail={participantAttrs?.email}
|
||||||
|
suiteUserId={participantAttrs?.suite_user_id}
|
||||||
|
isAuthenticated={participantAttrs?.is_authenticated === 'true'}
|
||||||
|
encryptionMode={roomData?.encryption_mode}
|
||||||
|
isSelf={trackReference.participant.isLocal}
|
||||||
|
preloadedFingerprint={participantFingerprint}
|
||||||
|
preloadedFingerprintStatus={fingerprintStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+53
-20
@@ -2,17 +2,19 @@ import { createPortal } from 'react-dom'
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||||
|
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||||
|
import { accessibilityStore } from '@/stores/accessibility'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { reactionsStore } from '@/stores/reactions'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { useAnnounceReaction } from '../hooks/useAnnounceReaction'
|
|
||||||
import { Reaction } from '../types'
|
export const ANIMATION_DURATION = 3000
|
||||||
import {
|
export const ANIMATION_DISTANCE = 300
|
||||||
ANIMATION_DISTANCE,
|
export const FADE_OUT_THRESHOLD = 0.7
|
||||||
ANIMATION_DURATION,
|
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
|
||||||
FADE_OUT_THRESHOLD,
|
export const INITIAL_POSITION = 200
|
||||||
INITIAL_POSITION,
|
|
||||||
REACTION_SPAWN_WIDTH_RATIO,
|
|
||||||
} from '../constants'
|
|
||||||
|
|
||||||
interface FloatingReactionProps {
|
interface FloatingReactionProps {
|
||||||
emoji: string
|
emoji: string
|
||||||
@@ -77,7 +79,7 @@ export function FloatingReaction({
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/assets/reactions/${emoji}.png`}
|
src={`/assets/reactions/${emoji}.png`}
|
||||||
alt=""
|
alt={''}
|
||||||
className={css({
|
className={css({
|
||||||
height: '50px',
|
height: '50px',
|
||||||
})}
|
})}
|
||||||
@@ -114,7 +116,14 @@ export function FloatingReaction({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReactionPortal = ({ reaction }: { reaction: Reaction }) => {
|
export function ReactionPortal({
|
||||||
|
emoji,
|
||||||
|
participant,
|
||||||
|
}: {
|
||||||
|
emoji: string
|
||||||
|
participant: Participant
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
|
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
|
||||||
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
|
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -129,27 +138,51 @@ const ReactionPortal = ({ reaction }: { reaction: Reaction }) => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<FloatingReaction
|
<FloatingReaction
|
||||||
emoji={reaction.emoji}
|
emoji={emoji}
|
||||||
speed={speed}
|
speed={speed}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
name={reaction.participantName}
|
name={participant?.isLocal ? t('you') : participant.name}
|
||||||
isLocal={reaction.isLocal}
|
isLocal={participant?.isLocal}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReactionPortals = () => {
|
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
||||||
const { reactions } = useSnapshot(reactionsStore)
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
const latestReaction = reactions.at(-1)
|
const { announceReactions } = useSnapshot(accessibilityStore)
|
||||||
|
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
useAnnounceReaction(latestReaction)
|
const latestReaction =
|
||||||
|
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!announceReactions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!latestReaction) return
|
||||||
|
const isNewReaction = latestReaction.id !== lastAnnouncedId
|
||||||
|
if (!isNewReaction) return
|
||||||
|
|
||||||
|
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
|
||||||
|
const participantName = latestReaction.participant?.isLocal
|
||||||
|
? t('you')
|
||||||
|
: latestReaction.participant?.name?.trim() ||
|
||||||
|
t('someone', { defaultValue: 'Someone' })
|
||||||
|
announce(t('announce', { name: participantName, emoji: emojiLabel }))
|
||||||
|
setLastAnnouncedId(latestReaction.id)
|
||||||
|
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{reactions.map((instance) => (
|
{reactions.map((instance) => (
|
||||||
<ReactionPortal key={instance.id} reaction={instance} />
|
<ReactionPortal
|
||||||
|
key={instance.id}
|
||||||
|
emoji={instance.emoji}
|
||||||
|
participant={instance.participant}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -14,7 +14,6 @@ import { Admin } from './Admin'
|
|||||||
import { Tools } from './Tools'
|
import { Tools } from './Tools'
|
||||||
import { Info } from './Info'
|
import { Info } from './Info'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -26,7 +25,6 @@ type StyledSidePanelProps = {
|
|||||||
isSubmenu: boolean
|
isSubmenu: boolean
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
backButtonLabel: string
|
backButtonLabel: string
|
||||||
isReactionToolbarOpen?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledSidePanel = ({
|
const StyledSidePanel = ({
|
||||||
@@ -35,7 +33,6 @@ const StyledSidePanel = ({
|
|||||||
children,
|
children,
|
||||||
onClose,
|
onClose,
|
||||||
isClosed,
|
isClosed,
|
||||||
isReactionToolbarOpen,
|
|
||||||
closeButtonTooltip,
|
closeButtonTooltip,
|
||||||
isSubmenu = false,
|
isSubmenu = false,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -54,23 +51,17 @@ const StyledSidePanel = ({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
margin: 'var(--sizes-room-side-panel-margin)',
|
margin: '1.5rem 1.5rem 1.5rem 0',
|
||||||
marginLeft: 0,
|
|
||||||
marginBottom: 0,
|
|
||||||
padding: 0,
|
padding: 0,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
width: 'var(--sizes-room-side-panel)',
|
bottom: '80px',
|
||||||
|
width: '360px',
|
||||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
transform: isClosed
|
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
||||||
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
|
||||||
: 'none',
|
|
||||||
bottom: isReactionToolbarOpen
|
|
||||||
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
|
||||||
: 'var(--sizes-room-control-bar)',
|
|
||||||
}}
|
}}
|
||||||
aria-hidden={isClosed}
|
aria-hidden={isClosed}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
@@ -160,8 +151,6 @@ export const SidePanel = () => {
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||||
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
||||||
|
|
||||||
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
title={title}
|
title={title}
|
||||||
@@ -175,7 +164,6 @@ export const SidePanel = () => {
|
|||||||
})}
|
})}
|
||||||
isClosed={!isSidePanelOpen}
|
isClosed={!isSidePanelOpen}
|
||||||
isSubmenu={isSubPanelOpen}
|
isSubmenu={isSubPanelOpen}
|
||||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
|
||||||
backButtonLabel={t('backToTools')}
|
backButtonLabel={t('backToTools')}
|
||||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ import {
|
|||||||
ScreenRecordingSidePanel,
|
ScreenRecordingSidePanel,
|
||||||
} from '@/features/recording'
|
} from '@/features/recording'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
|
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { RiLockLine } from '@remixicon/react'
|
||||||
|
|
||||||
export interface ToolsButtonProps {
|
export interface ToolsButtonProps {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
|
isDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolButton = ({
|
const ToolButton = ({
|
||||||
@@ -25,9 +29,11 @@ const ToolButton = ({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
onPress,
|
onPress,
|
||||||
|
isDisabled,
|
||||||
}: ToolsButtonProps) => {
|
}: ToolsButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<RACButton
|
<RACButton
|
||||||
|
isDisabled={isDisabled}
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -39,10 +45,14 @@ const ToolButton = ({
|
|||||||
width: 'full',
|
width: 'full',
|
||||||
backgroundColor: 'gray.50',
|
backgroundColor: 'gray.50',
|
||||||
textAlign: 'start',
|
textAlign: 'start',
|
||||||
'&[data-hovered]': {
|
'&[data-hovered]:not([data-disabled])': {
|
||||||
backgroundColor: 'primary.50',
|
backgroundColor: 'primary.50',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
|
'&[data-disabled]': {
|
||||||
|
opacity: 0.5,
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
>
|
>
|
||||||
@@ -132,6 +142,9 @@ export const Tools = () => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roomData = useRoomData()
|
||||||
|
const encrypted = isEncryptedRoom(roomData)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Div
|
<Div
|
||||||
display="flex"
|
display="flex"
|
||||||
@@ -166,12 +179,33 @@ export const Tools = () => {
|
|||||||
</A>
|
</A>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{encrypted && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
alignItems: 'start',
|
||||||
|
padding: '0.6rem 0.75rem',
|
||||||
|
backgroundColor: '#fffbeb',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid #fde68a',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
width: '100%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiLockLine size={16} color="#d97706" className={css({ flexShrink: 0, marginTop: '0.1rem' })} />
|
||||||
|
<Text variant="note" className={css({ fontSize: '0.8rem', color: '#92400e' })}>
|
||||||
|
{t('encryptedDisabled')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isTranscriptEnabled && (
|
{isTranscriptEnabled && (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
icon={<Icon type="symbols" name="speech_to_text" />}
|
icon={<Icon type="symbols" name="speech_to_text" />}
|
||||||
title={t('tools.transcript.title')}
|
title={t('tools.transcript.title')}
|
||||||
description={t('tools.transcript.body')}
|
description={t('tools.transcript.body')}
|
||||||
onPress={() => openTranscript()}
|
onPress={() => openTranscript()}
|
||||||
|
isDisabled={encrypted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isScreenRecordingEnabled && (
|
{isScreenRecordingEnabled && (
|
||||||
@@ -180,6 +214,7 @@ export const Tools = () => {
|
|||||||
title={t('tools.screenRecording.title')}
|
title={t('tools.screenRecording.title')}
|
||||||
description={t('tools.screenRecording.body')}
|
description={t('tools.screenRecording.body')}
|
||||||
onPress={() => openScreenRecording()}
|
onPress={() => openScreenRecording()}
|
||||||
|
isDisabled={encrypted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Div>
|
</Div>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export const ChatToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isChatOpen}
|
isSelected={isChatOpen}
|
||||||
aria-expanded={isChatOpen}
|
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleChat()
|
toggleChat()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export const InfoToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isInfoOpen}
|
isSelected={isInfoOpen}
|
||||||
aria-expanded={isInfoOpen}
|
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleInfo()
|
toggleInfo()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
+5
-1
@@ -5,18 +5,22 @@ import { menuRecipe } from '@/primitives/menuRecipe'
|
|||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
||||||
import { FeatureFlags } from '@/features/analytics/enums'
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
export const ScreenRecordingMenuItem = () => {
|
export const ScreenRecordingMenuItem = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
const { isScreenRecordingOpen, openScreenRecording, toggleTools } =
|
const { isScreenRecordingOpen, openScreenRecording, toggleTools } =
|
||||||
useSidePanel()
|
useSidePanel()
|
||||||
|
const roomData = useRoomData()
|
||||||
|
|
||||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||||
RecordingMode.ScreenRecording,
|
RecordingMode.ScreenRecording,
|
||||||
FeatureFlags.ScreenRecording
|
FeatureFlags.ScreenRecording
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!hasScreenRecordingAccess) return null
|
// Recording not available in encrypted rooms
|
||||||
|
if (!hasScreenRecordingAccess || checkEncryptedRoom(roomData)) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|||||||
+5
-1
@@ -5,17 +5,21 @@ import { menuRecipe } from '@/primitives/menuRecipe'
|
|||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
||||||
import { FeatureFlags } from '@/features/analytics/enums'
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
export const TranscriptMenuItem = () => {
|
export const TranscriptMenuItem = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel()
|
const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel()
|
||||||
|
const roomData = useRoomData()
|
||||||
|
|
||||||
const hasTranscriptAccess = useHasRecordingAccess(
|
const hasTranscriptAccess = useHasRecordingAccess(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
FeatureFlags.Transcript
|
FeatureFlags.Transcript
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!hasTranscriptAccess) return null
|
// Recording/transcription not available in encrypted rooms
|
||||||
|
if (!hasTranscriptAccess || checkEncryptedRoom(roomData)) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|||||||
+125
-23
@@ -21,6 +21,13 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
||||||
import { PinBadge } from './PinBadge'
|
import { PinBadge } from './PinBadge'
|
||||||
|
import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
|
||||||
|
import { useParticipantTrustLevel } from '@/features/encryption/useParticipantTrustLevel'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { isEncryptedRoom as isEncryptedRoomFn } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
|
import { useUser } from '@/features/auth'
|
||||||
|
import { TooltipWrapper } from '@/primitives/TooltipWrapper'
|
||||||
|
|
||||||
type MicIndicatorProps = {
|
type MicIndicatorProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
@@ -97,7 +104,16 @@ export const ParticipantListItem = ({
|
|||||||
participant,
|
participant,
|
||||||
}: ParticipantListItemProps) => {
|
}: ParticipantListItemProps) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const roomData = useRoomData()
|
||||||
|
const isEncryptedRoom = isEncryptedRoomFn(roomData)
|
||||||
|
const isAdmin = useIsAdminOrOwner()
|
||||||
|
const { isLoggedIn } = useUser()
|
||||||
|
const { t: tEncBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
|
||||||
|
const [isIdentityOpen, setIsFingerprintOpen] = useState(false)
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
const attrs = participant.attributes as Record<string, string> | undefined
|
||||||
|
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(attrs, roomData?.encryption_mode, isLocal(participant))
|
||||||
|
const badgeTooltip = tEncBadge(trustLevel)
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
role="listitem"
|
role="listitem"
|
||||||
@@ -118,45 +134,131 @@ export const ParticipantListItem = ({
|
|||||||
<PinBadge participant={participant} />
|
<PinBadge participant={participant} />
|
||||||
</div>
|
</div>
|
||||||
<VStack gap={0} alignItems="start">
|
<VStack gap={0} alignItems="start">
|
||||||
<Text
|
{isEncryptedRoom ? (
|
||||||
variant="sm"
|
<Button
|
||||||
className={css({
|
variant="greyscale"
|
||||||
userSelect: 'none',
|
size="sm"
|
||||||
cursor: 'default',
|
tooltip={badgeTooltip}
|
||||||
display: 'flex',
|
aria-label={badgeTooltip}
|
||||||
})}
|
onPress={() => setIsFingerprintOpen(true)}
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={css({
|
className={css({
|
||||||
|
padding: '0.1rem 0.25rem !important',
|
||||||
|
minWidth: 'auto !important',
|
||||||
|
height: 'auto !important',
|
||||||
|
gap: '0.15rem !important',
|
||||||
|
borderRadius: '0.25rem !important',
|
||||||
|
backgroundColor: 'transparent !important',
|
||||||
|
color: 'greyscale.900 !important',
|
||||||
|
cursor: isEncryptedRoom ? 'pointer' : 'default',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'greyscale.100 !important',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<EncryptionBadge
|
||||||
|
isEncrypted={true}
|
||||||
|
trustLevel={trustLevel}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
className={css({
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '120px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{isLocal(participant) && (
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
className={css({ whiteSpace: 'nowrap', flexShrink: 0 })}
|
||||||
|
>
|
||||||
|
({t('participants.you')})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
className={css({
|
||||||
|
userSelect: 'none',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
maxWidth: '120px',
|
maxWidth: '150px',
|
||||||
display: 'block',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
{isLocal(participant) && ` (${t('participants.you')})`}
|
||||||
{isLocal(participant) && (
|
</Text>
|
||||||
<span
|
)}
|
||||||
className={css({
|
|
||||||
marginLeft: '.25rem',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
({t('participants.you')})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
{getParticipantIsRoomAdmin(participant) && (
|
{getParticipantIsRoomAdmin(participant) && (
|
||||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||||
)}
|
)}
|
||||||
|
{/* Email is only in JWT for encrypted rooms (backend restriction).
|
||||||
|
Additionally, only show to authenticated users in the UI — anonymous
|
||||||
|
users in encrypted rooms could still extract it from LiveKit signaling
|
||||||
|
but won't see it in the interface. See utils.py for details. */}
|
||||||
|
{isEncryptedRoom && isLoggedIn && (() => {
|
||||||
|
const email = participant.attributes?.is_authenticated === 'true' && participant.attributes?.email
|
||||||
|
? participant.attributes.email
|
||||||
|
: null
|
||||||
|
const label = email || t('participants.anonymous')
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="greyscale"
|
||||||
|
size="sm"
|
||||||
|
tooltip={email || undefined}
|
||||||
|
aria-label={label}
|
||||||
|
className={css({
|
||||||
|
padding: '0 !important',
|
||||||
|
minWidth: 'auto !important',
|
||||||
|
height: 'auto !important',
|
||||||
|
backgroundColor: 'transparent !important',
|
||||||
|
color: 'greyscale.500 !important',
|
||||||
|
fontSize: '0.7rem !important',
|
||||||
|
fontWeight: 'normal !important',
|
||||||
|
width: '100%',
|
||||||
|
minW: 0,
|
||||||
|
justifyContent: 'flex-start !important',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'transparent !important',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span className={css({
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
minWidth: 0,
|
||||||
|
})}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack>
|
<HStack>
|
||||||
<MicIndicator participant={participant} />
|
<MicIndicator participant={participant} />
|
||||||
<ParticipantMenuButton participant={participant} />
|
<ParticipantMenuButton participant={participant} />
|
||||||
</HStack>
|
</HStack>
|
||||||
|
{isEncryptedRoom && (
|
||||||
|
<EncryptionIdentityDialog
|
||||||
|
isOpen={isIdentityOpen}
|
||||||
|
onOpenChange={setIsFingerprintOpen}
|
||||||
|
participantName={name}
|
||||||
|
participantEmail={attrs?.email}
|
||||||
|
suiteUserId={attrs?.suite_user_id}
|
||||||
|
isAuthenticated={attrs?.is_authenticated === 'true'}
|
||||||
|
encryptionMode={roomData?.encryption_mode}
|
||||||
|
isSelf={isLocal(participant)}
|
||||||
|
preloadedFingerprint={fingerprint}
|
||||||
|
preloadedFingerprintStatus={fingerprintStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
-2
@@ -48,7 +48,6 @@ export const ParticipantsToggle = ({
|
|||||||
count: announcedCount,
|
count: announcedCount,
|
||||||
})}.`}
|
})}.`}
|
||||||
isSelected={isParticipantsOpen}
|
isSelected={isParticipantsOpen}
|
||||||
aria-expanded={isParticipantsOpen}
|
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleParticipants()
|
toggleParticipants()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
@@ -77,7 +76,6 @@ export const ParticipantsToggle = ({
|
|||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
})}
|
})}
|
||||||
aria-hidden={true}
|
|
||||||
>
|
>
|
||||||
{numParticipants < 100 ? (
|
{numParticipants < 100 ? (
|
||||||
numParticipants || 1
|
numParticipants || 1
|
||||||
|
|||||||
+141
-26
@@ -1,10 +1,15 @@
|
|||||||
import { Button, Text } from '@/primitives'
|
import { Button, Text } from '@/primitives'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack, VStack } from '@/styled-system/jsx'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
|
||||||
|
import { useParticipantTrustLevel, formatFingerprint } from '@/features/encryption/useParticipantTrustLevel'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
export const WaitingParticipantListItem = ({
|
export const WaitingParticipantListItem = ({
|
||||||
participant,
|
participant,
|
||||||
@@ -14,6 +19,17 @@ export const WaitingParticipantListItem = ({
|
|||||||
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
|
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const roomData = useRoomData()
|
||||||
|
const encryptedRoom = isEncryptedRoom(roomData)
|
||||||
|
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
|
||||||
|
const [isIdentityOpen, setIsDialogOpen] = useState(false)
|
||||||
|
// Build attributes-like object for the hook (waiting participants aren't in LiveKit yet)
|
||||||
|
const waitingAttrs = {
|
||||||
|
is_authenticated: participant.is_authenticated ? 'true' : 'false',
|
||||||
|
suite_user_id: participant.suite_user_id || '',
|
||||||
|
}
|
||||||
|
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(waitingAttrs, roomData?.encryption_mode)
|
||||||
|
const badgeTooltip = encryptedRoom ? tBadge(trustLevel) : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
@@ -30,37 +46,123 @@ export const WaitingParticipantListItem = ({
|
|||||||
className={css({
|
className={css({
|
||||||
flex: '1',
|
flex: '1',
|
||||||
minWidth: '0',
|
minWidth: '0',
|
||||||
|
gap: '0.35rem',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Avatar name={participant.username} bgColor={participant.color} />
|
<Avatar name={participant.username} bgColor={participant.color} />
|
||||||
<Text
|
<VStack gap={0} alignItems="start" className={css({ flex: 1, minWidth: 0 })}>
|
||||||
variant={'sm'}
|
{encryptedRoom ? (
|
||||||
className={css({
|
<Button
|
||||||
userSelect: 'none',
|
variant="greyscale"
|
||||||
cursor: 'default',
|
size="sm"
|
||||||
display: 'flex',
|
tooltip={badgeTooltip}
|
||||||
flex: '1',
|
aria-label={badgeTooltip}
|
||||||
minWidth: '0',
|
onPress={() => setIsDialogOpen(true)}
|
||||||
})}
|
className={css({
|
||||||
>
|
padding: '0.1rem 0.25rem !important',
|
||||||
<span
|
minWidth: 'auto !important',
|
||||||
className={css({
|
height: 'auto !important',
|
||||||
whiteSpace: 'nowrap',
|
gap: '0.15rem !important',
|
||||||
overflow: 'hidden',
|
borderRadius: '0.25rem !important',
|
||||||
textOverflow: 'ellipsis',
|
backgroundColor: 'transparent !important',
|
||||||
width: '100%',
|
color: 'greyscale.900 !important',
|
||||||
display: 'block',
|
cursor: 'pointer',
|
||||||
})}
|
'&[data-hovered]': {
|
||||||
>
|
backgroundColor: 'greyscale.100 !important',
|
||||||
{participant.username}
|
},
|
||||||
</span>
|
})}
|
||||||
</Text>
|
>
|
||||||
|
<EncryptionBadge
|
||||||
|
isEncrypted={true}
|
||||||
|
trustLevel={trustLevel}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
className={css({
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
minWidth: 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{participant.username}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
className={css({
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
padding: '0.1rem 0.25rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{participant.username}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{encryptedRoom && fingerprint && (
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: 'greyscale.400',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
paddingLeft: '0.25rem',
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formatFingerprint(fingerprint)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{encryptedRoom && (() => {
|
||||||
|
const email = participant.is_authenticated && participant.email
|
||||||
|
? participant.email
|
||||||
|
: null
|
||||||
|
const label = email || t('participants.anonymous')
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="greyscale"
|
||||||
|
size="sm"
|
||||||
|
tooltip={email || undefined}
|
||||||
|
aria-label={label}
|
||||||
|
className={css({
|
||||||
|
padding: '0 0.25rem !important',
|
||||||
|
minWidth: 'auto !important',
|
||||||
|
height: 'auto !important',
|
||||||
|
backgroundColor: 'transparent !important',
|
||||||
|
color: 'greyscale.500 !important',
|
||||||
|
fontSize: '0.7rem !important',
|
||||||
|
fontWeight: 'normal !important',
|
||||||
|
width: '100%',
|
||||||
|
minW: 0,
|
||||||
|
justifyContent: 'flex-start !important',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'transparent !important',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span className={css({
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
minWidth: 0,
|
||||||
|
})}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack
|
<HStack
|
||||||
gap="0.25rem"
|
gap="0.25rem"
|
||||||
className={css({
|
className={css({ flexShrink: '0' })}
|
||||||
flexShrink: '0',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -83,6 +185,19 @@ export const WaitingParticipantListItem = ({
|
|||||||
<RiCloseLine />
|
<RiCloseLine />
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
{encryptedRoom && (
|
||||||
|
<EncryptionIdentityDialog
|
||||||
|
isOpen={isIdentityOpen}
|
||||||
|
onOpenChange={setIsDialogOpen}
|
||||||
|
participantName={participant.username}
|
||||||
|
participantEmail={participant.email}
|
||||||
|
suiteUserId={participant.suite_user_id}
|
||||||
|
isAuthenticated={participant.is_authenticated}
|
||||||
|
encryptionMode={roomData?.encryption_mode}
|
||||||
|
preloadedFingerprint={fingerprint}
|
||||||
|
preloadedFingerprintStatus={fingerprintStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiEmotionLine } from '@remixicon/react'
|
||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { ToggleButton, Button } from '@/primitives'
|
||||||
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||||
|
import {
|
||||||
|
ANIMATION_DURATION,
|
||||||
|
ReactionPortals,
|
||||||
|
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||||
|
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
import {
|
||||||
|
Popover as RACPopover,
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
import { FocusScope } from '@react-aria/focus'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export enum Emoji {
|
||||||
|
THUMBS_UP = 'thumbs-up',
|
||||||
|
THUMBS_DOWN = 'thumbs-down',
|
||||||
|
CLAP = 'clapping-hands',
|
||||||
|
HEART = 'red-heart',
|
||||||
|
LAUGHING = 'face-with-tears-of-joy',
|
||||||
|
SURPRISED = 'face-with-open-mouth',
|
||||||
|
CELEBRATION = 'party-popper',
|
||||||
|
PLEASE = 'folded-hands',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Reaction {
|
||||||
|
id: number
|
||||||
|
emoji: string
|
||||||
|
participant: Participant
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReactionsToggle = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
|
const [reactions, setReactions] = useState<Reaction[]>([])
|
||||||
|
const instanceIdRef = useRef(0)
|
||||||
|
const room = useRoomContext()
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'reaction',
|
||||||
|
handler: () => setIsOpen((prev) => !prev),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendReaction = async (emoji: string) => {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const payload: NotificationPayload = {
|
||||||
|
type: NotificationType.ReactionReceived,
|
||||||
|
data: {
|
||||||
|
emoji: emoji,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const data = encoder.encode(JSON.stringify(payload))
|
||||||
|
await room.localParticipant.publishData(data, { reliable: true })
|
||||||
|
|
||||||
|
const newReaction = {
|
||||||
|
id: instanceIdRef.current++,
|
||||||
|
emoji,
|
||||||
|
participant: room.localParticipant,
|
||||||
|
}
|
||||||
|
setReactions((prev) => [...prev, newReaction])
|
||||||
|
|
||||||
|
// Remove this reaction after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
setReactions((prev) =>
|
||||||
|
prev.filter((instance) => instance.id !== newReaction.id)
|
||||||
|
)
|
||||||
|
}, ANIMATION_DURATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSendReaction = useRateLimiter({
|
||||||
|
callback: sendReaction,
|
||||||
|
maxCalls: 10,
|
||||||
|
windowMs: 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={css({ position: 'relative' })}>
|
||||||
|
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<ToggleButton
|
||||||
|
square
|
||||||
|
variant="primaryDark"
|
||||||
|
aria-label={t('button')}
|
||||||
|
tooltip={t('button')}
|
||||||
|
isSelected={isOpen}
|
||||||
|
onChange={setIsOpen}
|
||||||
|
>
|
||||||
|
<RiEmotionLine />
|
||||||
|
</ToggleButton>
|
||||||
|
<RACPopover
|
||||||
|
placement="top"
|
||||||
|
offset={8}
|
||||||
|
isNonModal
|
||||||
|
shouldCloseOnInteractOutside={() => false}
|
||||||
|
className={css({
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '0.35rem',
|
||||||
|
backgroundColor: 'primaryDark.50',
|
||||||
|
'&[data-entering]': {
|
||||||
|
animation: 'fade 200ms ease',
|
||||||
|
},
|
||||||
|
'&[data-exiting]': {
|
||||||
|
animation: 'fade 200ms ease-in reverse',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Dialog className={css({ outline: 'none' })}>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
|
||||||
|
<FocusScope contain autoFocus restoreFocus>
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label={t('button')}
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{Object.values(Emoji).map((emoji, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
onPress={() => debouncedSendReaction(emoji)}
|
||||||
|
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||||
|
variant="primaryTextDark"
|
||||||
|
size="sm"
|
||||||
|
square
|
||||||
|
data-attr={`send-reaction-${emoji}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/assets/reactions/${emoji}.png`}
|
||||||
|
alt=""
|
||||||
|
className={css({
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FocusScope>
|
||||||
|
</Dialog>
|
||||||
|
</RACPopover>
|
||||||
|
</DialogTrigger>
|
||||||
|
</div>
|
||||||
|
<ReactionPortals reactions={reactions} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@ export const ToolsToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isToolsOpen}
|
isSelected={isToolsOpen}
|
||||||
aria-expanded={isToolsOpen}
|
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleTools()
|
toggleTools()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
+3
-3
@@ -7,9 +7,9 @@ import {
|
|||||||
useSwipe,
|
useSwipe,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { mergeProps } from '@/utils/mergeProps'
|
import { mergeProps } from '@/utils/mergeProps'
|
||||||
import { PaginationIndicator } from './PaginationIndicator'
|
import { PaginationIndicator } from '../controls/PaginationIndicator'
|
||||||
import { useGridLayout } from '../hooks/useGridLayout'
|
import { useGridLayout } from '../../hooks/useGridLayout'
|
||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from '../controls/PaginationControl'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
@@ -7,7 +7,7 @@ import { getRouteUrl } from '@/navigation/getRouteUrl'
|
|||||||
|
|
||||||
const COPY_SUCCESS_TIMEOUT = 3000
|
const COPY_SUCCESS_TIMEOUT = 3000
|
||||||
|
|
||||||
export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
export const useCopyRoomToClipboard = (room: ApiRoom | undefined, hashOverride?: string) => {
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
|
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
|
||||||
|
|
||||||
@@ -32,8 +32,12 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
|||||||
}, [isRoomUrlCopied])
|
}, [isRoomUrlCopied])
|
||||||
|
|
||||||
const roomUrl = useMemo(() => {
|
const roomUrl = useMemo(() => {
|
||||||
return room?.slug ? getRouteUrl('room', room.slug) : ''
|
if (!room?.slug) return ''
|
||||||
}, [room?.slug])
|
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])
|
||||||
|
|
||||||
const hasTelephonyInfo = useMemo(() => {
|
const hasTelephonyInfo = useMemo(() => {
|
||||||
return telephony.enabled && room?.pin_code
|
return telephony.enabled && room?.pin_code
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import * as React from 'react'
|
|||||||
import { MobileControlBar } from './MobileControlBar'
|
import { MobileControlBar } from './MobileControlBar'
|
||||||
import { DesktopControlBar } from './DesktopControlBar'
|
import { DesktopControlBar } from './DesktopControlBar'
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
import { useIsMobile } from '@/utils/useIsMobile'
|
||||||
import { ReactionsToolbar } from '@/features/reactions/components/toolbar/ReactionsToolbar'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
|
|
||||||
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
||||||
@@ -18,32 +16,10 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
return (
|
if (isMobile) {
|
||||||
<div
|
return <MobileControlBar onDeviceError={onDeviceError} />
|
||||||
className={css({
|
}
|
||||||
display: 'flex',
|
return <DesktopControlBar onDeviceError={onDeviceError} />
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ReactionsToolbar />
|
|
||||||
<div
|
|
||||||
id="control-bar"
|
|
||||||
className={css({
|
|
||||||
zIndex: 100,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isMobile ? (
|
|
||||||
<MobileControlBar onDeviceError={onDeviceError} />
|
|
||||||
) : (
|
|
||||||
<DesktopControlBar onDeviceError={onDeviceError} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
|
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ControlBarAuxProps } from './ControlBar'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
|
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
|
||||||
import { HandToggle } from '../../components/controls/HandToggle'
|
import { HandToggle } from '../../components/controls/HandToggle'
|
||||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||||
import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
|
import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
|
||||||
@@ -14,8 +15,6 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
|
|||||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||||
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
|
||||||
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
|
|
||||||
|
|
||||||
export function DesktopControlBar({
|
export function DesktopControlBar({
|
||||||
onDeviceError,
|
onDeviceError,
|
||||||
@@ -49,7 +48,11 @@ export function DesktopControlBar({
|
|||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
position: 'absolute',
|
||||||
padding: '1.125rem',
|
padding: '1.125rem',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -62,7 +65,15 @@ export function DesktopControlBar({
|
|||||||
marginLeft: '0.5rem',
|
marginLeft: '0.5rem',
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<ControlBarRegion>
|
<div
|
||||||
|
className={css({
|
||||||
|
flex: '1 1 33%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.65rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
<AudioDevicesControl
|
<AudioDevicesControl
|
||||||
onDeviceError={(error) =>
|
onDeviceError={(error) =>
|
||||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||||
@@ -86,7 +97,7 @@ export function DesktopControlBar({
|
|||||||
<OptionsButton />
|
<OptionsButton />
|
||||||
<LeaveButton />
|
<LeaveButton />
|
||||||
<StartMediaButton />
|
<StartMediaButton />
|
||||||
</ControlBarRegion>
|
</div>
|
||||||
<MoreOptions parentElement={desktopControlBarEl} />
|
<MoreOptions parentElement={desktopControlBarEl} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { useConfig } from '@/api/useConfig'
|
|||||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
|
|
||||||
|
|
||||||
export function MobileControlBar({
|
export function MobileControlBar({
|
||||||
onDeviceError,
|
onDeviceError,
|
||||||
@@ -44,11 +43,21 @@ export function MobileControlBar({
|
|||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
position: 'absolute',
|
||||||
padding: '1.125rem',
|
padding: '1.125rem',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ControlBarRegion mobile>
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '330px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
<LeaveButton />
|
<LeaveButton />
|
||||||
<AudioDevicesControl
|
<AudioDevicesControl
|
||||||
onDeviceError={(error) =>
|
onDeviceError={(error) =>
|
||||||
@@ -73,7 +82,7 @@ export function MobileControlBar({
|
|||||||
>
|
>
|
||||||
<RiMore2Line />
|
<RiMore2Line />
|
||||||
</Button>
|
</Button>
|
||||||
</ControlBarRegion>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveMenu
|
<ResponsiveMenu
|
||||||
isOpened={isMenuOpened}
|
isOpened={isMenuOpened}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const MoreOptions = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { width: parentWidth } = useSize(parentElement)
|
const { width: parentWidth } = useSize(parentElement)
|
||||||
return (
|
return (
|
||||||
<nav
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
@@ -88,6 +88,6 @@ export const MoreOptions = ({
|
|||||||
) : (
|
) : (
|
||||||
<LateralMenu />
|
<LateralMenu />
|
||||||
)}
|
)}
|
||||||
</nav>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ import {
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { ControlBar } from './ControlBar/ControlBar'
|
import { ControlBar } from './ControlBar/ControlBar'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { cva } from '@/styled-system/css'
|
||||||
|
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
||||||
import { FocusLayout } from '../components/FocusLayout'
|
import { FocusLayout } from '../components/FocusLayout'
|
||||||
import { ParticipantTile } from '../components/ParticipantTile'
|
import { ParticipantTile } from '../components/ParticipantTile'
|
||||||
import { SidePanel } from '../components/SidePanel'
|
import { SidePanel } from '../components/SidePanel'
|
||||||
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { RecordingProvider } from '@/features/recording'
|
import { RecordingProvider } from '@/features/recording'
|
||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||||
@@ -33,13 +37,36 @@ import { useSettingsDialog } from '@/features/settings'
|
|||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||||
|
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
||||||
|
import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
||||||
|
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||||
|
import { GridLayout } from '../components/layout/GridLayout'
|
||||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||||
|
import { EncryptedMeetingBanner } from '@/features/encryption/EncryptedMeetingBanner'
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
|
||||||
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
const LayoutWrapper = styled(
|
||||||
import { GridLayout } from '@/features/layout/components/GridLayout'
|
'div',
|
||||||
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
cva({
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
transition: 'height .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
areSubtitlesOpen: {
|
||||||
|
true: {
|
||||||
|
height: 'calc(100% - 12rem)',
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
@@ -227,6 +254,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
])
|
])
|
||||||
/* eslint-enable react-hooks/exhaustive-deps */
|
/* eslint-enable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
|
const { isSidePanelOpen } = useSidePanel()
|
||||||
|
const { areSubtitlesOpen } = useSubtitles()
|
||||||
|
|
||||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -247,35 +277,58 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
onClose={() => setIsShareErrorVisible(false)}
|
onClose={() => setIsShareErrorVisible(false)}
|
||||||
/>
|
/>
|
||||||
<IsIdleDisconnectModal />
|
<IsIdleDisconnectModal />
|
||||||
<RoomContentArea>
|
<EncryptedMeetingBanner />
|
||||||
{!focusTrack ? (
|
<div
|
||||||
|
// todo - extract these magic values into constant
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: isSidePanelOpen
|
||||||
|
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
|
||||||
|
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
|
||||||
|
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||||
|
maxHeight: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
|
||||||
<div
|
<div
|
||||||
className="lk-grid-layout-wrapper"
|
style={{
|
||||||
style={{ height: 'auto' }}
|
display: 'flex',
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
{!focusTrack ? (
|
||||||
<ParticipantTile />
|
<div
|
||||||
</GridLayout>
|
className="lk-grid-layout-wrapper"
|
||||||
</div>
|
style={{ height: 'auto' }}
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="lk-focus-layout-wrapper"
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
>
|
|
||||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
|
||||||
<CarouselLayout
|
|
||||||
tracks={carouselTracks}
|
|
||||||
style={{
|
|
||||||
minWidth: '200px',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ParticipantTile />
|
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||||
</CarouselLayout>
|
<ParticipantTile />
|
||||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
</GridLayout>
|
||||||
</FocusLayoutContainer>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="lk-focus-layout-wrapper"
|
||||||
|
style={{ height: 'auto' }}
|
||||||
|
>
|
||||||
|
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||||
|
<CarouselLayout
|
||||||
|
tracks={carouselTracks}
|
||||||
|
style={{
|
||||||
|
minWidth: '200px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParticipantTile />
|
||||||
|
</CarouselLayout>
|
||||||
|
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||||
|
</FocusLayoutContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</LayoutWrapper>
|
||||||
</RoomContentArea>
|
<Subtitles />
|
||||||
|
<MainNotificationToast />
|
||||||
|
</div>
|
||||||
<ControlBar
|
<ControlBar
|
||||||
onDeviceError={(e) => {
|
onDeviceError={(e) => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
@@ -295,7 +348,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<ConnectionStateToast />
|
<ConnectionStateToast />
|
||||||
<RecordingProvider />
|
<RecordingProvider />
|
||||||
<SettingsDialogProvider />
|
<SettingsDialogProvider />
|
||||||
<ReactionPortals />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -1,8 +1,7 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Emoji } from './types'
|
|
||||||
|
|
||||||
export const getEmojiLabel = (
|
export const getEmojiLabel = (
|
||||||
emoji: string | Emoji,
|
emoji: string,
|
||||||
t: ReturnType<typeof useTranslation>['t']
|
t: ReturnType<typeof useTranslation>['t']
|
||||||
) => {
|
) => {
|
||||||
const emojiLabels: Record<string, string> = {
|
const emojiLabels: Record<string, string> = {
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user