mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a357fb04d5 | |||
| e2a3b286ca | |||
| 668f093063 | |||
| f3476950c3 | |||
| 1aa8bc822d | |||
| 3698ac09eb |
@@ -8,6 +8,17 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(encryption) opt-in end-to-end encryption for meetings, with the
|
||||||
|
passphrase carried in the URL hash and an explicit "Create an
|
||||||
|
encrypted meeting" entry in the home create menu (gated by a
|
||||||
|
per-user Security preference) #1337
|
||||||
|
- ✨(encryption) authenticated participant emails surfaced in the
|
||||||
|
participants list of encrypted rooms; anonymous participants get a
|
||||||
|
red badge with tooltip in the list, waiting room, and the floating
|
||||||
|
join-request notification
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ⬆️(dependencies) update python dependencies
|
- ⬆️(dependencies) update python dependencies
|
||||||
|
|||||||
@@ -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,54 @@
|
|||||||
## 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 passphrase-in-link 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, so the media server (LiveKit SFU) cannot read audio, video or screen-share content.
|
||||||
|
|
||||||
|
#### How it works
|
||||||
|
|
||||||
|
- Each encrypted meeting carries a 48-character hex passphrase appended to the URL hash (`#…`) — 192 bits of entropy. The server never sees it; sharing the meeting link shares the key.
|
||||||
|
- Frames are encrypted in the browser via LiveKit's Worker + `crypto.subtle` (AES-GCM); only the media payload is encrypted, codec headers stay clear so the SFU can still packetize RTP.
|
||||||
|
- The runtime "is this call encrypted?" decision keys off the URL hash, not the database flag. The DB column (`Room.encryption_mode`) is only used as a sanity reference: if the URL hash and the server's claim disagree, the joining client surfaces an explicit mismatch screen instead of silently joining in clear or in a private encrypted bubble.
|
||||||
|
|
||||||
|
> **Threat model.** "Server doesn't see plaintext" — not "users are safe from a malicious server." A compromised server could still serve modified JavaScript to a participant, who would then leak their passphrase. The E2EE story protects the media path against a passive or compromised SFU, not against a fully compromised origin.
|
||||||
|
|
||||||
|
#### Encryption mode is set at creation, immutable after
|
||||||
|
|
||||||
|
`Room.encryption_mode` is a string enum (`none` / `basic`) chosen when the room is created and never mutated afterwards — changing it would change the link's semantics, since the passphrase lives in the URL hash. There is no mid-call "pause encryption" mechanism: while a meeting is encrypted, **recording and transcription endpoints reject requests with a 400** (`Recording is unavailable in encrypted rooms.` / `Subtitles are unavailable in encrypted rooms.`), the More-tools panel renders those items disabled with an explanatory banner, and the SIP gateway never gets a dispatch rule for encrypted rooms (so dial-in numbers and PINs aren't allocated). Encrypted rooms are also force-locked to `restricted` access level (lobby admission), since basic E2EE only meaningfully protects against passive eavesdropping if the host vets joiners before they receive the in-URL key.
|
||||||
|
|
||||||
|
#### Opt-in by user
|
||||||
|
|
||||||
|
End-to-end encryption is a per-user preference. In **Settings**, under the **Security** section, signed-in users can flip the **End-to-end encryption** toggle — once enabled, a third "Create an encrypted meeting" entry appears in the home-page create-menu (with its own confirmation modal that lists the disabled features and a "Treat this link like a password" connection-details dialog before the meeting starts). Joining is unaffected by the toggle: any participant clicking a meeting link that carries a valid hash joins encrypted, regardless of their own setting. Authenticated joiners of encrypted rooms cannot edit their displayed name — the server enforces the OIDC name on the JWT.
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENCRYPTION_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Setting `ENCRYPTION_ENABLED=false` rejects encrypted-room creation at the API level. Existing encrypted rooms stay encrypted (the mode is immutable), but no new ones can be created.
|
||||||
|
|
||||||
|
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||||
|
|
||||||
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 +91,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 +109,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. |
|
| [mosa.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -100,7 +127,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 +135,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 +146,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 +161,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).
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -73,5 +73,8 @@ def get_frontend_configuration(request):
|
|||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
frontend_configuration["encryption"] = {
|
||||||
|
"enabled": settings.ENCRYPTION_ENABLED,
|
||||||
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -30,9 +30,30 @@ 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",
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"short_name",
|
||||||
|
"timezone",
|
||||||
|
"language",
|
||||||
|
"default_encryption_mode",
|
||||||
|
]
|
||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||||
|
|
||||||
|
def validate_default_encryption_mode(self, value):
|
||||||
|
"""Reject a non-none default when the server has encryption disabled.
|
||||||
|
|
||||||
|
Keeps the user preference DB in sync with the deployment's posture:
|
||||||
|
if an operator flips ENCRYPTION_ENABLED off, no client should be able
|
||||||
|
to keep persisting `basic` as their default behind their back.
|
||||||
|
"""
|
||||||
|
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
_("End-to-end encryption is disabled on this server.")
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class UserLightSerializer(serializers.ModelSerializer):
|
class UserLightSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users with limited fields."""
|
"""Serialize users with limited fields."""
|
||||||
@@ -74,6 +95,7 @@ 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."
|
||||||
)
|
)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def validate_resource(self, resource):
|
def validate_resource(self, resource):
|
||||||
@@ -128,9 +150,60 @@ 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_encryption_mode(self, value):
|
||||||
|
"""Encryption mode is part of the link's semantics (the passphrase
|
||||||
|
lives in the URL hash for `basic` rooms) so it cannot be changed once
|
||||||
|
the room exists."""
|
||||||
|
instance = self.instance
|
||||||
|
if instance and instance.encryption_mode != value:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Encryption mode cannot be changed after room creation."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_access_level(self, value):
|
||||||
|
"""Encrypted rooms must stay restricted — the lobby is the only way
|
||||||
|
to enforce per-participant admission, and basic encryption relies on
|
||||||
|
the host vetting each joiner before they receive the in-URL key."""
|
||||||
|
instance = self.instance
|
||||||
|
if (
|
||||||
|
instance
|
||||||
|
and instance.encryption_mode != models.EncryptionMode.NONE
|
||||||
|
and value != models.RoomAccessLevel.RESTRICTED
|
||||||
|
):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Encrypted rooms require restricted access level."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Force encrypted rooms to RESTRICTED at creation time.
|
||||||
|
|
||||||
|
Doing this here (rather than in validate_access_level) lets the
|
||||||
|
client omit `access_level` entirely when creating an encrypted room
|
||||||
|
— we silently override whatever the default would have been.
|
||||||
|
"""
|
||||||
|
encryption_mode = attrs.get(
|
||||||
|
"encryption_mode",
|
||||||
|
self.instance.encryption_mode
|
||||||
|
if self.instance
|
||||||
|
else models.EncryptionMode.NONE,
|
||||||
|
)
|
||||||
|
if encryption_mode != models.EncryptionMode.NONE and not self.instance:
|
||||||
|
attrs["access_level"] = models.RoomAccessLevel.RESTRICTED
|
||||||
|
return super().validate(attrs)
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
"""
|
"""
|
||||||
Add users only for administrator users.
|
Add users only for administrator users.
|
||||||
@@ -172,12 +245,21 @@ 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 cannot pick an
|
||||||
|
# arbitrary display name — it must come from the OIDC profile.
|
||||||
|
# We enforce this server-side so a tampered client cannot
|
||||||
|
# override what other participants see.
|
||||||
|
if instance.is_encrypted and request.user.is_authenticated:
|
||||||
|
username = request.user.full_name or request.user.email
|
||||||
|
|
||||||
output["livekit"] = utils.generate_livekit_config(
|
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"]
|
||||||
@@ -265,7 +347,7 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||||
|
|||||||
@@ -281,6 +281,18 @@ 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
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
encryption_mode != models.EncryptionMode.NONE
|
||||||
|
and not settings.ENCRYPTION_ENABLED
|
||||||
|
):
|
||||||
|
raise drf_exceptions.ValidationError(
|
||||||
|
{"encryption_mode": "Encryption is not enabled on this server."}
|
||||||
|
)
|
||||||
|
|
||||||
room = serializer.save()
|
room = serializer.save()
|
||||||
models.ResourceAccess.objects.create(
|
models.ResourceAccess.objects.create(
|
||||||
resource=room,
|
resource=room,
|
||||||
@@ -304,16 +316,21 @@ class RoomViewSet(
|
|||||||
"""Start recording a room."""
|
"""Start recording a room."""
|
||||||
|
|
||||||
serializer = serializers.StartRecordingSerializer(data=request.data)
|
serializer = serializers.StartRecordingSerializer(data=request.data)
|
||||||
|
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
|
{"detail": "Invalid request."},
|
||||||
|
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
mode = serializer.validated_data["mode"]
|
mode = serializer.validated_data["mode"]
|
||||||
options = serializer.validated_data.get("options")
|
options = serializer.validated_data.get("options")
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
if room.is_encrypted:
|
||||||
|
raise drf_exceptions.ValidationError(
|
||||||
|
{"detail": "Recording is unavailable in encrypted rooms."}
|
||||||
|
)
|
||||||
|
|
||||||
# May raise exception if an active or initiated recording already exist for the room
|
# 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 +413,14 @@ 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
|
||||||
|
|
||||||
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)
|
||||||
@@ -464,6 +483,7 @@ class RoomViewSet(
|
|||||||
lobby_service = LobbyService()
|
lobby_service = LobbyService()
|
||||||
|
|
||||||
participants = lobby_service.list_waiting_participants(room.id)
|
participants = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
return drf_response.Response({"participants": participants})
|
return drf_response.Response({"participants": participants})
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
@@ -566,6 +586,11 @@ class RoomViewSet(
|
|||||||
|
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
if room.is_encrypted:
|
||||||
|
raise drf_exceptions.ValidationError(
|
||||||
|
{"detail": "Subtitles are unavailable in encrypted rooms."}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
SubtitleService().start_subtitle(room)
|
SubtitleService().start_subtitle(room)
|
||||||
except SubtitleException:
|
except SubtitleException:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Add Room.encryption_mode and User.default_encryption_mode (enum-based).
|
||||||
|
|
||||||
|
We store the mode as an enum (CharField with choices) rather than a boolean
|
||||||
|
so a future "advanced" mode (per-user vault keys, etc.) can be added without
|
||||||
|
a schema migration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0018_rename_active_application_is_active"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="room",
|
||||||
|
name="encryption_mode",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("none", "No encryption"),
|
||||||
|
("basic", "Passphrase-in-URL encryption"),
|
||||||
|
],
|
||||||
|
default="none",
|
||||||
|
help_text="End-to-end encryption mode for this room.",
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="Encryption mode",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="default_encryption_mode",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("none", "No encryption"),
|
||||||
|
("basic", "Passphrase-in-URL encryption"),
|
||||||
|
],
|
||||||
|
default="none",
|
||||||
|
help_text="Encryption mode pre-selected when this user creates a new meeting.",
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="Default encryption mode",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -98,6 +98,17 @@ class RoomAccessLevel(models.TextChoices):
|
|||||||
RESTRICTED = "restricted", _("Restricted Access")
|
RESTRICTED = "restricted", _("Restricted Access")
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptionMode(models.TextChoices):
|
||||||
|
"""Encryption mode for a room.
|
||||||
|
|
||||||
|
Kept as an enum (not a boolean) so future modes — e.g. a vault-managed
|
||||||
|
per-user key flow — can be added without another schema migration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NONE = "none", _("No encryption")
|
||||||
|
BASIC = "basic", _("Passphrase-in-URL encryption")
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(models.Model):
|
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
|
||||||
@@ -200,6 +211,15 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
"Unselect this instead of deleting accounts."
|
"Unselect this instead of deleting accounts."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
default_encryption_mode = models.CharField(
|
||||||
|
_("Default encryption mode"),
|
||||||
|
max_length=20,
|
||||||
|
choices=EncryptionMode.choices,
|
||||||
|
default=EncryptionMode.NONE,
|
||||||
|
help_text=_(
|
||||||
|
"Encryption mode pre-selected when this user creates a new meeting."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
objects = auth_models.UserManager()
|
objects = auth_models.UserManager()
|
||||||
|
|
||||||
@@ -388,6 +408,15 @@ class Room(Resource):
|
|||||||
choices=RoomAccessLevel.choices,
|
choices=RoomAccessLevel.choices,
|
||||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||||
)
|
)
|
||||||
|
# Set at creation, immutable after (the URL hash carries the passphrase,
|
||||||
|
# so changing the mode would break every previously-shared link).
|
||||||
|
encryption_mode = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=EncryptionMode.choices,
|
||||||
|
default=EncryptionMode.NONE,
|
||||||
|
verbose_name=_("Encryption mode"),
|
||||||
|
help_text=_("End-to-end encryption mode for this room."),
|
||||||
|
)
|
||||||
configuration = models.JSONField(
|
configuration = models.JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=dict,
|
default=dict,
|
||||||
@@ -413,13 +442,64 @@ class Room(Resource):
|
|||||||
return capfirst(self.name)
|
return capfirst(self.name)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Generate a unique n-digit pin code for new rooms."""
|
"""Generate a unique n-digit pin code for new rooms.
|
||||||
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
|
||||||
|
Skip PIN allocation for encrypted rooms — the SIP gateway will
|
||||||
|
always reject calls to them (no way to derive the key), and the
|
||||||
|
PIN namespace is finite (10**length): no point burning slots that
|
||||||
|
can never be dialed.
|
||||||
|
|
||||||
|
Also run `clean()` so the encryption invariants are enforced on
|
||||||
|
every save path (ORM, admin, shell), not only via the DRF
|
||||||
|
serializer.
|
||||||
|
"""
|
||||||
|
self.clean()
|
||||||
|
if (
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED
|
||||||
|
and not self.pk
|
||||||
|
and not self.pin_code
|
||||||
|
and self.encryption_mode == EncryptionMode.NONE
|
||||||
|
):
|
||||||
self.pin_code = self.generate_unique_pin_code(
|
self.pin_code = self.generate_unique_pin_code(
|
||||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||||
)
|
)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Enforce encryption-mode invariants outside DRF.
|
||||||
|
|
||||||
|
Two rules:
|
||||||
|
- `encryption_mode` is set at creation and never mutated afterwards
|
||||||
|
(the URL-hash passphrase encodes assumptions about it).
|
||||||
|
- An encrypted room must be at the RESTRICTED access level so the
|
||||||
|
host vets joiners before they ever see the in-URL key.
|
||||||
|
"""
|
||||||
|
super().clean()
|
||||||
|
if self.pk is not None:
|
||||||
|
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
|
||||||
|
if (
|
||||||
|
previous is not None
|
||||||
|
and previous.encryption_mode != self.encryption_mode
|
||||||
|
):
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"encryption_mode": _(
|
||||||
|
"Encryption mode cannot be changed after room creation."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.encryption_mode != EncryptionMode.NONE
|
||||||
|
and self.access_level != RoomAccessLevel.RESTRICTED
|
||||||
|
):
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"access_level": _(
|
||||||
|
"Encrypted rooms must use the 'restricted' access level."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def clean_fields(self, exclude=None):
|
def clean_fields(self, exclude=None):
|
||||||
"""
|
"""
|
||||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||||
@@ -442,6 +522,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 is_encrypted(self):
|
||||||
|
"""Convenience: any non-none encryption mode counts as encrypted."""
|
||||||
|
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"""
|
||||||
|
|||||||
@@ -202,7 +202,16 @@ class LiveKitEventsService:
|
|||||||
except models.Room.DoesNotExist as err:
|
except models.Room.DoesNotExist as err:
|
||||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||||
|
|
||||||
if settings.ROOM_TELEPHONY_ENABLED:
|
# Note: `encryption_mode` is stamped into the LK room's metadata at
|
||||||
|
# creation time via the access token's RoomConfiguration (see
|
||||||
|
# `utils.generate_token`), so we don't need to patch it here.
|
||||||
|
|
||||||
|
# Phone dial-in is incompatible with end-to-end encryption — a SIP
|
||||||
|
# caller has no way to derive the room key, and the SIP gateway will
|
||||||
|
# play "encryption_not_supported" and hang up on them anyway. Skip
|
||||||
|
# the dispatch rule for encrypted rooms so no metadata mentions a
|
||||||
|
# PIN that won't be reachable.
|
||||||
|
if settings.ROOM_TELEPHONY_ENABLED and not room.is_encrypted:
|
||||||
try:
|
try:
|
||||||
self.telephony_service.create_dispatch_rule(room)
|
self.telephony_service.create_dispatch_rule(room)
|
||||||
except TelephonyException as e:
|
except TelephonyException as e:
|
||||||
|
|||||||
@@ -46,14 +46,18 @@ class LobbyParticipant:
|
|||||||
username: str
|
username: str
|
||||||
color: str
|
color: str
|
||||||
id: str
|
id: str
|
||||||
|
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
|
||||||
|
# they can decide whether to accept self-declared identities.
|
||||||
|
is_authenticated: bool = False
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, str]:
|
def to_dict(self) -> Dict[str, object]:
|
||||||
"""Serialize the participant object to a dict representation."""
|
"""Serialize the participant object to a dict representation."""
|
||||||
return {
|
return {
|
||||||
"status": self.status.value,
|
"status": self.status.value,
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
|
"is_authenticated": self.is_authenticated,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -68,6 +72,7 @@ class LobbyParticipant:
|
|||||||
username=data["username"],
|
username=data["username"],
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
color=data["color"],
|
color=data["color"],
|
||||||
|
is_authenticated=bool(data.get("is_authenticated", False)),
|
||||||
)
|
)
|
||||||
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 +104,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",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,6 +145,19 @@ class LobbyService:
|
|||||||
5. If denied, do nothing.
|
5. If denied, do nothing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# In encrypted rooms, authenticated users cannot pick an arbitrary
|
||||||
|
# display name — server enforces the OIDC name so a tampered client
|
||||||
|
# can't impersonate someone else with their account. If both
|
||||||
|
# full_name and email are absent (degenerate OIDC payload), fall
|
||||||
|
# back to a server-controlled technical name rather than trusting
|
||||||
|
# whatever the client posted.
|
||||||
|
if room.is_encrypted and request.user.is_authenticated:
|
||||||
|
username = (
|
||||||
|
request.user.full_name
|
||||||
|
or request.user.email
|
||||||
|
or f"noname-{request.user.id}"
|
||||||
|
)
|
||||||
|
|
||||||
participant_id = self._get_or_create_participant_id(request)
|
participant_id = self._get_or_create_participant_id(request)
|
||||||
participant = self._get_participant(room.id, participant_id)
|
participant = self._get_participant(room.id, participant_id)
|
||||||
|
|
||||||
@@ -152,6 +170,7 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=utils.generate_color(participant_id),
|
color=utils.generate_color(participant_id),
|
||||||
|
is_authenticated=request.user.is_authenticated,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||||
@@ -164,19 +183,24 @@ 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,
|
||||||
|
)
|
||||||
|
|
||||||
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
|
|
||||||
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 +209,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 +226,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,
|
||||||
) -> LobbyParticipant:
|
) -> LobbyParticipant:
|
||||||
"""Add participant to waiting lobby.
|
"""Add participant to waiting lobby.
|
||||||
|
|
||||||
@@ -216,6 +245,7 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=color,
|
color=color,
|
||||||
|
is_authenticated=is_authenticated,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ def test_request_entry_anonymous(settings):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"is_authenticated": False,
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"is_authenticated": True,
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +182,7 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"is_authenticated": False,
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +235,7 @@ def test_request_entry_public_room(settings):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"is_authenticated": False,
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +288,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"is_authenticated": True,
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +343,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"is_authenticated": False,
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,12 +607,14 @@ def test_list_waiting_participants_success(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"is_authenticated": False,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||||
"username": "user2",
|
"username": "user2",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
|
"is_authenticated": False,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
|||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
|||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
|||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +89,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
|||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +106,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
|||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -211,6 +216,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
|||||||
"name": room.name,
|
"name": room.name,
|
||||||
"pin_code": room.pin_code,
|
"pin_code": room.pin_code,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_token.assert_called_once()
|
mock_token.assert_called_once()
|
||||||
@@ -257,6 +263,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
"name": room.name,
|
"name": room.name,
|
||||||
"pin_code": room.pin_code,
|
"pin_code": room.pin_code,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_token.assert_called_once_with(
|
mock_token.assert_called_once_with(
|
||||||
@@ -267,6 +274,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
sources=["mock-source"],
|
sources=["mock-source"],
|
||||||
is_admin_or_owner=False,
|
is_admin_or_owner=False,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -308,6 +316,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
|||||||
"name": room.name,
|
"name": room.name,
|
||||||
"pin_code": room.pin_code,
|
"pin_code": room.pin_code,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_token.assert_called_once_with(
|
mock_token.assert_called_once_with(
|
||||||
@@ -318,6 +327,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
|||||||
sources=None,
|
sources=None,
|
||||||
is_admin_or_owner=False,
|
is_admin_or_owner=False,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -343,6 +353,7 @@ def test_api_rooms_retrieve_authenticated():
|
|||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -394,6 +405,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
|||||||
"name": room.name,
|
"name": room.name,
|
||||||
"pin_code": room.pin_code,
|
"pin_code": room.pin_code,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_token.assert_called_once_with(
|
mock_token.assert_called_once_with(
|
||||||
@@ -404,6 +416,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
|||||||
sources=["mock-source"],
|
sources=["mock-source"],
|
||||||
is_admin_or_owner=False,
|
is_admin_or_owner=False,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -453,6 +466,7 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
"short_name": other_user_access.user.short_name,
|
"short_name": other_user_access.user.short_name,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
"language": other_user_access.user.language,
|
"language": other_user_access.user.language,
|
||||||
|
"default_encryption_mode": "none",
|
||||||
},
|
},
|
||||||
"resource": str(room.id),
|
"resource": str(room.id),
|
||||||
"role": other_user_access.role,
|
"role": other_user_access.role,
|
||||||
@@ -466,6 +480,7 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
"short_name": user_access.user.short_name,
|
"short_name": user_access.user.short_name,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
"language": user_access.user.language,
|
"language": user_access.user.language,
|
||||||
|
"default_encryption_mode": "none",
|
||||||
},
|
},
|
||||||
"resource": str(room.id),
|
"resource": str(room.id),
|
||||||
"role": user_access.role,
|
"role": user_access.role,
|
||||||
@@ -487,6 +502,7 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
"name": room.name,
|
"name": room.name,
|
||||||
"pin_code": room.pin_code,
|
"pin_code": room.pin_code,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_token.assert_called_once_with(
|
mock_token.assert_called_once_with(
|
||||||
@@ -497,4 +513,5 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
sources=None,
|
sources=None,
|
||||||
is_admin_or_owner=True,
|
is_admin_or_owner=True,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ def test_request_entry_public_room(
|
|||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
is_admin_or_owner=False,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
@@ -307,6 +308,7 @@ def test_request_entry_trusted_room(
|
|||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
is_admin_or_owner=False,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
@@ -337,7 +339,12 @@ def test_request_entry_new_participant(
|
|||||||
|
|
||||||
assert participant == participant_data
|
assert participant == participant_data
|
||||||
assert livekit_config is None
|
assert livekit_config is None
|
||||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
mock_enter.assert_called_once_with(
|
||||||
|
room.id,
|
||||||
|
participant_id,
|
||||||
|
username,
|
||||||
|
is_authenticated=request.user.is_authenticated,
|
||||||
|
)
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -402,6 +409,7 @@ def test_request_entry_accepted_participant(
|
|||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
is_admin_or_owner=False,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
@@ -777,6 +785,7 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"is_authenticated": False,
|
||||||
}
|
}
|
||||||
mock_cache.set.assert_called_once_with(
|
mock_cache.set.assert_called_once_with(
|
||||||
"mocked_cache_key", expected_data, timeout=60
|
"mocked_cache_key", expected_data, timeout=60
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ def test_api_users_retrieve_me_authenticated(settings):
|
|||||||
"short_name": user.short_name,
|
"short_name": user.short_name,
|
||||||
"language": user.language,
|
"language": user.language,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
|
"default_encryption_mode": "none",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from livekit.api import ( # pylint: disable=E0611
|
|||||||
UpdateRoomMetadataRequest,
|
UpdateRoomMetadataRequest,
|
||||||
VideoGrants,
|
VideoGrants,
|
||||||
)
|
)
|
||||||
|
from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -66,6 +67,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.
|
||||||
|
|
||||||
@@ -86,17 +88,26 @@ def generate_token(
|
|||||||
str: The LiveKit JWT access token.
|
str: The LiveKit JWT access token.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if is_admin_or_owner:
|
# Local import: core.models loads core.utils mid-import (see models.py:28),
|
||||||
|
# so importing EncryptionMode at module top would deadlock the bootstrap.
|
||||||
|
from core.models import ( # noqa: PLC0415 pylint: disable=import-outside-toplevel
|
||||||
|
EncryptionMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_admin_or_owner or sources is None:
|
||||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||||
|
|
||||||
if sources is None:
|
# In encrypted rooms, no one can change their name/attributes after the
|
||||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
# admin accepted them — otherwise a participant authenticated under one
|
||||||
|
# identity could rewrite their JWT-presented name to spoof someone else
|
||||||
|
# mid-meeting. In plain rooms, free naming is fine.
|
||||||
|
can_update_own_metadata = encryption_mode == EncryptionMode.NONE
|
||||||
|
|
||||||
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_own_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 +123,27 @@ def generate_token(
|
|||||||
if color is None:
|
if color is None:
|
||||||
color = generate_color(identity)
|
color = generate_color(identity)
|
||||||
|
|
||||||
|
attributes = {
|
||||||
|
"color": color,
|
||||||
|
"room_admin": "true" if is_admin_or_owner else "false",
|
||||||
|
"is_authenticated": "true" if not user.is_anonymous else "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Emit the email only for authenticated participants of *encrypted*
|
||||||
|
# rooms. LK signaling broadcasts attributes to every peer in the room,
|
||||||
|
# so making this conditional on the encryption gate is what prevents
|
||||||
|
# an anonymous joiner of a public/trusted room from harvesting all
|
||||||
|
# authenticated users' emails. Frontend hiding (the
|
||||||
|
# `isLoggedIn`-gated render in ParticipantListItem) is only
|
||||||
|
# defense-in-depth — anyone with devtools can read attributes
|
||||||
|
# otherwise.
|
||||||
|
if (
|
||||||
|
not user.is_anonymous
|
||||||
|
and encryption_mode != EncryptionMode.NONE
|
||||||
|
and getattr(user, "email", None)
|
||||||
|
):
|
||||||
|
attributes["email"] = user.email
|
||||||
|
|
||||||
token = (
|
token = (
|
||||||
AccessToken(
|
AccessToken(
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
@@ -120,11 +152,24 @@ 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"}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Encode the encryption mode into the room's metadata at LK-creation
|
||||||
|
# time (via the access token's room_config). LiveKit creates the room
|
||||||
|
# lazily when the first participant joins; the embedded config tells
|
||||||
|
# it to stamp `{"encryption_mode": "<mode>"}` into the metadata at
|
||||||
|
# that moment — no extra round-trip, no race window where a SIP
|
||||||
|
# caller could read empty metadata before the `room_started` webhook
|
||||||
|
# has time to push it.
|
||||||
|
if encryption_mode != EncryptionMode.NONE:
|
||||||
|
token = token.with_room_config(
|
||||||
|
RoomConfiguration(
|
||||||
|
name=room,
|
||||||
|
metadata=json.dumps({"encryption_mode": encryption_mode}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|
||||||
@@ -136,6 +181,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 +214,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,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -808,6 +808,13 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# End-to-end encryption (passphrase-in-URL-hash mode).
|
||||||
|
# When True, users may opt in (account preference) to have their meetings
|
||||||
|
# created as end-to-end encrypted by default.
|
||||||
|
ENCRYPTION_ENABLED = values.BooleanValue(
|
||||||
|
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# External Applications
|
# External Applications
|
||||||
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
|
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
|
||||||
40,
|
40,
|
||||||
|
|||||||
Generated
+870
-1
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface ApiConfig {
|
|||||||
help_article_transcript: string
|
help_article_transcript: string
|
||||||
help_article_recording: string
|
help_article_recording: string
|
||||||
help_article_more_tools: string
|
help_article_more_tools: string
|
||||||
|
help_article_encryption?: string
|
||||||
}
|
}
|
||||||
feedback: {
|
feedback: {
|
||||||
url: string
|
url: string
|
||||||
@@ -52,6 +53,9 @@ export interface ApiConfig {
|
|||||||
enable_firefox_proxy_workaround: boolean
|
enable_firefox_proxy_workaround: boolean
|
||||||
default_sources: string[]
|
default_sources: string[]
|
||||||
}
|
}
|
||||||
|
encryption?: {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
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}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { BackendLanguage } from '@/utils/languages'
|
import { BackendLanguage } from '@/utils/languages'
|
||||||
|
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
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
|
||||||
|
default_encryption_mode: ApiEncryptionMode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { type ApiUser } from './ApiUser'
|
import { type ApiUser } from './ApiUser'
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
|
||||||
export type ApiUserPreferences = Pick<ApiUser, 'id' | 'timezone' | 'language'>
|
export type ApiUserPreferences = Partial<
|
||||||
|
Pick<ApiUser, 'timezone' | 'language' | 'default_encryption_mode'>
|
||||||
|
> & { id: string }
|
||||||
|
|
||||||
export const updateUserPreferences = async ({
|
export const updateUserPreferences = async ({
|
||||||
user,
|
user,
|
||||||
}: {
|
}: {
|
||||||
user: ApiUserPreferences
|
user: ApiUserPreferences
|
||||||
}): Promise<ApiUser> => {
|
}): Promise<ApiUser> => {
|
||||||
return await fetchApi(`/users/${user.id}/`, {
|
const { id, ...payload } = user
|
||||||
method: 'PUT',
|
return await fetchApi(`/users/${id}/`, {
|
||||||
body: JSON.stringify({ timezone: user.timezone, language: user.language }),
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Tile overlay shown when LiveKit raises an EncryptionError for a remote
|
||||||
|
* participant (a passphrase/key mismatch — "you and they don't share the
|
||||||
|
* same encryption key"). Renders the participant's avatar placeholder
|
||||||
|
* over the broken video, plus a black banner at the bottom of the tile
|
||||||
|
* explaining the issue.
|
||||||
|
*
|
||||||
|
* Cleared automatically once frames decrypt again
|
||||||
|
* (ParticipantEncryptionStatusChanged with encrypted=true).
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Participant, RoomEvent } from 'livekit-client'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { RiLockFill } from '@remixicon/react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder'
|
||||||
|
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
participant: Participant
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DecryptionFailedTileOverlay({ participant }: Props) {
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'encryption.decryptionFailed',
|
||||||
|
})
|
||||||
|
const room = useRoomContext()
|
||||||
|
const roomData = useRoomData()
|
||||||
|
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEncrypted) return
|
||||||
|
if (participant.isLocal) return
|
||||||
|
|
||||||
|
const identity = participant.identity
|
||||||
|
|
||||||
|
const onError = (_err: Error, p?: Participant) => {
|
||||||
|
if (p?.identity === identity) setFailed(true)
|
||||||
|
}
|
||||||
|
const onStatus = (encrypted: boolean, p?: Participant) => {
|
||||||
|
if (p?.identity === identity && encrypted) setFailed(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
room.on(RoomEvent.EncryptionError, onError)
|
||||||
|
room.on(RoomEvent.ParticipantEncryptionStatusChanged, onStatus)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.EncryptionError, onError)
|
||||||
|
room.off(RoomEvent.ParticipantEncryptionStatusChanged, onStatus)
|
||||||
|
}
|
||||||
|
}, [room, isEncrypted, participant])
|
||||||
|
|
||||||
|
if (!failed) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<output
|
||||||
|
aria-label={t('title')}
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 3,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ParticipantPlaceholder participant={participant} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '2.5rem',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.6rem 1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.3rem',
|
||||||
|
maxWidth: '85%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
color: '#f87171',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiLockFill size={14} />
|
||||||
|
<span>{t('title')}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#d1d5db',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
maxWidth: '22rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('body')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</output>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Shown when the URL hash and the room's encryption_mode disagree.
|
||||||
|
*
|
||||||
|
* - missingPassphrase: room is encrypted on the server, but the URL has no
|
||||||
|
* (or an invalid) passphrase. The user opened the wrong link.
|
||||||
|
* - unexpectedPassphrase: the URL has a passphrase, but the server says the
|
||||||
|
* room is not encrypted. Either the room was created differently or the
|
||||||
|
* link looks tampered with — either way, joining as "encrypted" would
|
||||||
|
* leave the user alone in an encrypted bubble. Better to bail.
|
||||||
|
*/
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Center } from '@/styled-system/jsx'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiAlertLine, RiLockUnlockLine } from '@remixicon/react'
|
||||||
|
import { Button, Text } from '@/primitives'
|
||||||
|
import { Screen } from '@/layout/Screen'
|
||||||
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reason: 'missingPassphrase' | 'unexpectedPassphrase'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EncryptionMismatchScreen({ reason }: Props) {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.mismatch' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen layout="centered">
|
||||||
|
<CenteredContent>
|
||||||
|
<Center>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
maxWidth: '420px',
|
||||||
|
padding: '2rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: '3.5rem',
|
||||||
|
height: '3.5rem',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#fffbeb',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{reason === 'missingPassphrase' ? (
|
||||||
|
<RiLockUnlockLine size={28} color="#b45309" />
|
||||||
|
) : (
|
||||||
|
<RiAlertLine size={28} color="#b45309" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Text
|
||||||
|
as="h2"
|
||||||
|
className={css({ fontWeight: 700, fontSize: '1.15rem' })}
|
||||||
|
>
|
||||||
|
{t(`${reason}.title`)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className={css({ fontSize: '0.9rem', color: 'greyscale.700' })}
|
||||||
|
>
|
||||||
|
{t(`${reason}.body`)}
|
||||||
|
</Text>
|
||||||
|
<Button variant="primary" onPress={() => navigateTo('home')}>
|
||||||
|
{t('backHome')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Center>
|
||||||
|
</CenteredContent>
|
||||||
|
</Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Small pill used to surface a feature name with an icon, e.g. in the
|
||||||
|
* encrypted-room create dialog, the "Meeting information" panel and the
|
||||||
|
* floating share dialog — the three places that list disabled features.
|
||||||
|
*/
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeaturePill = ({ icon, label, size = 'md' }: Props) => {
|
||||||
|
const fontSize = size === 'sm' ? '0.8rem' : '0.85rem'
|
||||||
|
const padding = size === 'sm' ? '0.3rem 0.6rem' : '0.4rem 0.7rem'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.250',
|
||||||
|
color: 'greyscale.700',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})}
|
||||||
|
style={{ fontSize, padding }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Top-left status banner shown during a meeting.
|
||||||
|
*
|
||||||
|
* Renders a horizontal stack of pills, one per active state:
|
||||||
|
* - "End-to-end encrypted"
|
||||||
|
* - "Recording in progress"
|
||||||
|
* - "Transcription in progress"
|
||||||
|
*
|
||||||
|
* Each pill auto-collapses to its icon a few seconds after appearing,
|
||||||
|
* and expands back on hover.
|
||||||
|
*/
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import {
|
||||||
|
RiFileTextFill,
|
||||||
|
RiRecordCircleFill,
|
||||||
|
RiShieldCheckLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { RecordingMode, useRecordingStatuses } from '@/features/recording'
|
||||||
|
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
|
const COLLAPSE_DELAY_MS = 4000
|
||||||
|
|
||||||
|
interface PillProps {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
background: string
|
||||||
|
pulse?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ icon, label, background, pulse }: PillProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setCollapsed(true), COLLAPSE_DELAY_MS)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<output
|
||||||
|
onMouseEnter={() => setCollapsed(false)}
|
||||||
|
onMouseLeave={() => setCollapsed(true)}
|
||||||
|
aria-label={label}
|
||||||
|
className={css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.35rem',
|
||||||
|
padding: '0.3rem 0.6rem',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
border: '2px solid rgba(0, 0, 0, 0.3)',
|
||||||
|
cursor: 'default',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'max-width 300ms ease, padding-right 200ms ease',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
backgroundColor: background,
|
||||||
|
maxWidth: collapsed ? '2.2rem' : '20rem',
|
||||||
|
paddingRight: collapsed ? '0.3rem' : '0.6rem',
|
||||||
|
animation: pulse ? 'pulse_background 1.6s infinite' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={css({ flexShrink: 0, display: 'inline-flex' })}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'white',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
transition: 'opacity 200ms ease',
|
||||||
|
})}
|
||||||
|
style={{ opacity: collapsed ? 0 : 1 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</output>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomStatusBanner() {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'roomStatus' })
|
||||||
|
const roomData = useRoomData()
|
||||||
|
|
||||||
|
// Use the metadata-driven `isStarted` for both pills — it flips to
|
||||||
|
// false the moment the user clicks stop (recording_status moves to
|
||||||
|
// Saving), so the pill disappears immediately instead of lingering
|
||||||
|
// through LK's 1-2s post-stop callback delay.
|
||||||
|
const screenRec = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||||
|
const transcript = useRecordingStatuses(RecordingMode.Transcript)
|
||||||
|
const isRecording = screenRec.isStarted
|
||||||
|
const isTranscribing = transcript.isStarted
|
||||||
|
|
||||||
|
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
|
||||||
|
if (!isEncrypted && !isRecording && !isTranscribing) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack
|
||||||
|
gap="0.4rem"
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0.5rem',
|
||||||
|
left: '0.5rem',
|
||||||
|
zIndex: 10,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isEncrypted && (
|
||||||
|
<StatusPill
|
||||||
|
key="encrypted"
|
||||||
|
icon={<RiShieldCheckLine size={14} color="white" />}
|
||||||
|
label={t('encrypted')}
|
||||||
|
background="#1e3a5f"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isTranscribing && (
|
||||||
|
<StatusPill
|
||||||
|
key="transcript"
|
||||||
|
icon={<RiFileTextFill size={13} color="white" />}
|
||||||
|
label={t('transcribing')}
|
||||||
|
background="#7c2d12"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isRecording && (
|
||||||
|
<StatusPill
|
||||||
|
key="recording"
|
||||||
|
icon={<RiRecordCircleFill size={13} color="white" />}
|
||||||
|
label={t('recording')}
|
||||||
|
background="#b91c1c"
|
||||||
|
pulse
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
generatePassphrase,
|
||||||
|
isValidPassphrase,
|
||||||
|
getPassphraseFromHash,
|
||||||
|
PASSPHRASE_LENGTH,
|
||||||
|
} from './passphrase'
|
||||||
|
export { RoomStatusBanner } from './RoomStatusBanner'
|
||||||
|
export { FeaturePill } from './FeaturePill'
|
||||||
|
export { EncryptionMismatchScreen } from './EncryptionMismatchScreen'
|
||||||
|
export { DecryptionFailedTileOverlay } from './DecryptionFailedTileOverlay'
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Passphrase utilities for end-to-end encryption.
|
||||||
|
*
|
||||||
|
* The passphrase is appended to a room URL as the hash fragment
|
||||||
|
* (e.g. `https://meet.example.com/abc-defg-hij#<passphrase>`). The
|
||||||
|
* server never sees it; participants share it by sharing the link.
|
||||||
|
*
|
||||||
|
* Encoding is plain hex so that the validator's regex matches exactly
|
||||||
|
* what the generator produces: 48 lowercase hex characters = 192 bits
|
||||||
|
* of entropy, no overlap with looser "looks like a passphrase" inputs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PASSPHRASE_BYTES = 24
|
||||||
|
|
||||||
|
/** Length, in characters, of a generated passphrase. */
|
||||||
|
export const PASSPHRASE_LENGTH = PASSPHRASE_BYTES * 2
|
||||||
|
|
||||||
|
/** Generate a random passphrase suitable for an encrypted room. */
|
||||||
|
export function generatePassphrase(): string {
|
||||||
|
return Array.from(crypto.getRandomValues(new Uint8Array(PASSPHRASE_BYTES)))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a string is exactly a generator-shaped passphrase. */
|
||||||
|
export function isValidPassphrase(value: string): boolean {
|
||||||
|
return value.length === PASSPHRASE_LENGTH && /^[0-9a-f]+$/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the current URL hash (without the leading `#`). */
|
||||||
|
export function getPassphraseFromHash(): string {
|
||||||
|
return window.location.hash.replace(/^#/, '')
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button, Dialog } from '@/primitives'
|
||||||
|
import { Checkbox } from '@/primitives/Checkbox'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
|
import { RiAlertFill, RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
||||||
|
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
room: ApiRoom | null
|
||||||
|
hash: string
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onStart: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConnectionDetailsDialog = ({
|
||||||
|
room,
|
||||||
|
hash,
|
||||||
|
onOpenChange,
|
||||||
|
onStart,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation('home', { keyPrefix: 'connectionDetailsDialog' })
|
||||||
|
const [acknowledged, setAcknowledged] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
if (!room) return null
|
||||||
|
|
||||||
|
const url = `${getRouteUrl('room', room.slug)}#${hash}`
|
||||||
|
const displayUrl = url.replace(/^https?:\/\//, '')
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
setCopied(true)
|
||||||
|
window.setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('copy failed', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={!!room}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={t('title')}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
color: 'greyscale.700',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('description')}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.6rem 0.9rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.250',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
flexGrow: 1,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{displayUrl}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant={copied ? 'success' : 'tertiaryText'}
|
||||||
|
square
|
||||||
|
size="sm"
|
||||||
|
onPress={copy}
|
||||||
|
aria-label={t('copy')}
|
||||||
|
tooltip={t('copy')}
|
||||||
|
>
|
||||||
|
{copied ? <RiCheckLine size={16} /> : <RiFileCopyLine size={16} />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.75rem 0.9rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
backgroundColor: '#fff7ed',
|
||||||
|
border: '1px solid #fed7aa',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiAlertFill
|
||||||
|
size={18}
|
||||||
|
color="#b45309"
|
||||||
|
className={css({ flexShrink: 0 })}
|
||||||
|
/>
|
||||||
|
<div className={css({ flex: 1 })}>
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: '#7c2d12',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('warning')}
|
||||||
|
</p>
|
||||||
|
<Checkbox
|
||||||
|
isSelected={acknowledged}
|
||||||
|
onChange={setAcknowledged}
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
color: '#7c2d12',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('iUnderstand')}
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HStack gap="0.5rem" justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
isDisabled={!acknowledged}
|
||||||
|
onPress={onStart}
|
||||||
|
data-attr="encrypted-start"
|
||||||
|
>
|
||||||
|
{t('startMeeting')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button, Dialog } from '@/primitives'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import {
|
||||||
|
RiPhoneLine,
|
||||||
|
RiComputerLine,
|
||||||
|
RiFileTextLine,
|
||||||
|
RiRecordCircleLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { FeaturePill } from '@/features/encryption'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateEncryptedMeetingDialog = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation('home', {
|
||||||
|
keyPrefix: 'createEncryptedMeetingDialog',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={t('title')}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
color: 'greyscale.700',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('description')}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FeaturePill
|
||||||
|
icon={<RiPhoneLine size={14} />}
|
||||||
|
label={t('features.dialIn')}
|
||||||
|
/>
|
||||||
|
<FeaturePill
|
||||||
|
icon={<RiComputerLine size={14} />}
|
||||||
|
label={t('features.meetingRoom')}
|
||||||
|
/>
|
||||||
|
<FeaturePill
|
||||||
|
icon={<RiFileTextLine size={14} />}
|
||||||
|
label={t('features.transcription')}
|
||||||
|
/>
|
||||||
|
<FeaturePill
|
||||||
|
icon={<RiRecordCircleLine size={14} />}
|
||||||
|
label={t('features.recording')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'greyscale.700',
|
||||||
|
marginBottom: '1.25rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('warning')}
|
||||||
|
</p>
|
||||||
|
<HStack gap="0.5rem" justify="flex-end">
|
||||||
|
<Button variant="tertiary" onPress={() => onOpenChange(false)}>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onPress={onConfirm}
|
||||||
|
data-attr="create-encrypted-confirm"
|
||||||
|
>
|
||||||
|
{t('confirm')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,35 +1,163 @@
|
|||||||
|
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 { isValidPassphrase } from '@/features/encryption'
|
||||||
|
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)
|
||||||
navigateTo('room', roomId)
|
const id = url.pathname.replace(/^\//, '')
|
||||||
|
return { roomId: id, hash: url.hash.slice(1) }
|
||||||
|
} catch {
|
||||||
|
// Not a URL — treat as room code, normalize (add hyphens if 10 chars)
|
||||||
|
const raw = trimmed.replace(`${window.location.origin}/`, '')
|
||||||
|
return { roomId: normalizeRoomId(raw), hash: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRoomSubmit = async (data: { roomId?: FormDataEntryValue }) => {
|
||||||
|
const input = data.roomId as string
|
||||||
|
const parsed = parseInput(input)
|
||||||
|
|
||||||
|
if (parsed.hash) {
|
||||||
|
navigateTo('room', parsed.roomId, { hash: parsed.hash })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const room = await fetchRoom({ roomId: parsed.roomId })
|
||||||
|
if (room.encryption_mode === ApiEncryptionMode.BASIC) {
|
||||||
|
setRoomId(parsed.roomId)
|
||||||
|
setStep('passphrase')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigateTo('room', parsed.roomId)
|
||||||
|
} catch {
|
||||||
|
// Room doesn't exist yet or error — navigate anyway
|
||||||
|
navigateTo('room', parsed.roomId)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePassphraseSubmit = (data: {
|
||||||
|
passphrase?: FormDataEntryValue
|
||||||
|
}) => {
|
||||||
|
const passphrase = (data.passphrase as string).trim()
|
||||||
|
navigateTo('room', roomId, { hash: passphrase })
|
||||||
}
|
}
|
||||||
|
|
||||||
const 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, hash } = parseInput(trimmed)
|
||||||
<>
|
if (!isRoomValid(id))
|
||||||
<p>{t('joinInputError')}</p>
|
return (
|
||||||
<Ul>
|
<>
|
||||||
<li>{window.location.origin}/uio-azer-jkl</li>
|
<p>{t('joinInputError')}</p>
|
||||||
<li>uio-azer-jkl</li>
|
<Ul>
|
||||||
</Ul>
|
<li>{window.location.origin}/uio-azer-jkl</li>
|
||||||
</>
|
<li>uio-azer-jkl</li>
|
||||||
) : null
|
<li>uioazerjkl</li>
|
||||||
|
</Ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
// If a hash is pasted in, refuse malformed passphrases now (instead of
|
||||||
|
// letting Conference render the mismatch screen after navigation).
|
||||||
|
if (hash && !isValidPassphrase(hash))
|
||||||
|
return <p>{t('joinPassphraseInvalidFormat')}</p>
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'passphrase') {
|
||||||
|
return (
|
||||||
|
<Dialog title={t('joinMeeting')}>
|
||||||
|
<Form
|
||||||
|
onSubmit={handlePassphraseSubmit}
|
||||||
|
submitLabel={t('joinPassphraseSubmit')}
|
||||||
|
>
|
||||||
|
<P
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: t('joinPassphraseDescription', {
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'greyscale.100',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.200',
|
||||||
|
'& strong': {
|
||||||
|
color: '#16a34a',
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: t('joinPassphraseExample', {
|
||||||
|
origin: window.location.origin,
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* eslint-disable jsx-a11y/no-autofocus */}
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
isRequired
|
||||||
|
name="passphrase"
|
||||||
|
label={t('joinPassphraseLabel')}
|
||||||
|
validate={(value: string) => {
|
||||||
|
const v = (value || '').trim()
|
||||||
|
if (!v) return t('joinPassphraseError')
|
||||||
|
if (!isValidPassphrase(v)) return t('joinPassphraseInvalidFormat')
|
||||||
|
return null
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<P
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: '#b45309',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('joinPassphraseWarning')}
|
||||||
|
</P>
|
||||||
|
</Form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const LaterMeetingDialog = ({
|
|||||||
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
|
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
|
||||||
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
||||||
|
|
||||||
const roomUrl = room && getRouteUrl('room', room?.slug)
|
const roomUrl = room ? getRouteUrl('room', room.slug) : null
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
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 +12,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, RiShieldCrossLine } from '@remixicon/react'
|
||||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||||
|
import { CreateEncryptedMeetingDialog } from '@/features/home/components/CreateEncryptedMeetingDialog'
|
||||||
|
import { ConnectionDetailsDialog } from '@/features/home/components/ConnectionDetailsDialog'
|
||||||
|
import { generatePassphrase } from '@/features/encryption'
|
||||||
|
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
import { 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'
|
||||||
@@ -148,17 +157,42 @@ const IntroText = styled('div', {
|
|||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { isLoggedIn } = useUser()
|
const { isLoggedIn, user } = useUser()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userChoices: { username },
|
userChoices: { username },
|
||||||
} = usePersistentUserChoices()
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom }>(null)
|
||||||
|
const [encryptedRoom, setEncryptedRoom] = useState<null | {
|
||||||
|
room: ApiRoom
|
||||||
|
hash: string
|
||||||
|
}>(null)
|
||||||
|
const [showEncryptedConfirm, setShowEncryptedConfirm] = useState(false)
|
||||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
|
// The encrypted dropdown entry is offered only when:
|
||||||
|
// - the server has encryption enabled (instance config), AND
|
||||||
|
// - the user opted into the feature in their preferences.
|
||||||
|
// "Instant meeting" and "Later date" stay plain regardless — encryption
|
||||||
|
// is always an explicit, opt-in flow with its own confirmation modal.
|
||||||
|
const encryptionAvailable =
|
||||||
|
!!data?.encryption?.enabled &&
|
||||||
|
user?.default_encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
|
||||||
|
const buildRoomBundle = async (
|
||||||
|
encryptionMode: ApiEncryptionMode = ApiEncryptionMode.NONE
|
||||||
|
) => {
|
||||||
|
const slug = generateRoomId()
|
||||||
|
const hash =
|
||||||
|
encryptionMode === ApiEncryptionMode.BASIC
|
||||||
|
? generatePassphrase()
|
||||||
|
: undefined
|
||||||
|
const room = await createRoom({ slug, username, encryptionMode })
|
||||||
|
return { room, hash }
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkSiteAndRedirect = async () => {
|
const checkSiteAndRedirect = async () => {
|
||||||
@@ -210,12 +244,10 @@ export const Home = () => {
|
|||||||
menuRecipe({ icon: true, variant: 'light' }).item
|
menuRecipe({ icon: true, variant: 'light' }).item
|
||||||
}
|
}
|
||||||
onAction={async () => {
|
onAction={async () => {
|
||||||
const slug = generateRoomId()
|
const { room } = await buildRoomBundle()
|
||||||
createRoom({ slug, username }).then((data) =>
|
navigateTo('room', room.slug, {
|
||||||
navigateTo('room', data.slug, {
|
state: { create: true, initialRoomData: room },
|
||||||
state: { create: true, initialRoomData: data },
|
})
|
||||||
})
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
data-attr="create-option-instant"
|
data-attr="create-option-instant"
|
||||||
>
|
>
|
||||||
@@ -226,17 +258,37 @@ export const Home = () => {
|
|||||||
className={
|
className={
|
||||||
menuRecipe({ icon: true, variant: 'light' }).item
|
menuRecipe({ icon: true, variant: 'light' }).item
|
||||||
}
|
}
|
||||||
onAction={() => {
|
onAction={async () => {
|
||||||
const slug = generateRoomId()
|
const { room } = await buildRoomBundle()
|
||||||
createRoom({ slug, username }).then((data) =>
|
setLaterRoom({ room })
|
||||||
setLaterRoom(data)
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
data-attr="create-option-later"
|
data-attr="create-option-later"
|
||||||
>
|
>
|
||||||
<RiLink size={18} />
|
<RiLink size={18} />
|
||||||
{t('createMenu.laterOption')}
|
{t('createMenu.laterOption')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{encryptionAvailable && (
|
||||||
|
<>
|
||||||
|
<RACSeparator
|
||||||
|
className={css({
|
||||||
|
border: 'none',
|
||||||
|
height: '1px',
|
||||||
|
background: 'greyscale.250',
|
||||||
|
margin: '0.35rem 0',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
className={
|
||||||
|
menuRecipe({ icon: true, variant: 'light' }).item
|
||||||
|
}
|
||||||
|
onAction={() => setShowEncryptedConfirm(true)}
|
||||||
|
data-attr="create-option-encrypted"
|
||||||
|
>
|
||||||
|
<RiShieldCrossLine size={18} />
|
||||||
|
{t('createMenu.encryptedOption')}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</RACMenu>
|
</RACMenu>
|
||||||
</Menu>
|
</Menu>
|
||||||
) : (
|
) : (
|
||||||
@@ -265,9 +317,36 @@ export const Home = () => {
|
|||||||
</RightColumn>
|
</RightColumn>
|
||||||
</Columns>
|
</Columns>
|
||||||
<LaterMeetingDialog
|
<LaterMeetingDialog
|
||||||
room={laterRoom}
|
room={laterRoom?.room ?? null}
|
||||||
onOpenChange={() => setLaterRoom(null)}
|
onOpenChange={() => setLaterRoom(null)}
|
||||||
/>
|
/>
|
||||||
|
<CreateEncryptedMeetingDialog
|
||||||
|
isOpen={showEncryptedConfirm}
|
||||||
|
onOpenChange={setShowEncryptedConfirm}
|
||||||
|
onConfirm={async () => {
|
||||||
|
setShowEncryptedConfirm(false)
|
||||||
|
const { room, hash } = await buildRoomBundle(
|
||||||
|
ApiEncryptionMode.BASIC
|
||||||
|
)
|
||||||
|
if (hash) setEncryptedRoom({ room, hash })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConnectionDetailsDialog
|
||||||
|
room={encryptedRoom?.room ?? null}
|
||||||
|
hash={encryptedRoom?.hash ?? ''}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEncryptedRoom(null)
|
||||||
|
}}
|
||||||
|
onStart={() => {
|
||||||
|
if (!encryptedRoom) return
|
||||||
|
const { room, hash } = encryptedRoom
|
||||||
|
setEncryptedRoom(null)
|
||||||
|
navigateTo('room', room.slug, {
|
||||||
|
state: { create: true, initialRoomData: room },
|
||||||
|
hash,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Screen>
|
</Screen>
|
||||||
</UserAware>
|
</UserAware>
|
||||||
)
|
)
|
||||||
|
|||||||
+22
-7
@@ -3,7 +3,8 @@ import { HStack, VStack } from '@/styled-system/jsx'
|
|||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { Button, Text } from '@/primitives'
|
import { Button, Text } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { RiInfinityLine } from '@remixicon/react'
|
import { RiErrorWarningLine, RiInfinityLine } from '@remixicon/react'
|
||||||
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { usePrevious } from '@/hooks/usePrevious'
|
import { usePrevious } from '@/hooks/usePrevious'
|
||||||
@@ -21,6 +22,8 @@ export const WaitingParticipantNotification = () => {
|
|||||||
const { t } = useTranslation('notifications', {
|
const { t } = useTranslation('notifications', {
|
||||||
keyPrefix: 'waitingParticipants',
|
keyPrefix: 'waitingParticipants',
|
||||||
})
|
})
|
||||||
|
const { t: tRooms } = useTranslation('rooms', { keyPrefix: 'identity' })
|
||||||
|
const anonymousLabel = tRooms('anonymous.tooltip')
|
||||||
|
|
||||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||||
@@ -34,7 +37,6 @@ export const WaitingParticipantNotification = () => {
|
|||||||
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Show notification when the first participant enters the waiting room
|
|
||||||
if (
|
if (
|
||||||
!isParticipantListEmpty(waitingParticipants) &&
|
!isParticipantListEmpty(waitingParticipants) &&
|
||||||
isParticipantListEmpty(prevWaitingParticipant) &&
|
isParticipantListEmpty(prevWaitingParticipant) &&
|
||||||
@@ -49,10 +51,9 @@ export const WaitingParticipantNotification = () => {
|
|||||||
}
|
}
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
setShowQuickActionsMessage(false)
|
setShowQuickActionsMessage(false)
|
||||||
timerRef.current = null // Clear the ref when timeout completes
|
timerRef.current = null
|
||||||
}, NOTIFICATION_DISPLAY_DURATION)
|
}, NOTIFICATION_DISPLAY_DURATION)
|
||||||
} else if (waitingParticipants.length !== prevWaitingParticipant?.length) {
|
} else if (waitingParticipants.length !== prevWaitingParticipant?.length) {
|
||||||
// Hide notification when the participant count changes
|
|
||||||
setShowQuickActionsMessage(false)
|
setShowQuickActionsMessage(false)
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -63,7 +64,6 @@ export const WaitingParticipantNotification = () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// This cleanup function will only run when the component unmounts
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current !== null) {
|
if (timerRef.current !== null) {
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
@@ -72,7 +72,6 @@ export const WaitingParticipantNotification = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Hide notification when participants panel is opened
|
|
||||||
if (isParticipantsOpen) {
|
if (isParticipantsOpen) {
|
||||||
setShowQuickActionsMessage(false)
|
setShowQuickActionsMessage(false)
|
||||||
}
|
}
|
||||||
@@ -100,7 +99,7 @@ export const WaitingParticipantNotification = () => {
|
|||||||
>
|
>
|
||||||
{t('one')}
|
{t('one')}
|
||||||
</Text>
|
</Text>
|
||||||
<HStack gap="1rem">
|
<HStack gap="0.5rem" alignItems="center">
|
||||||
<Avatar
|
<Avatar
|
||||||
name={waitingParticipants[0].username}
|
name={waitingParticipants[0].username}
|
||||||
bgColor={waitingParticipants[0].color}
|
bgColor={waitingParticipants[0].color}
|
||||||
@@ -119,6 +118,22 @@ export const WaitingParticipantNotification = () => {
|
|||||||
>
|
>
|
||||||
{waitingParticipants[0].username}
|
{waitingParticipants[0].username}
|
||||||
</Text>
|
</Text>
|
||||||
|
{!waitingParticipants[0].is_authenticated && (
|
||||||
|
<VisualOnlyTooltip
|
||||||
|
tooltip={anonymousLabel}
|
||||||
|
ariaLabel={anonymousLabel}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'help',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiErrorWarningLine size={16} color="#f87171" />
|
||||||
|
</span>
|
||||||
|
</VisualOnlyTooltip>
|
||||||
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack gap="0.25rem" marginLeft="auto">
|
<HStack gap="0.25rem" marginLeft="auto">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
||||||
import { RecordingStateToast } from './RecordingStateToast'
|
|
||||||
import { ErrorAlertDialog } from './ErrorAlertDialog'
|
import { ErrorAlertDialog } from './ErrorAlertDialog'
|
||||||
|
|
||||||
|
// RecordingStateToast removed — the RoomStatusBanner (top-left pill row)
|
||||||
|
// now shows "Recording in progress" and "Transcription in progress" in the
|
||||||
|
// same place, so the standalone toast was rendering behind the new pills.
|
||||||
|
|
||||||
export const RecordingProvider = () => {
|
export const RecordingProvider = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RecordingStateToast />
|
|
||||||
<LimitReachedAlertDialog />
|
<LimitReachedAlertDialog />
|
||||||
<ErrorAlertDialog />
|
<ErrorAlertDialog />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export enum ApiAccessLevel {
|
|||||||
RESTRICTED = 'restricted',
|
RESTRICTED = 'restricted',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ApiEncryptionMode {
|
||||||
|
NONE = 'none',
|
||||||
|
BASIC = 'basic',
|
||||||
|
}
|
||||||
|
|
||||||
export type ApiRoom = {
|
export type ApiRoom = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -17,6 +22,7 @@ export type ApiRoom = {
|
|||||||
pin_code: string
|
pin_code: string
|
||||||
is_administrable: boolean
|
is_administrable: boolean
|
||||||
access_level: ApiAccessLevel
|
access_level: ApiAccessLevel
|
||||||
|
encryption_mode: ApiEncryptionMode
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
configuration?: {
|
configuration?: {
|
||||||
[key: string]: string | number | boolean | string[]
|
[key: string]: string | number | boolean | string[]
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
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 { ApiEncryptionMode, ApiRoom } from './ApiRoom'
|
||||||
|
|
||||||
export interface CreateRoomParams {
|
export interface CreateRoomParams {
|
||||||
slug: string
|
slug: string
|
||||||
callbackId?: string
|
callbackId?: string
|
||||||
username?: string
|
username?: string
|
||||||
|
encryptionMode?: ApiEncryptionMode
|
||||||
}
|
}
|
||||||
|
|
||||||
const createRoom = ({
|
const createRoom = ({
|
||||||
slug,
|
slug,
|
||||||
callbackId,
|
callbackId,
|
||||||
username = '',
|
username = '',
|
||||||
|
encryptionMode = ApiEncryptionMode.NONE,
|
||||||
}: 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,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type WaitingParticipant = {
|
|||||||
status: string
|
status: string
|
||||||
username: string
|
username: string
|
||||||
color: string
|
color: string
|
||||||
|
is_authenticated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WaitingParticipantsResponse = {
|
export type WaitingParticipantsResponse = {
|
||||||
|
|||||||
@@ -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,18 +7,24 @@ 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 {
|
||||||
|
getPassphraseFromHash,
|
||||||
|
isValidPassphrase,
|
||||||
|
EncryptionMismatchScreen,
|
||||||
|
} 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 { 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'
|
||||||
import { ApiRoom } from '../api/ApiRoom'
|
import { ApiEncryptionMode, ApiRoom } from '../api/ApiRoom'
|
||||||
import { useCreateRoom } from '../api/createRoom'
|
import { useCreateRoom } from '../api/createRoom'
|
||||||
import { InviteDialog } from './InviteDialog'
|
import { InviteDialog } from './InviteDialog'
|
||||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||||
@@ -86,12 +92,74 @@ export const Conference = ({
|
|||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The URL hash is the *source of truth* for whether to encrypt: the
|
||||||
|
// server is never given the passphrase, so a compromised server can't
|
||||||
|
// fabricate or suppress encryption — it can only claim a status, and we
|
||||||
|
// use that claim only as a sanity reference for the mismatch screen.
|
||||||
|
//
|
||||||
|
// `hasValidHash` is synchronous (reads window.location.hash), so it's
|
||||||
|
// either true or false on every render — never "we don't know yet".
|
||||||
|
const hashPassphrase = getPassphraseFromHash()
|
||||||
|
const hasValidHash = isValidPassphrase(hashPassphrase)
|
||||||
|
const dbSaysEncrypted = data?.encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
|
||||||
|
type EncryptionMismatch =
|
||||||
|
| 'missingPassphrase'
|
||||||
|
| 'unexpectedPassphrase'
|
||||||
|
| null
|
||||||
|
|
||||||
|
let encryptionMismatch: EncryptionMismatch = null
|
||||||
|
if (data !== undefined) {
|
||||||
|
if (dbSaysEncrypted && !hasValidHash) {
|
||||||
|
encryptionMismatch = 'missingPassphrase'
|
||||||
|
} else if (!dbSaysEncrypted && hashPassphrase.length > 0) {
|
||||||
|
encryptionMismatch = 'unexpectedPassphrase'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We treat the room as encrypted purely because we have a valid hash.
|
||||||
|
// No server condition. If the hash is valid we MUST run E2EE; if not,
|
||||||
|
// there's nothing to encrypt with.
|
||||||
|
const isEncrypted = hasValidHash
|
||||||
|
|
||||||
|
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
|
||||||
|
const workerRef = useRef<Worker | null>(null)
|
||||||
|
// `roomWithE2EE` is the actual `Room` instance for which we've already
|
||||||
|
// run `setKey + setE2EEEnabled(true)`. Comparing it by reference with the
|
||||||
|
// currently-memoised `room` lets us derive the "setup complete" status
|
||||||
|
// synchronously during render — no separate boolean, no useEffect-driven
|
||||||
|
// reset, no race window between a new Room appearing and a flag flipping.
|
||||||
|
//
|
||||||
|
// A device-pref change rebuilds `roomOptions` → a new `Room` instance is
|
||||||
|
// memoised; on that same render `roomWithE2EE !== room` so the gate
|
||||||
|
// below stays closed until the setup effect has stamped the new Room.
|
||||||
|
const [roomWithE2EE, setRoomWithE2EE] = useState<Room | null>(null)
|
||||||
|
const [encryptionSetupError, setEncryptionSetupError] =
|
||||||
|
useState<Error | null>(null)
|
||||||
|
|
||||||
|
const getKeyProvider = () => {
|
||||||
|
if (!keyProviderRef.current && isEncrypted) {
|
||||||
|
keyProviderRef.current = new ExternalE2EEKeyProvider()
|
||||||
|
}
|
||||||
|
return keyProviderRef.current
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWorker = () => {
|
||||||
|
if (!workerRef.current && isEncrypted && typeof window !== 'undefined') {
|
||||||
|
workerRef.current = new Worker(
|
||||||
|
new URL('livekit-client/e2ee-worker', import.meta.url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return workerRef.current
|
||||||
|
}
|
||||||
|
|
||||||
const roomOptions = useMemo((): RoomOptions => {
|
const roomOptions = useMemo((): RoomOptions => {
|
||||||
return {
|
const baseOptions: RoomOptions = {
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
videoCodec: 'vp9',
|
videoCodec: isEncrypted ? undefined : 'vp9',
|
||||||
|
red: !isEncrypted,
|
||||||
},
|
},
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||||
@@ -106,8 +174,20 @@ export const Conference = ({
|
|||||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isEncrypted) {
|
||||||
|
const worker = getWorker()
|
||||||
|
const keyProvider = getKeyProvider()
|
||||||
|
if (keyProvider && worker) {
|
||||||
|
baseOptions.encryption = { keyProvider, worker }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseOptions
|
||||||
// do not rely on the userConfig object directly as its reference may change on every render
|
// do not rely on the userConfig object directly as its reference may change on every render
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
|
isEncrypted,
|
||||||
userConfig.videoDeviceId,
|
userConfig.videoDeviceId,
|
||||||
userConfig.videoPublishResolution,
|
userConfig.videoPublishResolution,
|
||||||
userConfig.audioDeviceId,
|
userConfig.audioDeviceId,
|
||||||
@@ -116,61 +196,14 @@ export const Conference = ({
|
|||||||
|
|
||||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||||
|
|
||||||
useEffect(() => {
|
const encryptionSetupComplete = !isEncrypted || roomWithE2EE === room
|
||||||
/**
|
// Never let LiveKitRoom connect in an indeterminate state:
|
||||||
* Warm up connection to LiveKit server before joining room
|
// 1. `data` must have arrived from the server so we know whether to
|
||||||
* This prefetch helps reduce initial connection latency by establishing
|
// show the mismatch screen.
|
||||||
* an early HTTP connection to the WebRTC signaling server
|
// 2. If the URL has a valid hash, the *current* Room must have already
|
||||||
*
|
// been armed with setKey + setE2EEEnabled — otherwise the camera
|
||||||
* It should cache DNS and TLS keys.
|
// goes out in clear.
|
||||||
*/
|
const canConnectMediaWise = data !== undefined && encryptionSetupComplete
|
||||||
const prepareConnection = async () => {
|
|
||||||
if (!apiConfig || isConnectionWarmedUp) return
|
|
||||||
await room.prepareConnection(apiConfig.livekit.url)
|
|
||||||
|
|
||||||
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
|
||||||
try {
|
|
||||||
const wssUrl =
|
|
||||||
apiConfig.livekit.url
|
|
||||||
.replace('https://', 'wss://')
|
|
||||||
.replace(/\/$/, '') + '/rtc'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FIREFOX + PROXY WORKAROUND:
|
|
||||||
*
|
|
||||||
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
|
||||||
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
|
||||||
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
|
|
||||||
*
|
|
||||||
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
|
||||||
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
|
||||||
*
|
|
||||||
* Note: This issue is reproducible on LiveKit's demo app.
|
|
||||||
* Reference: livekit-examples/meet/issues/466
|
|
||||||
*/
|
|
||||||
const ws = new WebSocket(wssUrl)
|
|
||||||
// 401 unauthorized response is expected
|
|
||||||
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
|
||||||
} catch (e) {
|
|
||||||
console.debug('Firefox WebSocket workaround failed.', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsConnectionWarmedUp(true)
|
|
||||||
}
|
|
||||||
prepareConnection()
|
|
||||||
}, [room, apiConfig, isConnectionWarmedUp])
|
|
||||||
|
|
||||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
|
||||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
|
||||||
error: MediaDeviceFailure | null
|
|
||||||
kind: MediaDeviceKind | null
|
|
||||||
}>({
|
|
||||||
error: null,
|
|
||||||
kind: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||||
@@ -186,9 +219,98 @@ export const Conference = ({
|
|||||||
return livekit_url
|
return livekit_url
|
||||||
}, [apiConfig?.livekit])
|
}, [apiConfig?.livekit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEncrypted || roomWithE2EE === room) return
|
||||||
|
|
||||||
|
const keyProvider = getKeyProvider()
|
||||||
|
if (!keyProvider) return
|
||||||
|
|
||||||
|
// `isEncrypted === hasValidHash`, so by the time we get here the URL
|
||||||
|
// already carries a valid passphrase. Hash generation happens upstream
|
||||||
|
// (in `Home.tsx` for new encrypted meetings); we just read it here.
|
||||||
|
const passphrase = getPassphraseFromHash()
|
||||||
|
if (!passphrase) return
|
||||||
|
|
||||||
|
// Must only stamp the room as "armed" after the chain has actually
|
||||||
|
// succeeded. If `setE2EEEnabled` rejects we surface the failure to
|
||||||
|
// the user via `encryptionSetupError` and stay disconnected.
|
||||||
|
let cancelled = false
|
||||||
|
keyProvider
|
||||||
|
.setKey(passphrase)
|
||||||
|
.then(() => room.setE2EEEnabled(true))
|
||||||
|
.then(() => {
|
||||||
|
if (!cancelled) setRoomWithE2EE(room)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return
|
||||||
|
console.error('[Encryption] setup failed:', err)
|
||||||
|
setEncryptionSetupError(
|
||||||
|
err instanceof Error ? err : new Error(String(err))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [room, isEncrypted, roomWithE2EE])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return
|
||||||
|
let currentHash = getPassphraseFromHash()
|
||||||
|
const onHashChange = () => {
|
||||||
|
const next = getPassphraseFromHash()
|
||||||
|
if (next === currentHash) return
|
||||||
|
currentHash = next
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', onHashChange)
|
||||||
|
return () => window.removeEventListener('hashchange', onHashChange)
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/**
|
||||||
|
* Warm up connection to LiveKit server before joining room.
|
||||||
|
* Use the normalized `serverUrl` (the same value the LiveKitRoom
|
||||||
|
* will connect to after `force_wss_protocol`) so the warm-up matches
|
||||||
|
* the actual connection target.
|
||||||
|
*/
|
||||||
|
const prepareConnection = async () => {
|
||||||
|
if (!apiConfig || !serverUrl || isConnectionWarmedUp) return
|
||||||
|
await room.prepareConnection(serverUrl)
|
||||||
|
|
||||||
|
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
||||||
|
try {
|
||||||
|
const wssUrl =
|
||||||
|
serverUrl.replace('https://', 'wss://').replace(/\/$/, '') + '/rtc'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIREFOX + PROXY WORKAROUND — see livekit-examples/meet/issues/466
|
||||||
|
*/
|
||||||
|
const ws = new WebSocket(wssUrl)
|
||||||
|
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('Firefox WebSocket workaround failed.', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnectionWarmedUp(true)
|
||||||
|
}
|
||||||
|
prepareConnection()
|
||||||
|
}, [room, apiConfig, serverUrl, isConnectionWarmedUp])
|
||||||
|
|
||||||
|
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
|
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||||
|
error: MediaDeviceFailure | null
|
||||||
|
kind: MediaDeviceKind | null
|
||||||
|
}>({
|
||||||
|
error: null,
|
||||||
|
kind: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
if (isCreateError) {
|
if (isCreateError) {
|
||||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
|
||||||
return (
|
return (
|
||||||
<ErrorScreen
|
<ErrorScreen
|
||||||
title={t('error.createRoom.heading')}
|
title={t('error.createRoom.heading')}
|
||||||
@@ -197,11 +319,23 @@ export const Conference = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (encryptionMismatch) {
|
||||||
|
return <EncryptionMismatchScreen reason={encryptionMismatch} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encryptionSetupError) {
|
||||||
|
return (
|
||||||
|
<ErrorScreen
|
||||||
|
title={t('error.encryptionSetup.heading')}
|
||||||
|
body={t('error.encryptionSetup.body')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
||||||
// These settings help ensure successful connections in poor network conditions
|
|
||||||
const connectOptions = {
|
const connectOptions = {
|
||||||
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
maxRetries: 5,
|
||||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
peerConnectionTimeout: 60000,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -211,7 +345,7 @@ export const Conference = ({
|
|||||||
room={room}
|
room={room}
|
||||||
serverUrl={serverUrl}
|
serverUrl={serverUrl}
|
||||||
token={data?.livekit?.token}
|
token={data?.livekit?.token}
|
||||||
connect={isConnectionWarmedUp}
|
connect={isConnectionWarmedUp && canConnectMediaWise}
|
||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={
|
video={
|
||||||
userConfig.videoEnabled && {
|
userConfig.videoEnabled && {
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ import { HStack, styled, VStack } from '@/styled-system/jsx'
|
|||||||
import { Heading, Dialog } from 'react-aria-components'
|
import { Heading, Dialog } from 'react-aria-components'
|
||||||
import { Text, text } from '@/primitives/Text'
|
import { Text, text } from '@/primitives/Text'
|
||||||
import {
|
import {
|
||||||
|
RiAlertFill,
|
||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiCloseLine,
|
RiCloseLine,
|
||||||
|
RiComputerLine,
|
||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
|
RiPhoneLine,
|
||||||
RiSpam2Fill,
|
RiSpam2Fill,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import { FeaturePill } from '@/features/encryption'
|
||||||
|
import { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
|
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
|
||||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||||
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
|
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
|
||||||
@@ -41,15 +45,30 @@ const StyledRACDialog = styled(Dialog, {
|
|||||||
|
|
||||||
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
|
const { t: tHome } = useTranslation('home', {
|
||||||
|
keyPrefix: 'connectionDetailsDialog',
|
||||||
|
})
|
||||||
|
const { t: tFeatures } = useTranslation('home', {
|
||||||
|
keyPrefix: 'createEncryptedMeetingDialog',
|
||||||
|
})
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
const roomUrl = getRouteUrl('room', roomData?.slug)
|
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
const isAdminOrOwner = !!roomData?.is_administrable
|
||||||
|
const baseRoomUrl = getRouteUrl('room', roomData?.slug)
|
||||||
|
// Include the hash (passphrase) for basic encrypted rooms so the full link is visible
|
||||||
|
const roomUrl = window.location.hash
|
||||||
|
? `${baseRoomUrl}${window.location.hash}`
|
||||||
|
: baseRoomUrl
|
||||||
|
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
|
// Encrypted rooms never get a working PIN (backend skips both pin_code
|
||||||
|
// and dispatch_rule allocation), so the phone block must stay hidden
|
||||||
|
// even if a stale pin_code somehow slipped through.
|
||||||
const isTelephonyReadyForUse = useMemo(() => {
|
const isTelephonyReadyForUse = useMemo(() => {
|
||||||
return telephony?.enabled && roomData?.pin_code
|
return telephony?.enabled && roomData?.pin_code && !isEncrypted
|
||||||
}, [telephony?.enabled, roomData?.pin_code])
|
}, [telephony?.enabled, roomData?.pin_code, isEncrypted])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isCopied,
|
isCopied,
|
||||||
@@ -68,7 +87,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||||
>
|
>
|
||||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||||
{t('heading')}
|
{isEncrypted ? t('encryptedHeading') : t('heading')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Div position="absolute" top="5" right="5">
|
<Div position="absolute" top="5" right="5">
|
||||||
<Button
|
<Button
|
||||||
@@ -84,113 +103,235 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
<RiCloseLine />
|
<RiCloseLine />
|
||||||
</Button>
|
</Button>
|
||||||
</Div>
|
</Div>
|
||||||
<P>{t('description')}</P>
|
{isEncrypted && !isAdminOrOwner ? (
|
||||||
{isTelephonyReadyForUse ? (
|
<P>{t('encryptedGuestBody')}</P>
|
||||||
<div
|
) : (
|
||||||
className={css({
|
<P>{t('description')}</P>
|
||||||
width: '100%',
|
)}
|
||||||
display: 'flex',
|
{(() => {
|
||||||
flexDirection: 'column',
|
if (isEncrypted && !isAdminOrOwner) return null
|
||||||
marginTop: '0.5rem',
|
if (isEncrypted) {
|
||||||
gap: '1rem',
|
return (
|
||||||
overflow: 'visible',
|
<div
|
||||||
})}
|
className={css({
|
||||||
>
|
width: '100%',
|
||||||
<div
|
marginTop: '0.5rem',
|
||||||
className={css({
|
display: 'flex',
|
||||||
display: 'flex',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
gap: '0.75rem',
|
||||||
justifyContent: 'space-between',
|
})}
|
||||||
})}
|
>
|
||||||
>
|
<div
|
||||||
<Text as="p" wrap="pretty">
|
role="alert"
|
||||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
className={css({
|
||||||
</Text>
|
display: 'flex',
|
||||||
{isTelephonyReadyForUse && roomUrl && (
|
gap: '0.5rem',
|
||||||
<Button
|
alignItems: 'center',
|
||||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
padding: '0.6rem 0.85rem',
|
||||||
square
|
borderRadius: '0.5rem',
|
||||||
size={'sm'}
|
backgroundColor: '#fff7ed',
|
||||||
onPress={copyRoomUrlToClipboard}
|
border: '1px solid #fed7aa',
|
||||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
color: '#7c2d12',
|
||||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
})}
|
||||||
>
|
>
|
||||||
{isRoomUrlCopied ? (
|
<RiAlertFill
|
||||||
<RiCheckLine aria-hidden="true" />
|
size={18}
|
||||||
|
color="#b45309"
|
||||||
|
className={css({ flexShrink: 0 })}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
color: '#7c2d12',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{tHome('warning')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.250',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||||
|
square
|
||||||
|
size="sm"
|
||||||
|
onPress={copyRoomUrlToClipboard}
|
||||||
|
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
|
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
|
>
|
||||||
|
{isRoomUrlCopied ? (
|
||||||
|
<RiCheckLine size={16} />
|
||||||
|
) : (
|
||||||
|
<RiFileCopyLine size={16} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Text
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'greyscale.500',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('encryptedDisabledHeading')}
|
||||||
|
</Text>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.4rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FeaturePill
|
||||||
|
size="sm"
|
||||||
|
icon={<RiPhoneLine size={13} />}
|
||||||
|
label={tFeatures('features.dialIn')}
|
||||||
|
/>
|
||||||
|
<FeaturePill
|
||||||
|
size="sm"
|
||||||
|
icon={<RiComputerLine size={13} />}
|
||||||
|
label={tFeatures('features.meetingRoom')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isTelephonyReadyForUse) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
gap: '1rem',
|
||||||
|
overflow: 'visible',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||||
|
</Text>
|
||||||
|
{isTelephonyReadyForUse && roomUrl && (
|
||||||
|
<Button
|
||||||
|
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||||
|
square
|
||||||
|
size={'sm'}
|
||||||
|
onPress={copyRoomUrlToClipboard}
|
||||||
|
aria-label={
|
||||||
|
isRoomUrlCopied ? t('copied') : t('copyUrl')
|
||||||
|
}
|
||||||
|
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
|
>
|
||||||
|
{isRoomUrlCopied ? (
|
||||||
|
<RiCheckLine aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<RiFileCopyLine aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||||
|
{telephony?.internationalPhoneNumber}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||||
|
{formatPinCode(roomData?.pin_code)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={isCopied ? 'success' : 'secondaryText'}
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
onPress={copyRoomToClipboard}
|
||||||
|
data-attr="share-dialog-copy"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine
|
||||||
|
size={18}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('copied')}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<RiFileCopyLine aria-hidden="true" />
|
<>
|
||||||
|
<RiFileCopyLine
|
||||||
|
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('copy')}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)
|
||||||
<div
|
}
|
||||||
className={css({
|
return (
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Text as="p" wrap="pretty">
|
|
||||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
|
||||||
{telephony?.internationalPhoneNumber}
|
|
||||||
</Text>
|
|
||||||
<Text as="p" wrap="pretty">
|
|
||||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
|
||||||
{formatPinCode(roomData?.pin_code)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={isCopied ? 'success' : 'secondaryText'}
|
variant={isCopied ? 'success' : 'tertiary'}
|
||||||
size="sm"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
style={{
|
|
||||||
justifyContent: 'start',
|
|
||||||
}}
|
|
||||||
onPress={copyRoomToClipboard}
|
onPress={copyRoomToClipboard}
|
||||||
data-attr="share-dialog-copy"
|
data-attr="share-dialog-copy"
|
||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
<>
|
<>
|
||||||
<RiCheckLine
|
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||||
size={18}
|
|
||||||
style={{ marginRight: '8px' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copied')}
|
{t('copied')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RiFileCopyLine
|
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
{t('copyUrl')}
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copy')}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)
|
||||||
) : (
|
})()}
|
||||||
<Button
|
|
||||||
variant={isCopied ? 'success' : 'tertiary'}
|
|
||||||
fullWidth
|
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
|
||||||
onPress={copyRoomToClipboard}
|
|
||||||
data-attr="share-dialog-copy"
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<>
|
|
||||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
|
||||||
{t('copied')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
|
||||||
{t('copyUrl')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
||||||
<HStack>
|
<HStack>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -32,8 +32,15 @@ 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 } from '../api/ApiRoom'
|
||||||
|
import {
|
||||||
|
isValidPassphrase,
|
||||||
|
getPassphraseFromHash,
|
||||||
|
EncryptionMismatchScreen,
|
||||||
|
} from '@/features/encryption'
|
||||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||||
|
import { RiInformationLine, RiLockLine } from '@remixicon/react'
|
||||||
|
import { useUser } from '@/features/auth'
|
||||||
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'
|
||||||
@@ -103,6 +110,39 @@ export const Join = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||||
|
|
||||||
|
// Early fetch to inspect the room (encrypted? requires passphrase?)
|
||||||
|
const { data: roomInfo } = useQuery({
|
||||||
|
queryKey: [keys.room, roomId, 'info'],
|
||||||
|
queryFn: () => fetchRoom({ roomId }),
|
||||||
|
staleTime: 6 * 60 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEncryptedRoom = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
const { user, isLoggedIn } = useUser()
|
||||||
|
// Authenticated joiners of an encrypted room can't pick an arbitrary
|
||||||
|
// display name — it must match the OIDC profile, and the backend
|
||||||
|
// re-enforces this when minting the JWT.
|
||||||
|
const isNameLocked = isEncryptedRoom && !!isLoggedIn
|
||||||
|
const lockedName = user?.full_name || user?.email || ''
|
||||||
|
|
||||||
|
// Keep the passphrase in state and refresh on `hashchange` so the
|
||||||
|
// mismatch screen recovers immediately when the user pastes the
|
||||||
|
// correct hash into the address bar.
|
||||||
|
const [passphrase, setPassphrase] = useState(getPassphraseFromHash)
|
||||||
|
useEffect(() => {
|
||||||
|
const onHashChange = () => setPassphrase(getPassphraseFromHash())
|
||||||
|
window.addEventListener('hashchange', onHashChange)
|
||||||
|
return () => window.removeEventListener('hashchange', onHashChange)
|
||||||
|
}, [])
|
||||||
|
const hasValidPassphrase = isEncryptedRoom
|
||||||
|
? isValidPassphrase(passphrase)
|
||||||
|
: true
|
||||||
|
// If the URL has a passphrase but the room itself is not encrypted, the
|
||||||
|
// link looks tampered with — refuse to join and offer a fresh room.
|
||||||
|
const unexpectedPassphrase =
|
||||||
|
!!roomInfo && !isEncryptedRoom && passphrase.length > 0
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userChoices: {
|
userChoices: {
|
||||||
audioEnabled,
|
audioEnabled,
|
||||||
@@ -173,11 +213,6 @@ export const Join = ({
|
|||||||
[tracks]
|
[tracks]
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
|
||||||
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
|
|
||||||
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
|
|
||||||
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const createVideoTrack = async () => {
|
const createVideoTrack = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -245,8 +280,6 @@ export const Join = ({
|
|||||||
const videoTrack = dynamicVideoTrack || previewVideoTrack
|
const videoTrack = dynamicVideoTrack || previewVideoTrack
|
||||||
const audioTrack = dynamicAudioTrack || previewAudioTrack
|
const audioTrack = dynamicAudioTrack || previewAudioTrack
|
||||||
|
|
||||||
// LiveKit by default populates device choices with "default" value.
|
|
||||||
// Instead, use the current device id used by the preview track as a default
|
|
||||||
useResolveInitiallyDefaultDeviceId(
|
useResolveInitiallyDefaultDeviceId(
|
||||||
audioDeviceId,
|
audioDeviceId,
|
||||||
audioTrack,
|
audioTrack,
|
||||||
@@ -286,12 +319,6 @@ export const Join = ({
|
|||||||
}
|
}
|
||||||
}, [videoTrack, videoEnabled])
|
}, [videoTrack, videoEnabled])
|
||||||
|
|
||||||
// Room data strategy:
|
|
||||||
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
|
||||||
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
|
||||||
// 3. State is manually updated via queryClient when a waiting participant is accepted
|
|
||||||
// 4. No automatic refetching or revalidation occurs during this period
|
|
||||||
// todo - refactor in a hook
|
|
||||||
const {
|
const {
|
||||||
data: roomData,
|
data: roomData,
|
||||||
error,
|
error,
|
||||||
@@ -333,7 +360,6 @@ export const Join = ({
|
|||||||
const { data } = await refetchRoom()
|
const { data } = await refetchRoom()
|
||||||
|
|
||||||
if (!data?.livekit) {
|
if (!data?.livekit) {
|
||||||
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
|
|
||||||
if (data?.access_level == ApiAccessLevel.TRUSTED) {
|
if (data?.access_level == ApiAccessLevel.TRUSTED) {
|
||||||
openLoginHint()
|
openLoginHint()
|
||||||
}
|
}
|
||||||
@@ -426,6 +452,12 @@ export const Join = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
if (unexpectedPassphrase) {
|
||||||
|
return <EncryptionMismatchScreen reason="unexpectedPassphrase" />
|
||||||
|
}
|
||||||
|
if (isEncryptedRoom && !hasValidPassphrase) {
|
||||||
|
return <EncryptionMismatchScreen reason="missingPassphrase" />
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@@ -438,20 +470,75 @@ 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')}
|
width: '100%',
|
||||||
id="input-name"
|
display: 'flex',
|
||||||
defaultValue={username}
|
flexDirection: 'column',
|
||||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
gap: '0.25rem',
|
||||||
wrapperProps={{
|
})}
|
||||||
noMargin: true,
|
>
|
||||||
fullWidth: true,
|
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||||
}}
|
{t('usernameLabel')}
|
||||||
autoComplete="name"
|
</Text>
|
||||||
maxLength={50}
|
<div
|
||||||
/>
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
backgroundColor: 'greyscale.100',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.200',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiLockLine size={14} color="#6b7280" />
|
||||||
|
<Text variant="sm" margin={false}>
|
||||||
|
{lockedName}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiInformationLine
|
||||||
|
size={18}
|
||||||
|
color="#6b7280"
|
||||||
|
className={css({ flexShrink: 0 })}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="note"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'greyscale.500',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('encryptedNameLocked')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
onChange={saveUsername}
|
||||||
|
label={t('usernameLabel')}
|
||||||
|
id="input-name"
|
||||||
|
defaultValue={username}
|
||||||
|
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||||
|
wrapperProps={{
|
||||||
|
noMargin: true,
|
||||||
|
fullWidth: true,
|
||||||
|
}}
|
||||||
|
autoComplete="name"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export const useLobby = ({
|
|||||||
}) => {
|
}) => {
|
||||||
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)
|
||||||
@@ -40,10 +39,7 @@ export const useLobby = ({
|
|||||||
/* eslint-disable @tanstack/query/exhaustive-deps */
|
/* eslint-disable @tanstack/query/exhaustive-deps */
|
||||||
queryKey: [keys.requestEntry, roomId],
|
queryKey: [keys.requestEntry, roomId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await requestEntry({
|
const response = await requestEntry({ roomId, username })
|
||||||
roomId,
|
|
||||||
username,
|
|
||||||
})
|
|
||||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||||
clearWaitingTimeout()
|
clearWaitingTimeout()
|
||||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const useWaitingParticipants = () => {
|
|||||||
allowEntry: boolean
|
allowEntry: boolean
|
||||||
) => {
|
) => {
|
||||||
await enterRoom({
|
await enterRoom({
|
||||||
roomId: roomId,
|
roomId,
|
||||||
allowEntry,
|
allowEntry,
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
})
|
})
|
||||||
@@ -78,7 +78,7 @@ export const useWaitingParticipants = () => {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
waitingParticipants.map((participant) =>
|
waitingParticipants.map((participant) =>
|
||||||
enterRoom({
|
enterRoom({
|
||||||
roomId: roomId,
|
roomId,
|
||||||
allowEntry,
|
allowEntry,
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Div, Field, H, Text } from '@/primitives'
|
import { Div, Field, H, Text } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Separator as RACSeparator } from 'react-aria-components'
|
import { Separator as RACSeparator } from 'react-aria-components'
|
||||||
|
import { RiAlertFill } from '@remixicon/react'
|
||||||
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 { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
import { ApiAccessLevel } 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 { useParams } from 'wouter'
|
import { useParams } from 'wouter'
|
||||||
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
|
||||||
export const Admin = () => {
|
export const Admin = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||||
@@ -22,12 +22,7 @@ export const Admin = () => {
|
|||||||
|
|
||||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||||
|
|
||||||
const { data: readOnlyData } = useQuery({
|
const readOnlyData = useRoomData()
|
||||||
queryKey: [keys.room, roomId],
|
|
||||||
queryFn: () => fetchRoom({ roomId }),
|
|
||||||
retry: false,
|
|
||||||
enabled: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
toggleMicrophone,
|
toggleMicrophone,
|
||||||
@@ -166,45 +161,99 @@ export const Admin = () => {
|
|||||||
>
|
>
|
||||||
{t('access.description')}
|
{t('access.description')}
|
||||||
</Text>
|
</Text>
|
||||||
<Field
|
{(() => {
|
||||||
type="radioGroup"
|
const isEncrypted =
|
||||||
label={t('access.type')}
|
readOnlyData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||||
aria-label={t('access.type')}
|
return (
|
||||||
labelProps={{
|
<>
|
||||||
className: css({
|
{isEncrypted && (
|
||||||
fontSize: '1rem',
|
<div
|
||||||
paddingBottom: '1rem',
|
role="alert"
|
||||||
}),
|
className={css({
|
||||||
}}
|
display: 'flex',
|
||||||
value={readOnlyData?.access_level}
|
gap: '0.5rem',
|
||||||
onChange={(value) =>
|
alignItems: 'center',
|
||||||
patchRoom({
|
padding: '0.6rem 0.85rem',
|
||||||
roomId,
|
marginBottom: '0.75rem',
|
||||||
room: { access_level: value as ApiAccessLevel },
|
borderRadius: '0.5rem',
|
||||||
})
|
backgroundColor: '#fff7ed',
|
||||||
.then((room) => {
|
border: '1px solid #fed7aa',
|
||||||
queryClient.setQueryData([keys.room, roomId], room)
|
color: '#7c2d12',
|
||||||
})
|
})}
|
||||||
.catch((e) => console.error(e))
|
>
|
||||||
}
|
<RiAlertFill
|
||||||
items={[
|
size={18}
|
||||||
{
|
color="#b45309"
|
||||||
value: ApiAccessLevel.PUBLIC,
|
className={css({ flexShrink: 0 })}
|
||||||
label: t('access.levels.public.label'),
|
/>
|
||||||
description: t('access.levels.public.description'),
|
<Text
|
||||||
},
|
margin={false}
|
||||||
{
|
className={css({
|
||||||
value: ApiAccessLevel.TRUSTED,
|
color: '#7c2d12',
|
||||||
label: t('access.levels.trusted.label'),
|
fontSize: '0.85rem',
|
||||||
description: t('access.levels.trusted.description'),
|
lineHeight: 1.4,
|
||||||
},
|
})}
|
||||||
{
|
>
|
||||||
value: ApiAccessLevel.RESTRICTED,
|
{t('access.encryptedLocked')}
|
||||||
label: t('access.levels.restricted.label'),
|
</Text>
|
||||||
description: t('access.levels.restricted.description'),
|
</div>
|
||||||
},
|
)}
|
||||||
]}
|
<div
|
||||||
/>
|
className={css({
|
||||||
|
opacity: isEncrypted ? 0.7 : 1,
|
||||||
|
pointerEvents: isEncrypted ? 'none' : undefined,
|
||||||
|
transition: 'opacity 200ms ease',
|
||||||
|
})}
|
||||||
|
aria-disabled={isEncrypted || undefined}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
type="radioGroup"
|
||||||
|
label={t('access.type')}
|
||||||
|
aria-label={t('access.type')}
|
||||||
|
labelProps={{
|
||||||
|
className: css({
|
||||||
|
fontSize: '1rem',
|
||||||
|
paddingBottom: '1rem',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
isDisabled={isEncrypted}
|
||||||
|
value={
|
||||||
|
isEncrypted
|
||||||
|
? ApiAccessLevel.RESTRICTED
|
||||||
|
: readOnlyData?.access_level
|
||||||
|
}
|
||||||
|
onChange={(value) =>
|
||||||
|
patchRoom({
|
||||||
|
roomId,
|
||||||
|
room: { access_level: value as ApiAccessLevel },
|
||||||
|
})
|
||||||
|
.then((room) => {
|
||||||
|
queryClient.setQueryData([keys.room, roomId], room)
|
||||||
|
})
|
||||||
|
.catch((e) => console.error(e))
|
||||||
|
}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
value: ApiAccessLevel.PUBLIC,
|
||||||
|
label: t('access.levels.public.label'),
|
||||||
|
description: t('access.levels.public.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: ApiAccessLevel.TRUSTED,
|
||||||
|
label: t('access.levels.trusted.label'),
|
||||||
|
description: t('access.levels.trusted.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: ApiAccessLevel.RESTRICTED,
|
||||||
|
label: t('access.levels.restricted.label'),
|
||||||
|
description: t('access.levels.restricted.description'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Div>
|
</Div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,28 +2,186 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { VStack } from '@/styled-system/jsx'
|
import { VStack } from '@/styled-system/jsx'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
import {
|
||||||
|
RiAlertFill,
|
||||||
|
RiCheckLine,
|
||||||
|
RiComputerLine,
|
||||||
|
RiFileCopyLine,
|
||||||
|
RiPhoneLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
import { Bold, Button, Div, Text } from '@/primitives'
|
import { Bold, Button, Div, Text } from '@/primitives'
|
||||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
import { useRoomData } from '../hooks/useRoomData'
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
import { formatPinCode } from '../../utils/telephony'
|
import { formatPinCode } from '../../utils/telephony'
|
||||||
import { useTelephony } from '../hooks/useTelephony'
|
import { useTelephony } from '../hooks/useTelephony'
|
||||||
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
|
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
|
||||||
|
import { FeaturePill } from '@/features/encryption'
|
||||||
|
import { ApiEncryptionMode } from '../../api/ApiRoom'
|
||||||
|
|
||||||
export const Info = () => {
|
export const Info = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||||
|
const { t: tHome } = useTranslation('home', {
|
||||||
|
keyPrefix: 'connectionDetailsDialog',
|
||||||
|
})
|
||||||
|
|
||||||
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()
|
||||||
|
const isEncrypted = data?.encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
const isAdminOrOwner = !!data?.is_administrable
|
||||||
|
|
||||||
const isTelephonyReadyForUse = useMemo(() => {
|
const isTelephonyReadyForUse = useMemo(() => {
|
||||||
return telephony?.enabled && data?.pin_code
|
return telephony?.enabled && data?.pin_code && !isEncrypted
|
||||||
}, [telephony?.enabled, data?.pin_code])
|
}, [telephony?.enabled, data?.pin_code, isEncrypted])
|
||||||
|
|
||||||
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
|
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
|
||||||
|
|
||||||
|
if (isEncrypted && !isAdminOrOwner) {
|
||||||
|
return (
|
||||||
|
<Div
|
||||||
|
display="flex"
|
||||||
|
overflowY="scroll"
|
||||||
|
padding="0 1.5rem"
|
||||||
|
flexGrow={1}
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="start"
|
||||||
|
>
|
||||||
|
<Text as="p" variant="note" wrap="pretty">
|
||||||
|
{t('encrypted.guestBody')}
|
||||||
|
</Text>
|
||||||
|
</Div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEncrypted) {
|
||||||
|
return (
|
||||||
|
<Div
|
||||||
|
display="flex"
|
||||||
|
overflowY="scroll"
|
||||||
|
padding="0 1.5rem"
|
||||||
|
flexGrow={1}
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="start"
|
||||||
|
>
|
||||||
|
<VStack
|
||||||
|
alignItems="stretch"
|
||||||
|
gap="0.75rem"
|
||||||
|
className={css({ width: '100%' })}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0.6rem 0.85rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
backgroundColor: '#fff7ed',
|
||||||
|
border: '1px solid #fed7aa',
|
||||||
|
color: '#7c2d12',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiAlertFill
|
||||||
|
size={18}
|
||||||
|
color="#b45309"
|
||||||
|
className={css({ flexShrink: 0 })}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
color: '#7c2d12',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{tHome('warning')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Text as="p" variant="note" margin={false}>
|
||||||
|
<Bold>{t('encrypted.linkLabel')}</Bold>
|
||||||
|
</Text>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.250',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{roomUrl.replace(/^https?:\/\//, '')}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
size="sm"
|
||||||
|
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||||
|
onPress={copyRoomToClipboard}
|
||||||
|
aria-label={
|
||||||
|
isCopied
|
||||||
|
? t('roomInformation.button.copied')
|
||||||
|
: t('roomInformation.button.copy')
|
||||||
|
}
|
||||||
|
tooltip={
|
||||||
|
isCopied
|
||||||
|
? t('roomInformation.button.copied')
|
||||||
|
: t('roomInformation.button.copy')
|
||||||
|
}
|
||||||
|
data-attr="copy-info-sidepannel"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<RiCheckLine size={16} />
|
||||||
|
) : (
|
||||||
|
<RiFileCopyLine size={16} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'greyscale.500',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('encrypted.disabledHeading')}
|
||||||
|
</Text>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.5rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FeaturePill
|
||||||
|
icon={<RiPhoneLine size={14} />}
|
||||||
|
label={t('encrypted.features.dialIn')}
|
||||||
|
/>
|
||||||
|
<FeaturePill
|
||||||
|
icon={<RiComputerLine size={14} />}
|
||||||
|
label={t('encrypted.features.meetingRoom')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VStack>
|
||||||
|
</Div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Div
|
<Div
|
||||||
display="flex"
|
display="flex"
|
||||||
@@ -50,7 +208,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,12 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
ConnectionQualityIndicator,
|
ConnectionQualityIndicator,
|
||||||
LockLockedIcon,
|
|
||||||
ParticipantTileProps,
|
ParticipantTileProps,
|
||||||
ScreenShareIcon,
|
ScreenShareIcon,
|
||||||
useEnsureTrackRef,
|
useEnsureTrackRef,
|
||||||
useFeatureContext,
|
useFeatureContext,
|
||||||
useIsEncrypted,
|
|
||||||
useMaybeLayoutContext,
|
useMaybeLayoutContext,
|
||||||
useMaybeTrackRefContext,
|
useMaybeTrackRefContext,
|
||||||
useParticipantTile,
|
useParticipantTile,
|
||||||
@@ -24,6 +22,7 @@ import { Track } from 'livekit-client'
|
|||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { DecryptionFailedTileOverlay } from '@/features/encryption'
|
||||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||||
@@ -78,7 +77,6 @@ export const ParticipantTile: (
|
|||||||
onParticipantClick,
|
onParticipantClick,
|
||||||
trackRef: trackReference,
|
trackRef: trackReference,
|
||||||
})
|
})
|
||||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
|
||||||
const layoutContext = useMaybeLayoutContext()
|
const layoutContext = useMaybeLayoutContext()
|
||||||
|
|
||||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||||
@@ -114,7 +112,6 @@ export const ParticipantTile: (
|
|||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
...elementProps,
|
...elementProps,
|
||||||
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
'aria-label': t('containerLabel', { name: participantName }),
|
'aria-label': t('containerLabel', { name: participantName }),
|
||||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
@@ -215,9 +212,6 @@ export const ParticipantTile: (
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isEncrypted && !isScreenShare && (
|
|
||||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
|
||||||
)}
|
|
||||||
<div className="lk-participant-name-wrapper">
|
<div className="lk-participant-name-wrapper">
|
||||||
<ParticipantName
|
<ParticipantName
|
||||||
isScreenShare={isScreenShare}
|
isScreenShare={isScreenShare}
|
||||||
@@ -237,6 +231,11 @@ export const ParticipantTile: (
|
|||||||
hasKeyboardFocus={hasKeyboardFocus}
|
hasKeyboardFocus={hasKeyboardFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!isScreenShare && (
|
||||||
|
<DecryptionFailedTileOverlay
|
||||||
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
</TrackRefContextIfNeeded>
|
</TrackRefContextIfNeeded>
|
||||||
<KeyboardShortcutHint>
|
<KeyboardShortcutHint>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { css } from '@/styled-system/css'
|
|||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
|
import { RiAlertFill } from '@remixicon/react'
|
||||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
import {
|
import {
|
||||||
@@ -12,12 +13,15 @@ import {
|
|||||||
ScreenRecordingSidePanel,
|
ScreenRecordingSidePanel,
|
||||||
} from '@/features/recording'
|
} from '@/features/recording'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
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}
|
||||||
>
|
>
|
||||||
@@ -98,17 +108,15 @@ export const Tools = () => {
|
|||||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||||
useSidePanel()
|
useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||||
|
const roomData = useRoomData()
|
||||||
|
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
|
||||||
// Restore focus to the element that opened the Tools panel
|
// Restore focus to the element that opened the Tools panel
|
||||||
// following the same pattern as Chat.
|
|
||||||
useRestoreFocus(isToolsOpen, {
|
useRestoreFocus(isToolsOpen, {
|
||||||
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
|
|
||||||
// find the "more options" button ("Plus d'options") that opened the menu
|
|
||||||
resolveTrigger: (activeEl) => {
|
resolveTrigger: (activeEl) => {
|
||||||
if (activeEl?.tagName === 'DIV') {
|
if (activeEl?.tagName === 'DIV') {
|
||||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||||
}
|
}
|
||||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
|
||||||
return activeEl
|
return activeEl
|
||||||
},
|
},
|
||||||
restoreFocusRaf: true,
|
restoreFocusRaf: true,
|
||||||
@@ -166,12 +174,42 @@ export const Tools = () => {
|
|||||||
</A>
|
</A>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{isEncrypted && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0.6rem 0.85rem',
|
||||||
|
margin: '0 0.75rem 0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
backgroundColor: '#fff7ed',
|
||||||
|
border: '1px solid #fed7aa',
|
||||||
|
color: '#7c2d12',
|
||||||
|
})}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<RiAlertFill size={18} color="#b45309" />
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
color: '#7c2d12',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('encryptedBlock')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isTranscriptEnabled && (
|
{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={isEncrypted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isScreenRecordingEnabled && (
|
{isScreenRecordingEnabled && (
|
||||||
@@ -179,7 +217,8 @@ export const Tools = () => {
|
|||||||
icon={<Icon type="symbols" name="mode_standby" />}
|
icon={<Icon type="symbols" name="mode_standby" />}
|
||||||
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={isEncrypted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Div>
|
</Div>
|
||||||
|
|||||||
+10
-3
@@ -5,11 +5,15 @@ 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 { ApiEncryptionMode } 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 isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
|
||||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||||
RecordingMode.ScreenRecording,
|
RecordingMode.ScreenRecording,
|
||||||
@@ -21,9 +25,12 @@ export const ScreenRecordingMenuItem = () => {
|
|||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||||
onAction={() =>
|
isDisabled={isEncrypted}
|
||||||
!isScreenRecordingOpen ? openScreenRecording() : toggleTools()
|
onAction={() => {
|
||||||
}
|
if (isEncrypted) return
|
||||||
|
if (!isScreenRecordingOpen) openScreenRecording()
|
||||||
|
else toggleTools()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RiRecordCircleLine size={20} />
|
<RiRecordCircleLine size={20} />
|
||||||
{t('screenRecording')}
|
{t('screenRecording')}
|
||||||
|
|||||||
+10
-1
@@ -5,10 +5,14 @@ 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 { ApiEncryptionMode } 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 isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
|
||||||
const hasTranscriptAccess = useHasRecordingAccess(
|
const hasTranscriptAccess = useHasRecordingAccess(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
@@ -20,7 +24,12 @@ export const TranscriptMenuItem = () => {
|
|||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||||
onAction={() => (!isTranscriptOpen ? openTranscript() : toggleTools())}
|
isDisabled={isEncrypted}
|
||||||
|
onAction={() => {
|
||||||
|
if (isEncrypted) return
|
||||||
|
if (!isTranscriptOpen) openTranscript()
|
||||||
|
else toggleTools()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RiFileTextLine size={20} />
|
<RiFileTextLine size={20} />
|
||||||
{t('transcript')}
|
{t('transcript')}
|
||||||
|
|||||||
+54
-27
@@ -13,7 +13,7 @@ import {
|
|||||||
useTrackMutedIndicator,
|
useTrackMutedIndicator,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
import { RiErrorWarningLine, RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { MuteAlertDialog } from '../../MuteAlertDialog'
|
import { MuteAlertDialog } from '../../MuteAlertDialog'
|
||||||
@@ -21,6 +21,8 @@ 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 { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
|
import { useUser } from '@/features/auth'
|
||||||
|
|
||||||
type MicIndicatorProps = {
|
type MicIndicatorProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
@@ -97,7 +99,16 @@ export const ParticipantListItem = ({
|
|||||||
participant,
|
participant,
|
||||||
}: ParticipantListItemProps) => {
|
}: ParticipantListItemProps) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const { isLoggedIn } = useUser()
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
const isParticipantAuthenticated =
|
||||||
|
participant.attributes?.is_authenticated === 'true'
|
||||||
|
const anonymousLabel = t('identity.anonymous.tooltip')
|
||||||
|
// Email is only displayed to authenticated viewers (defense-in-depth on
|
||||||
|
// top of the JWT-level guarantee that it's only emitted for authenticated
|
||||||
|
// participants). The LK signaling channel broadcasts attributes to every
|
||||||
|
// peer, so the UI is what protects anonymous viewers from seeing it.
|
||||||
|
const email = isLoggedIn ? participant.attributes?.email : undefined
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
role="listitem"
|
role="listitem"
|
||||||
@@ -118,38 +129,54 @@ export const ParticipantListItem = ({
|
|||||||
<PinBadge participant={participant} />
|
<PinBadge participant={participant} />
|
||||||
</div>
|
</div>
|
||||||
<VStack gap={0} alignItems="start">
|
<VStack gap={0} alignItems="start">
|
||||||
<Text
|
<HStack gap="0.2rem" alignItems="center">
|
||||||
variant="sm"
|
<Text
|
||||||
className={css({
|
variant="sm"
|
||||||
userSelect: 'none',
|
margin={false}
|
||||||
cursor: 'default',
|
className={css({
|
||||||
display: 'flex',
|
userSelect: 'none',
|
||||||
})}
|
whiteSpace: 'nowrap',
|
||||||
>
|
overflow: 'hidden',
|
||||||
<span
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '150px',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
{isLocal(participant) && ` (${t('participants.you')})`}
|
||||||
|
</Text>
|
||||||
|
{!isParticipantAuthenticated && (
|
||||||
|
<VisualOnlyTooltip
|
||||||
|
tooltip={anonymousLabel}
|
||||||
|
ariaLabel={anonymousLabel}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'help',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiErrorWarningLine size={14} color="#dc2626" />
|
||||||
|
</span>
|
||||||
|
</VisualOnlyTooltip>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
{getParticipantIsRoomAdmin(participant) && (
|
||||||
|
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||||
|
)}
|
||||||
|
{email && (
|
||||||
|
<Text
|
||||||
|
variant="xsNote"
|
||||||
className={css({
|
className={css({
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
maxWidth: '120px',
|
maxWidth: '180px',
|
||||||
display: 'block',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{name}
|
{email}
|
||||||
</span>
|
</Text>
|
||||||
{isLocal(participant) && (
|
|
||||||
<span
|
|
||||||
className={css({
|
|
||||||
marginLeft: '.25rem',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
({t('participants.you')})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
{getParticipantIsRoomAdmin(participant) && (
|
|
||||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
+43
-29
@@ -1,10 +1,11 @@
|
|||||||
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, RiErrorWarningLine } from '@remixicon/react'
|
||||||
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
|
|
||||||
export const WaitingParticipantListItem = ({
|
export const WaitingParticipantListItem = ({
|
||||||
participant,
|
participant,
|
||||||
@@ -14,6 +15,7 @@ export const WaitingParticipantListItem = ({
|
|||||||
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
|
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const anonymousLabel = t('identity.anonymous.tooltip')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
@@ -30,38 +32,50 @@ 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
|
||||||
variant={'sm'}
|
gap={0}
|
||||||
className={css({
|
alignItems="start"
|
||||||
userSelect: 'none',
|
className={css({ flex: 1, minWidth: 0 })}
|
||||||
cursor: 'default',
|
|
||||||
display: 'flex',
|
|
||||||
flex: '1',
|
|
||||||
minWidth: '0',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<span
|
<HStack gap="0.2rem" alignItems="center">
|
||||||
className={css({
|
<Text
|
||||||
whiteSpace: 'nowrap',
|
variant="sm"
|
||||||
overflow: 'hidden',
|
margin={false}
|
||||||
textOverflow: 'ellipsis',
|
className={css({
|
||||||
width: '100%',
|
userSelect: 'none',
|
||||||
display: 'block',
|
whiteSpace: 'nowrap',
|
||||||
})}
|
overflow: 'hidden',
|
||||||
>
|
textOverflow: 'ellipsis',
|
||||||
{participant.username}
|
padding: '0.1rem 0.25rem',
|
||||||
</span>
|
lineHeight: 1.2,
|
||||||
</Text>
|
})}
|
||||||
|
>
|
||||||
|
{participant.username}
|
||||||
|
</Text>
|
||||||
|
{!participant.is_authenticated && (
|
||||||
|
<VisualOnlyTooltip
|
||||||
|
tooltip={anonymousLabel}
|
||||||
|
ariaLabel={anonymousLabel}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'help',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiErrorWarningLine size={14} color="#dc2626" />
|
||||||
|
</span>
|
||||||
|
</VisualOnlyTooltip>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack
|
<HStack gap="0.25rem" className={css({ flexShrink: '0' })}>
|
||||||
gap="0.25rem"
|
|
||||||
className={css({
|
|
||||||
flexShrink: '0',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { useTelephony } from './useTelephony'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import { ApiRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
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,12 +35,23 @@ 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])
|
||||||
|
|
||||||
|
// Encrypted rooms never get a dispatch rule on the SIP gateway side
|
||||||
|
// (the backend skips it because no PIN-driven join can ever decrypt),
|
||||||
|
// so make sure we don't paste a non-functional phone+PIN snippet either.
|
||||||
const hasTelephonyInfo = useMemo(() => {
|
const hasTelephonyInfo = useMemo(() => {
|
||||||
return telephony.enabled && room?.pin_code
|
return (
|
||||||
}, [telephony.enabled, room?.pin_code])
|
telephony.enabled &&
|
||||||
|
room?.pin_code &&
|
||||||
|
room?.encryption_mode !== ApiEncryptionMode.BASIC
|
||||||
|
)
|
||||||
|
}, [telephony.enabled, room?.pin_code, room?.encryption_mode])
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
if (!roomUrl || !room) return ''
|
if (!roomUrl || !room) return ''
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
|||||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||||
import { GridLayout } from '../components/layout/GridLayout'
|
import { GridLayout } from '../components/layout/GridLayout'
|
||||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||||
|
import { RoomStatusBanner } from '@/features/encryption'
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
@@ -276,6 +277,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
onClose={() => setIsShareErrorVisible(false)}
|
onClose={() => setIsShareErrorVisible(false)}
|
||||||
/>
|
/>
|
||||||
<IsIdleDisconnectModal />
|
<IsIdleDisconnectModal />
|
||||||
|
<RoomStatusBanner />
|
||||||
<div
|
<div
|
||||||
// todo - extract these magic values into constant
|
// todo - extract these magic values into constant
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ export const flexibleRoomIdPattern =
|
|||||||
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
|
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
|
||||||
|
|
||||||
const roomRegex = new RegExp(`^${roomIdPattern}$`)
|
const roomRegex = new RegExp(`^${roomIdPattern}$`)
|
||||||
|
const roomWithoutHyphensRegex = /^[a-z]{10}$/
|
||||||
|
|
||||||
export const isRoomValid = (roomIdOrUrl: string) =>
|
export const isRoomValid = (roomIdOrUrl: string) => {
|
||||||
roomRegex.test(roomIdOrUrl) ||
|
const lower = roomIdOrUrl.toLowerCase()
|
||||||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
|
return (
|
||||||
|
roomRegex.test(lower) ||
|
||||||
|
roomWithoutHyphensRegex.test(lower) ||
|
||||||
|
new RegExp(`^${window.location.origin}/${roomIdPattern}`).test(roomIdOrUrl)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const normalizeRoomId = (roomId: string) => {
|
export const normalizeRoomId = (roomId: string) => {
|
||||||
const cleanId = roomId.toLowerCase().replace(/-/g, '')
|
const cleanId = roomId.toLowerCase().replace(/-/g, '')
|
||||||
|
|||||||
@@ -39,18 +39,24 @@ export const CreateMeetingButton = () => {
|
|||||||
|
|
||||||
const { data } = useRoomCreationCallback({ callbackId })
|
const { data } = useRoomCreationCallback({ callbackId })
|
||||||
|
|
||||||
|
const [basicHash, setBasicHash] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
const roomUrl = useMemo(() => {
|
const roomUrl = useMemo(() => {
|
||||||
if (room?.slug) return getRouteUrl('room', room.slug)
|
if (!room?.slug) return undefined
|
||||||
}, [room])
|
const base = getRouteUrl('room', room.slug)
|
||||||
|
return basicHash ? `${base}#${basicHash}` : base
|
||||||
|
}, [room, basicHash])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data?.room?.slug) return
|
if (!data?.room?.slug) return
|
||||||
setRoom(data.room)
|
setRoom(data.room)
|
||||||
setCallbackId(undefined)
|
setCallbackId(undefined)
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
|
|
||||||
|
const url = getRouteUrl('room', data.room.slug)
|
||||||
popupManager.sendRoomData({
|
popupManager.sendRoomData({
|
||||||
room: {
|
room: {
|
||||||
url: getRouteUrl('room', data.room.slug),
|
url,
|
||||||
...data.room,
|
...data.room,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -61,6 +67,7 @@ export const CreateMeetingButton = () => {
|
|||||||
(id) => setCallbackId(id),
|
(id) => setCallbackId(id),
|
||||||
(data) => {
|
(data) => {
|
||||||
setRoom(data)
|
setRoom(data)
|
||||||
|
if (data.hash) setBasicHash(data.hash)
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -68,6 +75,25 @@ export const CreateMeetingButton = () => {
|
|||||||
return () => popupManager.cleanup()
|
return () => popupManager.cleanup()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Communicate iframe height to parent for proper sizing
|
||||||
|
useEffect(() => {
|
||||||
|
const targetOrigin = (() => {
|
||||||
|
try {
|
||||||
|
return document.referrer ? new URL(document.referrer).origin : '/'
|
||||||
|
} catch {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{ type: 'RESIZE', data: { height: document.body.scrollHeight } },
|
||||||
|
targetOrigin
|
||||||
|
)
|
||||||
|
})
|
||||||
|
observer.observe(document.body)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const resetState = () => {
|
const resetState = () => {
|
||||||
setRoom(undefined)
|
setRoom(undefined)
|
||||||
setCallbackId(undefined)
|
setCallbackId(undefined)
|
||||||
|
|||||||
@@ -1,64 +1,77 @@
|
|||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { generateRoomId, useCreateRoom } from '../../rooms'
|
import { generateRoomId, useCreateRoom } from '../../rooms'
|
||||||
import { useUser } from '@/features/auth'
|
import { useUser } from '@/features/auth'
|
||||||
import { Spinner } from '@/primitives/Spinner'
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
|
import { Button, Text } from '@/primitives'
|
||||||
|
import { VStack } from '@/styled-system/jsx'
|
||||||
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
|
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
|
||||||
import { PopupWindow } from '../utils/PopupWindow'
|
import { PopupWindow } from '../utils/PopupWindow'
|
||||||
|
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { RiVideoOnLine } from '@remixicon/react'
|
||||||
|
|
||||||
const callbackIdHandler = new CallbackIdHandler()
|
const callbackIdHandler = new CallbackIdHandler()
|
||||||
const popupWindow = new PopupWindow()
|
const popupWindow = new PopupWindow()
|
||||||
|
|
||||||
export const CreatePopup = () => {
|
export const CreatePopup = () => {
|
||||||
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
|
const { isLoggedIn } = useUser({
|
||||||
|
fetchUserOptions: { attemptSilent: false },
|
||||||
|
})
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
|
const { t } = useTranslation('sdk', { keyPrefix: 'createPopup' })
|
||||||
|
|
||||||
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle unauthenticated users by redirecting to login
|
|
||||||
*
|
|
||||||
* When redirecting to authentication, the window.location change breaks the connection
|
|
||||||
* between this popup and its parent window. We need to send the callbackId to the parent
|
|
||||||
* before redirecting so it can re-establish connection after authentication completes.
|
|
||||||
* This prevents the popup from becoming orphaned and ensures state consistency.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn === false) {
|
if (isLoggedIn === false) {
|
||||||
// redirection loses the connection to the manager
|
popupWindow.navigateToAuthentication()
|
||||||
// prevent it passing an async callback id
|
|
||||||
popupWindow.sendCallbackId(callbackId, () => {
|
|
||||||
popupWindow.navigateToAuthentication()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [isLoggedIn, callbackId])
|
}, [isLoggedIn])
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setIsCreating(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const slug = generateRoomId()
|
||||||
|
const roomData = await createRoom({
|
||||||
|
slug,
|
||||||
|
encryptionMode: ApiEncryptionMode.NONE,
|
||||||
|
})
|
||||||
|
|
||||||
|
popupWindow.sendRoomData({ slug: roomData.slug }, () => {
|
||||||
|
callbackIdHandler.clear()
|
||||||
|
popupWindow.close()
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create meeting room:', error)
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatically create meeting room once user is authenticated
|
|
||||||
* This effect will trigger either immediately if the user is already logged in,
|
|
||||||
* or after successful authentication and return to this popup
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const createMeetingRoom = async () => {
|
if (isLoggedIn && !isCreating && callbackId) {
|
||||||
try {
|
void handleCreate()
|
||||||
const slug = generateRoomId()
|
|
||||||
const roomData = await createRoom({
|
|
||||||
slug,
|
|
||||||
callbackId,
|
|
||||||
})
|
|
||||||
// Send room data back to parent window and clean up resources
|
|
||||||
popupWindow.sendRoomData(roomData, () => {
|
|
||||||
callbackIdHandler.clear()
|
|
||||||
popupWindow.close()
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create meeting room:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (isLoggedIn && callbackId) {
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
createMeetingRoom()
|
}, [isLoggedIn])
|
||||||
}
|
|
||||||
}, [isLoggedIn, callbackId, createRoom])
|
if (!isLoggedIn || isCreating) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -68,9 +81,26 @@ export const CreatePopup = () => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
padding: '2rem',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Spinner />
|
<VStack gap="0.75rem" alignItems="stretch" maxWidth="22rem" width="100%">
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
bold
|
||||||
|
className={css({
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('title')}
|
||||||
|
</Text>
|
||||||
|
<Button variant="primary" fullWidth onPress={handleCreate}>
|
||||||
|
<RiVideoOnLine size={18} />
|
||||||
|
{t('create')}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,16 +56,21 @@ export class PopupManager {
|
|||||||
case PopupMessageType.CALLBACK_ID:
|
case PopupMessageType.CALLBACK_ID:
|
||||||
onCallbackId(data.callbackId as string)
|
onCallbackId(data.callbackId as string)
|
||||||
return
|
return
|
||||||
case PopupMessageType.ROOM_DATA:
|
case PopupMessageType.ROOM_DATA: {
|
||||||
if (!data?.room) return
|
if (!data?.room) return
|
||||||
onRoomData(data.room)
|
onRoomData(data.room)
|
||||||
|
const baseUrl = getRouteUrl('room', data.room.slug)
|
||||||
|
const roomUrl = data.room.hash
|
||||||
|
? `${baseUrl}#${data.room.hash}`
|
||||||
|
: baseUrl
|
||||||
this.sendRoomData({
|
this.sendRoomData({
|
||||||
room: {
|
room: {
|
||||||
url: getRouteUrl('room', data.room.slug),
|
url: roomUrl,
|
||||||
...data.room,
|
...data.room,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('message', this.messageHandler)
|
window.addEventListener('message', this.messageHandler)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class PopupWindow {
|
|||||||
public sendRoomData(data: CallbackCreationRoomData, callback?: () => void) {
|
public sendRoomData(data: CallbackCreationRoomData, callback?: () => void) {
|
||||||
this.sendMessageToManager(
|
this.sendMessageToManager(
|
||||||
PopupMessageType.ROOM_DATA,
|
PopupMessageType.ROOM_DATA,
|
||||||
{ room: { slug: data.slug } },
|
{ room: { slug: data.slug, hash: data.hash } },
|
||||||
callback
|
callback
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export type CallbackCreationRoomData = {
|
export type CallbackCreationRoomData = {
|
||||||
slug: string
|
slug: string
|
||||||
|
hash?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ClientMessageType {
|
export enum ClientMessageType {
|
||||||
ROOM_CREATED = 'ROOM_CREATED',
|
ROOM_CREATED = 'ROOM_CREATED',
|
||||||
STATE_CLEAR = 'STATE_CLEAR',
|
STATE_CLEAR = 'STATE_CLEAR',
|
||||||
|
RESIZE = 'RESIZE',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PopupMessageData {
|
export interface PopupMessageData {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* "Encrypt new meetings by default" toggle used both in the in-meeting
|
||||||
|
* Security tab and the home-page SettingsDialog. Flipping it directly
|
||||||
|
* patches the user preference — the per-meeting CreateEncryptedMeeting
|
||||||
|
* dialog already surfaces the disabled-features warning when needed.
|
||||||
|
*/
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { A, Field, Text } from '@/primitives'
|
||||||
|
import { useUser } from '@/features/auth'
|
||||||
|
import { updateUserPreferences } from '@/features/auth/api/updateUserPreferences'
|
||||||
|
import { queryClient } from '@/api/queryClient'
|
||||||
|
import { keys } from '@/api/queryKeys'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
|
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
|
export const EncryptionDefaultField = () => {
|
||||||
|
const { t } = useTranslation('settings', { keyPrefix: 'security' })
|
||||||
|
const { user, isLoggedIn } = useUser()
|
||||||
|
const { data: config } = useConfig()
|
||||||
|
const isFeatureEnabled = !!config?.encryption?.enabled
|
||||||
|
|
||||||
|
const { mutateAsync, isPending } = useMutation({
|
||||||
|
mutationFn: updateUserPreferences,
|
||||||
|
onSuccess: (updatedUser) => {
|
||||||
|
queryClient.setQueryData([keys.user], updatedUser)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOn = user?.default_encryption_mode === ApiEncryptionMode.BASIC
|
||||||
|
|
||||||
|
const handleToggle = async (next: boolean) => {
|
||||||
|
if (!user) return
|
||||||
|
try {
|
||||||
|
await mutateAsync({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
default_encryption_mode: next
|
||||||
|
? ApiEncryptionMode.BASIC
|
||||||
|
: ApiEncryptionMode.NONE,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// react-query stores the error on the mutation; the UI shows
|
||||||
|
// isPending while in flight and reverts the toggle on failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text variant="note" margin={false}>
|
||||||
|
{t('signInRequired')}
|
||||||
|
</Text>
|
||||||
|
<LoginButton />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFeatureEnabled) {
|
||||||
|
return (
|
||||||
|
<Text variant="note" margin={false}>
|
||||||
|
{t('featureDisabled')}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
type="switch"
|
||||||
|
label={t('encryption.label')}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{t('encryption.description')}{' '}
|
||||||
|
{config?.support?.help_article_encryption && (
|
||||||
|
<A
|
||||||
|
href={config.support.help_article_encryption}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
externalIcon
|
||||||
|
color="note"
|
||||||
|
>
|
||||||
|
{t('encryption.learnMore')}
|
||||||
|
</A>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
isSelected={isOn}
|
||||||
|
isDisabled={isPending}
|
||||||
|
onChange={handleToggle}
|
||||||
|
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||||
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
import {
|
||||||
|
A,
|
||||||
|
Badge,
|
||||||
|
Dialog,
|
||||||
|
type DialogProps,
|
||||||
|
Field,
|
||||||
|
H,
|
||||||
|
P,
|
||||||
|
Text,
|
||||||
|
} from '@/primitives'
|
||||||
import { useUser } from '@/features/auth'
|
import { useUser } from '@/features/auth'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
|
import { EncryptionDefaultField } from './EncryptionDefaultField'
|
||||||
|
|
||||||
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
||||||
|
|
||||||
@@ -46,6 +56,13 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
|||||||
i18n.changeLanguage(lang as string)
|
i18n.changeLanguage(lang as string)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<H lvl={2} margin={false}>
|
||||||
|
{t('security.heading')}
|
||||||
|
</H>
|
||||||
|
<Text variant="note" margin="md">
|
||||||
|
{t('security.subtitle')}
|
||||||
|
</Text>
|
||||||
|
<EncryptionDefaultField />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
RiAccountCircleLine,
|
RiAccountCircleLine,
|
||||||
RiNotification3Line,
|
RiNotification3Line,
|
||||||
RiSettings3Line,
|
RiSettings3Line,
|
||||||
|
RiShieldKeyholeLine,
|
||||||
RiSpeakerLine,
|
RiSpeakerLine,
|
||||||
RiVideoOnLine,
|
RiVideoOnLine,
|
||||||
RiEyeLine,
|
RiEyeLine,
|
||||||
@@ -19,6 +20,7 @@ import { NotificationsTab } from './tabs/NotificationsTab'
|
|||||||
import { GeneralTab } from './tabs/GeneralTab'
|
import { GeneralTab } from './tabs/GeneralTab'
|
||||||
import { AudioTab } from './tabs/AudioTab'
|
import { AudioTab } from './tabs/AudioTab'
|
||||||
import { VideoTab } from './tabs/VideoTab'
|
import { VideoTab } from './tabs/VideoTab'
|
||||||
|
import { SecurityTab } from './tabs/SecurityTab'
|
||||||
import { TranscriptionTab } from './tabs/TranscriptionTab'
|
import { TranscriptionTab } from './tabs/TranscriptionTab'
|
||||||
import { ShortcutTab } from './tabs/ShortcutTab'
|
import { ShortcutTab } from './tabs/ShortcutTab'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
@@ -105,6 +107,10 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
|||||||
<RiSettings3Line />
|
<RiSettings3Line />
|
||||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
|
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab icon highlight id={SettingsDialogExtendedKey.SECURITY}>
|
||||||
|
<RiShieldKeyholeLine />
|
||||||
|
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SECURITY}`)}
|
||||||
|
</Tab>
|
||||||
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
|
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
|
||||||
<RiNotification3Line />
|
<RiNotification3Line />
|
||||||
{isWideScreen &&
|
{isWideScreen &&
|
||||||
@@ -136,6 +142,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
|||||||
<AudioTab id={SettingsDialogExtendedKey.AUDIO} />
|
<AudioTab id={SettingsDialogExtendedKey.AUDIO} />
|
||||||
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
|
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
|
||||||
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
|
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
|
||||||
|
<SecurityTab id={SettingsDialogExtendedKey.SECURITY} />
|
||||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||||
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
|
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
|
||||||
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { H, Text } from '@/primitives'
|
||||||
|
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||||
|
import { EncryptionDefaultField } from '../EncryptionDefaultField'
|
||||||
|
|
||||||
|
export type SecurityTabProps = Pick<TabPanelProps, 'id'>
|
||||||
|
|
||||||
|
export const SecurityTab = ({ id }: SecurityTabProps) => {
|
||||||
|
const { t } = useTranslation('settings', { keyPrefix: 'security' })
|
||||||
|
return (
|
||||||
|
<TabPanel padding="md" flex id={id}>
|
||||||
|
<H lvl={2} margin={false}>
|
||||||
|
{t('heading')}
|
||||||
|
</H>
|
||||||
|
<Text variant="note" margin="md">
|
||||||
|
{t('subtitle')}
|
||||||
|
</Text>
|
||||||
|
<EncryptionDefaultField />
|
||||||
|
</TabPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export enum SettingsDialogExtendedKey {
|
|||||||
AUDIO = 'audio',
|
AUDIO = 'audio',
|
||||||
VIDEO = 'video',
|
VIDEO = 'video',
|
||||||
GENERAL = 'general',
|
GENERAL = 'general',
|
||||||
|
SECURITY = 'security',
|
||||||
NOTIFICATIONS = 'notifications',
|
NOTIFICATIONS = 'notifications',
|
||||||
TRANSCRIPTION = 'transcription',
|
TRANSCRIPTION = 'transcription',
|
||||||
SHORTCUTS = 'shortcuts',
|
SHORTCUTS = 'shortcuts',
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { Menu } from '@/primitives/Menu'
|
|||||||
import { MenuList } from '@/primitives/MenuList'
|
import { MenuList } from '@/primitives/MenuList'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
|
|
||||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||||
|
|
||||||
const Logo = () => (
|
const Logo = () => (
|
||||||
@@ -91,7 +90,7 @@ export const Header = () => {
|
|||||||
const isTermsOfService = useMatchesRoute('termsOfService')
|
const isTermsOfService = useMatchesRoute('termsOfService')
|
||||||
const isRoom = useMatchesRoute('room')
|
const isRoom = useMatchesRoute('room')
|
||||||
const { user, isLoggedIn, logout } = useUser()
|
const { user, isLoggedIn, logout } = useUser()
|
||||||
const userLabel = user?.full_name || user?.email
|
const userLabel = user?.full_name || user?.short_name || user?.email
|
||||||
const loggedInTooltip = t('loggedInUserTooltip')
|
const loggedInTooltip = t('loggedInUserTooltip')
|
||||||
const loggedInAriaLabel = userLabel
|
const loggedInAriaLabel = userLabel
|
||||||
? `${loggedInTooltip} ${userLabel}`
|
? `${loggedInTooltip} ${userLabel}`
|
||||||
@@ -174,7 +173,7 @@ export const Header = () => {
|
|||||||
display: { base: 'none', xsm: 'block' },
|
display: { base: 'none', xsm: 'block' },
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{user?.full_name || user?.email}
|
{user?.full_name || user?.short_name || user?.email}
|
||||||
</span>
|
</span>
|
||||||
</VisualOnlyTooltip>
|
</VisualOnlyTooltip>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,14 @@
|
|||||||
"joinInputLabel": "Meeting link",
|
"joinInputLabel": "Meeting link",
|
||||||
"joinInputSubmit": "Join meeting",
|
"joinInputSubmit": "Join meeting",
|
||||||
"joinMeeting": "Join a meeting",
|
"joinMeeting": "Join a meeting",
|
||||||
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
"joinPassphraseLabel": "Encryption passphrase",
|
||||||
|
"joinPassphraseDescription": "This meeting uses basic encryption. Enter the passphrase — it's the part after the <strong>#</strong> symbol in the meeting link.",
|
||||||
|
"joinPassphraseExample": "{{origin}}/abc-defg-hij#<strong>the-passphrase-is-here</strong>",
|
||||||
|
"joinPassphraseWarning": "If the passphrase is incorrect, you will not be able to see, hear, or read messages from other participants.",
|
||||||
|
"joinPassphraseSubmit": "Join encrypted meeting",
|
||||||
|
"joinPassphraseBack": "Back",
|
||||||
|
"joinPassphraseError": "A passphrase is required to join this encrypted meeting",
|
||||||
|
"joinMeetingTipContent": "You can join a meeting by pasting its full link (including the # part for encrypted meetings) in the browser's address bar.",
|
||||||
"joinMeetingTipHeading": "Did you know?",
|
"joinMeetingTipHeading": "Did you know?",
|
||||||
"loginToCreateMeeting": "Login to create a meeting",
|
"loginToCreateMeeting": "Login to create a meeting",
|
||||||
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
|
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
|
||||||
@@ -15,7 +22,29 @@
|
|||||||
"moreAbout": "about {{appTitle}}",
|
"moreAbout": "about {{appTitle}}",
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Create a meeting for a later date",
|
"laterOption": "Create a meeting for a later date",
|
||||||
"instantOption": "Start an instant meeting"
|
"instantOption": "Start an instant meeting",
|
||||||
|
"encryptedOption": "Create an encrypted meeting"
|
||||||
|
},
|
||||||
|
"createEncryptedMeetingDialog": {
|
||||||
|
"title": "Create an encrypted meeting",
|
||||||
|
"description": "Encryption disables these features:",
|
||||||
|
"features": {
|
||||||
|
"dialIn": "Phone dial-in",
|
||||||
|
"meetingRoom": "Meeting room devices",
|
||||||
|
"transcription": "Transcription",
|
||||||
|
"recording": "Recording"
|
||||||
|
},
|
||||||
|
"warning": "Encryption may slow down your meeting and can't be turned off later.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Create encrypted meeting"
|
||||||
|
},
|
||||||
|
"connectionDetailsDialog": {
|
||||||
|
"title": "Your connection details",
|
||||||
|
"description": "Share this link with your guests. They'll wait in the lobby until you let them in.",
|
||||||
|
"warning": "Treat this link like a password. Anyone with it can join the lobby, and decrypt the meeting once you let them in.",
|
||||||
|
"iUnderstand": "I understand",
|
||||||
|
"startMeeting": "Start meeting",
|
||||||
|
"copy": "Copy meeting link"
|
||||||
},
|
},
|
||||||
"laterMeetingDialog": {
|
"laterMeetingDialog": {
|
||||||
"heading": "Your connection details",
|
"heading": "Your connection details",
|
||||||
@@ -58,5 +87,6 @@
|
|||||||
},
|
},
|
||||||
"carouselLabel": "Introduction slideshow",
|
"carouselLabel": "Introduction slideshow",
|
||||||
"slidePosition": "Slide {{current}} of {{total}}"
|
"slidePosition": "Slide {{current}} of {{total}}"
|
||||||
}
|
},
|
||||||
|
"joinPassphraseInvalidFormat": "The passphrase looks malformed. It should be 48 hexadecimal characters from the meeting link."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,12 @@
|
|||||||
"timeoutInvite": {
|
"timeoutInvite": {
|
||||||
"title": "You cannot join this call",
|
"title": "You cannot join this call",
|
||||||
"body": "No one responded to your request"
|
"body": "No one responded to your request"
|
||||||
}
|
},
|
||||||
|
"invalidKey": {
|
||||||
|
"title": "Invalid meeting link",
|
||||||
|
"body": "This encrypted meeting requires a valid encryption key in the URL. Please ask the meeting organizer for the correct link."
|
||||||
|
},
|
||||||
|
"encryptedNameLocked": "Authenticated identity — you can't change your name in an encrypted meeting."
|
||||||
},
|
},
|
||||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
@@ -94,7 +99,10 @@
|
|||||||
"phone": {
|
"phone": {
|
||||||
"call": "Call:",
|
"call": "Call:",
|
||||||
"pinCode": "Code:"
|
"pinCode": "Code:"
|
||||||
}
|
},
|
||||||
|
"encryptedHeading": "Meeting information",
|
||||||
|
"encryptedGuestBody": "This meeting is encrypted. The meeting link is only visible on the device where the meeting was created.",
|
||||||
|
"encryptedDisabledHeading": "The following features are disabled:"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"count": "{{currentPage}} of {{totalPageCount}}",
|
"count": "{{currentPage}} of {{totalPageCount}}",
|
||||||
@@ -153,6 +161,10 @@
|
|||||||
"helpLinkLabel": "Presentation issue",
|
"helpLinkLabel": "Presentation issue",
|
||||||
"closeButton": "Dismiss",
|
"closeButton": "Dismiss",
|
||||||
"newTab": "New window"
|
"newTab": "New window"
|
||||||
|
},
|
||||||
|
"encryptionSetup": {
|
||||||
|
"heading": "Encryption setup failed",
|
||||||
|
"body": "Your browser couldn't set up the encryption layer for this meeting. Reload the page; if it keeps failing, try another browser."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"isIdleDisconnectModal": {
|
"isIdleDisconnectModal": {
|
||||||
@@ -360,20 +372,31 @@
|
|||||||
"title": "Record",
|
"title": "Record",
|
||||||
"body": "Save meetings as video."
|
"body": "Save meetings as video."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"encryptedBlock": "Tools are unavailable in encrypted mode."
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"roomInformation": {
|
"roomInformation": {
|
||||||
"title": "Connection Information",
|
"title": "Connection information",
|
||||||
"button": {
|
"button": {
|
||||||
"ariaLabel": "Copy the information from your meeting",
|
|
||||||
"copy": "Copy information",
|
"copy": "Copy information",
|
||||||
"copied": "Information copied"
|
"copied": "Information copied to clipboard",
|
||||||
|
"ariaLabel": "Copy meeting information"
|
||||||
},
|
},
|
||||||
"phone": {
|
"phone": {
|
||||||
"call": "Call:",
|
"call": "Call:",
|
||||||
"pinCode": "Code:"
|
"pinCode": "Code:"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"encrypted": {
|
||||||
|
"heading": "Meeting information",
|
||||||
|
"guestBody": "This meeting is encrypted. The meeting link is only visible on the device where the meeting was created.",
|
||||||
|
"linkLabel": "Connection information",
|
||||||
|
"disabledHeading": "The following features are disabled:",
|
||||||
|
"features": {
|
||||||
|
"dialIn": "Phone dial-in",
|
||||||
|
"meetingRoom": "Meeting room devices"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"transcript": {
|
"transcript": {
|
||||||
@@ -494,7 +517,8 @@
|
|||||||
"label": "Restricted",
|
"label": "Restricted",
|
||||||
"description": "People who have not been invited to the meeting must request to join."
|
"description": "People who have not been invited to the meeting must request to join."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"encryptedLocked": "Encrypted meetings are always restricted — guests wait in the lobby until you let them in."
|
||||||
},
|
},
|
||||||
"moderation": {
|
"moderation": {
|
||||||
"title": "Meeting Moderation",
|
"title": "Meeting Moderation",
|
||||||
@@ -541,6 +565,7 @@
|
|||||||
"subheading": "In room",
|
"subheading": "In room",
|
||||||
"you": "You",
|
"you": "You",
|
||||||
"unknown": "Unknown participant",
|
"unknown": "Unknown participant",
|
||||||
|
"anonymous": "Unverified identity",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"contributors": "Contributors",
|
"contributors": "Contributors",
|
||||||
"collapsable": {
|
"collapsable": {
|
||||||
@@ -670,5 +695,32 @@
|
|||||||
},
|
},
|
||||||
"participantTile": {
|
"participantTile": {
|
||||||
"screenShare": "{{name}}'s screen"
|
"screenShare": "{{name}}'s screen"
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"anonymous": {
|
||||||
|
"tooltip": "This user is not authenticated"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"roomStatus": {
|
||||||
|
"encrypted": "End-to-end encrypted",
|
||||||
|
"recording": "Recording in progress",
|
||||||
|
"transcribing": "Transcription in progress"
|
||||||
|
},
|
||||||
|
"encryption": {
|
||||||
|
"mismatch": {
|
||||||
|
"missingPassphrase": {
|
||||||
|
"title": "This meeting needs an encryption passphrase",
|
||||||
|
"body": "The link you used does not include the encryption key. Ask the organizer to share the full link, or create a new encrypted meeting."
|
||||||
|
},
|
||||||
|
"unexpectedPassphrase": {
|
||||||
|
"title": "This meeting is not encrypted",
|
||||||
|
"body": "The link contains an encryption key but the meeting is not configured for end-to-end encryption. The link may have been altered. Create a fresh encrypted meeting to keep your conversation safe."
|
||||||
|
},
|
||||||
|
"backHome": "Back to home"
|
||||||
|
},
|
||||||
|
"decryptionFailed": {
|
||||||
|
"title": "Decryption failed",
|
||||||
|
"body": "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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,9 @@
|
|||||||
"resetLabel": "Reset",
|
"resetLabel": "Reset",
|
||||||
"participantLimit": "Up to 150 participants.",
|
"participantLimit": "Up to 150 participants.",
|
||||||
"popupBlocked": "Popup was blocked. Please allow popups for this site."
|
"popupBlocked": "Popup was blocked. Please allow popups for this site."
|
||||||
|
},
|
||||||
|
"createPopup": {
|
||||||
|
"title": "Create a meeting",
|
||||||
|
"create": "Create"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
"authentication": "Authentication",
|
"authentication": "Authentication",
|
||||||
"nameError": "Your name cannot be empty"
|
"nameError": "Your name cannot be empty"
|
||||||
},
|
},
|
||||||
|
"security": {
|
||||||
|
"heading": "Security",
|
||||||
|
"subtitle": "Add security options.",
|
||||||
|
"signInRequired": "Sign in to set encryption preferences.",
|
||||||
|
"featureDisabled": "End-to-end encryption is not available on this server.",
|
||||||
|
"encryption": {
|
||||||
|
"label": "End-to-end encryption",
|
||||||
|
"description": "You can create encrypted meetings on demand. Only participants can access the content, not even our servers.",
|
||||||
|
"learnMore": "Learn more"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preferences": {
|
"preferences": {
|
||||||
"title": "Preferences",
|
"title": "Preferences",
|
||||||
"idleDisconnectModal": {
|
"idleDisconnectModal": {
|
||||||
@@ -162,6 +173,7 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
|
"security": "Security",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibility",
|
"accessibility": "Accessibility",
|
||||||
"transcription": "Transcription",
|
"transcription": "Transcription",
|
||||||
|
|||||||
@@ -7,7 +7,14 @@
|
|||||||
"joinInputLabel": "Lien complet ou code de la réunion",
|
"joinInputLabel": "Lien complet ou code de la réunion",
|
||||||
"joinInputSubmit": "Rejoindre la réunion",
|
"joinInputSubmit": "Rejoindre la réunion",
|
||||||
"joinMeeting": "Rejoindre une réunion",
|
"joinMeeting": "Rejoindre une réunion",
|
||||||
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
"joinPassphraseLabel": "Phrase secrète de chiffrement",
|
||||||
|
"joinPassphraseDescription": "Cette réunion utilise le chiffrement basique. Entrez la phrase secrète — c'est la partie après le symbole <strong>#</strong> dans le lien de la réunion.",
|
||||||
|
"joinPassphraseExample": "{{origin}}/abc-defg-hij#<strong>la-phrase-secrete-est-ici</strong>",
|
||||||
|
"joinPassphraseWarning": "Si la phrase secrète est incorrecte, vous ne pourrez ni voir, ni entendre, ni lire les messages des autres participants.",
|
||||||
|
"joinPassphraseSubmit": "Rejoindre la réunion chiffrée",
|
||||||
|
"joinPassphraseBack": "Retour",
|
||||||
|
"joinPassphraseError": "Une phrase secrète est nécessaire pour rejoindre cette réunion chiffrée",
|
||||||
|
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet (y compris la partie # pour les réunions chiffrées) dans la barre d'adresse du navigateur.",
|
||||||
"joinMeetingTipHeading": "Astuce",
|
"joinMeetingTipHeading": "Astuce",
|
||||||
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
||||||
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
|
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
|
||||||
@@ -15,7 +22,29 @@
|
|||||||
"moreAbout": "sur {{appTitle}}",
|
"moreAbout": "sur {{appTitle}}",
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Créer une réunion pour une date ultérieure",
|
"laterOption": "Créer une réunion pour une date ultérieure",
|
||||||
"instantOption": "Démarrer une réunion instantanée"
|
"instantOption": "Démarrer une réunion instantanée",
|
||||||
|
"encryptedOption": "Créer une réunion chiffrée"
|
||||||
|
},
|
||||||
|
"createEncryptedMeetingDialog": {
|
||||||
|
"title": "Créer une réunion chiffrée",
|
||||||
|
"description": "Le chiffrement désactive ces fonctionnalités :",
|
||||||
|
"features": {
|
||||||
|
"dialIn": "Appel téléphonique",
|
||||||
|
"meetingRoom": "Appareils de salle de réunion",
|
||||||
|
"transcription": "Transcription",
|
||||||
|
"recording": "Enregistrement"
|
||||||
|
},
|
||||||
|
"warning": "Le chiffrement peut ralentir votre réunion et ne pourra pas être désactivé par la suite.",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"confirm": "Créer la réunion chiffrée"
|
||||||
|
},
|
||||||
|
"connectionDetailsDialog": {
|
||||||
|
"title": "Vos informations de connexion",
|
||||||
|
"description": "Partagez ce lien avec vos invités. Ils patienteront dans le salon d'attente jusqu'à ce que vous les autorisiez.",
|
||||||
|
"warning": "Traitez ce lien comme un mot de passe. Toute personne qui le possède peut rejoindre le salon d'attente et déchiffrer la réunion dès que vous l'aurez laissée entrer.",
|
||||||
|
"iUnderstand": "J'ai compris",
|
||||||
|
"startMeeting": "Démarrer la réunion",
|
||||||
|
"copy": "Copier le lien de la réunion"
|
||||||
},
|
},
|
||||||
"laterMeetingDialog": {
|
"laterMeetingDialog": {
|
||||||
"heading": "Vos informations de connexion",
|
"heading": "Vos informations de connexion",
|
||||||
@@ -58,5 +87,6 @@
|
|||||||
"title": "Transformez vos réunions avec l'IA",
|
"title": "Transformez vos réunions avec l'IA",
|
||||||
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !"
|
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"joinPassphraseInvalidFormat": "La phrase secrète semble incorrecte. Elle doit être composée de 48 caractères hexadécimaux issus du lien de la réunion."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,12 @@
|
|||||||
"timeoutInvite": {
|
"timeoutInvite": {
|
||||||
"title": "Vous ne pouvez pas participer à cet appel",
|
"title": "Vous ne pouvez pas participer à cet appel",
|
||||||
"body": "Personne n'a répondu à votre demande de participation à l'appel"
|
"body": "Personne n'a répondu à votre demande de participation à l'appel"
|
||||||
}
|
},
|
||||||
|
"invalidKey": {
|
||||||
|
"title": "Lien de réunion invalide",
|
||||||
|
"body": "Cette réunion chiffrée nécessite une clé de chiffrement valide dans l'URL. Veuillez demander le lien correct à l'organisateur de la réunion."
|
||||||
|
},
|
||||||
|
"encryptedNameLocked": "Identité authentifiée — vous ne pouvez pas modifier votre nom dans une réunion chiffrée."
|
||||||
},
|
},
|
||||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
@@ -94,7 +99,10 @@
|
|||||||
"phone": {
|
"phone": {
|
||||||
"call": "Appelez le :",
|
"call": "Appelez le :",
|
||||||
"pinCode": "Code :"
|
"pinCode": "Code :"
|
||||||
}
|
},
|
||||||
|
"encryptedHeading": "Informations de la réunion",
|
||||||
|
"encryptedGuestBody": "Cette réunion est chiffrée. Le lien de la réunion n'est visible que sur l'appareil sur lequel elle a été créée.",
|
||||||
|
"encryptedDisabledHeading": "Les fonctionnalités suivantes sont désactivées :"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"count": "{{currentPage}} sur {{totalPageCount}}",
|
"count": "{{currentPage}} sur {{totalPageCount}}",
|
||||||
@@ -153,6 +161,10 @@
|
|||||||
"helpLinkLabel": "Problème de présentation",
|
"helpLinkLabel": "Problème de présentation",
|
||||||
"closeButton": "Ignorer",
|
"closeButton": "Ignorer",
|
||||||
"newTab": "Nouvelle fenêtre"
|
"newTab": "Nouvelle fenêtre"
|
||||||
|
},
|
||||||
|
"encryptionSetup": {
|
||||||
|
"heading": "Échec de l'activation du chiffrement",
|
||||||
|
"body": "Votre navigateur n'a pas pu configurer la couche de chiffrement pour cette réunion. Rechargez la page ; si l'erreur persiste, essayez un autre navigateur."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"isIdleDisconnectModal": {
|
"isIdleDisconnectModal": {
|
||||||
@@ -360,20 +372,31 @@
|
|||||||
"title": "Enregistrer",
|
"title": "Enregistrer",
|
||||||
"body": "Enregistrer la réunion en vidéo."
|
"body": "Enregistrer la réunion en vidéo."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"encryptedBlock": "Les outils ne sont pas disponibles en mode chiffré."
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"roomInformation": {
|
"roomInformation": {
|
||||||
"title": "Informations de connexions",
|
"title": "Informations de connexion",
|
||||||
"button": {
|
"button": {
|
||||||
"ariaLabel": "Copier les informations de votre réunion",
|
|
||||||
"copy": "Copier les informations",
|
"copy": "Copier les informations",
|
||||||
"copied": "Informations copiées"
|
"copied": "Informations copiées",
|
||||||
|
"ariaLabel": "Copier les informations de la réunion"
|
||||||
},
|
},
|
||||||
"phone": {
|
"phone": {
|
||||||
"call": "Appelez le :",
|
"call": "Appel :",
|
||||||
"pinCode": "Code :"
|
"pinCode": "Code :"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"encrypted": {
|
||||||
|
"heading": "Informations de la réunion",
|
||||||
|
"guestBody": "Cette réunion est chiffrée. Le lien de la réunion n'est visible que sur l'appareil sur lequel elle a été créée.",
|
||||||
|
"linkLabel": "Informations de connexion",
|
||||||
|
"disabledHeading": "Les fonctionnalités suivantes sont désactivées :",
|
||||||
|
"features": {
|
||||||
|
"dialIn": "Appel téléphonique",
|
||||||
|
"meetingRoom": "Appareils de salle de réunion"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"transcript": {
|
"transcript": {
|
||||||
@@ -494,7 +517,8 @@
|
|||||||
"label": "Restreindre",
|
"label": "Restreindre",
|
||||||
"description": "Les personnes qui n'ont pas été invitées à la réunion doivent demander à la rejoindre."
|
"description": "Les personnes qui n'ont pas été invitées à la réunion doivent demander à la rejoindre."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"encryptedLocked": "Les réunions chiffrées sont toujours restreintes — les invités patientent dans le salon d'attente jusqu'à ce que vous les autorisiez."
|
||||||
},
|
},
|
||||||
"moderation": {
|
"moderation": {
|
||||||
"title": "Modération de la réunion",
|
"title": "Modération de la réunion",
|
||||||
@@ -541,6 +565,7 @@
|
|||||||
"subheading": "Dans la réunion",
|
"subheading": "Dans la réunion",
|
||||||
"you": "Vous",
|
"you": "Vous",
|
||||||
"unknown": "Participant inconnu",
|
"unknown": "Participant inconnu",
|
||||||
|
"anonymous": "Identité non vérifiée",
|
||||||
"contributors": "Contributeurs",
|
"contributors": "Contributeurs",
|
||||||
"host": "Organisateur de la réunion",
|
"host": "Organisateur de la réunion",
|
||||||
"collapsable": {
|
"collapsable": {
|
||||||
@@ -670,5 +695,32 @@
|
|||||||
},
|
},
|
||||||
"participantTile": {
|
"participantTile": {
|
||||||
"screenShare": "Écran de {{name}}"
|
"screenShare": "Écran de {{name}}"
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"anonymous": {
|
||||||
|
"tooltip": "Cet utilisateur n'est pas authentifié"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"roomStatus": {
|
||||||
|
"encrypted": "Chiffrée de bout en bout",
|
||||||
|
"recording": "Enregistrement en cours",
|
||||||
|
"transcribing": "Transcription en cours"
|
||||||
|
},
|
||||||
|
"encryption": {
|
||||||
|
"mismatch": {
|
||||||
|
"missingPassphrase": {
|
||||||
|
"title": "Cette réunion nécessite une phrase secrète",
|
||||||
|
"body": "Le lien que vous utilisez ne contient pas la clé de chiffrement. Demandez au créateur de partager le lien complet, ou créez une nouvelle réunion chiffrée."
|
||||||
|
},
|
||||||
|
"unexpectedPassphrase": {
|
||||||
|
"title": "Cette réunion n'est pas chiffrée",
|
||||||
|
"body": "Le lien contient une clé de chiffrement mais la réunion n'est pas configurée pour le chiffrement bout-en-bout. Le lien a peut-être été altéré. Créez une nouvelle réunion chiffrée pour préserver la confidentialité."
|
||||||
|
},
|
||||||
|
"backHome": "Retour à l'accueil"
|
||||||
|
},
|
||||||
|
"decryptionFailed": {
|
||||||
|
"title": "Échec du déchiffrement",
|
||||||
|
"body": "Vérifiez que vous et cette personne utilisez bien le même lien de réunion. Si seule cette personne est concernée, le problème vient probablement de chez elle."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,9 @@
|
|||||||
"resetLabel": "Réinitialiser",
|
"resetLabel": "Réinitialiser",
|
||||||
"participantLimit": "Jusqu'à 150 participants.",
|
"participantLimit": "Jusqu'à 150 participants.",
|
||||||
"popupBlocked": "La fenêtre pop-up a été bloquée. Veuillez autoriser les pop-ups pour ce site."
|
"popupBlocked": "La fenêtre pop-up a été bloquée. Veuillez autoriser les pop-ups pour ce site."
|
||||||
|
},
|
||||||
|
"createPopup": {
|
||||||
|
"title": "Créer une réunion",
|
||||||
|
"create": "Créer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
"authentication": "Authentification",
|
"authentication": "Authentification",
|
||||||
"nameError": "Votre Nom ne peut pas être vide"
|
"nameError": "Votre Nom ne peut pas être vide"
|
||||||
},
|
},
|
||||||
|
"security": {
|
||||||
|
"heading": "Sécurité",
|
||||||
|
"subtitle": "Ajoutez des options de sécurité.",
|
||||||
|
"signInRequired": "Connectez-vous pour configurer vos préférences de chiffrement.",
|
||||||
|
"featureDisabled": "Le chiffrement de bout en bout n'est pas disponible sur ce serveur.",
|
||||||
|
"encryption": {
|
||||||
|
"label": "Chiffrement de bout en bout",
|
||||||
|
"description": "Vous pouvez créer des réunions chiffrées à la demande. Seuls les participants ont accès au contenu, pas même nos serveurs.",
|
||||||
|
"learnMore": "En savoir plus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preferences": {
|
"preferences": {
|
||||||
"title": "Préférences",
|
"title": "Préférences",
|
||||||
"idleDisconnectModal": {
|
"idleDisconnectModal": {
|
||||||
@@ -162,6 +173,7 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"video": "Vidéo",
|
"video": "Vidéo",
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
|
"security": "Sécurité",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibilité",
|
"accessibility": "Accessibilité",
|
||||||
"transcription": "Transcription",
|
"transcription": "Transcription",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const navigateTo = <S = unknown>(
|
|||||||
routeName: RouteName,
|
routeName: RouteName,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
params?: any,
|
params?: any,
|
||||||
options?: { replace?: boolean; state?: S }
|
options?: { replace?: boolean; state?: S; hash?: string }
|
||||||
) => {
|
) => {
|
||||||
const route = getRouteByName(routeName)
|
const route = getRouteByName(routeName)
|
||||||
const to = route.to
|
const to = route.to
|
||||||
@@ -17,5 +17,10 @@ export const navigateTo = <S = unknown>(
|
|||||||
if (!to) {
|
if (!to) {
|
||||||
throw new Error(`Can't find path to navigate to for ${routeName}`)
|
throw new Error(`Can't find path to navigate to for ${routeName}`)
|
||||||
}
|
}
|
||||||
return navigate(to, options)
|
// Including the hash in the URL passed to `navigate` lets us avoid a
|
||||||
|
// brittle pushState + replaceState dance at the call site: the URL the
|
||||||
|
// app first renders already carries the fragment.
|
||||||
|
const { hash, ...navigateOptions } = options ?? {}
|
||||||
|
const target = hash ? `${to}#${hash}` : to
|
||||||
|
return navigate(target, navigateOptions)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,8 +135,9 @@ export const Checkbox = ({
|
|||||||
<StyledCheckbox {...props}>
|
<StyledCheckbox {...props}>
|
||||||
{(renderProps) => {
|
{(renderProps) => {
|
||||||
if (renderProps.isInvalid && !!props.validate) {
|
if (renderProps.isInvalid && !!props.validate) {
|
||||||
setError(props.validate(renderProps.isSelected))
|
const next = props.validate(renderProps.isSelected)
|
||||||
} else {
|
if (next !== error) setError(next)
|
||||||
|
} else if (error !== null) {
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ const StyledLabel = styled(Label, {
|
|||||||
|
|
||||||
type OmittedRACProps = 'type' | 'label' | 'items' | 'description' | 'validate'
|
type OmittedRACProps = 'type' | 'label' | 'items' | 'description' | 'validate'
|
||||||
type Items<T = ReactNode> = {
|
type Items<T = ReactNode> = {
|
||||||
items: Array<{ value: string; description?: string; label: T }>
|
items: Array<{
|
||||||
|
value: string
|
||||||
|
description?: string
|
||||||
|
label: T
|
||||||
|
isDisabled?: boolean
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
type PartialTextFieldProps = Omit<TextFieldProps, OmittedRACProps>
|
type PartialTextFieldProps = Omit<TextFieldProps, OmittedRACProps>
|
||||||
type PartialCheckboxProps = Omit<CheckboxProps, OmittedRACProps>
|
type PartialCheckboxProps = Omit<CheckboxProps, OmittedRACProps>
|
||||||
@@ -110,7 +115,7 @@ type FieldProps<T extends object> = (
|
|||||||
} & PartialSwitchProps)
|
} & PartialSwitchProps)
|
||||||
) & {
|
) & {
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: ReactNode
|
||||||
wrapperProps?: React.ComponentProps<typeof FieldWrapper>
|
wrapperProps?: React.ComponentProps<typeof FieldWrapper>
|
||||||
labelProps?: React.ComponentProps<typeof StyledLabel>
|
labelProps?: React.ComponentProps<typeof StyledLabel>
|
||||||
}
|
}
|
||||||
@@ -216,6 +221,7 @@ export const Field = <T extends object>({
|
|||||||
<Radio
|
<Radio
|
||||||
value={item.value}
|
value={item.value}
|
||||||
alignment={item.description ? 'top' : undefined}
|
alignment={item.description ? 'top' : undefined}
|
||||||
|
isDisabled={item.isDisabled}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export const StyledRadio = styled(RACRadio, {
|
|||||||
'&[data-selected][data-pressed] .mt-Radio-check': {
|
'&[data-selected][data-pressed] .mt-Radio-check': {
|
||||||
backgroundColor: 'primary.active',
|
backgroundColor: 'primary.active',
|
||||||
},
|
},
|
||||||
|
'&[data-disabled]': {
|
||||||
|
opacity: 0.4,
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const TooltipWrapper = ({
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
} & TooltipWrapperProps) => {
|
} & TooltipWrapperProps) => {
|
||||||
return tooltip ? (
|
return tooltip ? (
|
||||||
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
|
<TooltipTrigger delay={tooltipType === 'instant' ? 500 : 1000}>
|
||||||
{children}
|
{children}
|
||||||
<Tooltip>{tooltip}</Tooltip>
|
<Tooltip>{tooltip}</Tooltip>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
@@ -7,12 +8,16 @@ export default defineConfig(({ mode }) => {
|
|||||||
const env = loadEnv(mode, process.cwd())
|
const env = loadEnv(mode, process.cwd())
|
||||||
return {
|
return {
|
||||||
plugins: [react(), tsconfigPaths()],
|
plugins: [react(), tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
|
sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: parseInt(env.VITE_PORT) || 3000,
|
port: parseInt(env.VITE_PORT) || 3000,
|
||||||
host: env.VITE_HOST ?? 'localhost',
|
host: env.VITE_HOST ?? '0.0.0.0',
|
||||||
allowedHosts: ['.nip.io'],
|
allowedHosts: ['.nip.io'],
|
||||||
// In a local dev setup, we proxy the media server ourselves to avoid CORS issues
|
// In a local dev setup, we proxy the media server ourselves to avoid CORS issues
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user