mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 20:08:24 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a357fb04d5 | |||
| e2a3b286ca | |||
| 668f093063 | |||
| f3476950c3 | |||
| 1aa8bc822d | |||
| 3698ac09eb |
@@ -8,6 +8,17 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(encryption) opt-in end-to-end encryption for meetings, with the
|
||||
passphrase carried in the URL hash and an explicit "Create an
|
||||
encrypted meeting" entry in the home create menu (gated by a
|
||||
per-user Security preference) #1337
|
||||
- ✨(encryption) authenticated participant emails surfaced in the
|
||||
participants list of encrypted rooms; anonymous participants get a
|
||||
red badge with tooltip in the list, waiting room, and the floating
|
||||
join-request notification
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dependencies) update python dependencies
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/suitenumerique/meet/stargazers/">
|
||||
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
|
||||
@@ -12,11 +11,11 @@
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
||||
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||
</a>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -28,25 +27,54 @@
|
||||
## La Suite Meet: Simple Video Conferencing
|
||||
|
||||
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||
|
||||
### Features
|
||||
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- End-to-end encryption (coming soon)
|
||||
- End-to-end encryption with passphrase-in-link key distribution
|
||||
- Meeting recording
|
||||
- Meeting transcription & Summary (currently in beta)
|
||||
- Telephony integration
|
||||
- Secure participation with robust authentication and access control
|
||||
- Customizable frontend style
|
||||
- LiveKit Advances features including :
|
||||
- speaker detection
|
||||
- simulcast
|
||||
- end-to-end optimizations
|
||||
- speaker detection
|
||||
- simulcast
|
||||
- end-to-end optimizations
|
||||
- selective subscription
|
||||
- SVC codecs (VP9, AV1)
|
||||
|
||||
### End-to-end encryption
|
||||
|
||||
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||
La Suite Meet supports end-to-end encryption (E2EE) for meetings, so the media server (LiveKit SFU) cannot read audio, video or screen-share content.
|
||||
|
||||
#### How it works
|
||||
|
||||
- Each encrypted meeting carries a 48-character hex passphrase appended to the URL hash (`#…`) — 192 bits of entropy. The server never sees it; sharing the meeting link shares the key.
|
||||
- Frames are encrypted in the browser via LiveKit's Worker + `crypto.subtle` (AES-GCM); only the media payload is encrypted, codec headers stay clear so the SFU can still packetize RTP.
|
||||
- The runtime "is this call encrypted?" decision keys off the URL hash, not the database flag. The DB column (`Room.encryption_mode`) is only used as a sanity reference: if the URL hash and the server's claim disagree, the joining client surfaces an explicit mismatch screen instead of silently joining in clear or in a private encrypted bubble.
|
||||
|
||||
> **Threat model.** "Server doesn't see plaintext" — not "users are safe from a malicious server." A compromised server could still serve modified JavaScript to a participant, who would then leak their passphrase. The E2EE story protects the media path against a passive or compromised SFU, not against a fully compromised origin.
|
||||
|
||||
#### Encryption mode is set at creation, immutable after
|
||||
|
||||
`Room.encryption_mode` is a string enum (`none` / `basic`) chosen when the room is created and never mutated afterwards — changing it would change the link's semantics, since the passphrase lives in the URL hash. There is no mid-call "pause encryption" mechanism: while a meeting is encrypted, **recording and transcription endpoints reject requests with a 400** (`Recording is unavailable in encrypted rooms.` / `Subtitles are unavailable in encrypted rooms.`), the More-tools panel renders those items disabled with an explanatory banner, and the SIP gateway never gets a dispatch rule for encrypted rooms (so dial-in numbers and PINs aren't allocated). Encrypted rooms are also force-locked to `restricted` access level (lobby admission), since basic E2EE only meaningfully protects against passive eavesdropping if the host vets joiners before they receive the in-URL key.
|
||||
|
||||
#### Opt-in by user
|
||||
|
||||
End-to-end encryption is a per-user preference. In **Settings**, under the **Security** section, signed-in users can flip the **End-to-end encryption** toggle — once enabled, a third "Create an encrypted meeting" entry appears in the home-page create-menu (with its own confirmation modal that lists the disabled features and a "Treat this link like a password" connection-details dialog before the meeting starts). Joining is unaffected by the toggle: any participant clicking a meeting link that carries a valid hash joins encrypted, regardless of their own setting. Authenticated joiners of encrypted rooms cannot edit their displayed name — the server enforces the OIDC name on the JWT.
|
||||
|
||||
#### Configuration
|
||||
|
||||
```env
|
||||
ENCRYPTION_ENABLED=true
|
||||
```
|
||||
|
||||
Setting `ENCRYPTION_ENABLED=false` rejects encrypted-room creation at the API level. Existing encrypted rooms stay encrypted (the mode is immutable), but no new ones can be created.
|
||||
|
||||
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||
|
||||
@@ -63,7 +91,6 @@ On the 25th of January 2026, David Amiel, France’s Minister for Civil Service
|
||||
- [Philosophy](#philosophy)
|
||||
- [Open source](#open-source)
|
||||
|
||||
|
||||
## Get started
|
||||
|
||||
## 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.
|
||||
|
||||
#### Known instances
|
||||
|
||||
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
|
||||
|
||||
| Url | Org | Access |
|
||||
|---------------------------------------------------------------| --- | ------- |
|
||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
|
||||
| Url | Org | Access |
|
||||
| ------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [mosa.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
|
||||
## 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))
|
||||
- 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
|
||||
|
||||
We’re relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
|
||||
@@ -109,7 +135,6 @@ Our users come first. We’re committed to making La Suite Meet as accessible an
|
||||
|
||||
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
|
||||
|
||||
|
||||
## Open-source
|
||||
|
||||
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).
|
||||
|
||||
|
||||
## Contributors 🧞
|
||||
|
||||
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
|
||||
</a>
|
||||
|
||||
## Credits
|
||||
## Credits
|
||||
|
||||
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
|
||||
This project is tested with BrowserStack.
|
||||
@@ -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).
|
||||
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
"email": "user.test@chromium.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": "true",
|
||||
@@ -74,7 +74,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user@webkit.e2e",
|
||||
"email": "user.test@webkit.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": "true",
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user@firefox.e2e",
|
||||
"email": "user.test@firefox.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Firefox",
|
||||
"enabled": "true",
|
||||
|
||||
@@ -73,5 +73,8 @@ def get_frontend_configuration(request):
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
},
|
||||
}
|
||||
frontend_configuration["encryption"] = {
|
||||
"enabled": settings.ENCRYPTION_ENABLED,
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
|
||||
@@ -30,9 +30,30 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
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"]
|
||||
|
||||
def validate_default_encryption_mode(self, value):
|
||||
"""Reject a non-none default when the server has encryption disabled.
|
||||
|
||||
Keeps the user preference DB in sync with the deployment's posture:
|
||||
if an operator flips ENCRYPTION_ENABLED off, no client should be able
|
||||
to keep persisting `basic` as their default behind their back.
|
||||
"""
|
||||
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
|
||||
raise serializers.ValidationError(
|
||||
_("End-to-end encryption is disabled on this server.")
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class UserLightSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users with limited fields."""
|
||||
@@ -74,6 +95,7 @@ class ResourceAccessSerializerMixin:
|
||||
raise PermissionDenied(
|
||||
"Only owners of a room can assign other users as owners."
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def validate_resource(self, resource):
|
||||
@@ -128,9 +150,60 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
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"]
|
||||
|
||||
def validate_encryption_mode(self, value):
|
||||
"""Encryption mode is part of the link's semantics (the passphrase
|
||||
lives in the URL hash for `basic` rooms) so it cannot be changed once
|
||||
the room exists."""
|
||||
instance = self.instance
|
||||
if instance and instance.encryption_mode != value:
|
||||
raise serializers.ValidationError(
|
||||
"Encryption mode cannot be changed after room creation."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_access_level(self, value):
|
||||
"""Encrypted rooms must stay restricted — the lobby is the only way
|
||||
to enforce per-participant admission, and basic encryption relies on
|
||||
the host vetting each joiner before they receive the in-URL key."""
|
||||
instance = self.instance
|
||||
if (
|
||||
instance
|
||||
and instance.encryption_mode != models.EncryptionMode.NONE
|
||||
and value != models.RoomAccessLevel.RESTRICTED
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Encrypted rooms require restricted access level."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Force encrypted rooms to RESTRICTED at creation time.
|
||||
|
||||
Doing this here (rather than in validate_access_level) lets the
|
||||
client omit `access_level` entirely when creating an encrypted room
|
||||
— we silently override whatever the default would have been.
|
||||
"""
|
||||
encryption_mode = attrs.get(
|
||||
"encryption_mode",
|
||||
self.instance.encryption_mode
|
||||
if self.instance
|
||||
else models.EncryptionMode.NONE,
|
||||
)
|
||||
if encryption_mode != models.EncryptionMode.NONE and not self.instance:
|
||||
attrs["access_level"] = models.RoomAccessLevel.RESTRICTED
|
||||
return super().validate(attrs)
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Add users only for administrator users.
|
||||
@@ -172,12 +245,21 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
if should_access_room:
|
||||
room_id = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
|
||||
# In encrypted rooms, authenticated users cannot pick an
|
||||
# arbitrary display name — it must come from the OIDC profile.
|
||||
# We enforce this server-side so a tampered client cannot
|
||||
# override what other participants see.
|
||||
if instance.is_encrypted and request.user.is_authenticated:
|
||||
username = request.user.full_name or request.user.email
|
||||
|
||||
output["livekit"] = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=configuration,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
encryption_mode=instance.encryption_mode,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
@@ -265,7 +347,7 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
username = serializers.CharField(required=True)
|
||||
username = serializers.CharField(required=True, allow_blank=True)
|
||||
|
||||
|
||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||
|
||||
@@ -281,6 +281,18 @@ class RoomViewSet(
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created room."""
|
||||
encryption_mode = serializer.validated_data.get(
|
||||
"encryption_mode", models.EncryptionMode.NONE
|
||||
)
|
||||
|
||||
if (
|
||||
encryption_mode != models.EncryptionMode.NONE
|
||||
and not settings.ENCRYPTION_ENABLED
|
||||
):
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"encryption_mode": "Encryption is not enabled on this server."}
|
||||
)
|
||||
|
||||
room = serializer.save()
|
||||
models.ResourceAccess.objects.create(
|
||||
resource=room,
|
||||
@@ -304,16 +316,21 @@ class RoomViewSet(
|
||||
"""Start recording a room."""
|
||||
|
||||
serializer = serializers.StartRecordingSerializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
|
||||
{"detail": "Invalid request."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
mode = serializer.validated_data["mode"]
|
||||
options = serializer.validated_data.get("options")
|
||||
room = self.get_object()
|
||||
|
||||
if room.is_encrypted:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"detail": "Recording is unavailable in encrypted rooms."}
|
||||
)
|
||||
|
||||
# May raise exception if an active or initiated recording already exist for the room
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
@@ -396,12 +413,14 @@ class RoomViewSet(
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
room = self.get_object()
|
||||
validated_data = serializer.validated_data
|
||||
|
||||
lobby_service = LobbyService()
|
||||
|
||||
participant, livekit = lobby_service.request_entry(
|
||||
room=room,
|
||||
request=request,
|
||||
**serializer.validated_data,
|
||||
**validated_data,
|
||||
)
|
||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||
lobby_service.prepare_response(response, participant.id)
|
||||
@@ -464,6 +483,7 @@ class RoomViewSet(
|
||||
lobby_service = LobbyService()
|
||||
|
||||
participants = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
return drf_response.Response({"participants": participants})
|
||||
|
||||
@decorators.action(
|
||||
@@ -566,6 +586,11 @@ class RoomViewSet(
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
if room.is_encrypted:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"detail": "Subtitles are unavailable in encrypted rooms."}
|
||||
)
|
||||
|
||||
try:
|
||||
SubtitleService().start_subtitle(room)
|
||||
except SubtitleException:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Add Room.encryption_mode and User.default_encryption_mode (enum-based).
|
||||
|
||||
We store the mode as an enum (CharField with choices) rather than a boolean
|
||||
so a future "advanced" mode (per-user vault keys, etc.) can be added without
|
||||
a schema migration.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0018_rename_active_application_is_active"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="room",
|
||||
name="encryption_mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("none", "No encryption"),
|
||||
("basic", "Passphrase-in-URL encryption"),
|
||||
],
|
||||
default="none",
|
||||
help_text="End-to-end encryption mode for this room.",
|
||||
max_length=20,
|
||||
verbose_name="Encryption mode",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="default_encryption_mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("none", "No encryption"),
|
||||
("basic", "Passphrase-in-URL encryption"),
|
||||
],
|
||||
default="none",
|
||||
help_text="Encryption mode pre-selected when this user creates a new meeting.",
|
||||
max_length=20,
|
||||
verbose_name="Default encryption mode",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -98,6 +98,17 @@ class RoomAccessLevel(models.TextChoices):
|
||||
RESTRICTED = "restricted", _("Restricted Access")
|
||||
|
||||
|
||||
class EncryptionMode(models.TextChoices):
|
||||
"""Encryption mode for a room.
|
||||
|
||||
Kept as an enum (not a boolean) so future modes — e.g. a vault-managed
|
||||
per-user key flow — can be added without another schema migration.
|
||||
"""
|
||||
|
||||
NONE = "none", _("No encryption")
|
||||
BASIC = "basic", _("Passphrase-in-URL encryption")
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -200,6 +211,15 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"Unselect this instead of deleting accounts."
|
||||
),
|
||||
)
|
||||
default_encryption_mode = models.CharField(
|
||||
_("Default encryption mode"),
|
||||
max_length=20,
|
||||
choices=EncryptionMode.choices,
|
||||
default=EncryptionMode.NONE,
|
||||
help_text=_(
|
||||
"Encryption mode pre-selected when this user creates a new meeting."
|
||||
),
|
||||
)
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
|
||||
@@ -388,6 +408,15 @@ class Room(Resource):
|
||||
choices=RoomAccessLevel.choices,
|
||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
)
|
||||
# Set at creation, immutable after (the URL hash carries the passphrase,
|
||||
# so changing the mode would break every previously-shared link).
|
||||
encryption_mode = models.CharField(
|
||||
max_length=20,
|
||||
choices=EncryptionMode.choices,
|
||||
default=EncryptionMode.NONE,
|
||||
verbose_name=_("Encryption mode"),
|
||||
help_text=_("End-to-end encryption mode for this room."),
|
||||
)
|
||||
configuration = models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
@@ -413,13 +442,64 @@ class Room(Resource):
|
||||
return capfirst(self.name)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate a unique n-digit pin code for new rooms."""
|
||||
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||
"""Generate a unique n-digit pin code for new rooms.
|
||||
|
||||
Skip PIN allocation for encrypted rooms — the SIP gateway will
|
||||
always reject calls to them (no way to derive the key), and the
|
||||
PIN namespace is finite (10**length): no point burning slots that
|
||||
can never be dialed.
|
||||
|
||||
Also run `clean()` so the encryption invariants are enforced on
|
||||
every save path (ORM, admin, shell), not only via the DRF
|
||||
serializer.
|
||||
"""
|
||||
self.clean()
|
||||
if (
|
||||
settings.ROOM_TELEPHONY_ENABLED
|
||||
and not self.pk
|
||||
and not self.pin_code
|
||||
and self.encryption_mode == EncryptionMode.NONE
|
||||
):
|
||||
self.pin_code = self.generate_unique_pin_code(
|
||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""Enforce encryption-mode invariants outside DRF.
|
||||
|
||||
Two rules:
|
||||
- `encryption_mode` is set at creation and never mutated afterwards
|
||||
(the URL-hash passphrase encodes assumptions about it).
|
||||
- An encrypted room must be at the RESTRICTED access level so the
|
||||
host vets joiners before they ever see the in-URL key.
|
||||
"""
|
||||
super().clean()
|
||||
if self.pk is not None:
|
||||
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
|
||||
if (
|
||||
previous is not None
|
||||
and previous.encryption_mode != self.encryption_mode
|
||||
):
|
||||
raise ValidationError(
|
||||
{
|
||||
"encryption_mode": _(
|
||||
"Encryption mode cannot be changed after room creation."
|
||||
)
|
||||
}
|
||||
)
|
||||
if (
|
||||
self.encryption_mode != EncryptionMode.NONE
|
||||
and self.access_level != RoomAccessLevel.RESTRICTED
|
||||
):
|
||||
raise ValidationError(
|
||||
{
|
||||
"access_level": _(
|
||||
"Encrypted rooms must use the 'restricted' access level."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
"""
|
||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||
@@ -442,6 +522,11 @@ class Room(Resource):
|
||||
"""Check if a room is public"""
|
||||
return self.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
@property
|
||||
def is_encrypted(self):
|
||||
"""Convenience: any non-none encryption mode counts as encrypted."""
|
||||
return self.encryption_mode != EncryptionMode.NONE
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_pin_code(length):
|
||||
"""Generate a unique n-digit PIN code"""
|
||||
|
||||
@@ -202,7 +202,16 @@ class LiveKitEventsService:
|
||||
except models.Room.DoesNotExist as 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:
|
||||
self.telephony_service.create_dispatch_rule(room)
|
||||
except TelephonyException as e:
|
||||
|
||||
@@ -46,14 +46,18 @@ class LobbyParticipant:
|
||||
username: str
|
||||
color: str
|
||||
id: str
|
||||
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
|
||||
# they can decide whether to accept self-declared identities.
|
||||
is_authenticated: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
def to_dict(self) -> Dict[str, object]:
|
||||
"""Serialize the participant object to a dict representation."""
|
||||
return {
|
||||
"status": self.status.value,
|
||||
"username": self.username,
|
||||
"id": self.id,
|
||||
"color": self.color,
|
||||
"is_authenticated": self.is_authenticated,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -68,6 +72,7 @@ class LobbyParticipant:
|
||||
username=data["username"],
|
||||
id=data["id"],
|
||||
color=data["color"],
|
||||
is_authenticated=bool(data.get("is_authenticated", False)),
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.exception("Error creating Participant from dict:")
|
||||
@@ -99,7 +104,7 @@ class LobbyService:
|
||||
key=settings.LOBBY_COOKIE_NAME,
|
||||
value=participant_id,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
secure=not settings.DEBUG,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
@@ -140,6 +145,19 @@ class LobbyService:
|
||||
5. If denied, do nothing.
|
||||
"""
|
||||
|
||||
# In encrypted rooms, authenticated users cannot pick an arbitrary
|
||||
# display name — server enforces the OIDC name so a tampered client
|
||||
# can't impersonate someone else with their account. If both
|
||||
# full_name and email are absent (degenerate OIDC payload), fall
|
||||
# back to a server-controlled technical name rather than trusting
|
||||
# whatever the client posted.
|
||||
if room.is_encrypted and request.user.is_authenticated:
|
||||
username = (
|
||||
request.user.full_name
|
||||
or request.user.email
|
||||
or f"noname-{request.user.id}"
|
||||
)
|
||||
|
||||
participant_id = self._get_or_create_participant_id(request)
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
@@ -152,6 +170,7 @@ class LobbyService:
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
@@ -164,19 +183,24 @@ class LobbyService:
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
encryption_mode=room.encryption_mode,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
livekit_config = None
|
||||
|
||||
if participant is None:
|
||||
participant = self.enter(room.id, participant_id, username)
|
||||
participant = self.enter(
|
||||
room.id,
|
||||
participant_id,
|
||||
username,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||
self.refresh_waiting_status(room.id, participant_id)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||
# wrongly named, contains access token to join a room
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
@@ -185,6 +209,7 @@ class LobbyService:
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
encryption_mode=room.encryption_mode,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
@@ -201,7 +226,11 @@ class LobbyService:
|
||||
)
|
||||
|
||||
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:
|
||||
"""Add participant to waiting lobby.
|
||||
|
||||
@@ -216,6 +245,7 @@ class LobbyService:
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=color,
|
||||
is_authenticated=is_authenticated,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -59,6 +59,7 @@ def test_request_entry_anonymous(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": False,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -108,6 +109,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": True,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -180,6 +182,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": False,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -232,6 +235,7 @@ def test_request_entry_public_room(settings):
|
||||
"username": "test_user",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": False,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -284,6 +288,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
"username": "test_user",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": True,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -338,6 +343,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
"username": "user1",
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
"is_authenticated": False,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -601,12 +607,14 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"is_authenticated": False,
|
||||
},
|
||||
{
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
"is_authenticated": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +53,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"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,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +89,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"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,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +216,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once()
|
||||
@@ -257,6 +263,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -267,6 +274,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
sources=["mock-source"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -308,6 +316,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -318,6 +327,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
sources=None,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -343,6 +353,7 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"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,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
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"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -453,6 +466,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
"short_name": other_user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": other_user_access.user.language,
|
||||
"default_encryption_mode": "none",
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
@@ -466,6 +480,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
"short_name": user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": user_access.user.language,
|
||||
"default_encryption_mode": "none",
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
@@ -487,6 +502,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -497,4 +513,5 @@ def test_api_rooms_retrieve_administrators(
|
||||
sources=None,
|
||||
is_admin_or_owner=True,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
@@ -268,6 +268,7 @@ def test_request_entry_public_room(
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -307,6 +308,7 @@ def test_request_entry_trusted_room(
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -337,7 +339,12 @@ def test_request_entry_new_participant(
|
||||
|
||||
assert participant == participant_data
|
||||
assert livekit_config is None
|
||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
||||
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)
|
||||
|
||||
|
||||
@@ -402,6 +409,7 @@ def test_request_entry_accepted_participant(
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
encryption_mode="none",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -777,6 +785,7 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"is_authenticated": False,
|
||||
}
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key", expected_data, timeout=60
|
||||
|
||||
@@ -125,6 +125,7 @@ def test_api_users_retrieve_me_authenticated(settings):
|
||||
"short_name": user.short_name,
|
||||
"language": user.language,
|
||||
"timezone": "UTC",
|
||||
"default_encryption_mode": "none",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from livekit.api import ( # pylint: disable=E0611
|
||||
UpdateRoomMetadataRequest,
|
||||
VideoGrants,
|
||||
)
|
||||
from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,6 +67,7 @@ def generate_token(
|
||||
sources: Optional[List[str]] = None,
|
||||
is_admin_or_owner: bool = False,
|
||||
participant_id: Optional[str] = None,
|
||||
encryption_mode: str = "none",
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
@@ -86,17 +88,26 @@ def generate_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
|
||||
|
||||
if sources is None:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
# In encrypted rooms, no one can change their name/attributes after the
|
||||
# admin accepted them — otherwise a participant authenticated under one
|
||||
# identity could rewrite their JWT-presented name to spoof someone else
|
||||
# mid-meeting. In plain rooms, free naming is fine.
|
||||
can_update_own_metadata = encryption_mode == EncryptionMode.NONE
|
||||
|
||||
video_grants = VideoGrants(
|
||||
room=room,
|
||||
room_join=True,
|
||||
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_sources=sources,
|
||||
can_subscribe=True,
|
||||
@@ -112,6 +123,27 @@ def generate_token(
|
||||
if color is None:
|
||||
color = generate_color(identity)
|
||||
|
||||
attributes = {
|
||||
"color": color,
|
||||
"room_admin": "true" if is_admin_or_owner else "false",
|
||||
"is_authenticated": "true" if not user.is_anonymous else "false",
|
||||
}
|
||||
|
||||
# Emit the email only for authenticated participants of *encrypted*
|
||||
# rooms. LK signaling broadcasts attributes to every peer in the room,
|
||||
# so making this conditional on the encryption gate is what prevents
|
||||
# an anonymous joiner of a public/trusted room from harvesting all
|
||||
# authenticated users' emails. Frontend hiding (the
|
||||
# `isLoggedIn`-gated render in ParticipantListItem) is only
|
||||
# defense-in-depth — anyone with devtools can read attributes
|
||||
# otherwise.
|
||||
if (
|
||||
not user.is_anonymous
|
||||
and encryption_mode != EncryptionMode.NONE
|
||||
and getattr(user, "email", None)
|
||||
):
|
||||
attributes["email"] = user.email
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
@@ -120,11 +152,24 @@ def generate_token(
|
||||
.with_grants(video_grants)
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_attributes(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
)
|
||||
.with_attributes(attributes)
|
||||
)
|
||||
|
||||
# Encode the encryption mode into the room's metadata at LK-creation
|
||||
# time (via the access token's room_config). LiveKit creates the room
|
||||
# lazily when the first participant joins; the embedded config tells
|
||||
# it to stamp `{"encryption_mode": "<mode>"}` into the metadata at
|
||||
# that moment — no extra round-trip, no race window where a SIP
|
||||
# caller could read empty metadata before the `room_started` webhook
|
||||
# has time to push it.
|
||||
if encryption_mode != EncryptionMode.NONE:
|
||||
token = token.with_room_config(
|
||||
RoomConfiguration(
|
||||
name=room,
|
||||
metadata=json.dumps({"encryption_mode": encryption_mode}),
|
||||
)
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
@@ -136,6 +181,7 @@ def generate_livekit_config(
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
encryption_mode: str = "none",
|
||||
) -> dict:
|
||||
"""Generate LiveKit configuration for room access.
|
||||
|
||||
@@ -168,6 +214,7 @@ def generate_livekit_config(
|
||||
sources=sources,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
participant_id=participant_id,
|
||||
encryption_mode=encryption_mode,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -808,6 +808,13 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# End-to-end encryption (passphrase-in-URL-hash mode).
|
||||
# When True, users may opt in (account preference) to have their meetings
|
||||
# created as end-to-end encrypted by default.
|
||||
ENCRYPTION_ENABLED = values.BooleanValue(
|
||||
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
# External Applications
|
||||
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
|
||||
40,
|
||||
|
||||
Generated
+870
-1
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,9 @@
|
||||
"preview": "vite preview",
|
||||
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
||||
"format": "prettier --write ./src",
|
||||
"check": "prettier --check ./src"
|
||||
"check": "prettier --check ./src",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
@@ -59,10 +61,12 @@
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"jsdom": "^29.0.2",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.1",
|
||||
"vite-tsconfig-paths": "6.1.1"
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ApiConfig {
|
||||
help_article_transcript: string
|
||||
help_article_recording: string
|
||||
help_article_more_tools: string
|
||||
help_article_encryption?: string
|
||||
}
|
||||
feedback: {
|
||||
url: string
|
||||
@@ -52,6 +53,9 @@ export interface ApiConfig {
|
||||
enable_firefox_proxy_workaround: boolean
|
||||
default_sources: string[]
|
||||
}
|
||||
encryption?: {
|
||||
enabled: boolean
|
||||
}
|
||||
transcription_destination?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export const Avatar = ({
|
||||
style,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) ?? ''
|
||||
const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? ''
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -70,7 +70,7 @@ export const Avatar = ({
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
marginTop: '-0.3rem',
|
||||
lineHeight: 1,
|
||||
})}
|
||||
>
|
||||
{initial}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { BackendLanguage } from '@/utils/languages'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export type ApiUser = {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string
|
||||
full_name: string | null
|
||||
short_name: string | null
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
timezone: string
|
||||
default_encryption_mode: ApiEncryptionMode
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { type ApiUser } from './ApiUser'
|
||||
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 ({
|
||||
user,
|
||||
}: {
|
||||
user: ApiUserPreferences
|
||||
}): Promise<ApiUser> => {
|
||||
return await fetchApi(`/users/${user.id}/`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ timezone: user.timezone, language: user.language }),
|
||||
const { id, ...payload } = user
|
||||
return await fetchApi(`/users/${id}/`, {
|
||||
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 { Field, Ul, H, P, Form, Dialog } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { isRoomValid } from '@/features/rooms'
|
||||
import { normalizeRoomId } from '@/features/rooms/utils/isRoomValid'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
import { isValidPassphrase } from '@/features/encryption'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export const JoinMeetingDialog = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const [step, setStep] = useState<'room' | 'passphrase'>('room')
|
||||
const [roomId, setRoomId] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
|
||||
const roomId = (data.roomId as string)
|
||||
.trim()
|
||||
.replace(`${window.location.origin}/`, '')
|
||||
navigateTo('room', roomId)
|
||||
const parseInput = (input: string): { roomId: string; hash: string } => {
|
||||
const trimmed = input.trim()
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
const id = url.pathname.replace(/^\//, '')
|
||||
return { roomId: id, hash: url.hash.slice(1) }
|
||||
} catch {
|
||||
// Not a URL — treat as room code, normalize (add hyphens if 10 chars)
|
||||
const raw = trimmed.replace(`${window.location.origin}/`, '')
|
||||
return { roomId: normalizeRoomId(raw), hash: '' }
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoomSubmit = async (data: { roomId?: FormDataEntryValue }) => {
|
||||
const input = data.roomId as string
|
||||
const parsed = parseInput(input)
|
||||
|
||||
if (parsed.hash) {
|
||||
navigateTo('room', parsed.roomId, { hash: parsed.hash })
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const room = await fetchRoom({ roomId: parsed.roomId })
|
||||
if (room.encryption_mode === ApiEncryptionMode.BASIC) {
|
||||
setRoomId(parsed.roomId)
|
||||
setStep('passphrase')
|
||||
return
|
||||
}
|
||||
navigateTo('room', parsed.roomId)
|
||||
} catch {
|
||||
// Room doesn't exist yet or error — navigate anyway
|
||||
navigateTo('room', parsed.roomId)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePassphraseSubmit = (data: {
|
||||
passphrase?: FormDataEntryValue
|
||||
}) => {
|
||||
const passphrase = (data.passphrase as string).trim()
|
||||
navigateTo('room', roomId, { hash: passphrase })
|
||||
}
|
||||
|
||||
const validateRoomId = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
return !isRoomValid(trimmed) ? (
|
||||
<>
|
||||
<p>{t('joinInputError')}</p>
|
||||
<Ul>
|
||||
<li>{window.location.origin}/uio-azer-jkl</li>
|
||||
<li>uio-azer-jkl</li>
|
||||
</Ul>
|
||||
</>
|
||||
) : null
|
||||
const { roomId: id, hash } = parseInput(trimmed)
|
||||
if (!isRoomValid(id))
|
||||
return (
|
||||
<>
|
||||
<p>{t('joinInputError')}</p>
|
||||
<Ul>
|
||||
<li>{window.location.origin}/uio-azer-jkl</li>
|
||||
<li>uio-azer-jkl</li>
|
||||
<li>uioazerjkl</li>
|
||||
</Ul>
|
||||
</>
|
||||
)
|
||||
// If a hash is pasted in, refuse malformed passphrases now (instead of
|
||||
// letting Conference render the mismatch screen after navigation).
|
||||
if (hash && !isValidPassphrase(hash))
|
||||
return <p>{t('joinPassphraseInvalidFormat')}</p>
|
||||
return null
|
||||
}
|
||||
|
||||
if (step === 'passphrase') {
|
||||
return (
|
||||
<Dialog title={t('joinMeeting')}>
|
||||
<Form
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
submitLabel={t('joinPassphraseSubmit')}
|
||||
>
|
||||
<P
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('joinPassphraseDescription', {
|
||||
interpolation: { escapeValue: false },
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'greyscale.100',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
marginBottom: '1rem',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
lineHeight: '1.5',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
'& strong': {
|
||||
color: '#16a34a',
|
||||
fontWeight: 700,
|
||||
},
|
||||
})}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('joinPassphraseExample', {
|
||||
origin: window.location.origin,
|
||||
interpolation: { escapeValue: false },
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* eslint-disable jsx-a11y/no-autofocus */}
|
||||
<Field
|
||||
type="text"
|
||||
autoFocus
|
||||
isRequired
|
||||
name="passphrase"
|
||||
label={t('joinPassphraseLabel')}
|
||||
validate={(value: string) => {
|
||||
const v = (value || '').trim()
|
||||
if (!v) return t('joinPassphraseError')
|
||||
if (!isValidPassphrase(v)) return t('joinPassphraseInvalidFormat')
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
|
||||
<P
|
||||
className={css({
|
||||
fontSize: '0.8rem',
|
||||
color: '#b45309',
|
||||
marginTop: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{t('joinPassphraseWarning')}
|
||||
</P>
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<Field
|
||||
type="text"
|
||||
|
||||
@@ -17,7 +17,7 @@ export const LaterMeetingDialog = ({
|
||||
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
||||
|
||||
const roomUrl = room && getRouteUrl('room', room?.slug)
|
||||
const roomUrl = room ? getRouteUrl('room', room.slug) : null
|
||||
const telephony = useTelephony()
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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 { styled } from '@/styled-system/jsx'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
@@ -7,8 +12,12 @@ import { Screen } from '@/layout/Screen'
|
||||
import { generateRoomId, useCreateRoom } from '@/features/rooms'
|
||||
import { useUser, UserAware } from '@/features/auth'
|
||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { RiAddLine, RiLink, RiShieldCrossLine } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { CreateEncryptedMeetingDialog } from '@/features/home/components/CreateEncryptedMeetingDialog'
|
||||
import { ConnectionDetailsDialog } from '@/features/home/components/ConnectionDetailsDialog'
|
||||
import { generatePassphrase } from '@/features/encryption'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
@@ -148,17 +157,42 @@ const IntroText = styled('div', {
|
||||
|
||||
export const Home = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const { isLoggedIn } = useUser()
|
||||
const { isLoggedIn, user } = useUser()
|
||||
|
||||
const {
|
||||
userChoices: { username },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
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 { data } = useConfig()
|
||||
// The encrypted dropdown entry is offered only when:
|
||||
// - the server has encryption enabled (instance config), AND
|
||||
// - the user opted into the feature in their preferences.
|
||||
// "Instant meeting" and "Later date" stay plain regardless — encryption
|
||||
// is always an explicit, opt-in flow with its own confirmation modal.
|
||||
const encryptionAvailable =
|
||||
!!data?.encryption?.enabled &&
|
||||
user?.default_encryption_mode === ApiEncryptionMode.BASIC
|
||||
|
||||
const buildRoomBundle = async (
|
||||
encryptionMode: ApiEncryptionMode = ApiEncryptionMode.NONE
|
||||
) => {
|
||||
const slug = generateRoomId()
|
||||
const hash =
|
||||
encryptionMode === ApiEncryptionMode.BASIC
|
||||
? generatePassphrase()
|
||||
: undefined
|
||||
const room = await createRoom({ slug, username, encryptionMode })
|
||||
return { room, hash }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const checkSiteAndRedirect = async () => {
|
||||
@@ -210,12 +244,10 @@ export const Home = () => {
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
const { room } = await buildRoomBundle()
|
||||
navigateTo('room', room.slug, {
|
||||
state: { create: true, initialRoomData: room },
|
||||
})
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
@@ -226,17 +258,37 @@ export const Home = () => {
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoom(data)
|
||||
)
|
||||
onAction={async () => {
|
||||
const { room } = await buildRoomBundle()
|
||||
setLaterRoom({ room })
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
{encryptionAvailable && (
|
||||
<>
|
||||
<RACSeparator
|
||||
className={css({
|
||||
border: 'none',
|
||||
height: '1px',
|
||||
background: 'greyscale.250',
|
||||
margin: '0.35rem 0',
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => setShowEncryptedConfirm(true)}
|
||||
data-attr="create-option-encrypted"
|
||||
>
|
||||
<RiShieldCrossLine size={18} />
|
||||
{t('createMenu.encryptedOption')}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
) : (
|
||||
@@ -265,9 +317,36 @@ export const Home = () => {
|
||||
</RightColumn>
|
||||
</Columns>
|
||||
<LaterMeetingDialog
|
||||
room={laterRoom}
|
||||
room={laterRoom?.room ?? 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>
|
||||
</UserAware>
|
||||
)
|
||||
|
||||
+22
-7
@@ -3,7 +3,8 @@ import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { Button, Text } from '@/primitives'
|
||||
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 { useEffect, useRef, useState } from 'react'
|
||||
import { usePrevious } from '@/hooks/usePrevious'
|
||||
@@ -21,6 +22,8 @@ export const WaitingParticipantNotification = () => {
|
||||
const { t } = useTranslation('notifications', {
|
||||
keyPrefix: 'waitingParticipants',
|
||||
})
|
||||
const { t: tRooms } = useTranslation('rooms', { keyPrefix: 'identity' })
|
||||
const anonymousLabel = tRooms('anonymous.tooltip')
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
@@ -34,7 +37,6 @@ export const WaitingParticipantNotification = () => {
|
||||
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
||||
|
||||
useEffect(() => {
|
||||
// Show notification when the first participant enters the waiting room
|
||||
if (
|
||||
!isParticipantListEmpty(waitingParticipants) &&
|
||||
isParticipantListEmpty(prevWaitingParticipant) &&
|
||||
@@ -49,10 +51,9 @@ export const WaitingParticipantNotification = () => {
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowQuickActionsMessage(false)
|
||||
timerRef.current = null // Clear the ref when timeout completes
|
||||
timerRef.current = null
|
||||
}, NOTIFICATION_DISPLAY_DURATION)
|
||||
} else if (waitingParticipants.length !== prevWaitingParticipant?.length) {
|
||||
// Hide notification when the participant count changes
|
||||
setShowQuickActionsMessage(false)
|
||||
}
|
||||
}, [
|
||||
@@ -63,7 +64,6 @@ export const WaitingParticipantNotification = () => {
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
// This cleanup function will only run when the component unmounts
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
@@ -72,7 +72,6 @@ export const WaitingParticipantNotification = () => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Hide notification when participants panel is opened
|
||||
if (isParticipantsOpen) {
|
||||
setShowQuickActionsMessage(false)
|
||||
}
|
||||
@@ -100,7 +99,7 @@ export const WaitingParticipantNotification = () => {
|
||||
>
|
||||
{t('one')}
|
||||
</Text>
|
||||
<HStack gap="1rem">
|
||||
<HStack gap="0.5rem" alignItems="center">
|
||||
<Avatar
|
||||
name={waitingParticipants[0].username}
|
||||
bgColor={waitingParticipants[0].color}
|
||||
@@ -119,6 +118,22 @@ export const WaitingParticipantNotification = () => {
|
||||
>
|
||||
{waitingParticipants[0].username}
|
||||
</Text>
|
||||
{!waitingParticipants[0].is_authenticated && (
|
||||
<VisualOnlyTooltip
|
||||
tooltip={anonymousLabel}
|
||||
ariaLabel={anonymousLabel}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={16} color="#f87171" />
|
||||
</span>
|
||||
</VisualOnlyTooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack gap="0.25rem" marginLeft="auto">
|
||||
<Button
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
||||
import { RecordingStateToast } from './RecordingStateToast'
|
||||
import { ErrorAlertDialog } from './ErrorAlertDialog'
|
||||
|
||||
// RecordingStateToast removed — the RoomStatusBanner (top-left pill row)
|
||||
// now shows "Recording in progress" and "Transcription in progress" in the
|
||||
// same place, so the standalone toast was rendering behind the new pills.
|
||||
|
||||
export const RecordingProvider = () => {
|
||||
return (
|
||||
<>
|
||||
<RecordingStateToast />
|
||||
<LimitReachedAlertDialog />
|
||||
<ErrorAlertDialog />
|
||||
</>
|
||||
|
||||
@@ -10,6 +10,11 @@ export enum ApiAccessLevel {
|
||||
RESTRICTED = 'restricted',
|
||||
}
|
||||
|
||||
export enum ApiEncryptionMode {
|
||||
NONE = 'none',
|
||||
BASIC = 'basic',
|
||||
}
|
||||
|
||||
export type ApiRoom = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -17,6 +22,7 @@ export type ApiRoom = {
|
||||
pin_code: string
|
||||
is_administrable: boolean
|
||||
access_level: ApiAccessLevel
|
||||
encryption_mode: ApiEncryptionMode
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: {
|
||||
[key: string]: string | number | boolean | string[]
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
import { ApiEncryptionMode, ApiRoom } from './ApiRoom'
|
||||
|
||||
export interface CreateRoomParams {
|
||||
slug: string
|
||||
callbackId?: string
|
||||
username?: string
|
||||
encryptionMode?: ApiEncryptionMode
|
||||
}
|
||||
|
||||
const createRoom = ({
|
||||
slug,
|
||||
callbackId,
|
||||
username = '',
|
||||
encryptionMode = ApiEncryptionMode.NONE,
|
||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
|
||||
const queryParams = username
|
||||
? `?username=${encodeURIComponent(username)}`
|
||||
: ''
|
||||
return fetchApi(`rooms/${queryParams}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
callback_id: callbackId,
|
||||
encryption_mode: encryptionMode,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export type WaitingParticipant = {
|
||||
status: string
|
||||
username: string
|
||||
color: string
|
||||
is_authenticated: boolean
|
||||
}
|
||||
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
@@ -7,18 +7,24 @@ import {
|
||||
} from '@livekit/components-react'
|
||||
import {
|
||||
DisconnectReason,
|
||||
ExternalE2EEKeyProvider,
|
||||
MediaDeviceFailure,
|
||||
Room,
|
||||
RoomOptions,
|
||||
VideoPresets,
|
||||
} from 'livekit-client'
|
||||
import {
|
||||
getPassphraseFromHash,
|
||||
isValidPassphrase,
|
||||
EncryptionMismatchScreen,
|
||||
} from '@/features/encryption'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { QueryAware } from '@/components/QueryAware'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { fetchRoom } from '../api/fetchRoom'
|
||||
import { ApiRoom } from '../api/ApiRoom'
|
||||
import { ApiEncryptionMode, ApiRoom } from '../api/ApiRoom'
|
||||
import { useCreateRoom } from '../api/createRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
@@ -86,12 +92,74 @@ export const Conference = ({
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// The URL hash is the *source of truth* for whether to encrypt: the
|
||||
// server is never given the passphrase, so a compromised server can't
|
||||
// fabricate or suppress encryption — it can only claim a status, and we
|
||||
// use that claim only as a sanity reference for the mismatch screen.
|
||||
//
|
||||
// `hasValidHash` is synchronous (reads window.location.hash), so it's
|
||||
// either true or false on every render — never "we don't know yet".
|
||||
const hashPassphrase = getPassphraseFromHash()
|
||||
const hasValidHash = isValidPassphrase(hashPassphrase)
|
||||
const dbSaysEncrypted = data?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
|
||||
type EncryptionMismatch =
|
||||
| 'missingPassphrase'
|
||||
| 'unexpectedPassphrase'
|
||||
| null
|
||||
|
||||
let encryptionMismatch: EncryptionMismatch = null
|
||||
if (data !== undefined) {
|
||||
if (dbSaysEncrypted && !hasValidHash) {
|
||||
encryptionMismatch = 'missingPassphrase'
|
||||
} else if (!dbSaysEncrypted && hashPassphrase.length > 0) {
|
||||
encryptionMismatch = 'unexpectedPassphrase'
|
||||
}
|
||||
}
|
||||
|
||||
// We treat the room as encrypted purely because we have a valid hash.
|
||||
// No server condition. If the hash is valid we MUST run E2EE; if not,
|
||||
// there's nothing to encrypt with.
|
||||
const isEncrypted = hasValidHash
|
||||
|
||||
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
|
||||
const workerRef = useRef<Worker | null>(null)
|
||||
// `roomWithE2EE` is the actual `Room` instance for which we've already
|
||||
// run `setKey + setE2EEEnabled(true)`. Comparing it by reference with the
|
||||
// currently-memoised `room` lets us derive the "setup complete" status
|
||||
// synchronously during render — no separate boolean, no useEffect-driven
|
||||
// reset, no race window between a new Room appearing and a flag flipping.
|
||||
//
|
||||
// A device-pref change rebuilds `roomOptions` → a new `Room` instance is
|
||||
// memoised; on that same render `roomWithE2EE !== room` so the gate
|
||||
// below stays closed until the setup effect has stamped the new Room.
|
||||
const [roomWithE2EE, setRoomWithE2EE] = useState<Room | null>(null)
|
||||
const [encryptionSetupError, setEncryptionSetupError] =
|
||||
useState<Error | null>(null)
|
||||
|
||||
const getKeyProvider = () => {
|
||||
if (!keyProviderRef.current && isEncrypted) {
|
||||
keyProviderRef.current = new ExternalE2EEKeyProvider()
|
||||
}
|
||||
return keyProviderRef.current
|
||||
}
|
||||
|
||||
const getWorker = () => {
|
||||
if (!workerRef.current && isEncrypted && typeof window !== 'undefined') {
|
||||
workerRef.current = new Worker(
|
||||
new URL('livekit-client/e2ee-worker', import.meta.url)
|
||||
)
|
||||
}
|
||||
return workerRef.current
|
||||
}
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
const baseOptions: RoomOptions = {
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
publishDefaults: {
|
||||
videoCodec: 'vp9',
|
||||
videoCodec: isEncrypted ? undefined : 'vp9',
|
||||
red: !isEncrypted,
|
||||
},
|
||||
videoCaptureDefaults: {
|
||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||
@@ -106,8 +174,20 @@ export const Conference = ({
|
||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||
},
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
const worker = getWorker()
|
||||
const keyProvider = getKeyProvider()
|
||||
if (keyProvider && worker) {
|
||||
baseOptions.encryption = { keyProvider, worker }
|
||||
}
|
||||
}
|
||||
|
||||
return baseOptions
|
||||
// do not rely on the userConfig object directly as its reference may change on every render
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isEncrypted,
|
||||
userConfig.videoDeviceId,
|
||||
userConfig.videoPublishResolution,
|
||||
userConfig.audioDeviceId,
|
||||
@@ -116,61 +196,14 @@ export const Conference = ({
|
||||
|
||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Warm up connection to LiveKit server before joining room
|
||||
* This prefetch helps reduce initial connection latency by establishing
|
||||
* an early HTTP connection to the WebRTC signaling server
|
||||
*
|
||||
* It should cache DNS and TLS keys.
|
||||
*/
|
||||
const prepareConnection = async () => {
|
||||
if (!apiConfig || isConnectionWarmedUp) return
|
||||
await room.prepareConnection(apiConfig.livekit.url)
|
||||
|
||||
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
||||
try {
|
||||
const wssUrl =
|
||||
apiConfig.livekit.url
|
||||
.replace('https://', 'wss://')
|
||||
.replace(/\/$/, '') + '/rtc'
|
||||
|
||||
/**
|
||||
* FIREFOX + PROXY WORKAROUND:
|
||||
*
|
||||
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
||||
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
||||
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
|
||||
*
|
||||
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
||||
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
||||
*
|
||||
* Note: This issue is reproducible on LiveKit's demo app.
|
||||
* Reference: livekit-examples/meet/issues/466
|
||||
*/
|
||||
const ws = new WebSocket(wssUrl)
|
||||
// 401 unauthorized response is expected
|
||||
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||
} catch (e) {
|
||||
console.debug('Firefox WebSocket workaround failed.', e)
|
||||
}
|
||||
}
|
||||
|
||||
setIsConnectionWarmedUp(true)
|
||||
}
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||
error: MediaDeviceFailure | null
|
||||
kind: MediaDeviceKind | null
|
||||
}>({
|
||||
error: null,
|
||||
kind: null,
|
||||
})
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const encryptionSetupComplete = !isEncrypted || roomWithE2EE === room
|
||||
// Never let LiveKitRoom connect in an indeterminate state:
|
||||
// 1. `data` must have arrived from the server so we know whether to
|
||||
// show the mismatch screen.
|
||||
// 2. If the URL has a valid hash, the *current* Room must have already
|
||||
// been armed with setKey + setE2EEEnabled — otherwise the camera
|
||||
// goes out in clear.
|
||||
const canConnectMediaWise = data !== undefined && encryptionSetupComplete
|
||||
|
||||
/*
|
||||
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||
@@ -186,9 +219,98 @@ export const Conference = ({
|
||||
return livekit_url
|
||||
}, [apiConfig?.livekit])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEncrypted || roomWithE2EE === room) return
|
||||
|
||||
const keyProvider = getKeyProvider()
|
||||
if (!keyProvider) return
|
||||
|
||||
// `isEncrypted === hasValidHash`, so by the time we get here the URL
|
||||
// already carries a valid passphrase. Hash generation happens upstream
|
||||
// (in `Home.tsx` for new encrypted meetings); we just read it here.
|
||||
const passphrase = getPassphraseFromHash()
|
||||
if (!passphrase) return
|
||||
|
||||
// Must only stamp the room as "armed" after the chain has actually
|
||||
// succeeded. If `setE2EEEnabled` rejects we surface the failure to
|
||||
// the user via `encryptionSetupError` and stay disconnected.
|
||||
let cancelled = false
|
||||
keyProvider
|
||||
.setKey(passphrase)
|
||||
.then(() => room.setE2EEEnabled(true))
|
||||
.then(() => {
|
||||
if (!cancelled) setRoomWithE2EE(room)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
console.error('[Encryption] setup failed:', err)
|
||||
setEncryptionSetupError(
|
||||
err instanceof Error ? err : new Error(String(err))
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room, isEncrypted, roomWithE2EE])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
let currentHash = getPassphraseFromHash()
|
||||
const onHashChange = () => {
|
||||
const next = getPassphraseFromHash()
|
||||
if (next === currentHash) return
|
||||
currentHash = next
|
||||
window.location.reload()
|
||||
}
|
||||
window.addEventListener('hashchange', onHashChange)
|
||||
return () => window.removeEventListener('hashchange', onHashChange)
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Warm up connection to LiveKit server before joining room.
|
||||
* Use the normalized `serverUrl` (the same value the LiveKitRoom
|
||||
* will connect to after `force_wss_protocol`) so the warm-up matches
|
||||
* the actual connection target.
|
||||
*/
|
||||
const prepareConnection = async () => {
|
||||
if (!apiConfig || !serverUrl || isConnectionWarmedUp) return
|
||||
await room.prepareConnection(serverUrl)
|
||||
|
||||
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
||||
try {
|
||||
const wssUrl =
|
||||
serverUrl.replace('https://', 'wss://').replace(/\/$/, '') + '/rtc'
|
||||
|
||||
/**
|
||||
* FIREFOX + PROXY WORKAROUND — see livekit-examples/meet/issues/466
|
||||
*/
|
||||
const ws = new WebSocket(wssUrl)
|
||||
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||
} catch (e) {
|
||||
console.debug('Firefox WebSocket workaround failed.', e)
|
||||
}
|
||||
}
|
||||
|
||||
setIsConnectionWarmedUp(true)
|
||||
}
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, serverUrl, isConnectionWarmedUp])
|
||||
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||
error: MediaDeviceFailure | null
|
||||
kind: MediaDeviceKind | null
|
||||
}>({
|
||||
error: null,
|
||||
kind: null,
|
||||
})
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const { t } = useTranslation('rooms')
|
||||
if (isCreateError) {
|
||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
||||
return (
|
||||
<ErrorScreen
|
||||
title={t('error.createRoom.heading')}
|
||||
@@ -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
|
||||
// These settings help ensure successful connections in poor network conditions
|
||||
const connectOptions = {
|
||||
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||
maxRetries: 5,
|
||||
peerConnectionTimeout: 60000,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -211,7 +345,7 @@ export const Conference = ({
|
||||
room={room}
|
||||
serverUrl={serverUrl}
|
||||
token={data?.livekit?.token}
|
||||
connect={isConnectionWarmedUp}
|
||||
connect={isConnectionWarmedUp && canConnectMediaWise}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={
|
||||
userConfig.videoEnabled && {
|
||||
|
||||
@@ -5,15 +5,19 @@ import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import { Heading, Dialog } from 'react-aria-components'
|
||||
import { Text, text } from '@/primitives/Text'
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiComputerLine,
|
||||
RiFileCopyLine,
|
||||
RiPhoneLine,
|
||||
RiSpam2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { 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 { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
|
||||
@@ -41,15 +45,30 @@ const StyledRACDialog = styled(Dialog, {
|
||||
|
||||
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||
const { t: tHome } = useTranslation('home', {
|
||||
keyPrefix: 'connectionDetailsDialog',
|
||||
})
|
||||
const { t: tFeatures } = useTranslation('home', {
|
||||
keyPrefix: 'createEncryptedMeetingDialog',
|
||||
})
|
||||
|
||||
const roomData = useRoomData()
|
||||
const 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()
|
||||
|
||||
// Encrypted rooms never get a working PIN (backend skips both pin_code
|
||||
// and dispatch_rule allocation), so the phone block must stay hidden
|
||||
// even if a stale pin_code somehow slipped through.
|
||||
const isTelephonyReadyForUse = useMemo(() => {
|
||||
return telephony?.enabled && roomData?.pin_code
|
||||
}, [telephony?.enabled, roomData?.pin_code])
|
||||
return telephony?.enabled && roomData?.pin_code && !isEncrypted
|
||||
}, [telephony?.enabled, roomData?.pin_code, isEncrypted])
|
||||
|
||||
const {
|
||||
isCopied,
|
||||
@@ -68,7 +87,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||
>
|
||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||
{t('heading')}
|
||||
{isEncrypted ? t('encryptedHeading') : t('heading')}
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
@@ -84,113 +103,235 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
<P>{t('description')}</P>
|
||||
{isTelephonyReadyForUse ? (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '0.5rem',
|
||||
gap: '1rem',
|
||||
overflow: 'visible',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{isTelephonyReadyForUse && roomUrl && (
|
||||
<Button
|
||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
{isEncrypted && !isAdminOrOwner ? (
|
||||
<P>{t('encryptedGuestBody')}</P>
|
||||
) : (
|
||||
<P>{t('description')}</P>
|
||||
)}
|
||||
{(() => {
|
||||
if (isEncrypted && !isAdminOrOwner) return null
|
||||
if (isEncrypted) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
marginTop: '0.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 0.85rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
<RiAlertFill
|
||||
size={18}
|
||||
color="#b45309"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{tHome('warning')}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.250',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
flex: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</span>
|
||||
<Button
|
||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size="sm"
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine size={16} />
|
||||
) : (
|
||||
<RiFileCopyLine size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Text
|
||||
margin={false}
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
color: 'greyscale.500',
|
||||
})}
|
||||
>
|
||||
{t('encryptedDisabledHeading')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.4rem',
|
||||
})}
|
||||
>
|
||||
<FeaturePill
|
||||
size="sm"
|
||||
icon={<RiPhoneLine size={13} />}
|
||||
label={tFeatures('features.dialIn')}
|
||||
/>
|
||||
<FeaturePill
|
||||
size="sm"
|
||||
icon={<RiComputerLine size={13} />}
|
||||
label={tFeatures('features.meetingRoom')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isTelephonyReadyForUse) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '0.5rem',
|
||||
gap: '1rem',
|
||||
overflow: 'visible',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{isTelephonyReadyForUse && roomUrl && (
|
||||
<Button
|
||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={
|
||||
isRoomUrlCopied ? t('copied') : t('copyUrl')
|
||||
}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
) : (
|
||||
<RiFileCopyLine aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||
{telephony?.internationalPhoneNumber}
|
||||
</Text>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||
{formatPinCode(roomData?.pin_code)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'secondaryText'}
|
||||
size="sm"
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
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"
|
||||
/>
|
||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copy')}
|
||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('copyUrl')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
fullWidth
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('copyUrl')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
||||
<HStack>
|
||||
<div
|
||||
|
||||
@@ -32,8 +32,15 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
||||
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 { RiInformationLine, RiLockLine } from '@remixicon/react'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
@@ -103,6 +110,39 @@ export const Join = ({
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
// Early fetch to inspect the room (encrypted? requires passphrase?)
|
||||
const { data: roomInfo } = useQuery({
|
||||
queryKey: [keys.room, roomId, 'info'],
|
||||
queryFn: () => fetchRoom({ roomId }),
|
||||
staleTime: 6 * 60 * 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const isEncryptedRoom = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
const { user, isLoggedIn } = useUser()
|
||||
// Authenticated joiners of an encrypted room can't pick an arbitrary
|
||||
// display name — it must match the OIDC profile, and the backend
|
||||
// re-enforces this when minting the JWT.
|
||||
const isNameLocked = isEncryptedRoom && !!isLoggedIn
|
||||
const lockedName = user?.full_name || user?.email || ''
|
||||
|
||||
// Keep the passphrase in state and refresh on `hashchange` so the
|
||||
// mismatch screen recovers immediately when the user pastes the
|
||||
// correct hash into the address bar.
|
||||
const [passphrase, setPassphrase] = useState(getPassphraseFromHash)
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setPassphrase(getPassphraseFromHash())
|
||||
window.addEventListener('hashchange', onHashChange)
|
||||
return () => window.removeEventListener('hashchange', onHashChange)
|
||||
}, [])
|
||||
const hasValidPassphrase = isEncryptedRoom
|
||||
? isValidPassphrase(passphrase)
|
||||
: true
|
||||
// If the URL has a passphrase but the room itself is not encrypted, the
|
||||
// link looks tampered with — refuse to join and offer a fresh room.
|
||||
const unexpectedPassphrase =
|
||||
!!roomInfo && !isEncryptedRoom && passphrase.length > 0
|
||||
|
||||
const {
|
||||
userChoices: {
|
||||
audioEnabled,
|
||||
@@ -173,11 +213,6 @@ export const Join = ({
|
||||
[tracks]
|
||||
)
|
||||
|
||||
/*
|
||||
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
|
||||
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
|
||||
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const createVideoTrack = async () => {
|
||||
try {
|
||||
@@ -245,8 +280,6 @@ export const Join = ({
|
||||
const videoTrack = dynamicVideoTrack || previewVideoTrack
|
||||
const audioTrack = dynamicAudioTrack || previewAudioTrack
|
||||
|
||||
// LiveKit by default populates device choices with "default" value.
|
||||
// Instead, use the current device id used by the preview track as a default
|
||||
useResolveInitiallyDefaultDeviceId(
|
||||
audioDeviceId,
|
||||
audioTrack,
|
||||
@@ -286,12 +319,6 @@ export const Join = ({
|
||||
}
|
||||
}, [videoTrack, videoEnabled])
|
||||
|
||||
// Room data strategy:
|
||||
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
||||
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
||||
// 3. State is manually updated via queryClient when a waiting participant is accepted
|
||||
// 4. No automatic refetching or revalidation occurs during this period
|
||||
// todo - refactor in a hook
|
||||
const {
|
||||
data: roomData,
|
||||
error,
|
||||
@@ -333,7 +360,6 @@ export const Join = ({
|
||||
const { data } = await refetchRoom()
|
||||
|
||||
if (!data?.livekit) {
|
||||
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
|
||||
if (data?.access_level == ApiAccessLevel.TRUSTED) {
|
||||
openLoginHint()
|
||||
}
|
||||
@@ -426,6 +452,12 @@ export const Join = ({
|
||||
)
|
||||
|
||||
default:
|
||||
if (unexpectedPassphrase) {
|
||||
return <EncryptionMismatchScreen reason="unexpectedPassphrase" />
|
||||
}
|
||||
if (isEncryptedRoom && !hasValidPassphrase) {
|
||||
return <EncryptionMismatchScreen reason="missingPassphrase" />
|
||||
}
|
||||
return (
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
@@ -438,20 +470,75 @@ export const Join = ({
|
||||
<H lvl={1} margin="sm" centered>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<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}
|
||||
/>
|
||||
{isNameLocked ? (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||
{t('usernameLabel')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
backgroundColor: 'greyscale.100',
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
})}
|
||||
>
|
||||
<RiLockLine size={14} color="#6b7280" />
|
||||
<Text variant="sm" margin={false}>
|
||||
{lockedName}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
})}
|
||||
>
|
||||
<RiInformationLine
|
||||
size={18}
|
||||
color="#6b7280"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<Text
|
||||
variant="note"
|
||||
margin={false}
|
||||
className={css({
|
||||
fontSize: '0.8rem',
|
||||
color: 'greyscale.500',
|
||||
})}
|
||||
>
|
||||
{t('encryptedNameLocked')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Field
|
||||
type="text"
|
||||
onChange={saveUsername}
|
||||
label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
autoComplete="name"
|
||||
maxLength={50}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</Form>
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@ export const useLobby = ({
|
||||
}) => {
|
||||
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
|
||||
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const clearWaitingTimeout = useCallback(() => {
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
@@ -40,10 +39,7 @@ export const useLobby = ({
|
||||
/* eslint-disable @tanstack/query/exhaustive-deps */
|
||||
queryKey: [keys.requestEntry, roomId],
|
||||
queryFn: async () => {
|
||||
const response = await requestEntry({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
const response = await requestEntry({ roomId, username })
|
||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||
|
||||
@@ -62,7 +62,7 @@ export const useWaitingParticipants = () => {
|
||||
allowEntry: boolean
|
||||
) => {
|
||||
await enterRoom({
|
||||
roomId: roomId,
|
||||
roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
})
|
||||
@@ -78,7 +78,7 @@ export const useWaitingParticipants = () => {
|
||||
await Promise.all(
|
||||
waitingParticipants.map((participant) =>
|
||||
enterRoom({
|
||||
roomId: roomId,
|
||||
roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Div, Field, H, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Separator as RACSeparator } from 'react-aria-components'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'wouter'
|
||||
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
|
||||
export const Admin = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||
@@ -22,12 +22,7 @@ export const Admin = () => {
|
||||
|
||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||
|
||||
const { data: readOnlyData } = useQuery({
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId }),
|
||||
retry: false,
|
||||
enabled: false,
|
||||
})
|
||||
const readOnlyData = useRoomData()
|
||||
|
||||
const {
|
||||
toggleMicrophone,
|
||||
@@ -166,45 +161,99 @@ export const Admin = () => {
|
||||
>
|
||||
{t('access.description')}
|
||||
</Text>
|
||||
<Field
|
||||
type="radioGroup"
|
||||
label={t('access.type')}
|
||||
aria-label={t('access.type')}
|
||||
labelProps={{
|
||||
className: css({
|
||||
fontSize: '1rem',
|
||||
paddingBottom: '1rem',
|
||||
}),
|
||||
}}
|
||||
value={readOnlyData?.access_level}
|
||||
onChange={(value) =>
|
||||
patchRoom({
|
||||
roomId,
|
||||
room: { access_level: value as ApiAccessLevel },
|
||||
})
|
||||
.then((room) => {
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
})
|
||||
.catch((e) => console.error(e))
|
||||
}
|
||||
items={[
|
||||
{
|
||||
value: ApiAccessLevel.PUBLIC,
|
||||
label: t('access.levels.public.label'),
|
||||
description: t('access.levels.public.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.TRUSTED,
|
||||
label: t('access.levels.trusted.label'),
|
||||
description: t('access.levels.trusted.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.RESTRICTED,
|
||||
label: t('access.levels.restricted.label'),
|
||||
description: t('access.levels.restricted.description'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{(() => {
|
||||
const isEncrypted =
|
||||
readOnlyData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
return (
|
||||
<>
|
||||
{isEncrypted && (
|
||||
<div
|
||||
role="alert"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 0.85rem',
|
||||
marginBottom: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
>
|
||||
<RiAlertFill
|
||||
size={18}
|
||||
color="#b45309"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<Text
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{t('access.encryptedLocked')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={css({
|
||||
opacity: isEncrypted ? 0.7 : 1,
|
||||
pointerEvents: isEncrypted ? 'none' : undefined,
|
||||
transition: 'opacity 200ms ease',
|
||||
})}
|
||||
aria-disabled={isEncrypted || undefined}
|
||||
>
|
||||
<Field
|
||||
type="radioGroup"
|
||||
label={t('access.type')}
|
||||
aria-label={t('access.type')}
|
||||
labelProps={{
|
||||
className: css({
|
||||
fontSize: '1rem',
|
||||
paddingBottom: '1rem',
|
||||
}),
|
||||
}}
|
||||
isDisabled={isEncrypted}
|
||||
value={
|
||||
isEncrypted
|
||||
? ApiAccessLevel.RESTRICTED
|
||||
: readOnlyData?.access_level
|
||||
}
|
||||
onChange={(value) =>
|
||||
patchRoom({
|
||||
roomId,
|
||||
room: { access_level: value as ApiAccessLevel },
|
||||
})
|
||||
.then((room) => {
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
})
|
||||
.catch((e) => console.error(e))
|
||||
}
|
||||
items={[
|
||||
{
|
||||
value: ApiAccessLevel.PUBLIC,
|
||||
label: t('access.levels.public.label'),
|
||||
description: t('access.levels.public.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.TRUSTED,
|
||||
label: t('access.levels.trusted.label'),
|
||||
description: t('access.levels.trusted.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.RESTRICTED,
|
||||
label: t('access.levels.restricted.label'),
|
||||
description: t('access.levels.restricted.description'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</Div>
|
||||
)
|
||||
|
||||
@@ -2,28 +2,186 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
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 { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { formatPinCode } from '../../utils/telephony'
|
||||
import { useTelephony } from '../hooks/useTelephony'
|
||||
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
|
||||
import { FeaturePill } from '@/features/encryption'
|
||||
import { ApiEncryptionMode } from '../../api/ApiRoom'
|
||||
|
||||
export const Info = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||
const { t: tHome } = useTranslation('home', {
|
||||
keyPrefix: 'connectionDetailsDialog',
|
||||
})
|
||||
|
||||
const data = useRoomData()
|
||||
const 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 isEncrypted = data?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
const isAdminOrOwner = !!data?.is_administrable
|
||||
|
||||
const isTelephonyReadyForUse = useMemo(() => {
|
||||
return telephony?.enabled && data?.pin_code
|
||||
}, [telephony?.enabled, data?.pin_code])
|
||||
return telephony?.enabled && data?.pin_code && !isEncrypted
|
||||
}, [telephony?.enabled, data?.pin_code, isEncrypted])
|
||||
|
||||
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
|
||||
|
||||
if (isEncrypted && !isAdminOrOwner) {
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
>
|
||||
<Text as="p" variant="note" wrap="pretty">
|
||||
{t('encrypted.guestBody')}
|
||||
</Text>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
>
|
||||
<VStack
|
||||
alignItems="stretch"
|
||||
gap="0.75rem"
|
||||
className={css({ width: '100%' })}
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 0.85rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
>
|
||||
<RiAlertFill
|
||||
size={18}
|
||||
color="#b45309"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{tHome('warning')}
|
||||
</Text>
|
||||
</div>
|
||||
<Text as="p" variant="note" margin={false}>
|
||||
<Bold>{t('encrypted.linkLabel')}</Bold>
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.250',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
flex: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
wordBreak: 'break-all',
|
||||
})}
|
||||
>
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
</span>
|
||||
<Button
|
||||
square
|
||||
size="sm"
|
||||
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||
onPress={copyRoomToClipboard}
|
||||
aria-label={
|
||||
isCopied
|
||||
? t('roomInformation.button.copied')
|
||||
: t('roomInformation.button.copy')
|
||||
}
|
||||
tooltip={
|
||||
isCopied
|
||||
? t('roomInformation.button.copied')
|
||||
: t('roomInformation.button.copy')
|
||||
}
|
||||
data-attr="copy-info-sidepannel"
|
||||
>
|
||||
{isCopied ? (
|
||||
<RiCheckLine size={16} />
|
||||
) : (
|
||||
<RiFileCopyLine size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Text
|
||||
as="p"
|
||||
margin={false}
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
color: 'greyscale.500',
|
||||
})}
|
||||
>
|
||||
{t('encrypted.disabledHeading')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<FeaturePill
|
||||
icon={<RiPhoneLine size={14} />}
|
||||
label={t('encrypted.features.dialIn')}
|
||||
/>
|
||||
<FeaturePill
|
||||
icon={<RiComputerLine size={14} />}
|
||||
label={t('encrypted.features.meetingRoom')}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
@@ -50,7 +208,14 @@ export const Info = () => {
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<Text as="p" variant="xsNote" wrap="pretty">
|
||||
<Text
|
||||
as="p"
|
||||
variant="xsNote"
|
||||
className={css({
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'normal',
|
||||
})}
|
||||
>
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{isTelephonyReadyForUse && (
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ParticipantTileProps,
|
||||
ScreenShareIcon,
|
||||
useEnsureTrackRef,
|
||||
useFeatureContext,
|
||||
useIsEncrypted,
|
||||
useMaybeLayoutContext,
|
||||
useMaybeTrackRefContext,
|
||||
useParticipantTile,
|
||||
@@ -24,6 +22,7 @@ import { Track } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { DecryptionFailedTileOverlay } from '@/features/encryption'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
@@ -78,7 +77,6 @@ export const ParticipantTile: (
|
||||
onParticipantClick,
|
||||
trackRef: trackReference,
|
||||
})
|
||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
|
||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||
@@ -114,7 +112,6 @@ export const ParticipantTile: (
|
||||
|
||||
const interactiveProps = {
|
||||
...elementProps,
|
||||
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
||||
tabIndex: 0,
|
||||
'aria-label': t('containerLabel', { name: participantName }),
|
||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
@@ -215,9 +212,6 @@ export const ParticipantTile: (
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
@@ -237,6 +231,11 @@ export const ParticipantTile: (
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
/>
|
||||
)}
|
||||
{!isScreenShare && (
|
||||
<DecryptionFailedTileOverlay
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
<KeyboardShortcutHint>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { css } from '@/styled-system/css'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactNode } from 'react'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import {
|
||||
@@ -12,12 +13,15 @@ import {
|
||||
ScreenRecordingSidePanel,
|
||||
} from '@/features/recording'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export interface ToolsButtonProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
onPress: () => void
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
const ToolButton = ({
|
||||
@@ -25,9 +29,11 @@ const ToolButton = ({
|
||||
title,
|
||||
description,
|
||||
onPress,
|
||||
isDisabled,
|
||||
}: ToolsButtonProps) => {
|
||||
return (
|
||||
<RACButton
|
||||
isDisabled={isDisabled}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
@@ -39,10 +45,14 @@ const ToolButton = ({
|
||||
width: 'full',
|
||||
backgroundColor: 'gray.50',
|
||||
textAlign: 'start',
|
||||
'&[data-hovered]': {
|
||||
'&[data-hovered]:not([data-disabled])': {
|
||||
backgroundColor: 'primary.50',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
onPress={onPress}
|
||||
>
|
||||
@@ -98,17 +108,15 @@ export const Tools = () => {
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
const roomData = useRoomData()
|
||||
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
|
||||
// Restore focus to the element that opened the Tools panel
|
||||
// following the same pattern as Chat.
|
||||
useRestoreFocus(isToolsOpen, {
|
||||
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
|
||||
// find the "more options" button ("Plus d'options") that opened the menu
|
||||
resolveTrigger: (activeEl) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||
}
|
||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
||||
return activeEl
|
||||
},
|
||||
restoreFocusRaf: true,
|
||||
@@ -166,12 +174,42 @@ export const Tools = () => {
|
||||
</A>
|
||||
)}
|
||||
</Text>
|
||||
{isEncrypted && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 0.85rem',
|
||||
margin: '0 0.75rem 0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
role="alert"
|
||||
>
|
||||
<RiAlertFill size={18} color="#b45309" />
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{t('encryptedBlock')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{isTranscriptEnabled && (
|
||||
<ToolButton
|
||||
icon={<Icon type="symbols" name="speech_to_text" />}
|
||||
title={t('tools.transcript.title')}
|
||||
description={t('tools.transcript.body')}
|
||||
onPress={() => openTranscript()}
|
||||
onPress={openTranscript}
|
||||
isDisabled={isEncrypted}
|
||||
/>
|
||||
)}
|
||||
{isScreenRecordingEnabled && (
|
||||
@@ -179,7 +217,8 @@ export const Tools = () => {
|
||||
icon={<Icon type="symbols" name="mode_standby" />}
|
||||
title={t('tools.screenRecording.title')}
|
||||
description={t('tools.screenRecording.body')}
|
||||
onPress={() => openScreenRecording()}
|
||||
onPress={openScreenRecording}
|
||||
isDisabled={isEncrypted}
|
||||
/>
|
||||
)}
|
||||
</Div>
|
||||
|
||||
+10
-3
@@ -5,11 +5,15 @@ import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export const ScreenRecordingMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { isScreenRecordingOpen, openScreenRecording, toggleTools } =
|
||||
useSidePanel()
|
||||
const roomData = useRoomData()
|
||||
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
@@ -21,9 +25,12 @@ export const ScreenRecordingMenuItem = () => {
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={() =>
|
||||
!isScreenRecordingOpen ? openScreenRecording() : toggleTools()
|
||||
}
|
||||
isDisabled={isEncrypted}
|
||||
onAction={() => {
|
||||
if (isEncrypted) return
|
||||
if (!isScreenRecordingOpen) openScreenRecording()
|
||||
else toggleTools()
|
||||
}}
|
||||
>
|
||||
<RiRecordCircleLine size={20} />
|
||||
{t('screenRecording')}
|
||||
|
||||
+10
-1
@@ -5,10 +5,14 @@ import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export const TranscriptMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel()
|
||||
const roomData = useRoomData()
|
||||
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
@@ -20,7 +24,12 @@ export const TranscriptMenuItem = () => {
|
||||
return (
|
||||
<MenuItem
|
||||
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} />
|
||||
{t('transcript')}
|
||||
|
||||
+54
-27
@@ -13,7 +13,7 @@ import {
|
||||
useTrackMutedIndicator,
|
||||
} from '@livekit/components-react'
|
||||
import Source = Track.Source
|
||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||
import { RiErrorWarningLine, RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { useState } from 'react'
|
||||
import { MuteAlertDialog } from '../../MuteAlertDialog'
|
||||
@@ -21,6 +21,8 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
||||
import { PinBadge } from './PinBadge'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { useUser } from '@/features/auth'
|
||||
|
||||
type MicIndicatorProps = {
|
||||
participant: Participant
|
||||
@@ -97,7 +99,16 @@ export const ParticipantListItem = ({
|
||||
participant,
|
||||
}: ParticipantListItemProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { isLoggedIn } = useUser()
|
||||
const name = participant.name || participant.identity
|
||||
const isParticipantAuthenticated =
|
||||
participant.attributes?.is_authenticated === 'true'
|
||||
const anonymousLabel = t('identity.anonymous.tooltip')
|
||||
// Email is only displayed to authenticated viewers (defense-in-depth on
|
||||
// top of the JWT-level guarantee that it's only emitted for authenticated
|
||||
// participants). The LK signaling channel broadcasts attributes to every
|
||||
// peer, so the UI is what protects anonymous viewers from seeing it.
|
||||
const email = isLoggedIn ? participant.attributes?.email : undefined
|
||||
return (
|
||||
<HStack
|
||||
role="listitem"
|
||||
@@ -118,38 +129,54 @@ export const ParticipantListItem = ({
|
||||
<PinBadge participant={participant} />
|
||||
</div>
|
||||
<VStack gap={0} alignItems="start">
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
<HStack gap="0.2rem" alignItems="center">
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
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({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '120px',
|
||||
display: 'block',
|
||||
maxWidth: '180px',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{isLocal(participant) && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '.25rem',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
({t('participants.you')})
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
{getParticipantIsRoomAdmin(participant) && (
|
||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||
{email}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
+43
-29
@@ -1,10 +1,11 @@
|
||||
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 { Avatar } from '@/components/Avatar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 = ({
|
||||
participant,
|
||||
@@ -14,6 +15,7 @@ export const WaitingParticipantListItem = ({
|
||||
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const anonymousLabel = t('identity.anonymous.tooltip')
|
||||
|
||||
return (
|
||||
<HStack
|
||||
@@ -30,38 +32,50 @@ export const WaitingParticipantListItem = ({
|
||||
className={css({
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
gap: '0.35rem',
|
||||
})}
|
||||
>
|
||||
<Avatar name={participant.username} bgColor={participant.color} />
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
})}
|
||||
<VStack
|
||||
gap={0}
|
||||
alignItems="start"
|
||||
className={css({ flex: 1, minWidth: 0 })}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
})}
|
||||
>
|
||||
{participant.username}
|
||||
</span>
|
||||
</Text>
|
||||
<HStack gap="0.2rem" alignItems="center">
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0.1rem 0.25rem',
|
||||
lineHeight: 1.2,
|
||||
})}
|
||||
>
|
||||
{participant.username}
|
||||
</Text>
|
||||
{!participant.is_authenticated && (
|
||||
<VisualOnlyTooltip
|
||||
tooltip={anonymousLabel}
|
||||
ariaLabel={anonymousLabel}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={14} color="#dc2626" />
|
||||
</span>
|
||||
</VisualOnlyTooltip>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack
|
||||
gap="0.25rem"
|
||||
className={css({
|
||||
flexShrink: '0',
|
||||
})}
|
||||
>
|
||||
<HStack gap="0.25rem" className={css({ flexShrink: '0' })}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
|
||||
@@ -2,12 +2,15 @@ import { useTelephony } from './useTelephony'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
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'
|
||||
|
||||
const COPY_SUCCESS_TIMEOUT = 3000
|
||||
|
||||
export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
||||
export const useCopyRoomToClipboard = (
|
||||
room: ApiRoom | undefined,
|
||||
hashOverride?: string
|
||||
) => {
|
||||
const telephony = useTelephony()
|
||||
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
|
||||
|
||||
@@ -32,12 +35,23 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
||||
}, [isRoomUrlCopied])
|
||||
|
||||
const roomUrl = useMemo(() => {
|
||||
return room?.slug ? getRouteUrl('room', room.slug) : ''
|
||||
}, [room?.slug])
|
||||
if (!room?.slug) return ''
|
||||
const base = getRouteUrl('room', room.slug)
|
||||
// In basic encrypted mode, the passphrase is in the URL hash
|
||||
const hash = hashOverride ? `#${hashOverride}` : window.location.hash
|
||||
return hash ? `${base}${hash}` : base
|
||||
}, [room?.slug, hashOverride])
|
||||
|
||||
// Encrypted rooms never get a dispatch rule on the SIP gateway side
|
||||
// (the backend skips it because no PIN-driven join can ever decrypt),
|
||||
// so make sure we don't paste a non-functional phone+PIN snippet either.
|
||||
const hasTelephonyInfo = useMemo(() => {
|
||||
return telephony.enabled && room?.pin_code
|
||||
}, [telephony.enabled, room?.pin_code])
|
||||
return (
|
||||
telephony.enabled &&
|
||||
room?.pin_code &&
|
||||
room?.encryption_mode !== ApiEncryptionMode.BASIC
|
||||
)
|
||||
}, [telephony.enabled, room?.pin_code, room?.encryption_mode])
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!roomUrl || !room) return ''
|
||||
|
||||
@@ -42,6 +42,7 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||
import { GridLayout } from '../components/layout/GridLayout'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
import { RoomStatusBanner } from '@/features/encryption'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
@@ -276,6 +277,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
onClose={() => setIsShareErrorVisible(false)}
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<RoomStatusBanner />
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
|
||||
@@ -5,10 +5,16 @@ export const flexibleRoomIdPattern =
|
||||
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
|
||||
|
||||
const roomRegex = new RegExp(`^${roomIdPattern}$`)
|
||||
const roomWithoutHyphensRegex = /^[a-z]{10}$/
|
||||
|
||||
export const isRoomValid = (roomIdOrUrl: string) =>
|
||||
roomRegex.test(roomIdOrUrl) ||
|
||||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
|
||||
export const isRoomValid = (roomIdOrUrl: string) => {
|
||||
const lower = roomIdOrUrl.toLowerCase()
|
||||
return (
|
||||
roomRegex.test(lower) ||
|
||||
roomWithoutHyphensRegex.test(lower) ||
|
||||
new RegExp(`^${window.location.origin}/${roomIdPattern}`).test(roomIdOrUrl)
|
||||
)
|
||||
}
|
||||
|
||||
export const normalizeRoomId = (roomId: string) => {
|
||||
const cleanId = roomId.toLowerCase().replace(/-/g, '')
|
||||
|
||||
@@ -39,18 +39,24 @@ export const CreateMeetingButton = () => {
|
||||
|
||||
const { data } = useRoomCreationCallback({ callbackId })
|
||||
|
||||
const [basicHash, setBasicHash] = useState<string | undefined>(undefined)
|
||||
|
||||
const roomUrl = useMemo(() => {
|
||||
if (room?.slug) return getRouteUrl('room', room.slug)
|
||||
}, [room])
|
||||
if (!room?.slug) return undefined
|
||||
const base = getRouteUrl('room', room.slug)
|
||||
return basicHash ? `${base}#${basicHash}` : base
|
||||
}, [room, basicHash])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.room?.slug) return
|
||||
setRoom(data.room)
|
||||
setCallbackId(undefined)
|
||||
setIsPending(false)
|
||||
|
||||
const url = getRouteUrl('room', data.room.slug)
|
||||
popupManager.sendRoomData({
|
||||
room: {
|
||||
url: getRouteUrl('room', data.room.slug),
|
||||
url,
|
||||
...data.room,
|
||||
},
|
||||
})
|
||||
@@ -61,6 +67,7 @@ export const CreateMeetingButton = () => {
|
||||
(id) => setCallbackId(id),
|
||||
(data) => {
|
||||
setRoom(data)
|
||||
if (data.hash) setBasicHash(data.hash)
|
||||
setIsPending(false)
|
||||
}
|
||||
)
|
||||
@@ -68,6 +75,25 @@ export const CreateMeetingButton = () => {
|
||||
return () => popupManager.cleanup()
|
||||
}, [])
|
||||
|
||||
// Communicate iframe height to parent for proper sizing
|
||||
useEffect(() => {
|
||||
const targetOrigin = (() => {
|
||||
try {
|
||||
return document.referrer ? new URL(document.referrer).origin : '/'
|
||||
} catch {
|
||||
return '/'
|
||||
}
|
||||
})()
|
||||
const observer = new ResizeObserver(() => {
|
||||
window.parent.postMessage(
|
||||
{ type: 'RESIZE', data: { height: document.body.scrollHeight } },
|
||||
targetOrigin
|
||||
)
|
||||
})
|
||||
observer.observe(document.body)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const resetState = () => {
|
||||
setRoom(undefined)
|
||||
setCallbackId(undefined)
|
||||
|
||||
@@ -1,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 { generateRoomId, useCreateRoom } from '../../rooms'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
|
||||
import { PopupWindow } from '../utils/PopupWindow'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { RiVideoOnLine } from '@remixicon/react'
|
||||
|
||||
const callbackIdHandler = new CallbackIdHandler()
|
||||
const popupWindow = new PopupWindow()
|
||||
|
||||
export const CreatePopup = () => {
|
||||
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
|
||||
const { isLoggedIn } = useUser({
|
||||
fetchUserOptions: { attemptSilent: false },
|
||||
})
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const { t } = useTranslation('sdk', { keyPrefix: 'createPopup' })
|
||||
|
||||
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
/**
|
||||
* Handle unauthenticated users by redirecting to login
|
||||
*
|
||||
* When redirecting to authentication, the window.location change breaks the connection
|
||||
* between this popup and its parent window. We need to send the callbackId to the parent
|
||||
* before redirecting so it can re-establish connection after authentication completes.
|
||||
* This prevents the popup from becoming orphaned and ensures state consistency.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isLoggedIn === false) {
|
||||
// redirection loses the connection to the manager
|
||||
// prevent it passing an async callback id
|
||||
popupWindow.sendCallbackId(callbackId, () => {
|
||||
popupWindow.navigateToAuthentication()
|
||||
})
|
||||
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(() => {
|
||||
const createMeetingRoom = async () => {
|
||||
try {
|
||||
const slug = generateRoomId()
|
||||
const roomData = await createRoom({
|
||||
slug,
|
||||
callbackId,
|
||||
})
|
||||
// Send room data back to parent window and clean up resources
|
||||
popupWindow.sendRoomData(roomData, () => {
|
||||
callbackIdHandler.clear()
|
||||
popupWindow.close()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to create meeting room:', error)
|
||||
}
|
||||
if (isLoggedIn && !isCreating && callbackId) {
|
||||
void handleCreate()
|
||||
}
|
||||
if (isLoggedIn && callbackId) {
|
||||
createMeetingRoom()
|
||||
}
|
||||
}, [isLoggedIn, callbackId, createRoom])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoggedIn])
|
||||
|
||||
if (!isLoggedIn || isCreating) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -68,9 +81,26 @@ export const CreatePopup = () => {
|
||||
alignItems: 'center',
|
||||
height: '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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,16 +56,21 @@ export class PopupManager {
|
||||
case PopupMessageType.CALLBACK_ID:
|
||||
onCallbackId(data.callbackId as string)
|
||||
return
|
||||
case PopupMessageType.ROOM_DATA:
|
||||
case PopupMessageType.ROOM_DATA: {
|
||||
if (!data?.room) return
|
||||
onRoomData(data.room)
|
||||
const baseUrl = getRouteUrl('room', data.room.slug)
|
||||
const roomUrl = data.room.hash
|
||||
? `${baseUrl}#${data.room.hash}`
|
||||
: baseUrl
|
||||
this.sendRoomData({
|
||||
room: {
|
||||
url: getRouteUrl('room', data.room.slug),
|
||||
url: roomUrl,
|
||||
...data.room,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', this.messageHandler)
|
||||
|
||||
@@ -27,7 +27,7 @@ export class PopupWindow {
|
||||
public sendRoomData(data: CallbackCreationRoomData, callback?: () => void) {
|
||||
this.sendMessageToManager(
|
||||
PopupMessageType.ROOM_DATA,
|
||||
{ room: { slug: data.slug } },
|
||||
{ room: { slug: data.slug, hash: data.hash } },
|
||||
callback
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export type CallbackCreationRoomData = {
|
||||
slug: string
|
||||
hash?: string
|
||||
}
|
||||
|
||||
export enum ClientMessageType {
|
||||
ROOM_CREATED = 'ROOM_CREATED',
|
||||
STATE_CLEAR = 'STATE_CLEAR',
|
||||
RESIZE = 'RESIZE',
|
||||
}
|
||||
|
||||
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 { 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 { LoginButton } from '@/components/LoginButton'
|
||||
import { EncryptionDefaultField } from './EncryptionDefaultField'
|
||||
|
||||
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
||||
|
||||
@@ -46,6 +56,13 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||
i18n.changeLanguage(lang as string)
|
||||
}}
|
||||
/>
|
||||
<H lvl={2} margin={false}>
|
||||
{t('security.heading')}
|
||||
</H>
|
||||
<Text variant="note" margin="md">
|
||||
{t('security.subtitle')}
|
||||
</Text>
|
||||
<EncryptionDefaultField />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
RiAccountCircleLine,
|
||||
RiNotification3Line,
|
||||
RiSettings3Line,
|
||||
RiShieldKeyholeLine,
|
||||
RiSpeakerLine,
|
||||
RiVideoOnLine,
|
||||
RiEyeLine,
|
||||
@@ -19,6 +20,7 @@ import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
import { GeneralTab } from './tabs/GeneralTab'
|
||||
import { AudioTab } from './tabs/AudioTab'
|
||||
import { VideoTab } from './tabs/VideoTab'
|
||||
import { SecurityTab } from './tabs/SecurityTab'
|
||||
import { TranscriptionTab } from './tabs/TranscriptionTab'
|
||||
import { ShortcutTab } from './tabs/ShortcutTab'
|
||||
import { useRef } from 'react'
|
||||
@@ -105,6 +107,10 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
<RiSettings3Line />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.SECURITY}>
|
||||
<RiShieldKeyholeLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SECURITY}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
|
||||
<RiNotification3Line />
|
||||
{isWideScreen &&
|
||||
@@ -136,6 +142,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
<AudioTab id={SettingsDialogExtendedKey.AUDIO} />
|
||||
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
|
||||
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
|
||||
<SecurityTab id={SettingsDialogExtendedKey.SECURITY} />
|
||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
|
||||
{/* 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',
|
||||
VIDEO = 'video',
|
||||
GENERAL = 'general',
|
||||
SECURITY = 'security',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
TRANSCRIPTION = 'transcription',
|
||||
SHORTCUTS = 'shortcuts',
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Menu } from '@/primitives/Menu'
|
||||
import { MenuList } from '@/primitives/MenuList'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
|
||||
const Logo = () => (
|
||||
@@ -91,7 +90,7 @@ export const Header = () => {
|
||||
const isTermsOfService = useMatchesRoute('termsOfService')
|
||||
const isRoom = useMatchesRoute('room')
|
||||
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 loggedInAriaLabel = userLabel
|
||||
? `${loggedInTooltip} ${userLabel}`
|
||||
@@ -174,7 +173,7 @@ export const Header = () => {
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
>
|
||||
{user?.full_name || user?.email}
|
||||
{user?.full_name || user?.short_name || user?.email}
|
||||
</span>
|
||||
</VisualOnlyTooltip>
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,14 @@
|
||||
"joinInputLabel": "Meeting link",
|
||||
"joinInputSubmit": "Join 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?",
|
||||
"loginToCreateMeeting": "Login to create a meeting",
|
||||
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
|
||||
@@ -15,7 +22,29 @@
|
||||
"moreAbout": "about {{appTitle}}",
|
||||
"createMenu": {
|
||||
"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": {
|
||||
"heading": "Your connection details",
|
||||
@@ -58,5 +87,6 @@
|
||||
},
|
||||
"carouselLabel": "Introduction slideshow",
|
||||
"slidePosition": "Slide {{current}} of {{total}}"
|
||||
}
|
||||
},
|
||||
"joinPassphraseInvalidFormat": "The passphrase looks malformed. It should be 48 hexadecimal characters from the meeting link."
|
||||
}
|
||||
|
||||
@@ -80,7 +80,12 @@
|
||||
"timeoutInvite": {
|
||||
"title": "You cannot join this call",
|
||||
"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.",
|
||||
"shareDialog": {
|
||||
@@ -94,7 +99,10 @@
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"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": {
|
||||
"count": "{{currentPage}} of {{totalPageCount}}",
|
||||
@@ -153,6 +161,10 @@
|
||||
"helpLinkLabel": "Presentation issue",
|
||||
"closeButton": "Dismiss",
|
||||
"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": {
|
||||
@@ -360,20 +372,31 @@
|
||||
"title": "Record",
|
||||
"body": "Save meetings as video."
|
||||
}
|
||||
}
|
||||
},
|
||||
"encryptedBlock": "Tools are unavailable in encrypted mode."
|
||||
},
|
||||
"info": {
|
||||
"roomInformation": {
|
||||
"title": "Connection Information",
|
||||
"title": "Connection information",
|
||||
"button": {
|
||||
"ariaLabel": "Copy the information from your meeting",
|
||||
"copy": "Copy information",
|
||||
"copied": "Information copied"
|
||||
"copied": "Information copied to clipboard",
|
||||
"ariaLabel": "Copy meeting information"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"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": {
|
||||
@@ -494,7 +517,8 @@
|
||||
"label": "Restricted",
|
||||
"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": {
|
||||
"title": "Meeting Moderation",
|
||||
@@ -541,6 +565,7 @@
|
||||
"subheading": "In room",
|
||||
"you": "You",
|
||||
"unknown": "Unknown participant",
|
||||
"anonymous": "Unverified identity",
|
||||
"host": "Host",
|
||||
"contributors": "Contributors",
|
||||
"collapsable": {
|
||||
@@ -670,5 +695,32 @@
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": "{{name}}'s screen"
|
||||
},
|
||||
"identity": {
|
||||
"anonymous": {
|
||||
"tooltip": "This user is not authenticated"
|
||||
}
|
||||
},
|
||||
"roomStatus": {
|
||||
"encrypted": "End-to-end encrypted",
|
||||
"recording": "Recording in progress",
|
||||
"transcribing": "Transcription in progress"
|
||||
},
|
||||
"encryption": {
|
||||
"mismatch": {
|
||||
"missingPassphrase": {
|
||||
"title": "This meeting needs an encryption passphrase",
|
||||
"body": "The link you used does not include the encryption key. Ask the organizer to share the full link, or create a new encrypted meeting."
|
||||
},
|
||||
"unexpectedPassphrase": {
|
||||
"title": "This meeting is not encrypted",
|
||||
"body": "The link contains an encryption key but the meeting is not configured for end-to-end encryption. The link may have been altered. Create a fresh encrypted meeting to keep your conversation safe."
|
||||
},
|
||||
"backHome": "Back to home"
|
||||
},
|
||||
"decryptionFailed": {
|
||||
"title": "Decryption failed",
|
||||
"body": "Check that you and this person are using the correct meeting link. If they are the only one you can't see, the issue is likely on their side."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
"resetLabel": "Reset",
|
||||
"participantLimit": "Up to 150 participants.",
|
||||
"popupBlocked": "Popup was blocked. Please allow popups for this site."
|
||||
},
|
||||
"createPopup": {
|
||||
"title": "Create a meeting",
|
||||
"create": "Create"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"authentication": "Authentication",
|
||||
"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": {
|
||||
"title": "Preferences",
|
||||
"idleDisconnectModal": {
|
||||
@@ -162,6 +173,7 @@
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "General",
|
||||
"security": "Security",
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibility",
|
||||
"transcription": "Transcription",
|
||||
|
||||
@@ -7,7 +7,14 @@
|
||||
"joinInputLabel": "Lien complet ou code de la réunion",
|
||||
"joinInputSubmit": "Rejoindre la 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",
|
||||
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
||||
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
|
||||
@@ -15,7 +22,29 @@
|
||||
"moreAbout": "sur {{appTitle}}",
|
||||
"createMenu": {
|
||||
"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": {
|
||||
"heading": "Vos informations de connexion",
|
||||
@@ -58,5 +87,6 @@
|
||||
"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 !"
|
||||
}
|
||||
}
|
||||
},
|
||||
"joinPassphraseInvalidFormat": "La phrase secrète semble incorrecte. Elle doit être composée de 48 caractères hexadécimaux issus du lien de la réunion."
|
||||
}
|
||||
|
||||
@@ -80,7 +80,12 @@
|
||||
"timeoutInvite": {
|
||||
"title": "Vous ne pouvez pas participer à cet 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.",
|
||||
"shareDialog": {
|
||||
@@ -94,7 +99,10 @@
|
||||
"phone": {
|
||||
"call": "Appelez le :",
|
||||
"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": {
|
||||
"count": "{{currentPage}} sur {{totalPageCount}}",
|
||||
@@ -153,6 +161,10 @@
|
||||
"helpLinkLabel": "Problème de présentation",
|
||||
"closeButton": "Ignorer",
|
||||
"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": {
|
||||
@@ -360,20 +372,31 @@
|
||||
"title": "Enregistrer",
|
||||
"body": "Enregistrer la réunion en vidéo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"encryptedBlock": "Les outils ne sont pas disponibles en mode chiffré."
|
||||
},
|
||||
"info": {
|
||||
"roomInformation": {
|
||||
"title": "Informations de connexions",
|
||||
"title": "Informations de connexion",
|
||||
"button": {
|
||||
"ariaLabel": "Copier les informations de votre réunion",
|
||||
"copy": "Copier les informations",
|
||||
"copied": "Informations copiées"
|
||||
"copied": "Informations copiées",
|
||||
"ariaLabel": "Copier les informations de la réunion"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Appelez le :",
|
||||
"call": "Appel :",
|
||||
"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": {
|
||||
@@ -494,7 +517,8 @@
|
||||
"label": "Restreindre",
|
||||
"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": {
|
||||
"title": "Modération de la réunion",
|
||||
@@ -541,6 +565,7 @@
|
||||
"subheading": "Dans la réunion",
|
||||
"you": "Vous",
|
||||
"unknown": "Participant inconnu",
|
||||
"anonymous": "Identité non vérifiée",
|
||||
"contributors": "Contributeurs",
|
||||
"host": "Organisateur de la réunion",
|
||||
"collapsable": {
|
||||
@@ -670,5 +695,32 @@
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": "Écran de {{name}}"
|
||||
},
|
||||
"identity": {
|
||||
"anonymous": {
|
||||
"tooltip": "Cet utilisateur n'est pas authentifié"
|
||||
}
|
||||
},
|
||||
"roomStatus": {
|
||||
"encrypted": "Chiffrée de bout en bout",
|
||||
"recording": "Enregistrement en cours",
|
||||
"transcribing": "Transcription en cours"
|
||||
},
|
||||
"encryption": {
|
||||
"mismatch": {
|
||||
"missingPassphrase": {
|
||||
"title": "Cette réunion nécessite une phrase secrète",
|
||||
"body": "Le lien que vous utilisez ne contient pas la clé de chiffrement. Demandez au créateur de partager le lien complet, ou créez une nouvelle réunion chiffrée."
|
||||
},
|
||||
"unexpectedPassphrase": {
|
||||
"title": "Cette réunion n'est pas chiffrée",
|
||||
"body": "Le lien contient une clé de chiffrement mais la réunion n'est pas configurée pour le chiffrement bout-en-bout. Le lien a peut-être été altéré. Créez une nouvelle réunion chiffrée pour préserver la confidentialité."
|
||||
},
|
||||
"backHome": "Retour à l'accueil"
|
||||
},
|
||||
"decryptionFailed": {
|
||||
"title": "Échec du déchiffrement",
|
||||
"body": "Vérifiez que vous et cette personne utilisez bien le même lien de réunion. Si seule cette personne est concernée, le problème vient probablement de chez elle."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
"resetLabel": "Réinitialiser",
|
||||
"participantLimit": "Jusqu'à 150 participants.",
|
||||
"popupBlocked": "La fenêtre pop-up a été bloquée. Veuillez autoriser les pop-ups pour ce site."
|
||||
},
|
||||
"createPopup": {
|
||||
"title": "Créer une réunion",
|
||||
"create": "Créer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"authentication": "Authentification",
|
||||
"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": {
|
||||
"title": "Préférences",
|
||||
"idleDisconnectModal": {
|
||||
@@ -162,6 +173,7 @@
|
||||
"audio": "Audio",
|
||||
"video": "Vidéo",
|
||||
"general": "Général",
|
||||
"security": "Sécurité",
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibilité",
|
||||
"transcription": "Transcription",
|
||||
|
||||
@@ -6,7 +6,7 @@ export const navigateTo = <S = unknown>(
|
||||
routeName: RouteName,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
params?: any,
|
||||
options?: { replace?: boolean; state?: S }
|
||||
options?: { replace?: boolean; state?: S; hash?: string }
|
||||
) => {
|
||||
const route = getRouteByName(routeName)
|
||||
const to = route.to
|
||||
@@ -17,5 +17,10 @@ export const navigateTo = <S = unknown>(
|
||||
if (!to) {
|
||||
throw new Error(`Can't find path to navigate to for ${routeName}`)
|
||||
}
|
||||
return navigate(to, options)
|
||||
// Including the hash in the URL passed to `navigate` lets us avoid a
|
||||
// brittle pushState + replaceState dance at the call site: the URL the
|
||||
// app first renders already carries the fragment.
|
||||
const { hash, ...navigateOptions } = options ?? {}
|
||||
const target = hash ? `${to}#${hash}` : to
|
||||
return navigate(target, navigateOptions)
|
||||
}
|
||||
|
||||
@@ -135,8 +135,9 @@ export const Checkbox = ({
|
||||
<StyledCheckbox {...props}>
|
||||
{(renderProps) => {
|
||||
if (renderProps.isInvalid && !!props.validate) {
|
||||
setError(props.validate(renderProps.isSelected))
|
||||
} else {
|
||||
const next = props.validate(renderProps.isSelected)
|
||||
if (next !== error) setError(next)
|
||||
} else if (error !== null) {
|
||||
setError(null)
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -58,7 +58,12 @@ const StyledLabel = styled(Label, {
|
||||
|
||||
type OmittedRACProps = 'type' | 'label' | 'items' | 'description' | 'validate'
|
||||
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 PartialCheckboxProps = Omit<CheckboxProps, OmittedRACProps>
|
||||
@@ -110,7 +115,7 @@ type FieldProps<T extends object> = (
|
||||
} & PartialSwitchProps)
|
||||
) & {
|
||||
label: string
|
||||
description?: string
|
||||
description?: ReactNode
|
||||
wrapperProps?: React.ComponentProps<typeof FieldWrapper>
|
||||
labelProps?: React.ComponentProps<typeof StyledLabel>
|
||||
}
|
||||
@@ -216,6 +221,7 @@ export const Field = <T extends object>({
|
||||
<Radio
|
||||
value={item.value}
|
||||
alignment={item.description ? 'top' : undefined}
|
||||
isDisabled={item.isDisabled}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -48,6 +48,11 @@ export const StyledRadio = styled(RACRadio, {
|
||||
'&[data-selected][data-pressed] .mt-Radio-check': {
|
||||
backgroundColor: 'primary.active',
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
|
||||
@@ -25,7 +25,7 @@ export const TooltipWrapper = ({
|
||||
children: ReactNode
|
||||
} & TooltipWrapperProps) => {
|
||||
return tooltip ? (
|
||||
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
|
||||
<TooltipTrigger delay={tooltipType === 'instant' ? 500 : 1000}>
|
||||
{children}
|
||||
<Tooltip>{tooltip}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
@@ -7,12 +8,16 @@ export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd())
|
||||
return {
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
},
|
||||
build: {
|
||||
sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
|
||||
},
|
||||
server: {
|
||||
port: parseInt(env.VITE_PORT) || 3000,
|
||||
host: env.VITE_HOST ?? 'localhost',
|
||||
host: env.VITE_HOST ?? '0.0.0.0',
|
||||
allowedHosts: ['.nip.io'],
|
||||
// In a local dev setup, we proxy the media server ourselves to avoid CORS issues
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user