Compare commits

...

6 Commits

Author SHA1 Message Date
Thomas Ramé a357fb04d5 (ci) try to pass more checks with rabbitai + remove no longer used advanced encryption client in keycloak 2026-05-18 11:23:49 +02:00
Thomas Ramé e2a3b286ca (ci) try to pass more checks with sonarqube 2026-05-18 10:59:25 +02:00
Thomas Ramé 668f093063 (backend) align tests 2026-05-13 19:43:20 +02:00
Thomas Ramé f3476950c3 📝 (changelog) document encrypted-meeting feature 2026-05-13 19:34:13 +02:00
Thomas Ramé 1aa8bc822d (encryption) remove advanced mode and keep a distinct encryption hash mode 2026-05-13 17:59:43 +02:00
Thomas Ramé 3698ac09eb (all) implement the simplified and advanced modes of encryption 2026-05-06 09:49:21 +02:00
79 changed files with 3793 additions and 508 deletions
+11
View File
@@ -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
+44 -21
View File
@@ -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).
Were continuously adding new features to enhance your experience, with the latest updates coming soon! Were continuously adding new features to enhance your experience, with the latest updates coming soon!
@@ -63,7 +91,6 @@ On the 25th of January 2026, David Amiel, Frances Minister for Civil Service
- [Philosophy](#philosophy) - [Philosophy](#philosophy)
- [Open source](#open-source) - [Open source](#open-source)
## Get started ## Get started
## Docs ## Docs
@@ -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
Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics. Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
@@ -109,7 +135,6 @@ Our users come first. Were committed to making La Suite Meet as accessible an
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority. Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
## Open-source ## Open-source
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE). Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
@@ -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).
+3 -3
View File
@@ -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",
+3
View File
@@ -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)
+85 -3
View File
@@ -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):
+28 -3
View File
@@ -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",
),
),
]
+87 -2
View File
@@ -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"""
+10 -1
View File
@@ -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:
+35 -5
View File
@@ -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",
) )
+10 -1
View File
@@ -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
+1
View File
@@ -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",
} }
+54 -7
View File
@@ -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,
), ),
} }
+7
View File
@@ -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,
+870 -1
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -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"
} }
} }
+4
View File
@@ -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
} }
+2 -2
View File
@@ -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)
+95 -16
View File
@@ -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>
) )
@@ -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>
@@ -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')}
@@ -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')}
@@ -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>
@@ -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',
+2 -3
View File
@@ -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>
+33 -3
View File
@@ -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."
} }
+59 -7
View File
@@ -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."
}
} }
} }
+4
View File
@@ -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"
} }
} }
+12
View File
@@ -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",
+33 -3
View File
@@ -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."
} }
+60 -8
View File
@@ -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."
}
} }
} }
+4
View File
@@ -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"
} }
} }
+12
View File
@@ -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",
+7 -2
View File
@@ -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)
} }
+3 -2
View File
@@ -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 (
+8 -2
View File
@@ -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({
+5
View File
@@ -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>
+6 -1
View File
@@ -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: {