mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 11:58:53 +00:00
✨(encryption) remove advanced mode and keep a distinct encryption hash mode
This commit is contained in:
@@ -33,7 +33,7 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- End-to-end encryption with hybrid key distribution
|
||||
- End-to-end encryption with passphrase-in-link key distribution
|
||||
- Meeting recording
|
||||
- Meeting transcription & Summary (currently in beta)
|
||||
- Telephony integration
|
||||
@@ -48,52 +48,31 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
|
||||
|
||||
### End-to-end encryption
|
||||
|
||||
La Suite Meet supports end-to-end encryption (E2EE) for meetings, ensuring that the media server (LiveKit SFU) cannot access audio/video content. Two encryption modes are available:
|
||||
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.
|
||||
|
||||
#### Basic encryption
|
||||
#### How it works
|
||||
|
||||
- Passphrase-based — the encryption key is embedded in the meeting URL hash (`#passphrase`)
|
||||
- Uses LiveKit's built-in Worker + `crypto.subtle` (AES-GCM) for frame encryption
|
||||
- Sharing the meeting link shares the encryption key
|
||||
- No account or onboarding required
|
||||
- Security depends on keeping the link private
|
||||
- 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.
|
||||
|
||||
#### Advanced encryption
|
||||
> **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.
|
||||
|
||||
- Key managed by [La Suite Encryption](https://github.com/suitenumerique/encryption) — the symmetric key never leaves the vault iframe
|
||||
- Uses XChaCha20-Poly1305 (libsodium) via the VaultClient iframe for frame encryption
|
||||
- Key distribution uses `vaultClient.shareKeys()` (hybrid PKI with X25519 + post-quantum slot)
|
||||
- All participants must complete encryption onboarding (key generation + backup) before joining
|
||||
- Requires a Chromium-based browser (Chrome, Edge, Brave) — uses the Insertable Streams API
|
||||
#### Encryption mode is set at creation, immutable after
|
||||
|
||||
**Frame encryption (both modes):**
|
||||
`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.
|
||||
|
||||
- Codec header bytes (VP8 payload descriptor) are preserved unencrypted — required for proper RTP packetization
|
||||
- Only the media payload is encrypted, with a per-frame random nonce
|
||||
- The server (LiveKit SFU) only forwards encrypted data it cannot read
|
||||
#### Opt-in by user
|
||||
|
||||
**Trust levels (advanced mode):**
|
||||
| Badge | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 🟢 Green shield | Verified | User completed encryption onboarding (public key registered). Identity cryptographically verified. |
|
||||
| 🔵 Blue shield | Authenticated | User signed in via ProConnect/OIDC. Identity server-verified. |
|
||||
| 🟡 Orange warning | Anonymous | User not signed in. Self-declared name. Admin should verify identity before accepting. |
|
||||
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.
|
||||
|
||||
**Security guarantees:**
|
||||
|
||||
- Encrypted rooms enforce restricted access (lobby approval required)
|
||||
- Trust information (`is_authenticated`, `email`) comes from server-signed JWT tokens — cannot be spoofed
|
||||
- Recording and transcription are not available in encrypted rooms (server cannot decrypt media)
|
||||
|
||||
**Configuration:**
|
||||
#### Configuration
|
||||
|
||||
```env
|
||||
ENCRYPTION_ENABLED=true
|
||||
ENCRYPTION_VAULT_URL=https://data.encryption.example.fr
|
||||
ENCRYPTION_INTERFACE_URL=https://encryption.example.fr
|
||||
```
|
||||
|
||||
When the encryption service is deployed and configured, rooms can use advanced encryption. Without it, only basic (passphrase) encryption is available.
|
||||
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).
|
||||
|
||||
|
||||
@@ -73,11 +73,8 @@ def get_frontend_configuration(request):
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
},
|
||||
}
|
||||
if settings.ENCRYPTION_ENABLED and settings.ENCRYPTION_VAULT_URL:
|
||||
frontend_configuration["encryption"] = {
|
||||
"enabled": True,
|
||||
"vault_url": settings.ENCRYPTION_VAULT_URL,
|
||||
"interface_url": settings.ENCRYPTION_INTERFACE_URL,
|
||||
}
|
||||
frontend_configuration["encryption"] = {
|
||||
"enabled": settings.ENCRYPTION_ENABLED,
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
|
||||
@@ -30,7 +30,16 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "sub", "email", "full_name", "short_name", "timezone", "language"]
|
||||
fields = [
|
||||
"id",
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"timezone",
|
||||
"language",
|
||||
"default_encryption_mode",
|
||||
]
|
||||
read_only_fields = ["id", "sub", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
@@ -75,22 +84,6 @@ class ResourceAccessSerializerMixin:
|
||||
"Only owners of a room can assign other users as owners."
|
||||
)
|
||||
|
||||
# In advanced encrypted rooms, new accesses require an encrypted_symmetric_key
|
||||
# so the new member can decrypt the room's streams. Without it, they'd have
|
||||
# access but no key — which is useless and confusing.
|
||||
# Future: a sharing UI (like Docs) could provide the key via vault shareKeys.
|
||||
if not self.instance and "resource" in data:
|
||||
resource = data["resource"]
|
||||
if (
|
||||
hasattr(resource, 'encryption_mode')
|
||||
and resource.encryption_mode == models.EncryptionMode.ADVANCED
|
||||
and not data.get("encrypted_symmetric_key")
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Adding members to advanced encrypted rooms requires "
|
||||
"an encrypted_symmetric_key for the new user."
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def validate_resource(self, resource):
|
||||
@@ -115,7 +108,7 @@ class ResourceAccessSerializer(
|
||||
|
||||
class Meta:
|
||||
model = models.ResourceAccess
|
||||
fields = ["id", "user", "resource", "role", "encrypted_symmetric_key"]
|
||||
fields = ["id", "user", "resource", "role"]
|
||||
read_only_fields = ["id"]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -145,27 +138,60 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_mode"]
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"configuration",
|
||||
"access_level",
|
||||
"pin_code",
|
||||
"encryption_mode",
|
||||
]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def validate_access_level(self, value):
|
||||
"""Encrypted rooms must stay restricted — prevent downgrading access level."""
|
||||
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_enabled and value != models.RoomAccessLevel.RESTRICTED:
|
||||
if instance and instance.encryption_mode != value:
|
||||
raise serializers.ValidationError(
|
||||
"Encrypted rooms require restricted access level to enforce lobby approval."
|
||||
"Encryption mode cannot be changed after room creation."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_encryption_mode(self, value):
|
||||
"""Once encryption is enabled on a room, it cannot be disabled or downgraded."""
|
||||
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_enabled and value == models.EncryptionMode.NONE:
|
||||
if (
|
||||
instance
|
||||
and instance.encryption_mode != models.EncryptionMode.NONE
|
||||
and value != models.RoomAccessLevel.RESTRICTED
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Encryption cannot be disabled once enabled on a room."
|
||||
"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.
|
||||
@@ -208,9 +234,11 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
room_id = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
|
||||
# In encrypted rooms, authenticated users must use their real name from
|
||||
# the OIDC profile (ProConnect) — they cannot choose an arbitrary name.
|
||||
if instance.encryption_enabled and request.user.is_authenticated:
|
||||
# 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(
|
||||
@@ -226,15 +254,6 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
|
||||
# Include the current user's encrypted symmetric key for advanced E2EE
|
||||
if request.user.is_authenticated and instance.encryption_mode == models.EncryptionMode.ADVANCED:
|
||||
try:
|
||||
access = instance.accesses.get(user=request.user)
|
||||
if access.encrypted_symmetric_key:
|
||||
output["encrypted_symmetric_key"] = access.encrypted_symmetric_key
|
||||
except models.ResourceAccess.DoesNotExist:
|
||||
pass
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@@ -317,7 +336,6 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
username = serializers.CharField(required=True, allow_blank=True)
|
||||
ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
|
||||
|
||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||
@@ -325,9 +343,6 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||
|
||||
participant_id = serializers.UUIDField(required=True)
|
||||
allow_entry = serializers.BooleanField(required=True)
|
||||
encrypted_key = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
admin_ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
encrypted_vault_key = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
|
||||
|
||||
class CreationCallbackSerializer(BaseValidationOnlySerializer):
|
||||
|
||||
@@ -281,32 +281,23 @@ 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)
|
||||
encryption_mode = serializer.validated_data.get(
|
||||
"encryption_mode", models.EncryptionMode.NONE
|
||||
)
|
||||
|
||||
# Block encrypted room creation if encryption is not enabled on this instance
|
||||
if encryption_mode != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
|
||||
if (
|
||||
encryption_mode != models.EncryptionMode.NONE
|
||||
and not settings.ENCRYPTION_ENABLED
|
||||
):
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"encryption_mode": "Encryption is not enabled on this server."}
|
||||
)
|
||||
|
||||
# Advanced encryption requires the vault service to be configured
|
||||
if encryption_mode == models.EncryptionMode.ADVANCED and not getattr(settings, 'ENCRYPTION_VAULT_URL', ''):
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"encryption_mode": "Advanced encryption requires the encryption service to be configured."}
|
||||
)
|
||||
|
||||
# Encrypted rooms must use restricted access to enforce lobby approval
|
||||
# before the encryption key is shared with participants.
|
||||
if encryption_mode != models.EncryptionMode.NONE:
|
||||
serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED
|
||||
|
||||
room = serializer.save()
|
||||
encrypted_symmetric_key = self.request.data.get("encrypted_symmetric_key", "")
|
||||
models.ResourceAccess.objects.create(
|
||||
resource=room,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
encrypted_symmetric_key=encrypted_symmetric_key,
|
||||
)
|
||||
|
||||
if callback_id := self.request.data.get("callback_id"):
|
||||
@@ -325,20 +316,15 @@ 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
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
mode = serializer.validated_data["mode"]
|
||||
options = serializer.validated_data.get("options")
|
||||
room = self.get_object()
|
||||
|
||||
if room.encryption_enabled:
|
||||
return drf_response.Response(
|
||||
{"detail": "Recording is not available in encrypted rooms."},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
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
|
||||
@@ -425,20 +411,6 @@ class RoomViewSet(
|
||||
room = self.get_object()
|
||||
validated_data = serializer.validated_data
|
||||
|
||||
# Advanced encrypted rooms require authentication
|
||||
if room.encryption_mode == models.EncryptionMode.ADVANCED and not request.user.is_authenticated:
|
||||
return drf_response.Response(
|
||||
{"detail": "This meeting requires authentication to join."},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# In encrypted rooms, authenticated users must use their real name
|
||||
# from the OIDC profile — they cannot choose an arbitrary name.
|
||||
if room.encryption_enabled and request.user.is_authenticated:
|
||||
validated_data["username"] = (
|
||||
request.user.full_name or request.user.email
|
||||
)
|
||||
|
||||
lobby_service = LobbyService()
|
||||
|
||||
participant, livekit = lobby_service.request_entry(
|
||||
@@ -480,9 +452,6 @@ class RoomViewSet(
|
||||
room_id=room.id,
|
||||
participant_id=str(serializer.validated_data.get("participant_id")),
|
||||
allow_entry=serializer.validated_data.get("allow_entry"),
|
||||
encrypted_key=serializer.validated_data.get("encrypted_key", ''),
|
||||
admin_ephemeral_public_key=serializer.validated_data.get("admin_ephemeral_public_key", ''),
|
||||
encrypted_vault_key=serializer.validated_data.get("encrypted_vault_key", ''),
|
||||
)
|
||||
return drf_response.Response({"message": "Participant was updated."})
|
||||
|
||||
@@ -511,13 +480,6 @@ class RoomViewSet(
|
||||
|
||||
participants = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
# Only expose email and ephemeral keys in encrypted rooms.
|
||||
# Strip them otherwise to avoid leaking personal data.
|
||||
if not room.encryption_enabled:
|
||||
for p in participants:
|
||||
p.pop("email", None)
|
||||
p.pop("ephemeral_public_key", None)
|
||||
|
||||
return drf_response.Response({"participants": participants})
|
||||
|
||||
@decorators.action(
|
||||
@@ -620,10 +582,9 @@ class RoomViewSet(
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
if room.encryption_enabled:
|
||||
return drf_response.Response(
|
||||
{"error": "Transcription is not available in encrypted rooms."},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
if room.is_encrypted:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"detail": "Subtitles are unavailable in encrypted rooms."}
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0018_rename_active_application_is_active"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="room",
|
||||
name="encryption_enabled",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether end-to-end encryption is enabled for this room.",
|
||||
verbose_name="Encryption enabled",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Replace encryption_enabled boolean with encryption_mode enum."""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_encryption_enabled_to_mode(apps, schema_editor):
|
||||
"""Convert existing encryption_enabled=True rooms to encryption_mode='basic'."""
|
||||
Room = apps.get_model("core", "Room")
|
||||
Room.objects.filter(encryption_enabled=True).update(encryption_mode="basic")
|
||||
|
||||
|
||||
def migrate_mode_to_encryption_enabled(apps, schema_editor):
|
||||
"""Reverse: set encryption_enabled=True for any non-'none' encryption_mode."""
|
||||
Room = apps.get_model("core", "Room")
|
||||
Room.objects.exclude(encryption_mode="none").update(encryption_enabled=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0019_room_encryption_enabled"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 1. Add the new encryption_mode field
|
||||
migrations.AddField(
|
||||
model_name="room",
|
||||
name="encryption_mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("none", "No encryption"),
|
||||
("basic", "Basic encryption"),
|
||||
("advanced", "Advanced encryption"),
|
||||
],
|
||||
default="none",
|
||||
help_text="End-to-end encryption mode for this room.",
|
||||
max_length=20,
|
||||
verbose_name="Encryption mode",
|
||||
),
|
||||
),
|
||||
# 2. Migrate existing data
|
||||
migrations.RunPython(
|
||||
migrate_encryption_enabled_to_mode,
|
||||
migrate_mode_to_encryption_enabled,
|
||||
),
|
||||
# 3. Remove the old boolean field
|
||||
migrations.RemoveField(
|
||||
model_name="room",
|
||||
name="encryption_enabled",
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Add encrypted_symmetric_key to ResourceAccess for advanced E2EE mode."""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0020_room_encryption_mode"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="resourceaccess",
|
||||
name="encrypted_symmetric_key",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Vault-wrapped symmetric encryption key for advanced E2EE mode. Each user's copy is encrypted for their own vault public key.",
|
||||
verbose_name="Encrypted symmetric key",
|
||||
),
|
||||
),
|
||||
]
|
||||
+32
-16
@@ -99,11 +99,14 @@ class RoomAccessLevel(models.TextChoices):
|
||||
|
||||
|
||||
class EncryptionMode(models.TextChoices):
|
||||
"""Encryption mode choices for rooms."""
|
||||
"""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", _("Basic encryption")
|
||||
ADVANCED = "advanced", _("Advanced encryption")
|
||||
BASIC = "basic", _("Passphrase-in-URL encryption")
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
@@ -208,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()
|
||||
|
||||
@@ -332,15 +344,6 @@ class ResourceAccess(BaseModel):
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||
)
|
||||
encrypted_symmetric_key = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name=_("Encrypted symmetric key"),
|
||||
help_text=_(
|
||||
"Vault-wrapped symmetric encryption key for advanced E2EE mode. "
|
||||
"Each user's copy is encrypted for their own vault public key."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_resource_access"
|
||||
@@ -405,6 +408,8 @@ 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,
|
||||
@@ -437,8 +442,19 @@ 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.
|
||||
"""
|
||||
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
|
||||
)
|
||||
@@ -467,8 +483,8 @@ class Room(Resource):
|
||||
return self.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
@property
|
||||
def encryption_enabled(self):
|
||||
"""Check if any encryption mode is active."""
|
||||
def is_encrypted(self):
|
||||
"""Convenience: any non-none encryption mode counts as encrypted."""
|
||||
return self.encryption_mode != EncryptionMode.NONE
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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,36 +46,19 @@ 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
|
||||
email: Optional[str] = None
|
||||
suite_user_id: Optional[str] = None
|
||||
ephemeral_public_key: str = ''
|
||||
encrypted_key: str = ''
|
||||
admin_ephemeral_public_key: str = ''
|
||||
encrypted_vault_key: str = ''
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
def to_dict(self) -> Dict[str, object]:
|
||||
"""Serialize the participant object to a dict representation."""
|
||||
result = {
|
||||
return {
|
||||
"status": self.status.value,
|
||||
"username": self.username,
|
||||
"id": self.id,
|
||||
"color": self.color,
|
||||
"is_authenticated": self.is_authenticated,
|
||||
}
|
||||
if self.email:
|
||||
result["email"] = self.email
|
||||
if self.suite_user_id:
|
||||
result["suite_user_id"] = self.suite_user_id
|
||||
if self.ephemeral_public_key:
|
||||
result["ephemeral_public_key"] = self.ephemeral_public_key
|
||||
if self.encrypted_key:
|
||||
result["encrypted_key"] = self.encrypted_key
|
||||
if self.admin_ephemeral_public_key:
|
||||
result["admin_ephemeral_public_key"] = self.admin_ephemeral_public_key
|
||||
if self.encrypted_vault_key:
|
||||
result["encrypted_vault_key"] = self.encrypted_vault_key
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "LobbyParticipant":
|
||||
@@ -89,13 +72,7 @@ class LobbyParticipant:
|
||||
username=data["username"],
|
||||
id=data["id"],
|
||||
color=data["color"],
|
||||
is_authenticated=data.get("is_authenticated", False),
|
||||
email=data.get("email"),
|
||||
suite_user_id=data.get("suite_user_id"),
|
||||
ephemeral_public_key=data.get("ephemeral_public_key", ''),
|
||||
encrypted_key=data.get("encrypted_key", ''),
|
||||
admin_ephemeral_public_key=data.get("admin_ephemeral_public_key", ''),
|
||||
encrypted_vault_key=data.get("encrypted_vault_key", ''),
|
||||
is_authenticated=bool(data.get("is_authenticated", False)),
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.exception("Error creating Participant from dict:")
|
||||
@@ -139,16 +116,11 @@ class LobbyService:
|
||||
1. The room is public (open to everyone)
|
||||
2. The room has TRUSTED access level and the user is authenticated
|
||||
|
||||
Encrypted rooms never bypass the lobby — participants must go through
|
||||
the lobby key exchange to receive the encryption key.
|
||||
|
||||
Note: Room access levels can change while participants are waiting in the lobby.
|
||||
This function only checks the current state and should be called each time
|
||||
a participant requests entry to ensure consistent access control, even for
|
||||
participants who have already begun waiting.
|
||||
"""
|
||||
if hasattr(room, 'encryption_mode') and room.encryption_mode != 'none':
|
||||
return False
|
||||
return room.is_public or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
@@ -159,7 +131,6 @@ class LobbyService:
|
||||
room,
|
||||
request,
|
||||
username: str,
|
||||
ephemeral_public_key: str = '',
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
"""Request entry to a room for a participant.
|
||||
|
||||
@@ -174,6 +145,12 @@ 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 room.is_encrypted and request.user.is_authenticated:
|
||||
username = request.user.full_name or request.user.email or username
|
||||
|
||||
participant_id = self._get_or_create_participant_id(request)
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
@@ -186,6 +163,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
|
||||
@@ -206,34 +184,16 @@ class LobbyService:
|
||||
|
||||
if participant is None:
|
||||
participant = self.enter(
|
||||
room.id, participant_id, username,
|
||||
room.id,
|
||||
participant_id,
|
||||
username,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
|
||||
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
|
||||
ephemeral_public_key=ephemeral_public_key,
|
||||
)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||
self.refresh_waiting_status(room.id, participant_id)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||
# If the joiner comes back with a different ephemeral key (e.g. browser
|
||||
# closed and reopened), they can no longer decrypt the encrypted symmetric
|
||||
# key. Reset them to WAITING so the admin re-accepts with the new key.
|
||||
if (
|
||||
ephemeral_public_key
|
||||
and participant.ephemeral_public_key
|
||||
and ephemeral_public_key != participant.ephemeral_public_key
|
||||
):
|
||||
participant = self.enter(
|
||||
room.id, participant_id, username,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
|
||||
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
|
||||
ephemeral_public_key=ephemeral_public_key,
|
||||
)
|
||||
return participant, None
|
||||
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
@@ -259,11 +219,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,
|
||||
email: Optional[str] = None,
|
||||
suite_user_id: Optional[str] = None,
|
||||
ephemeral_public_key: str = '',
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby.
|
||||
|
||||
@@ -279,9 +239,6 @@ class LobbyService:
|
||||
id=participant_id,
|
||||
color=color,
|
||||
is_authenticated=is_authenticated,
|
||||
email=email,
|
||||
suite_user_id=suite_user_id,
|
||||
ephemeral_public_key=ephemeral_public_key,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -350,9 +307,6 @@ class LobbyService:
|
||||
room_id: UUID,
|
||||
participant_id: str,
|
||||
allow_entry: bool,
|
||||
encrypted_key: str = '',
|
||||
admin_ephemeral_public_key: str = '',
|
||||
encrypted_vault_key: str = '',
|
||||
) -> None:
|
||||
"""Handle decision on participant entry.
|
||||
|
||||
@@ -371,13 +325,7 @@ class LobbyService:
|
||||
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
||||
}
|
||||
|
||||
self._update_participant_status(
|
||||
room_id, participant_id,
|
||||
encrypted_key=encrypted_key,
|
||||
admin_ephemeral_public_key=admin_ephemeral_public_key,
|
||||
encrypted_vault_key=encrypted_vault_key,
|
||||
**decision,
|
||||
)
|
||||
self._update_participant_status(room_id, participant_id, **decision)
|
||||
|
||||
def _update_participant_status(
|
||||
self,
|
||||
@@ -385,9 +333,6 @@ class LobbyService:
|
||||
participant_id: str,
|
||||
status: LobbyParticipantStatus,
|
||||
timeout: int,
|
||||
encrypted_key: str = '',
|
||||
admin_ephemeral_public_key: str = '',
|
||||
encrypted_vault_key: str = '',
|
||||
) -> None:
|
||||
"""Update participant status with appropriate timeout."""
|
||||
|
||||
@@ -408,12 +353,6 @@ class LobbyService:
|
||||
raise
|
||||
|
||||
participant.status = status
|
||||
if encrypted_key:
|
||||
participant.encrypted_key = encrypted_key
|
||||
if admin_ephemeral_public_key:
|
||||
participant.admin_ephemeral_public_key = admin_ephemeral_public_key
|
||||
if encrypted_vault_key:
|
||||
participant.encrypted_vault_key = encrypted_vault_key
|
||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
|
||||
+38
-35
@@ -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,7 +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',
|
||||
encryption_mode: str = "none",
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
@@ -93,15 +94,17 @@ def generate_token(
|
||||
if sources is None:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
# In encrypted rooms, no one can change their name/metadata to prevent
|
||||
# identity spoofing — the admin accepted them based on their declared identity.
|
||||
can_update_metadata = encryption_mode == 'none'
|
||||
# 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 == "none"
|
||||
|
||||
video_grants = VideoGrants(
|
||||
room=room,
|
||||
room_join=True,
|
||||
room_admin=is_admin_or_owner,
|
||||
can_update_own_metadata=can_update_metadata,
|
||||
can_update_own_metadata=can_update_own_metadata,
|
||||
can_publish=bool(sources),
|
||||
can_publish_sources=sources,
|
||||
can_subscribe=True,
|
||||
@@ -117,41 +120,26 @@ def generate_token(
|
||||
if color is None:
|
||||
color = generate_color(identity)
|
||||
|
||||
# Build participant attributes — these are server-signed in the JWT
|
||||
# and visible to all participants in the room.
|
||||
attributes = {
|
||||
"color": color,
|
||||
"room_admin": "true" if is_admin_or_owner else "false",
|
||||
"is_authenticated": "true" if not user.is_anonymous else "false",
|
||||
}
|
||||
|
||||
# Add identity info for authenticated users in encrypted rooms only.
|
||||
#
|
||||
# Email and suite_user_id are included in the JWT attributes for encrypted
|
||||
# rooms because:
|
||||
# - Email: allows admins to verify participant identity in the lobby and
|
||||
# participant list (important for trust decisions in encrypted meetings)
|
||||
# - suite_user_id: required for vault key exchange in advanced encryption
|
||||
# (vaultClient.shareKeys needs the recipient's user ID)
|
||||
#
|
||||
# These attributes are NOT included in non-encrypted rooms because:
|
||||
# - Non-encrypted rooms have no waiting room, so anonymous users can join
|
||||
# freely and would see everyone's email via LiveKit signaling
|
||||
# - LiveKit JWT attributes are immutable and broadcast to ALL participants
|
||||
# equally — there is no way to show them only to authenticated users
|
||||
# at the protocol level
|
||||
# - The frontend additionally hides email from anonymous users in the UI,
|
||||
# but this is defense-in-depth, not the primary protection
|
||||
#
|
||||
# Future improvement: serve email via a Django API endpoint that checks
|
||||
# the requester's authentication, removing it from the JWT entirely.
|
||||
# This would require the backend to call LiveKit's ListParticipants API
|
||||
# to cross-reference identities with the user database.
|
||||
if not user.is_anonymous and encryption_mode != 'none':
|
||||
if user.email:
|
||||
attributes["email"] = user.email
|
||||
if user.sub:
|
||||
attributes["suite_user_id"] = str(user.sub)
|
||||
# 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 != "none"
|
||||
and getattr(user, "email", None)
|
||||
):
|
||||
attributes["email"] = user.email
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
@@ -164,6 +152,21 @@ def generate_token(
|
||||
.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 != "none":
|
||||
token = token.with_room_config(
|
||||
RoomConfiguration(
|
||||
name=room,
|
||||
metadata=json.dumps({"encryption_mode": encryption_mode}),
|
||||
)
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
@@ -175,7 +178,7 @@ def generate_livekit_config(
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
encryption_mode: str = 'none',
|
||||
encryption_mode: str = "none",
|
||||
) -> dict:
|
||||
"""Generate LiveKit configuration for room access.
|
||||
|
||||
|
||||
@@ -808,16 +808,12 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# End-to-end encryption settings
|
||||
# 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
|
||||
)
|
||||
ENCRYPTION_VAULT_URL = values.Value(
|
||||
None, environ_name="ENCRYPTION_VAULT_URL", environ_prefix=None
|
||||
)
|
||||
ENCRYPTION_INTERFACE_URL = values.Value(
|
||||
None, environ_name="ENCRYPTION_INTERFACE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# External Applications
|
||||
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
|
||||
|
||||
@@ -14,7 +14,6 @@ import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
import { VaultClientProvider } from '@/features/encryption'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
@@ -26,22 +25,20 @@ function App() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<VaultClientProvider>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</VaultClientProvider>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -54,8 +55,6 @@ export interface ApiConfig {
|
||||
}
|
||||
encryption?: {
|
||||
enabled: boolean
|
||||
vault_url: string
|
||||
interface_url: string
|
||||
}
|
||||
transcription_destination?: string
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BackendLanguage } from '@/utils/languages'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export type ApiUser = {
|
||||
id: string
|
||||
@@ -8,4 +9,5 @@ export type ApiUser = {
|
||||
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'
|
||||
|
||||
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 === '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 (
|
||||
<div
|
||||
role="status"
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Indicator shown at the top-left of an encrypted meeting.
|
||||
*
|
||||
* Initially shows the full label "End-to-end encrypted" with a lock icon.
|
||||
* After a few seconds, collapses to just the lock icon.
|
||||
* On hover, expands back with a smooth animation.
|
||||
* Clicking opens a modal explaining what E2EE means and its limitations.
|
||||
*/
|
||||
import { css } from '@/styled-system/css'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { RiLockFill, RiShieldCheckFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { isEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Dialog, Text } from '@/primitives'
|
||||
|
||||
const COLLAPSE_DELAY = 4000
|
||||
|
||||
export function EncryptedMeetingBanner() {
|
||||
const roomData = useRoomData()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const isStrongEncryption = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsCollapsed(true), COLLAPSE_DELAY)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
if (!isEncryptedRoom(roomData)) return null
|
||||
|
||||
const bgColor = isStrongEncryption ? '#166534' : '#1e3a5f'
|
||||
const hoverBgColor = isStrongEncryption ? '#15803d' : '#2563eb'
|
||||
const icon = isStrongEncryption
|
||||
? <RiShieldCheckFill size={13} color="white" className={css({ flexShrink: 0 })} />
|
||||
: <RiLockFill size={13} color="white" className={css({ flexShrink: 0 })} />
|
||||
const label = isStrongEncryption ? t('bannerStrong') : t('banner')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onMouseEnter={() => setIsCollapsed(false)}
|
||||
onMouseLeave={() => setIsCollapsed(true)}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setIsModalOpen(true)}
|
||||
aria-label={label}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '0.5rem',
|
||||
left: '0.5rem',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
padding: '0.3rem 0.6rem',
|
||||
borderRadius: '1rem',
|
||||
border: '2px solid rgba(0, 0, 0, 0.3)',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 300ms ease',
|
||||
maxWidth: isCollapsed ? '2.2rem' : '16rem',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
paddingRight: isCollapsed ? '0.3rem' : '0.6rem',
|
||||
}}
|
||||
onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = hoverBgColor }}
|
||||
onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = bgColor }}
|
||||
>
|
||||
{icon}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
letterSpacing: '0.02em',
|
||||
transition: 'opacity 200ms ease',
|
||||
})}
|
||||
style={{
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
isOpen={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title={t('bannerModal.title')}
|
||||
>
|
||||
<VStack
|
||||
gap="1rem"
|
||||
alignItems="start"
|
||||
className={css({ maxWidth: '24rem' })}
|
||||
>
|
||||
<Text variant="sm">
|
||||
{isStrongEncryption
|
||||
? t('bannerModal.descriptionAdvanced')
|
||||
: t('bannerModal.descriptionBasic')}
|
||||
</Text>
|
||||
|
||||
<VStack gap="0.5rem" alignItems="start" className={css({ width: '100%' })}>
|
||||
<Text variant="sm" className={css({ fontWeight: 600 })}>
|
||||
{t('bannerModal.guarantees')}
|
||||
</Text>
|
||||
<ul
|
||||
className={css({
|
||||
paddingLeft: '1.5rem',
|
||||
fontSize: '0.85rem',
|
||||
listStyleType: 'disc',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.4rem',
|
||||
'& li': {
|
||||
paddingLeft: '0.25rem',
|
||||
},
|
||||
'& li::marker': {
|
||||
color: '#22c55e',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<li>{t('bannerModal.guarantee1')}</li>
|
||||
<li>{t('bannerModal.guarantee2')}</li>
|
||||
<li>{t('bannerModal.guarantee3')}</li>
|
||||
</ul>
|
||||
</VStack>
|
||||
|
||||
<VStack gap="0.5rem" alignItems="start" className={css({ width: '100%' })}>
|
||||
<Text variant="sm" className={css({ fontWeight: 600 })}>
|
||||
{t('bannerModal.limitations')}
|
||||
</Text>
|
||||
<ul
|
||||
className={css({
|
||||
paddingLeft: '1.5rem',
|
||||
fontSize: '0.85rem',
|
||||
listStyleType: 'disc',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.4rem',
|
||||
'& li': {
|
||||
paddingLeft: '0.25rem',
|
||||
},
|
||||
'& li::marker': {
|
||||
color: '#f59e0b',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<li>{t('bannerModal.limitation1')}</li>
|
||||
<li>{isStrongEncryption
|
||||
? t('bannerModal.limitation2Advanced')
|
||||
: t('bannerModal.limitation2Basic')}
|
||||
</li>
|
||||
</ul>
|
||||
</VStack>
|
||||
|
||||
<Text
|
||||
variant="note"
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
paddingTop: '0.75rem',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{t('bannerModal.note')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Per-participant encryption trust badge.
|
||||
*
|
||||
* In advanced mode:
|
||||
* - "verified": Green shield — fingerprint explicitly trusted
|
||||
* - "unknown": Grey shield — has public key, not yet verified
|
||||
* - "refused": Red shield — fingerprint previously refused
|
||||
* - "authenticated": Blue shield — ProConnect, no vault keys
|
||||
* - "anonymous": Orange warning — not signed in
|
||||
*
|
||||
* In basic mode:
|
||||
* - "authenticated": Blue shield — ProConnect
|
||||
* - "anonymous": Orange warning — not signed in
|
||||
*/
|
||||
import {
|
||||
RiShieldCheckFill,
|
||||
RiShieldFill,
|
||||
RiShieldCrossFill,
|
||||
RiErrorWarningFill,
|
||||
RiLockFill,
|
||||
} from '@remixicon/react'
|
||||
import type { TrustLevel } from './types'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface EncryptionBadgeProps {
|
||||
trustLevel: TrustLevel | null
|
||||
isEncrypted: boolean
|
||||
}
|
||||
|
||||
export function EncryptionBadge({
|
||||
trustLevel,
|
||||
isEncrypted,
|
||||
}: EncryptionBadgeProps) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
|
||||
|
||||
if (!isEncrypted) return null
|
||||
|
||||
let icon: React.ReactNode
|
||||
let label: string
|
||||
|
||||
switch (trustLevel) {
|
||||
case 'verified':
|
||||
icon = <RiShieldCheckFill size={14} color="#22c55e" />
|
||||
label = t('verified')
|
||||
break
|
||||
case 'unknown':
|
||||
icon = <RiShieldFill size={14} color="#9ca3af" />
|
||||
label = t('unknown')
|
||||
break
|
||||
case 'refused':
|
||||
icon = <RiShieldCrossFill size={14} color="#ef4444" />
|
||||
label = t('refused')
|
||||
break
|
||||
case 'authenticated':
|
||||
icon = <RiShieldCheckFill size={14} color="#3b82f6" />
|
||||
label = t('authenticated')
|
||||
break
|
||||
case 'anonymous':
|
||||
icon = <RiErrorWarningFill size={15} color="#d97706" />
|
||||
label = t('anonymous')
|
||||
break
|
||||
default:
|
||||
icon = <RiLockFill size={14} />
|
||||
label = t('default')
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={label}
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginRight: '0.15rem',
|
||||
cursor: 'inherit',
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
interface EncryptionContextValue {
|
||||
symmetricKey?: Uint8Array
|
||||
}
|
||||
|
||||
const EncryptionContext = createContext<EncryptionContextValue>({})
|
||||
|
||||
export const EncryptionProvider = EncryptionContext.Provider
|
||||
export const useEncryptionContext = () => useContext(EncryptionContext)
|
||||
@@ -1,326 +0,0 @@
|
||||
/**
|
||||
* Dialog showing a participant's encryption fingerprint.
|
||||
* Allows the admin to verify, accept, or refuse the fingerprint.
|
||||
*
|
||||
* This connects to the encryption library's VaultClient to check/accept/refuse
|
||||
* fingerprints from the TOFU (Trust On First Use) registry.
|
||||
*/
|
||||
import { css } from '@/styled-system/css'
|
||||
import { VStack, HStack } from '@/styled-system/jsx'
|
||||
import { Dialog, Text, Button } from '@/primitives'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useUser } from '@/features/auth'
|
||||
import {
|
||||
RiShieldCheckFill,
|
||||
RiShieldCheckLine,
|
||||
RiAlertLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useVaultClient } from './VaultClientProvider'
|
||||
import { formatFingerprint } from './useParticipantTrustLevel'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface EncryptionIdentityDialogProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
participantName: string
|
||||
participantEmail?: string
|
||||
suiteUserId?: string
|
||||
isAuthenticated: boolean
|
||||
encryptionMode?: 'basic' | 'advanced' | 'none'
|
||||
isSelf?: boolean
|
||||
preloadedFingerprint?: string | null
|
||||
preloadedFingerprintStatus?: string | null
|
||||
}
|
||||
|
||||
type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error'
|
||||
|
||||
export function EncryptionIdentityDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
participantName,
|
||||
participantEmail,
|
||||
suiteUserId,
|
||||
isAuthenticated,
|
||||
encryptionMode,
|
||||
isSelf,
|
||||
preloadedFingerprint,
|
||||
preloadedFingerprintStatus,
|
||||
}: EncryptionIdentityDialogProps) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' })
|
||||
const { client: vaultClient } = useVaultClient()
|
||||
const { isLoggedIn } = useUser()
|
||||
const [status, setStatus] = useState<FingerprintStatus>(
|
||||
(preloadedFingerprintStatus as FingerprintStatus) || 'loading'
|
||||
)
|
||||
const [fingerprint, setFingerprint] = useState<string | null>(preloadedFingerprint || null)
|
||||
|
||||
// Sync preloaded data when it becomes available (hook resolves after mount)
|
||||
useEffect(() => {
|
||||
if (preloadedFingerprintStatus) setStatus(preloadedFingerprintStatus as FingerprintStatus)
|
||||
if (preloadedFingerprint) setFingerprint(preloadedFingerprint)
|
||||
}, [preloadedFingerprint, preloadedFingerprintStatus])
|
||||
|
||||
const isBasicMode = encryptionMode !== 'advanced'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
// In basic mode, no fingerprint check — identity is from ProConnect only
|
||||
if (isBasicMode) {
|
||||
setStatus(isAuthenticated ? 'no-key' : 'no-key')
|
||||
return
|
||||
}
|
||||
if (!vaultClient) {
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
if (!suiteUserId) {
|
||||
setStatus(isAuthenticated ? 'no-key' : 'no-key')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function checkFingerprint() {
|
||||
try {
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('timeout')), 3000)
|
||||
)
|
||||
|
||||
const fetchResult = await Promise.race([
|
||||
vaultClient!.fetchPublicKeys([suiteUserId!]),
|
||||
timeout,
|
||||
])
|
||||
|
||||
const publicKey = fetchResult.publicKeys[suiteUserId!]
|
||||
|
||||
if (!publicKey || cancelled) {
|
||||
setStatus('no-key')
|
||||
return
|
||||
}
|
||||
|
||||
// Compute fingerprint from the public key (SHA-256, first 16 hex chars)
|
||||
const hash = await crypto.subtle.digest('SHA-256', publicKey)
|
||||
const fp = Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.slice(0, 16)
|
||||
|
||||
if (cancelled) return
|
||||
setFingerprint(fp)
|
||||
|
||||
// Check local registry without triggering TOFU auto-trust
|
||||
const { fingerprints: known } = await Promise.race([
|
||||
vaultClient!.getKnownFingerprints(),
|
||||
timeout,
|
||||
])
|
||||
if (cancelled) return
|
||||
|
||||
const knownEntry = known[suiteUserId!]
|
||||
if (!knownEntry) {
|
||||
setStatus('unknown')
|
||||
} else if (knownEntry.fingerprint === fp) {
|
||||
setStatus(knownEntry.status)
|
||||
} else {
|
||||
// Fingerprint changed — needs re-verification
|
||||
setStatus('unknown')
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
checkFingerprint()
|
||||
return () => { cancelled = true }
|
||||
}, [isOpen, vaultClient, suiteUserId, isAuthenticated])
|
||||
|
||||
const handleAccept = async () => {
|
||||
if (!vaultClient || !suiteUserId || !fingerprint) return
|
||||
try {
|
||||
await vaultClient.acceptFingerprint(suiteUserId, fingerprint)
|
||||
setStatus('trusted')
|
||||
} catch {
|
||||
// Failed to accept
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefuse = async () => {
|
||||
if (!vaultClient || !suiteUserId || !fingerprint) return
|
||||
try {
|
||||
await vaultClient.refuseFingerprint(suiteUserId, fingerprint)
|
||||
setStatus('refused')
|
||||
} catch {
|
||||
// Failed to refuse
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title={t('title')}
|
||||
>
|
||||
<VStack
|
||||
gap="0.75rem"
|
||||
alignItems="start"
|
||||
className={css({ maxWidth: '22rem' })}
|
||||
>
|
||||
<HStack gap="0.65rem" className={css({ width: '100%' })}>
|
||||
<div className={css({ flexShrink: 0, transform: 'scale(0.85)' })}>
|
||||
<Avatar name={participantName} bgColor="rgb(87, 44, 216)" />
|
||||
</div>
|
||||
<VStack gap="0" alignItems="start">
|
||||
<Text className={css({ fontWeight: 600, fontSize: '0.9rem' })}>{participantName}</Text>
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}>
|
||||
{isLoggedIn && participantEmail ? participantEmail : (!isAuthenticated ? t('anonymous') : '')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||
{isSelf
|
||||
? (isAuthenticated ? t('descriptionSelf') : t('descriptionSelfAnonymous'))
|
||||
: t('description')}
|
||||
</Text>
|
||||
|
||||
{status === 'loading' && (
|
||||
<Text variant="note">{t('loading')}</Text>
|
||||
)}
|
||||
|
||||
{status === 'no-key' && isBasicMode && isAuthenticated && (
|
||||
<HStack
|
||||
gap="0.5rem"
|
||||
className={css({
|
||||
backgroundColor: '#eff6ff',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
width: '100%',
|
||||
border: '1px solid #bfdbfe',
|
||||
})}
|
||||
>
|
||||
<RiShieldCheckLine size={20} color="#3b82f6" className={css({ flexShrink: 0 })} />
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||
{t('noKeyBasicAuthenticated')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{status === 'no-key' && !(isBasicMode && isAuthenticated) && !isSelf && (
|
||||
<HStack
|
||||
gap="0.5rem"
|
||||
className={css({
|
||||
backgroundColor: '#fffbeb',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
width: '100%',
|
||||
border: '1px solid #fde68a',
|
||||
})}
|
||||
>
|
||||
<RiAlertLine size={20} color="#f59e0b" className={css({ flexShrink: 0 })} />
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||
{isAuthenticated ? t('noKey') : t('noKeyAnonymous')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<Text variant="note" className={css({ color: '#ef4444' })}>
|
||||
{t('error')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(status === 'trusted' || status === 'refused' || status === 'unknown') && fingerprint && (
|
||||
<>
|
||||
<VStack
|
||||
gap="0.25rem"
|
||||
className={css({
|
||||
backgroundColor: 'greyscale.50',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
width: '100%',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
letterSpacing: '0.05em',
|
||||
wordBreak: 'break-all',
|
||||
})}
|
||||
>
|
||||
<Text variant="note" className={css({ fontSize: '0.7rem', fontFamily: 'inherit' })}>
|
||||
{t('fingerprintLabel')}
|
||||
</Text>
|
||||
{formatFingerprint(fingerprint)}
|
||||
</VStack>
|
||||
|
||||
{status === 'trusted' && (
|
||||
<VStack gap="0.25rem" alignItems="start">
|
||||
<HStack gap="0.5rem" className={css({ color: '#22c55e' })}>
|
||||
<RiShieldCheckFill size={18} />
|
||||
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
|
||||
{t('trusted')}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||
{isSelf ? t('descriptionSelf') : t('trustedDescription')}
|
||||
</Text>
|
||||
{!isSelf && (
|
||||
<Text
|
||||
variant="note"
|
||||
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
|
||||
onClick={() => setStatus('unknown')}
|
||||
>
|
||||
{t('changeDecision')}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{status === 'refused' && (
|
||||
<VStack gap="0.25rem" alignItems="start">
|
||||
<HStack gap="0.5rem" className={css({ color: '#ef4444' })}>
|
||||
<RiCloseLine size={18} />
|
||||
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
|
||||
{t('refused')}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||
{t('refusedDescription')}
|
||||
</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
|
||||
onClick={() => setStatus('unknown')}
|
||||
>
|
||||
{t('changeDecision')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{status === 'unknown' && !isSelf && (
|
||||
<VStack gap="0.5rem" className={css({ width: '100%' })}>
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||
{t('unknownDescription')}
|
||||
</Text>
|
||||
<Text variant="note" className={css({ fontSize: '0.75rem', fontStyle: 'italic' })}>
|
||||
{t('fingerprintHint')}
|
||||
</Text>
|
||||
<HStack gap="0.5rem">
|
||||
<Button size="sm" variant="primary" onPress={handleAccept}>
|
||||
<RiCheckLine size={16} />
|
||||
{t('accept')}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondaryText" onPress={handleRefuse}>
|
||||
<RiCloseLine size={16} />
|
||||
{t('refuse')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* Overlay shown during encryption key exchange.
|
||||
*
|
||||
* When a participant joins an encrypted room, there's a brief period
|
||||
* between connection and receiving the symmetric key where media
|
||||
* cannot be decrypted. This overlay provides feedback during that time.
|
||||
*
|
||||
* After 20 seconds without the key, shows an error with a refresh button.
|
||||
*/
|
||||
import { css } from '@/styled-system/css'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Text, Button } from '@/primitives'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { RiLockFill, RiAlertFill, RiRefreshLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const KEY_EXCHANGE_TIMEOUT = 20000
|
||||
|
||||
export function EncryptionSetupOverlay({
|
||||
isSettingUp,
|
||||
error,
|
||||
}: {
|
||||
isSettingUp: boolean
|
||||
error: string | null
|
||||
}) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
|
||||
const [timedOut, setTimedOut] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingUp) {
|
||||
setTimedOut(false)
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => setTimedOut(true), KEY_EXCHANGE_TIMEOUT)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isSettingUp])
|
||||
|
||||
if (!isSettingUp && !error) return null
|
||||
|
||||
const showError = error || timedOut
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
})}
|
||||
>
|
||||
<VStack gap="1rem" alignItems="center">
|
||||
{showError ? (
|
||||
<>
|
||||
<RiAlertFill size={36} color="#f87171" />
|
||||
<Text
|
||||
className={css({
|
||||
color: '#f87171',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{timedOut ? t('error.timeout') : t('error.title')}
|
||||
</Text>
|
||||
<Text
|
||||
className={css({
|
||||
color: 'greyscale.300',
|
||||
fontSize: '0.85rem',
|
||||
textAlign: 'center',
|
||||
maxWidth: '20rem',
|
||||
})}
|
||||
>
|
||||
{error || t('error.timeoutHint')}
|
||||
</Text>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onPress={() => window.location.reload()}
|
||||
>
|
||||
<RiRefreshLine size={16} />
|
||||
{t('error.refresh')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiLockFill size={32} color="white" />
|
||||
<Text
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{t('settingUp.title')}
|
||||
</Text>
|
||||
<Text
|
||||
className={css({
|
||||
color: 'greyscale.300',
|
||||
fontSize: '0.85rem',
|
||||
textAlign: 'center',
|
||||
maxWidth: '20rem',
|
||||
})}
|
||||
>
|
||||
{t('settingUp.description')}
|
||||
</Text>
|
||||
<Spinner />
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Modal explaining encryption trust levels.
|
||||
* Shown when admin clicks the trust badge in the waiting room.
|
||||
*/
|
||||
import { css } from '@/styled-system/css'
|
||||
import { VStack, HStack } from '@/styled-system/jsx'
|
||||
import { Dialog, Text } from '@/primitives'
|
||||
import { RiShieldCheckFill, RiShieldCheckLine, RiAlertLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface EncryptionTrustModalProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
participantName: string
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
export function EncryptionTrustModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
participantName,
|
||||
isAuthenticated,
|
||||
}: EncryptionTrustModalProps) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.trustModal' })
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title={t('title')}
|
||||
>
|
||||
<VStack
|
||||
gap="1rem"
|
||||
alignItems="start"
|
||||
className={css({ maxWidth: '22rem' })}
|
||||
>
|
||||
<Text variant="sm">{t('intro', { name: participantName })}</Text>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<HStack
|
||||
gap="0.75rem"
|
||||
className={css({
|
||||
backgroundColor: '#eff6ff',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
width: '100%',
|
||||
border: '1px solid #bfdbfe',
|
||||
})}
|
||||
>
|
||||
<RiShieldCheckLine
|
||||
size={24}
|
||||
color="#3b82f6"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<VStack gap="0.25rem" alignItems="start">
|
||||
<Text className={css({ fontWeight: 600, fontSize: '0.85rem' })}>
|
||||
{t('authenticated.title')}
|
||||
</Text>
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||
{t('authenticated.description')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack
|
||||
gap="0.75rem"
|
||||
className={css({
|
||||
backgroundColor: '#fffbeb',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
width: '100%',
|
||||
border: '1px solid #fde68a',
|
||||
})}
|
||||
>
|
||||
<RiAlertLine
|
||||
size={24}
|
||||
color="#f59e0b"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<VStack gap="0.25rem" alignItems="start">
|
||||
<Text className={css({ fontWeight: 600, fontSize: '0.85rem' })}>
|
||||
{t('anonymous.title')}
|
||||
</Text>
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||
{t('anonymous.description')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<VStack
|
||||
gap="0.5rem"
|
||||
alignItems="start"
|
||||
className={css({
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
paddingTop: '0.75rem',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
variant="note"
|
||||
className={css({ fontWeight: 600, fontSize: '0.8rem' })}
|
||||
>
|
||||
{t('levels.title')}
|
||||
</Text>
|
||||
<HStack gap="0.5rem" alignItems="start">
|
||||
<RiShieldCheckFill
|
||||
size={16}
|
||||
color="#22c55e"
|
||||
className={css({ flexShrink: 0, marginTop: '2px' })}
|
||||
/>
|
||||
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
|
||||
{t('levels.verified')}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack gap="0.5rem" alignItems="start">
|
||||
<RiShieldCheckLine
|
||||
size={16}
|
||||
color="#3b82f6"
|
||||
className={css({ flexShrink: 0, marginTop: '2px' })}
|
||||
/>
|
||||
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
|
||||
{t('levels.authenticated')}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack gap="0.5rem" alignItems="start">
|
||||
<RiAlertLine
|
||||
size={16}
|
||||
color="#f59e0b"
|
||||
className={css({ flexShrink: 0, marginTop: '2px' })}
|
||||
/>
|
||||
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
|
||||
{t('levels.anonymous')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Hybrid key distributor: determines the best key distribution method per participant.
|
||||
*
|
||||
* For each participant joining an encrypted call:
|
||||
* 1. Check if they have a registered public key (via VaultClient/encryption library)
|
||||
* → If YES: wrap symmetric key with their public key (PKI path) → trust level "verified"
|
||||
* 2. Check if they are authenticated via ProConnect
|
||||
* → If YES but no public key: use ephemeral DH → trust level "authenticated"
|
||||
* 3. Otherwise: use ephemeral DH → trust level "anonymous"
|
||||
*
|
||||
* The symmetric key is always the same for everyone — only the distribution channel varies.
|
||||
*/
|
||||
import type { TrustLevel } from './types'
|
||||
import { PARTICIPANT_TRUST_ATTR } from './types'
|
||||
|
||||
export interface ParticipantEncryptionInfo {
|
||||
identity: string
|
||||
trustLevel: TrustLevel
|
||||
hasPublicKey: boolean
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the trust level for a participant based on their encryption capabilities.
|
||||
*/
|
||||
export function determineTrustLevel(
|
||||
hasPublicKey: boolean,
|
||||
isAuthenticated: boolean
|
||||
): TrustLevel {
|
||||
if (hasPublicKey) return 'verified'
|
||||
if (isAuthenticated) return 'authenticated'
|
||||
return 'anonymous'
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive trust level from participant's server-signed attributes.
|
||||
*
|
||||
* The `is_authenticated` attribute is set by the backend in the LiveKit JWT token
|
||||
* and cannot be spoofed by clients. It indicates whether the participant
|
||||
* authenticated via OIDC (ProConnect/Keycloak).
|
||||
*
|
||||
* In basic encryption mode, the "verified" level is never returned because
|
||||
* PKI keys are not used — encryption relies on a shared passphrase, not on
|
||||
* per-user public keys. The green shield would be misleading.
|
||||
*
|
||||
* In advanced encryption mode, "verified" means the participant has completed
|
||||
* encryption onboarding and their public key is used to encrypt the symmetric key.
|
||||
*/
|
||||
export function getTrustLevelFromAttributes(
|
||||
attributes: Record<string, string> | undefined,
|
||||
encryptionMode?: 'basic' | 'advanced' | 'none',
|
||||
): TrustLevel | null {
|
||||
if (!attributes) return null
|
||||
|
||||
const isAdvanced = encryptionMode === 'advanced'
|
||||
|
||||
// Check for explicit trust level (set by PKI integration)
|
||||
const explicitLevel = attributes[PARTICIPANT_TRUST_ATTR]
|
||||
if (explicitLevel === 'verified' && isAdvanced) {
|
||||
return 'verified'
|
||||
}
|
||||
if (explicitLevel === 'authenticated' || explicitLevel === 'anonymous') {
|
||||
return explicitLevel
|
||||
}
|
||||
|
||||
// Derive from server-signed is_authenticated attribute
|
||||
if (attributes.is_authenticated === 'true') {
|
||||
return 'authenticated'
|
||||
}
|
||||
|
||||
return 'anonymous'
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to distribute the symmetric key via PKI (encryption library).
|
||||
* Returns true if successful, false if the participant doesn't have a public key.
|
||||
*/
|
||||
export async function distributeKeyViaPKI(
|
||||
vaultClient: VaultClient,
|
||||
symmetricKey: Uint8Array,
|
||||
participantUserId: string
|
||||
): Promise<{ success: boolean; encryptedKey?: ArrayBuffer }> {
|
||||
try {
|
||||
const { publicKeys } = await vaultClient.fetchPublicKeys([
|
||||
participantUserId,
|
||||
])
|
||||
const publicKey = publicKeys[participantUserId]
|
||||
|
||||
if (!publicKey) {
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
// Use encryptWithoutKey to wrap the symmetric key for this user
|
||||
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||
symmetricKey.buffer as ArrayBuffer,
|
||||
{ [participantUserId]: publicKey }
|
||||
)
|
||||
|
||||
const encryptedKey = encryptedKeys[participantUserId]
|
||||
if (!encryptedKey) {
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
return { success: true, encryptedKey }
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[Encryption] PKI key distribution failed for participant:',
|
||||
participantUserId,
|
||||
err
|
||||
)
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode trust level into participant attributes for badge display.
|
||||
*/
|
||||
export function encodeTrustLevelAttribute(
|
||||
trustLevel: TrustLevel
|
||||
): Record<string, string> {
|
||||
return { [PARTICIPANT_TRUST_ATTR]: trustLevel }
|
||||
}
|
||||
@@ -0,0 +1,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'
|
||||
|
||||
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 (
|
||||
<div
|
||||
onMouseEnter={() => setCollapsed(false)}
|
||||
onMouseLeave={() => setCollapsed(true)}
|
||||
role="status"
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 === 'basic'
|
||||
|
||||
if (!isEncrypted && !isRecording && !isTranscribing) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack
|
||||
gap="0.4rem"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '0.5rem',
|
||||
left: '0.5rem',
|
||||
zIndex: 10,
|
||||
})}
|
||||
>
|
||||
{isEncrypted && (
|
||||
<StatusPill
|
||||
key="encrypted"
|
||||
icon={<RiShieldCheckLine size={14} color="white" />}
|
||||
label={t('encrypted')}
|
||||
background="#1e3a5f"
|
||||
/>
|
||||
)}
|
||||
{isTranscribing && (
|
||||
<StatusPill
|
||||
key="transcript"
|
||||
icon={<RiFileTextFill size={13} color="white" />}
|
||||
label={t('transcribing')}
|
||||
background="#7c2d12"
|
||||
/>
|
||||
)}
|
||||
{isRecording && (
|
||||
<StatusPill
|
||||
key="recording"
|
||||
icon={<RiRecordCircleFill size={13} color="white" />}
|
||||
label={t('recording')}
|
||||
background="#b91c1c"
|
||||
pulse
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
# Encryption Security Architecture
|
||||
|
||||
## Threat model
|
||||
|
||||
### What E2EE protects against
|
||||
- **Server-side data access**: The LiveKit SFU and Meet backend cannot read audio/video content
|
||||
- **Network interception**: Media frames are encrypted before leaving the client
|
||||
- **Unauthorized participants**: Restricted access + lobby ensures only admin-approved users join
|
||||
|
||||
### Known limitations and mitigations
|
||||
|
||||
#### Compromised LiveKit server (MITM on key exchange)
|
||||
|
||||
**Threat**: If the LiveKit server is compromised, it could perform a Man-in-the-Middle attack on the ephemeral DH key exchange, intercepting the symmetric key.
|
||||
|
||||
**Current mitigation**: KEY_RESPONSE is only accepted from participants with `room_admin: "true"` in their server-signed JWT attributes. This prevents non-admin participants from injecting fake keys, but does not protect against a compromised server that can forge JWT attributes.
|
||||
|
||||
**Planned mitigations (3 levels):**
|
||||
|
||||
##### Level 1 — Signed key exchange (requires encryption onboarding)
|
||||
|
||||
When the admin has completed encryption onboarding via `data.encryption`:
|
||||
1. Admin signs the KEY_RESPONSE with their permanent private key (stored in IndexedDB)
|
||||
2. Receiving participant fetches admin's public key from `data.encryption` registry
|
||||
3. Verifies the signature before accepting the symmetric key
|
||||
4. If signature is invalid → **reject the key, show error, cut video**
|
||||
|
||||
This protects against server compromise because the server cannot forge the admin's private key signature.
|
||||
|
||||
**Requirement**: Admin must have completed encryption onboarding. If not, falls back to Level 2.
|
||||
|
||||
##### Level 2 — SAS (Short Authentication String) verification
|
||||
|
||||
After the ephemeral DH key exchange:
|
||||
1. Both parties compute SAS = hash(DH_shared_secret) → displayed as 4 emojis or a 6-digit code
|
||||
2. Each participant sees the SAS on their own screen (local rendering)
|
||||
3. They read it aloud to each other during the call
|
||||
4. If the SAS matches → the key exchange was not intercepted
|
||||
5. If the SAS doesn't match → MITM detected → reject the key
|
||||
|
||||
This works because:
|
||||
- A MITM results in different DH shared secrets → different SAS codes
|
||||
- The SAS is rendered locally — the server cannot change what appears on screen
|
||||
- Real-time audio manipulation to fake the spoken SAS is extremely difficult
|
||||
|
||||
**Requirement**: Participants must verbally compare the SAS. Optional but recommended.
|
||||
|
||||
##### Level 3 — Trust the server (current default)
|
||||
|
||||
Relies on the LiveKit server's integrity (JWT-signed attributes). Suitable when:
|
||||
- The server infrastructure is self-hosted and trusted
|
||||
- The threat model does not include server compromise
|
||||
- Quick, frictionless meetings are prioritized over maximum security
|
||||
|
||||
#### Key propagation without admin
|
||||
|
||||
**Current behavior**: Any participant who has the symmetric key can relay it to new joiners.
|
||||
|
||||
**Risk**: If the server is compromised, it could inject a fake participant who relays a compromised key.
|
||||
|
||||
**Planned fix**: Only accept KEY_RESPONSE from participants whose identity can be:
|
||||
- Cryptographically verified (Level 1 — signature from registered public key), or
|
||||
- Manually verified (Level 2 — SAS comparison)
|
||||
|
||||
Non-verified key relays should show a clear warning.
|
||||
|
||||
## Trust levels
|
||||
|
||||
| Level | Badge | Identity verification | Key exchange | Server compromise protection |
|
||||
|-------|-------|----------------------|-------------|------------------------------|
|
||||
| Verified | 🟢 Green shield | Public key registered in `data.encryption` | Signed with permanent private key | Yes — signature cannot be forged |
|
||||
| Authenticated | 🔵 Blue shield | OIDC/ProConnect login | Ephemeral DH (unsigned) | No — relies on server integrity |
|
||||
| Anonymous | 🟡 Orange warning | None (self-declared name) | Ephemeral DH (unsigned) | No — relies on server integrity |
|
||||
|
||||
#### Basic mode: unencrypted frame window on connection
|
||||
|
||||
**Behavior**: LiveKit's built-in Worker passes frames through unencrypted when `!isEnabled()`.
|
||||
|
||||
**Mitigation**: `setE2EEEnabled(true)` is called BEFORE the room connects (in Conference.tsx),
|
||||
ensuring the 'enable' message reaches the Worker before any frames flow. This eliminates the
|
||||
unencrypted window in normal operation. However, edge cases (Worker message queue delays,
|
||||
race conditions during reconnection) could theoretically still allow a few unencrypted frames.
|
||||
|
||||
**Advanced mode**: VaultE2EEManager drops frames when the key isn't ready — no pass-through.
|
||||
|
||||
#### Basic mode: "Decryption failed" overlay may not appear with wrong passphrase
|
||||
|
||||
**Behavior**: When a participant joins with a wrong passphrase, the receiver may not show the
|
||||
"Decryption failed" overlay. The LiveKit Worker's error throttling (`MAX_ERRORS_PER_MINUTE = 5`)
|
||||
stops emitting `EncryptionError` events after 5 failures. Additionally, when a participant
|
||||
reconnects, the new `ParticipantTile` mounts fresh and may not receive errors referencing
|
||||
the new participant identity.
|
||||
|
||||
**Impact**: The user sees a black tile but no error message explaining why.
|
||||
|
||||
**Advanced mode**: VaultE2EEManager emits `EncryptionError` for each failure and signals
|
||||
`ParticipantEncryptionStatusChanged(true)` on first successful decrypt, ensuring the overlay
|
||||
appears and clears correctly.
|
||||
|
||||
## Implementation status
|
||||
|
||||
- [x] Basic E2EE with LiveKit Worker + passphrase in URL hash
|
||||
- [x] Advanced E2EE with VaultClient iframe (XChaCha20-Poly1305)
|
||||
- [x] Preserved codec header bytes for RTP compatibility
|
||||
- [x] Admin as key authority
|
||||
- [x] Server-signed trust attributes in JWT
|
||||
- [x] Trust badges (verified/unknown/refused/authenticated/anonymous)
|
||||
- [x] Encryption identity dialog with fingerprint verification
|
||||
- [x] Encryption settings in account menu (VaultClient onboarding)
|
||||
- [x] Fingerprint accept/refuse with `fingerprint-changed` event
|
||||
- [x] Disable recording/transcription in encrypted rooms (backend + frontend)
|
||||
- [x] Lobby bypass disabled for encrypted rooms
|
||||
- [x] Backend blocks encrypted room creation when `ENCRYPTION_ENABLED=false`
|
||||
- [ ] Signed KEY_RESPONSE (Level 1)
|
||||
- [ ] SAS verification (Level 2)
|
||||
- [ ] Restrict key propagation to verified participants only
|
||||
- [x] Mitigate unencrypted frame window (setE2EEEnabled before connection)
|
||||
@@ -1,231 +0,0 @@
|
||||
/**
|
||||
* React context provider for the centralized encryption VaultClient SDK.
|
||||
*
|
||||
* The client SDK is loaded at runtime via a <script> tag from the vault domain
|
||||
* (data.encryption). This provider:
|
||||
* - Loads the client.js script from the vault URL
|
||||
* - Creates and initializes the VaultClient instance
|
||||
* - Sets auth context when the user logs in
|
||||
* - Tracks key state (hasKeys, publicKey)
|
||||
* - Provides the client to all downstream components
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export interface VaultClientContextValue {
|
||||
client: VaultClient | null
|
||||
isReady: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
hasKeys: boolean | null
|
||||
publicKey: ArrayBuffer | null
|
||||
refreshKeyState: () => Promise<void>
|
||||
}
|
||||
|
||||
const VaultClientContext = createContext<VaultClientContextValue>({
|
||||
client: null,
|
||||
isReady: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
hasKeys: null,
|
||||
publicKey: null,
|
||||
refreshKeyState: async () => {},
|
||||
})
|
||||
|
||||
function loadClientScript(vaultUrl: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.EncryptionClient?.VaultClient) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const scriptSrc = `${vaultUrl}/client.js`
|
||||
const existing = document.querySelector(`script[src="${scriptSrc}"]`)
|
||||
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve())
|
||||
existing.addEventListener('error', () =>
|
||||
reject(new Error('Failed to load encryption client SDK'))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = scriptSrc
|
||||
script.async = true
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () =>
|
||||
reject(new Error('Failed to load encryption client SDK'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
export function VaultClientProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { data: config } = useConfig()
|
||||
const { i18n } = useTranslation()
|
||||
const { user } = useUser()
|
||||
const clientRef = useRef<VaultClient | null>(null)
|
||||
const [clientInitialized, setClientInitialized] = useState(false)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasKeys, setHasKeys] = useState<boolean | null>(null)
|
||||
const [publicKey, setPublicKey] = useState<ArrayBuffer | null>(null)
|
||||
const initRef = useRef(false)
|
||||
|
||||
const vaultUrl = config?.encryption?.vault_url
|
||||
const interfaceUrl = config?.encryption?.interface_url
|
||||
|
||||
// Load script + initialize VaultClient once
|
||||
useEffect(() => {
|
||||
if (initRef.current || !vaultUrl || !interfaceUrl) return
|
||||
initRef.current = true
|
||||
|
||||
let destroyed = false
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
await loadClientScript(vaultUrl!)
|
||||
|
||||
if (destroyed) return
|
||||
|
||||
const client = new window.EncryptionClient.VaultClient({
|
||||
vaultUrl: vaultUrl!,
|
||||
interfaceUrl: interfaceUrl!,
|
||||
lang: i18n.language,
|
||||
})
|
||||
|
||||
clientRef.current = client
|
||||
|
||||
client.on('onboarding:complete', () => {
|
||||
setHasKeys(true)
|
||||
client
|
||||
.getPublicKey()
|
||||
.then(({ publicKey: pk }) => setPublicKey(pk))
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
client.on('keys-changed', () => {
|
||||
client
|
||||
.hasKeys()
|
||||
.then(({ hasKeys: exists }) => {
|
||||
setHasKeys(exists)
|
||||
if (exists) {
|
||||
client
|
||||
.getPublicKey()
|
||||
.then(({ publicKey: pk }) => setPublicKey(pk))
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
client.on('keys-destroyed', () => {
|
||||
setHasKeys(false)
|
||||
setPublicKey(null)
|
||||
})
|
||||
|
||||
await client.init()
|
||||
|
||||
if (destroyed) {
|
||||
client.destroy()
|
||||
} else {
|
||||
setClientInitialized(true)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!destroyed) {
|
||||
setError((err as Error).message)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void init()
|
||||
|
||||
return () => {
|
||||
destroyed = true
|
||||
if (clientRef.current) {
|
||||
clientRef.current.destroy()
|
||||
clientRef.current = null
|
||||
}
|
||||
}
|
||||
}, [vaultUrl, interfaceUrl, i18n.language])
|
||||
|
||||
// Set auth context when user is available
|
||||
// Note: Meet may have anonymous users — VaultClient only works for authenticated users
|
||||
// with a suite_user_id. For anonymous users, isReady stays false.
|
||||
useEffect(() => {
|
||||
const client = clientRef.current
|
||||
if (!client || !clientInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
const suiteUserId = (user as Record<string, unknown>)?.sub as string | undefined
|
||||
if (suiteUserId) {
|
||||
client.setAuthContext({ suiteUserId })
|
||||
setIsReady(true)
|
||||
// Check key state now that auth context is set
|
||||
client.hasKeys()
|
||||
.then(({ hasKeys: exists }) => {
|
||||
setHasKeys(exists)
|
||||
if (exists) {
|
||||
client.getPublicKey()
|
||||
.then(({ publicKey: pk }) => setPublicKey(pk))
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
setIsLoading(false)
|
||||
}, [clientInitialized, (user as Record<string, unknown>)?.sub])
|
||||
|
||||
const refreshKeyState = useCallback(async () => {
|
||||
const client = clientRef.current
|
||||
if (!client) return
|
||||
|
||||
try {
|
||||
const { hasKeys: exists } = await client.hasKeys()
|
||||
setHasKeys(exists)
|
||||
if (exists) {
|
||||
const { publicKey: pk } = await client.getPublicKey()
|
||||
setPublicKey(pk)
|
||||
} else {
|
||||
setPublicKey(null)
|
||||
}
|
||||
} catch {
|
||||
// Vault not available
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<VaultClientContext.Provider
|
||||
value={{
|
||||
client: clientInitialized ? clientRef.current : null,
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
hasKeys,
|
||||
publicKey,
|
||||
refreshKeyState,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</VaultClientContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useVaultClient = (): VaultClientContextValue =>
|
||||
useContext(VaultClientContext)
|
||||
@@ -1,396 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
VaultE2EEManager,
|
||||
getUnencryptedBytes,
|
||||
UNENCRYPTED_BYTES,
|
||||
} from './VaultE2EEManager'
|
||||
|
||||
// ── getUnencryptedBytes ───────────────────────────────────────────────
|
||||
|
||||
describe('getUnencryptedBytes', () => {
|
||||
it('returns 10 for VP8 keyframes', () => {
|
||||
const frame = { type: 'key', data: new ArrayBuffer(100) }
|
||||
expect(getUnencryptedBytes(frame as unknown as RTCEncodedVideoFrame)).toBe(
|
||||
UNENCRYPTED_BYTES.key
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 3 for VP8 delta frames', () => {
|
||||
const frame = { type: 'delta', data: new ArrayBuffer(100) }
|
||||
expect(getUnencryptedBytes(frame as unknown as RTCEncodedVideoFrame)).toBe(
|
||||
UNENCRYPTED_BYTES.delta
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 1 for audio frames (no type property)', () => {
|
||||
const frame = { data: new ArrayBuffer(100) }
|
||||
expect(getUnencryptedBytes(frame as unknown as RTCEncodedAudioFrame)).toBe(
|
||||
UNENCRYPTED_BYTES.audio
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Mock VaultClient ──────────────────────────────────────────────────
|
||||
|
||||
function createMockVaultClient() {
|
||||
// Simulates vault crypto: prepends 24-byte nonce + appends 16-byte MAC
|
||||
const NONCE_LEN = 24
|
||||
const MAC_LEN = 16
|
||||
|
||||
return {
|
||||
encryptWithKey: vi.fn(async (data: ArrayBuffer, _key: ArrayBuffer) => {
|
||||
const input = new Uint8Array(data)
|
||||
const nonce = new Uint8Array(NONCE_LEN).fill(0xaa) // deterministic for tests
|
||||
const ciphertext = new Uint8Array(input.length + MAC_LEN)
|
||||
ciphertext.set(input) // "encrypt" = copy (for testing)
|
||||
ciphertext.set(new Uint8Array(MAC_LEN).fill(0xbb), input.length) // fake MAC
|
||||
|
||||
const result = new Uint8Array(NONCE_LEN + ciphertext.length)
|
||||
result.set(nonce)
|
||||
result.set(ciphertext, NONCE_LEN)
|
||||
return { encryptedData: result.buffer }
|
||||
}),
|
||||
|
||||
decryptWithKey: vi.fn(
|
||||
async (encryptedData: ArrayBuffer, _key: ArrayBuffer) => {
|
||||
const input = new Uint8Array(encryptedData)
|
||||
// Strip nonce (24B) and MAC (16B)
|
||||
const plaintext = input.slice(NONCE_LEN, input.length - MAC_LEN)
|
||||
return { data: plaintext.buffer }
|
||||
}
|
||||
),
|
||||
} as unknown as VaultClient
|
||||
}
|
||||
|
||||
// ── Key management ────────────────────────────────────────────────────
|
||||
|
||||
describe('VaultE2EEManager key management', () => {
|
||||
it('stores an independent copy of the key', () => {
|
||||
const vaultClient = createMockVaultClient()
|
||||
const manager = new VaultE2EEManager(vaultClient)
|
||||
|
||||
const original = new Uint8Array([1, 2, 3, 4])
|
||||
manager.setEncryptedSymmetricKey(original.buffer)
|
||||
|
||||
// Mutate original — should not affect stored key
|
||||
original[0] = 99
|
||||
|
||||
// Access internal state via encryptData (which uses freshKeyBuffer)
|
||||
// If the key was a view on the original, this would reflect the mutation
|
||||
expect(manager.isDataChannelEncryptionEnabled).toBe(false) // _isDataChannelEncryptionEnabled not set
|
||||
manager.isDataChannelEncryptionEnabled = true
|
||||
expect(manager.isDataChannelEncryptionEnabled).toBe(true) // key is set
|
||||
})
|
||||
|
||||
it('isDataChannelEncryptionEnabled is false without key', () => {
|
||||
const manager = new VaultE2EEManager(createMockVaultClient())
|
||||
manager.isDataChannelEncryptionEnabled = true
|
||||
expect(manager.isDataChannelEncryptionEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('isDataChannelEncryptionEnabled is true with key + flag', () => {
|
||||
const manager = new VaultE2EEManager(createMockVaultClient())
|
||||
manager.setEncryptedSymmetricKey(new ArrayBuffer(32))
|
||||
manager.isDataChannelEncryptionEnabled = true
|
||||
expect(manager.isDataChannelEncryptionEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Data channel encrypt/decrypt round-trip ───────────────────────────
|
||||
|
||||
describe('VaultE2EEManager data channel encryption', () => {
|
||||
let manager: VaultE2EEManager
|
||||
let vaultClient: ReturnType<typeof createMockVaultClient>
|
||||
|
||||
beforeEach(() => {
|
||||
vaultClient = createMockVaultClient()
|
||||
manager = new VaultE2EEManager(vaultClient as unknown as VaultClient)
|
||||
manager.setEncryptedSymmetricKey(new ArrayBuffer(32))
|
||||
})
|
||||
|
||||
it('encryptData calls vaultClient.encryptWithKey', async () => {
|
||||
const data = new Uint8Array([10, 20, 30])
|
||||
const result = await manager.encryptData(data)
|
||||
|
||||
expect(vaultClient.encryptWithKey).toHaveBeenCalledOnce()
|
||||
expect(result.payload).toBeInstanceOf(Uint8Array)
|
||||
expect(result.payload.length).toBeGreaterThan(data.length) // overhead from nonce+MAC
|
||||
})
|
||||
|
||||
it('handleEncryptedData calls vaultClient.decryptWithKey', async () => {
|
||||
const data = new Uint8Array([10, 20, 30])
|
||||
const encrypted = await manager.encryptData(data)
|
||||
const decrypted = await manager.handleEncryptedData(
|
||||
encrypted.payload,
|
||||
new Uint8Array(0),
|
||||
'participant-1',
|
||||
0
|
||||
)
|
||||
|
||||
expect(vaultClient.decryptWithKey).toHaveBeenCalledOnce()
|
||||
expect(new Uint8Array(decrypted.payload)).toEqual(data)
|
||||
})
|
||||
|
||||
it('encryptData throws without key', async () => {
|
||||
const noKeyManager = new VaultE2EEManager(
|
||||
vaultClient as unknown as VaultClient
|
||||
)
|
||||
await expect(noKeyManager.encryptData(new Uint8Array([1]))).rejects.toThrow(
|
||||
'No encrypted symmetric key set'
|
||||
)
|
||||
})
|
||||
|
||||
it('handleEncryptedData throws without key', async () => {
|
||||
const noKeyManager = new VaultE2EEManager(
|
||||
vaultClient as unknown as VaultClient
|
||||
)
|
||||
await expect(
|
||||
noKeyManager.handleEncryptedData(
|
||||
new Uint8Array([1]),
|
||||
new Uint8Array(0),
|
||||
'p',
|
||||
0
|
||||
)
|
||||
).rejects.toThrow('No encrypted symmetric key set')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Frame format (header preservation) ────────────────────────────────
|
||||
|
||||
describe('Frame format — header preservation', () => {
|
||||
let vaultClient: ReturnType<typeof createMockVaultClient>
|
||||
|
||||
beforeEach(() => {
|
||||
vaultClient = createMockVaultClient()
|
||||
})
|
||||
|
||||
it('encrypt preserves VP8 keyframe header (10 bytes)', async () => {
|
||||
// Simulate what the sender transform does
|
||||
const frameData = new Uint8Array(100)
|
||||
// Fill with recognizable pattern: header = 0x01-0x0A, payload = 0xFF
|
||||
for (let i = 0; i < 10; i++) frameData[i] = i + 1
|
||||
frameData.fill(0xff, 10)
|
||||
|
||||
const unencryptedBytes = UNENCRYPTED_BYTES.key // 10
|
||||
const header = frameData.slice(0, unencryptedBytes)
|
||||
const payload = frameData.slice(unencryptedBytes)
|
||||
|
||||
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||
payload.buffer,
|
||||
new ArrayBuffer(32)
|
||||
)
|
||||
const encrypted = new Uint8Array(encryptedData)
|
||||
|
||||
// Reconstruct frame: [header][encrypted payload]
|
||||
const newFrame = new Uint8Array(header.length + encrypted.length)
|
||||
newFrame.set(header)
|
||||
newFrame.set(encrypted, header.length)
|
||||
|
||||
// Verify header is preserved unencrypted
|
||||
expect(newFrame.slice(0, 10)).toEqual(header)
|
||||
// Verify the rest is different (encrypted)
|
||||
expect(newFrame.length).toBeGreaterThan(frameData.length) // overhead
|
||||
})
|
||||
|
||||
it('encrypt + decrypt round-trip preserves original frame', async () => {
|
||||
const frameData = new Uint8Array(50)
|
||||
for (let i = 0; i < 50; i++) frameData[i] = i
|
||||
|
||||
const unencryptedBytes = UNENCRYPTED_BYTES.delta // 3
|
||||
const header = frameData.slice(0, unencryptedBytes)
|
||||
const payload = frameData.slice(unencryptedBytes)
|
||||
|
||||
// Encrypt
|
||||
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||
payload.slice().buffer,
|
||||
new ArrayBuffer(32)
|
||||
)
|
||||
const encrypted = new Uint8Array(encryptedData)
|
||||
const encryptedFrame = new Uint8Array(header.length + encrypted.length)
|
||||
encryptedFrame.set(header)
|
||||
encryptedFrame.set(encrypted, header.length)
|
||||
|
||||
// Decrypt (receiver side)
|
||||
const rxHeader = encryptedFrame.slice(0, unencryptedBytes)
|
||||
const rxEncrypted = encryptedFrame.slice(unencryptedBytes)
|
||||
const { data } = await vaultClient.decryptWithKey(
|
||||
rxEncrypted.slice().buffer,
|
||||
new ArrayBuffer(32)
|
||||
)
|
||||
const plaintext = new Uint8Array(data)
|
||||
const decryptedFrame = new Uint8Array(rxHeader.length + plaintext.length)
|
||||
decryptedFrame.set(rxHeader)
|
||||
decryptedFrame.set(plaintext, rxHeader.length)
|
||||
|
||||
// Original frame should be recovered exactly
|
||||
expect(decryptedFrame).toEqual(frameData)
|
||||
})
|
||||
|
||||
it('audio frames preserve 1 byte header', async () => {
|
||||
const frameData = new Uint8Array(20)
|
||||
frameData[0] = 0xfc // Opus TOC byte
|
||||
frameData.fill(0xab, 1)
|
||||
|
||||
const unencryptedBytes = UNENCRYPTED_BYTES.audio // 1
|
||||
const header = frameData.slice(0, unencryptedBytes)
|
||||
const payload = frameData.slice(unencryptedBytes)
|
||||
|
||||
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||
payload.slice().buffer,
|
||||
new ArrayBuffer(32)
|
||||
)
|
||||
const encrypted = new Uint8Array(encryptedData)
|
||||
const encryptedFrame = new Uint8Array(header.length + encrypted.length)
|
||||
encryptedFrame.set(header)
|
||||
encryptedFrame.set(encrypted, header.length)
|
||||
|
||||
// First byte (Opus TOC) must be preserved
|
||||
expect(encryptedFrame[0]).toBe(0xfc)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Proof: data transiting through LiveKit SFU is not decipherable ────
|
||||
|
||||
describe('SFU sees only encrypted data', () => {
|
||||
let vaultClient: ReturnType<typeof createMockVaultClient>
|
||||
|
||||
beforeEach(() => {
|
||||
vaultClient = createMockVaultClient()
|
||||
})
|
||||
|
||||
it('encrypted frame payload does NOT match original payload', async () => {
|
||||
// Simulate a VP8 keyframe with recognizable pixel data
|
||||
const frameSize = 5000 // typical small video frame
|
||||
const originalFrame = new Uint8Array(frameSize)
|
||||
for (let i = 0; i < frameSize; i++) originalFrame[i] = i % 256
|
||||
|
||||
const headerSize = UNENCRYPTED_BYTES.key // 10
|
||||
const header = originalFrame.slice(0, headerSize)
|
||||
const payload = originalFrame.slice(headerSize)
|
||||
|
||||
// Encrypt (what the sender does before sending to SFU)
|
||||
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||
payload.slice().buffer,
|
||||
new ArrayBuffer(32)
|
||||
)
|
||||
const encrypted = new Uint8Array(encryptedData)
|
||||
|
||||
// This is what the SFU sees: [header][encrypted payload]
|
||||
const sfuFrame = new Uint8Array(header.length + encrypted.length)
|
||||
sfuFrame.set(header)
|
||||
sfuFrame.set(encrypted, header.length)
|
||||
|
||||
// The SFU frame is LARGER than original (nonce + MAC overhead)
|
||||
expect(sfuFrame.length).toBe(originalFrame.length + 24 + 16) // +40B
|
||||
|
||||
// The header bytes are the same (unencrypted, needed for RTP)
|
||||
expect(sfuFrame.slice(0, headerSize)).toEqual(header)
|
||||
|
||||
// The payload bytes are COMPLETELY DIFFERENT from the original
|
||||
const sfuPayload = sfuFrame.slice(headerSize)
|
||||
const originalPayload = originalFrame.slice(headerSize)
|
||||
expect(sfuPayload.length).not.toBe(originalPayload.length)
|
||||
expect(sfuPayload).not.toEqual(originalPayload)
|
||||
})
|
||||
|
||||
it('encrypted payload cannot be reversed without vault decryption', async () => {
|
||||
const originalPayload = new Uint8Array([72, 101, 108, 108, 111]) // "Hello"
|
||||
|
||||
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||
originalPayload.slice().buffer,
|
||||
new ArrayBuffer(32)
|
||||
)
|
||||
const encrypted = new Uint8Array(encryptedData)
|
||||
|
||||
// The encrypted data is 40 bytes larger (24B nonce + 16B MAC)
|
||||
expect(encrypted.length).toBe(originalPayload.length + 24 + 16)
|
||||
|
||||
// No substring of the encrypted data matches the original payload
|
||||
// (the nonce prepended and MAC appended obscure everything)
|
||||
for (let i = 0; i <= encrypted.length - originalPayload.length; i++) {
|
||||
const slice = encrypted.slice(i, i + originalPayload.length)
|
||||
if (i === 24) {
|
||||
// At offset 24 (after nonce), our mock "encrypts" by copying,
|
||||
// so in a real vault this would NOT match. Skip this offset for
|
||||
// the mock — the real test is the overhead structure.
|
||||
continue
|
||||
}
|
||||
expect(slice).not.toEqual(originalPayload)
|
||||
}
|
||||
})
|
||||
|
||||
it('overhead is exactly 40 bytes (24B nonce + 16B MAC) per frame', async () => {
|
||||
const testSizes = [10, 100, 1000, 5000, 20000]
|
||||
|
||||
for (const size of testSizes) {
|
||||
const payload = new Uint8Array(size)
|
||||
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||
payload.buffer,
|
||||
new ArrayBuffer(32)
|
||||
)
|
||||
const overhead = new Uint8Array(encryptedData).length - size
|
||||
expect(overhead).toBe(40) // 24B nonce + 16B MAC = XChaCha20-Poly1305
|
||||
}
|
||||
})
|
||||
|
||||
it('only codec header bytes leak — they contain no media content', () => {
|
||||
// VP8 keyframe header is 10 bytes of codec metadata (not pixels)
|
||||
// VP8 delta header is 3 bytes
|
||||
// Opus audio header is 1 byte (TOC byte = codec config, not audio samples)
|
||||
//
|
||||
// These bytes tell the RTP packetizer how to split the frame into packets.
|
||||
// They do NOT contain visual or audio content.
|
||||
|
||||
expect(UNENCRYPTED_BYTES.key).toBe(10) // VP8 payload descriptor
|
||||
expect(UNENCRYPTED_BYTES.delta).toBe(3) // VP8 payload descriptor
|
||||
expect(UNENCRYPTED_BYTES.audio).toBe(1) // Opus TOC byte
|
||||
|
||||
// Maximum leak per frame is 10 bytes out of typically 1000-50000 byte frames
|
||||
// = 0.02% to 1% of frame data, and it's codec metadata, not content
|
||||
const typicalKeyframeSize = 50000
|
||||
const leakRatio = UNENCRYPTED_BYTES.key / typicalKeyframeSize
|
||||
expect(leakRatio).toBeLessThan(0.001) // less than 0.1%
|
||||
})
|
||||
|
||||
it('full sender→SFU→receiver pipeline: receiver recovers original, SFU cannot', async () => {
|
||||
// Original video frame (sender side)
|
||||
const originalFrame = new Uint8Array(200)
|
||||
for (let i = 0; i < 200; i++) originalFrame[i] = (i * 7 + 13) % 256
|
||||
const headerSize = UNENCRYPTED_BYTES.delta // 3
|
||||
|
||||
// ── SENDER: encrypt and send ──
|
||||
const header = originalFrame.slice(0, headerSize)
|
||||
const payload = originalFrame.slice(headerSize)
|
||||
|
||||
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||
payload.slice().buffer,
|
||||
new ArrayBuffer(32)
|
||||
)
|
||||
const encrypted = new Uint8Array(encryptedData)
|
||||
const wireFrame = new Uint8Array(header.length + encrypted.length)
|
||||
wireFrame.set(header)
|
||||
wireFrame.set(encrypted, header.length)
|
||||
|
||||
// ── SFU: can only see wireFrame — cannot recover original ──
|
||||
// The SFU would need to strip the nonce and decrypt the ciphertext,
|
||||
// but it doesn't have the symmetric key (it's in the vault iframe).
|
||||
expect(wireFrame).not.toEqual(originalFrame)
|
||||
expect(wireFrame.length).not.toBe(originalFrame.length)
|
||||
|
||||
// ── RECEIVER: decrypt and recover ──
|
||||
const rxHeader = wireFrame.slice(0, headerSize)
|
||||
const rxEncrypted = wireFrame.slice(headerSize)
|
||||
|
||||
const { data } = await vaultClient.decryptWithKey(
|
||||
rxEncrypted.slice().buffer,
|
||||
new ArrayBuffer(32)
|
||||
)
|
||||
const decryptedPayload = new Uint8Array(data)
|
||||
const recoveredFrame = new Uint8Array(rxHeader.length + decryptedPayload.length)
|
||||
recoveredFrame.set(rxHeader)
|
||||
recoveredFrame.set(decryptedPayload, rxHeader.length)
|
||||
|
||||
// Receiver gets the EXACT original frame
|
||||
expect(recoveredFrame).toEqual(originalFrame)
|
||||
})
|
||||
})
|
||||
@@ -1,328 +0,0 @@
|
||||
/**
|
||||
* Custom E2EE Manager that delegates crypto to the VaultClient iframe.
|
||||
*
|
||||
* Uses XChaCha20-Poly1305 (libsodium) via the vault — the symmetric key
|
||||
* never leaves the iframe. Preserves codec header bytes unencrypted so
|
||||
* the WebRTC RTP packetizer can construct valid packets.
|
||||
*
|
||||
* Frame format (sender output / receiver input):
|
||||
* [unencrypted codec header][vault-encrypted payload]
|
||||
*
|
||||
* Where vault-encrypted payload = [24B nonce][ciphertext + 16B Poly1305 MAC]
|
||||
*
|
||||
* Unencrypted header sizes (VP8):
|
||||
* - keyframe: 10 bytes (VP8 payload descriptor)
|
||||
* - delta: 3 bytes
|
||||
* - audio: 1 byte (Opus TOC)
|
||||
*/
|
||||
import { EventEmitter } from 'events'
|
||||
import { Encryption_Type } from '@livekit/protocol'
|
||||
import type { Room, RemoteTrack, Track } from 'livekit-client'
|
||||
import { RoomEvent, ParticipantEvent, ConnectionState } from 'livekit-client'
|
||||
import type { RTCEngine } from 'livekit-client/src/room/RTCEngine'
|
||||
|
||||
const E2EE_FLAG = Symbol('e2ee')
|
||||
|
||||
enum EncryptionEvent {
|
||||
ParticipantEncryptionStatusChanged = 'participantEncryptionStatusChanged',
|
||||
EncryptionError = 'encryptionError',
|
||||
}
|
||||
|
||||
function isInsertableStreamSupported(): boolean {
|
||||
return (
|
||||
typeof window.RTCRtpSender !== 'undefined' &&
|
||||
// @ts-expect-error — createEncodedStreams not in TS types
|
||||
typeof window.RTCRtpSender.prototype.createEncodedStreams !== 'undefined'
|
||||
)
|
||||
}
|
||||
|
||||
export const UNENCRYPTED_BYTES = { key: 10, delta: 3, audio: 1 }
|
||||
|
||||
export function getUnencryptedBytes(
|
||||
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame
|
||||
): number {
|
||||
if (!('type' in frame)) return UNENCRYPTED_BYTES.audio
|
||||
return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta
|
||||
}
|
||||
|
||||
export class VaultE2EEManager extends EventEmitter {
|
||||
private vaultClient: VaultClient
|
||||
private room?: Room
|
||||
private encryptionEnabled = false
|
||||
private _isDataChannelEncryptionEnabled = false
|
||||
|
||||
/**
|
||||
* Encrypted symmetric key (wrapped for the user's vault public key).
|
||||
* Stored as an independent copy so the original ArrayBuffer can't be detached.
|
||||
*/
|
||||
private encryptedKeyBytes: Uint8Array | null = null
|
||||
|
||||
constructor(vaultClient: VaultClient) {
|
||||
super()
|
||||
this.vaultClient = vaultClient
|
||||
}
|
||||
|
||||
get isEnabled() {
|
||||
return this.encryptionEnabled
|
||||
}
|
||||
|
||||
get isDataChannelEncryptionEnabled() {
|
||||
return this._isDataChannelEncryptionEnabled && !!this.encryptedKeyBytes
|
||||
}
|
||||
|
||||
set isDataChannelEncryptionEnabled(enabled: boolean) {
|
||||
this._isDataChannelEncryptionEnabled = enabled
|
||||
}
|
||||
|
||||
/** Fresh ArrayBuffer copy of the key for each vault call (avoids postMessage detachment). */
|
||||
private freshKeyBuffer(): ArrayBuffer {
|
||||
return new Uint8Array(this.encryptedKeyBytes!).buffer
|
||||
}
|
||||
|
||||
setEncryptedSymmetricKey(key: ArrayBuffer): void {
|
||||
this.encryptedKeyBytes = new Uint8Array(new Uint8Array(key))
|
||||
}
|
||||
|
||||
// ── Lifecycle (mirrors built-in E2EEManager) ────────────────────────
|
||||
|
||||
setup(room: Room): void {
|
||||
if (!isInsertableStreamSupported()) {
|
||||
throw new Error(
|
||||
'End-to-end encryption is not supported in this browser. ' +
|
||||
'Please use a Chromium-based browser (Chrome, Edge, Brave).'
|
||||
)
|
||||
}
|
||||
if (room !== this.room) {
|
||||
this.room = room
|
||||
this.setupEventListeners(room)
|
||||
}
|
||||
}
|
||||
|
||||
setupEngine(_engine: RTCEngine): void {}
|
||||
|
||||
setParticipantCryptorEnabled(
|
||||
enabled: boolean,
|
||||
participantIdentity: string
|
||||
): void {
|
||||
if (
|
||||
participantIdentity === this.room?.localParticipant.identity &&
|
||||
this.encryptionEnabled !== enabled
|
||||
) {
|
||||
this.encryptionEnabled = enabled
|
||||
this.emit(
|
||||
EncryptionEvent.ParticipantEncryptionStatusChanged,
|
||||
enabled,
|
||||
this.room!.localParticipant
|
||||
)
|
||||
} else if (participantIdentity !== this.room?.localParticipant.identity) {
|
||||
const p = this.room?.getParticipantByIdentity(participantIdentity)
|
||||
if (p)
|
||||
this.emit(
|
||||
EncryptionEvent.ParticipantEncryptionStatusChanged,
|
||||
enabled,
|
||||
p
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setSifTrailer(_trailer: Uint8Array): void {}
|
||||
|
||||
async encryptData(data: Uint8Array) {
|
||||
if (!this.encryptedKeyBytes)
|
||||
throw new Error('No encrypted symmetric key set')
|
||||
const r = await this.vaultClient.encryptWithKey(
|
||||
data.slice().buffer,
|
||||
this.freshKeyBuffer()
|
||||
)
|
||||
return {
|
||||
uuid: crypto.randomUUID(),
|
||||
payload: new Uint8Array(r.encryptedData).slice(),
|
||||
iv: new Uint8Array(0),
|
||||
keyIndex: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async handleEncryptedData(
|
||||
payload: Uint8Array,
|
||||
_iv: Uint8Array,
|
||||
_participantIdentity: string,
|
||||
_keyIndex: number
|
||||
) {
|
||||
if (!this.encryptedKeyBytes)
|
||||
throw new Error('No encrypted symmetric key set')
|
||||
const r = await this.vaultClient.decryptWithKey(
|
||||
payload.slice().buffer,
|
||||
this.freshKeyBuffer()
|
||||
)
|
||||
return {
|
||||
uuid: crypto.randomUUID(),
|
||||
payload: new Uint8Array(r.data).slice(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event listeners ─────────────────────────────────────────────────
|
||||
|
||||
private setupEventListeners(room: Room): void {
|
||||
room.on(RoomEvent.TrackPublished, (pub, participant) => {
|
||||
this.setParticipantCryptorEnabled(
|
||||
pub.trackInfo!.encryption !== Encryption_Type.NONE,
|
||||
participant.identity
|
||||
)
|
||||
})
|
||||
|
||||
room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||
if (state === ConnectionState.Connected) {
|
||||
room.remoteParticipants.forEach((p) => {
|
||||
p.trackPublications.forEach((pub) => {
|
||||
this.setParticipantCryptorEnabled(
|
||||
pub.trackInfo!.encryption !== Encryption_Type.NONE,
|
||||
p.identity
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
|
||||
this.setupReceiver(track, participant.identity)
|
||||
})
|
||||
|
||||
room.on(RoomEvent.SignalConnected, () => {
|
||||
this.setParticipantCryptorEnabled(
|
||||
room.localParticipant.isE2EEEnabled,
|
||||
room.localParticipant.identity
|
||||
)
|
||||
})
|
||||
|
||||
room.localParticipant.on(
|
||||
ParticipantEvent.LocalSenderCreated,
|
||||
(sender: RTCRtpSender, track: Track) => {
|
||||
this.setupSender(sender, track.mediaStreamID)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sender (encrypt outgoing frames) ────────────────────────────────
|
||||
|
||||
private setupSender(sender: RTCRtpSender, _trackId: string): void {
|
||||
if (E2EE_FLAG in sender) return
|
||||
if (!this.room?.localParticipant.identity) return
|
||||
|
||||
// @ts-expect-error — createEncodedStreams not in TS types
|
||||
const streams = sender.createEncodedStreams()
|
||||
|
||||
const transformStream = new TransformStream({
|
||||
transform: async (
|
||||
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
|
||||
controller: TransformStreamDefaultController
|
||||
) => {
|
||||
try {
|
||||
if (!this.encryptedKeyBytes) return // drop — never send unencrypted
|
||||
if (!frame.data || frame.data.byteLength === 0)
|
||||
return controller.enqueue(frame)
|
||||
|
||||
const unencryptedBytes = getUnencryptedBytes(frame)
|
||||
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
|
||||
const payload = new Uint8Array(frame.data, unencryptedBytes)
|
||||
|
||||
const { encryptedData } = await this.vaultClient.encryptWithKey(
|
||||
payload.slice().buffer,
|
||||
this.freshKeyBuffer()
|
||||
)
|
||||
|
||||
const encrypted = new Uint8Array(encryptedData)
|
||||
const newData = new Uint8Array(
|
||||
header.byteLength + encrypted.byteLength
|
||||
)
|
||||
newData.set(header)
|
||||
newData.set(encrypted, header.byteLength)
|
||||
frame.data = newData.buffer
|
||||
controller.enqueue(frame)
|
||||
} catch {
|
||||
// Drop frame on error — never send unencrypted
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
streams.readable.pipeThrough(transformStream).pipeTo(streams.writable)
|
||||
// @ts-expect-error
|
||||
sender[E2EE_FLAG] = true
|
||||
}
|
||||
|
||||
// ── Receiver (decrypt incoming frames) ──────────────────────────────
|
||||
|
||||
private setupReceiver(track: RemoteTrack, participantIdentity: string): void {
|
||||
if (!track.receiver) return
|
||||
const receiver = track.receiver
|
||||
if (E2EE_FLAG in receiver) return
|
||||
|
||||
// @ts-expect-error
|
||||
let writable: WritableStream = receiver.writableStream
|
||||
// @ts-expect-error
|
||||
let readable: ReadableStream = receiver.readableStream
|
||||
|
||||
if (!writable || !readable) {
|
||||
// @ts-expect-error
|
||||
const streams = receiver.createEncodedStreams()
|
||||
// @ts-expect-error
|
||||
receiver.writableStream = streams.writable
|
||||
writable = streams.writable
|
||||
// @ts-expect-error
|
||||
receiver.readableStream = streams.readable
|
||||
readable = streams.readable
|
||||
}
|
||||
|
||||
let successEmitted = false
|
||||
|
||||
const transformStream = new TransformStream({
|
||||
transform: async (
|
||||
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
|
||||
controller: TransformStreamDefaultController
|
||||
) => {
|
||||
try {
|
||||
if (!this.encryptedKeyBytes) return // drop — can't decrypt without key
|
||||
if (!frame.data || frame.data.byteLength === 0)
|
||||
return controller.enqueue(frame)
|
||||
|
||||
const unencryptedBytes = getUnencryptedBytes(frame)
|
||||
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
|
||||
const encryptedPayload = new Uint8Array(frame.data, unencryptedBytes)
|
||||
|
||||
const { data } = await this.vaultClient.decryptWithKey(
|
||||
encryptedPayload.slice().buffer,
|
||||
this.freshKeyBuffer()
|
||||
)
|
||||
|
||||
const plaintext = new Uint8Array(data)
|
||||
const newData = new Uint8Array(
|
||||
header.byteLength + plaintext.byteLength
|
||||
)
|
||||
newData.set(header)
|
||||
newData.set(plaintext, header.byteLength)
|
||||
frame.data = newData.buffer
|
||||
controller.enqueue(frame)
|
||||
|
||||
if (!successEmitted) {
|
||||
successEmitted = true
|
||||
const p = this.room?.getParticipantByIdentity(participantIdentity)
|
||||
if (p)
|
||||
this.emit(
|
||||
EncryptionEvent.ParticipantEncryptionStatusChanged,
|
||||
true,
|
||||
p
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Drop frame — keeps pipe alive, avoids sending corrupt data to decoder
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
readable
|
||||
.pipeThrough(transformStream)
|
||||
.pipeTo(writable)
|
||||
.catch(() => {})
|
||||
// @ts-expect-error
|
||||
receiver[E2EE_FLAG] = true
|
||||
}
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface VaultClient {
|
||||
init(): Promise<void>
|
||||
destroy(): void
|
||||
setTheme(theme: string): void
|
||||
setAuthContext(context: { suiteUserId: string }): void
|
||||
hasKeys(): Promise<{ hasKeys: boolean }>
|
||||
getPublicKey(): Promise<{ publicKey: ArrayBuffer }>
|
||||
encryptWithoutKey(
|
||||
data: ArrayBuffer,
|
||||
userPublicKeys: Record<string, ArrayBuffer>,
|
||||
options?: { optimizeMemory?: boolean }
|
||||
): Promise<{
|
||||
encryptedContent: ArrayBuffer
|
||||
encryptedKeys: Record<string, ArrayBuffer>
|
||||
}>
|
||||
encryptWithKey(
|
||||
data: ArrayBuffer,
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
encryptedKeyChain?: ArrayBuffer[],
|
||||
options?: { optimizeMemory?: boolean }
|
||||
): Promise<{ encryptedData: ArrayBuffer }>
|
||||
decryptWithKey(
|
||||
encryptedData: ArrayBuffer,
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
encryptedKeyChain?: ArrayBuffer[],
|
||||
options?: { optimizeMemory?: boolean }
|
||||
): Promise<{ data: ArrayBuffer }>
|
||||
shareKeys(
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
userPublicKeys: Record<string, ArrayBuffer>
|
||||
): Promise<{ encryptedKeys: Record<string, ArrayBuffer> }>
|
||||
fetchPublicKeys(
|
||||
userIds: string[]
|
||||
): Promise<{ publicKeys: Record<string, ArrayBuffer> }>
|
||||
checkFingerprints(
|
||||
userFingerprints: Record<string, string>,
|
||||
currentUserId?: string
|
||||
): Promise<{
|
||||
results: Array<{
|
||||
userId: string
|
||||
knownFingerprint: string | null
|
||||
providedFingerprint: string
|
||||
status: 'trusted' | 'refused' | 'unknown'
|
||||
}>
|
||||
}>
|
||||
acceptFingerprint(userId: string, fingerprint: string): Promise<void>
|
||||
refuseFingerprint(userId: string, fingerprint: string): Promise<void>
|
||||
getKnownFingerprints(): Promise<{
|
||||
fingerprints: Record<
|
||||
string,
|
||||
{ fingerprint: string; status: 'trusted' | 'refused' | 'unknown' }
|
||||
>
|
||||
}>
|
||||
openOnboarding(container: HTMLElement): void
|
||||
openBackup(container: HTMLElement): void
|
||||
openRestore(container: HTMLElement): void
|
||||
openDeviceTransfer(container: HTMLElement): void
|
||||
openSettings(container: HTMLElement): void
|
||||
closeInterface(): void
|
||||
on<K extends string>(event: K, listener: (data: unknown) => void): void
|
||||
off<K extends string>(event: K, listener: (data: unknown) => void): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable error codes carried by `VaultError`. Sourced from the
|
||||
* encryption SDK (re-exported on `window.EncryptionClient.VaultErrorCode`)
|
||||
* — meet consumers match on these via `(err as VaultError).code` rather
|
||||
* than regexing message text. Keep in sync with the SDK definition.
|
||||
*/
|
||||
type VaultErrorCode =
|
||||
| 'MISSING_KEYS'
|
||||
| 'WRONG_SECRET_KEY'
|
||||
| 'INVALID_BACKUP'
|
||||
| 'INVALID_MNEMONIC'
|
||||
| 'NOT_INITIALIZED'
|
||||
| 'AUTH_REQUIRED'
|
||||
| 'PRIVILEGED_ORIGIN_REQUIRED'
|
||||
| 'TIMEOUT'
|
||||
| 'IFRAME_REQUIRED'
|
||||
| 'CIPHERTEXT_TOO_SHORT'
|
||||
| 'UNKNOWN'
|
||||
|
||||
interface VaultError extends Error {
|
||||
readonly code: VaultErrorCode
|
||||
}
|
||||
|
||||
interface Window {
|
||||
EncryptionClient: {
|
||||
VaultClient: new (options: {
|
||||
vaultUrl: string
|
||||
interfaceUrl: string
|
||||
timeout?: number
|
||||
theme?: string
|
||||
lang?: string
|
||||
}) => VaultClient
|
||||
VaultError: new (code: VaultErrorCode, message: string) => VaultError
|
||||
VaultErrorCode: { readonly [K in VaultErrorCode]: K }
|
||||
isVaultError: (err: unknown) => err is VaultError
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
export { VaultClientProvider, useVaultClient } from './VaultClientProvider'
|
||||
export type { VaultClientContextValue } from './VaultClientProvider'
|
||||
export {
|
||||
determineTrustLevel,
|
||||
getTrustLevelFromAttributes,
|
||||
distributeKeyViaPKI,
|
||||
encodeTrustLevelAttribute,
|
||||
} from './HybridKeyDistributor'
|
||||
export type { ParticipantEncryptionInfo } from './HybridKeyDistributor'
|
||||
export { EncryptionBadge } from './EncryptionBadge'
|
||||
export { EncryptedMeetingBanner } from './EncryptedMeetingBanner'
|
||||
export { EncryptionTrustModal } from './EncryptionTrustModal'
|
||||
export { EncryptionIdentityDialog } from './EncryptionIdentityDialog'
|
||||
export { useParticipantTrustLevel } from './useParticipantTrustLevel'
|
||||
|
||||
export { PARTICIPANT_TRUST_ATTR } from './types'
|
||||
export type { TrustLevel } from './types'
|
||||
generatePassphrase,
|
||||
isValidPassphrase,
|
||||
getPassphraseFromHash,
|
||||
PASSPHRASE_LENGTH,
|
||||
} from './passphrase'
|
||||
export { RoomStatusBanner } from './RoomStatusBanner'
|
||||
export { FeaturePill } from './FeaturePill'
|
||||
export { EncryptionMismatchScreen } from './EncryptionMismatchScreen'
|
||||
export { DecryptionFailedTileOverlay } from './DecryptionFailedTileOverlay'
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Key storage and passphrase utilities for E2EE lobby flow.
|
||||
*
|
||||
* Basic mode: passphrase is in the URL hash — shared by sharing the link.
|
||||
* Advanced mode: vault-wrapped symmetric key exchanged via lobby REST API.
|
||||
*/
|
||||
|
||||
// ── Module-level symmetric key (basic mode) ───────────────────────────
|
||||
|
||||
let _symmetricKey: Uint8Array | null = null
|
||||
|
||||
export function setSymmetricKey(key: Uint8Array): void {
|
||||
_symmetricKey = key
|
||||
}
|
||||
|
||||
export function getSymmetricKey(): Uint8Array | null {
|
||||
return _symmetricKey
|
||||
}
|
||||
|
||||
export function clearSymmetricKey(): void {
|
||||
_symmetricKey = null
|
||||
}
|
||||
|
||||
// ── Module-level encrypted vault key (advanced mode) ──────────────────
|
||||
|
||||
let _encryptedVaultKey: ArrayBuffer | null = null
|
||||
|
||||
export function setEncryptedVaultKey(key: ArrayBuffer): void {
|
||||
_encryptedVaultKey = key
|
||||
}
|
||||
|
||||
export function getEncryptedVaultKey(): ArrayBuffer | null {
|
||||
return _encryptedVaultKey
|
||||
}
|
||||
|
||||
// ── Passphrase generation (basic mode) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a random passphrase for basic mode encryption.
|
||||
* 24 random bytes encoded in base36 = 48 alphanumeric characters.
|
||||
*/
|
||||
export function generatePassphrase(): string {
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(24)))
|
||||
.map((b) => b.toString(36).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Expected length of a basic mode passphrase */
|
||||
export const BASIC_KEY_LENGTH = 48
|
||||
@@ -0,0 +1,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(/^#/, '')
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Trust level for a participant's encryption key distribution.
|
||||
*
|
||||
* - 'verified': Key was distributed via PKI (public key registered in encryption library).
|
||||
* Identity is cryptographically verified.
|
||||
* - 'authenticated': Key was distributed via ephemeral DH, but participant is authenticated
|
||||
* via ProConnect. Identity is server-verified, not cryptographically.
|
||||
* - 'anonymous': Key was distributed via ephemeral DH, participant is not authenticated.
|
||||
* Identity is self-declared.
|
||||
*/
|
||||
export type TrustLevel = 'verified' | 'authenticated' | 'anonymous' | 'refused' | 'unknown'
|
||||
|
||||
/**
|
||||
* Metadata attached to participant attributes for encryption trust level.
|
||||
*/
|
||||
export const PARTICIPANT_TRUST_ATTR = 'encryption.trustLevel'
|
||||
|
||||
/**
|
||||
* Data channel topic for encryption key exchange protocol.
|
||||
*/
|
||||
export const KEY_EXCHANGE_TOPIC = 'encryption-key-exchange'
|
||||
|
||||
/**
|
||||
* Message types for the in-call key exchange protocol.
|
||||
*/
|
||||
export enum KeyExchangeMessageType {
|
||||
/** New participant sends their ephemeral public key to request the symmetric key */
|
||||
KEY_REQUEST = 'KEY_REQUEST',
|
||||
/** Existing participant responds with the symmetric key encrypted for the requester */
|
||||
KEY_RESPONSE = 'KEY_RESPONSE',
|
||||
/** Requester confirms receipt of the key */
|
||||
KEY_ACK = 'KEY_ACK',
|
||||
}
|
||||
|
||||
export interface KeyExchangeMessage {
|
||||
type: KeyExchangeMessageType
|
||||
/** Sender's participant identity */
|
||||
senderIdentity: string
|
||||
/** Target participant identity (for directed messages) */
|
||||
targetIdentity?: string
|
||||
/** Base64-encoded payload */
|
||||
payload: string
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Hook that determines a participant's trust level and fingerprint status
|
||||
* by checking the vault (encryption library) via VaultClient.
|
||||
*
|
||||
* In advanced mode:
|
||||
* - Checks if the participant has a registered public key
|
||||
* - Checks the fingerprint status (trusted/refused/unknown)
|
||||
* - Returns "verified" only if they have a public key
|
||||
*
|
||||
* In basic mode:
|
||||
* - Only uses authentication status (no vault check)
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useVaultClient } from './VaultClientProvider'
|
||||
import type { TrustLevel } from './types'
|
||||
|
||||
/** Compute a fingerprint from a public key (same as encryption repo: SHA-256, first 16 hex chars) */
|
||||
async function computeFingerprint(publicKey: ArrayBuffer): Promise<string> {
|
||||
const hash = await crypto.subtle.digest('SHA-256', publicKey)
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.slice(0, 16)
|
||||
}
|
||||
|
||||
/** Format for display: "a1b2c3d4e5f67890" → "A1B2 C3D4 E5F6 7890" */
|
||||
export function formatFingerprint(fp: string): string {
|
||||
return fp.replace(/(.{4})/g, '$1 ').trim().toUpperCase()
|
||||
}
|
||||
|
||||
export type FingerprintStatus = 'loading' | 'trusted' | 'refused' | 'unknown' | 'no-key' | 'error'
|
||||
|
||||
export function useParticipantTrustLevel(
|
||||
attributes: Record<string, string> | undefined,
|
||||
encryptionMode?: string,
|
||||
isSelf?: boolean,
|
||||
): { trustLevel: TrustLevel; fingerprintStatus: FingerprintStatus; fingerprint: string | null } {
|
||||
const { client: vaultClient } = useVaultClient()
|
||||
const [fingerprintStatus, setFingerprintStatus] = useState<FingerprintStatus>('loading')
|
||||
const [fingerprint, setFingerprint] = useState<string | null>(null)
|
||||
|
||||
const isAuthenticated = attributes?.is_authenticated === 'true'
|
||||
const suiteUserId = attributes?.suite_user_id
|
||||
const isAdvanced = encryptionMode === 'advanced'
|
||||
|
||||
// Re-check when a fingerprint is accepted/refused via VaultClient
|
||||
const [revision, setRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!vaultClient) return
|
||||
const handler = () => setRevision((r) => r + 1)
|
||||
vaultClient.on('fingerprint-changed', handler)
|
||||
return () => { vaultClient.off('fingerprint-changed', handler) }
|
||||
}, [vaultClient])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdvanced || !isAuthenticated) {
|
||||
setFingerprintStatus('no-key')
|
||||
return
|
||||
}
|
||||
if (!vaultClient || !suiteUserId) {
|
||||
setFingerprintStatus(vaultClient ? 'no-key' : 'error')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const { publicKeys } = await vaultClient!.fetchPublicKeys([suiteUserId!])
|
||||
if (cancelled) return
|
||||
|
||||
const publicKey = publicKeys[suiteUserId!]
|
||||
if (!publicKey) {
|
||||
setFingerprintStatus('no-key')
|
||||
return
|
||||
}
|
||||
|
||||
// Compute the fingerprint from the public key (SHA-256, first 16 hex chars)
|
||||
const fp = await computeFingerprint(publicKey)
|
||||
if (cancelled) return
|
||||
setFingerprint(fp)
|
||||
|
||||
// Own fingerprint is always trusted — we hold the private key
|
||||
if (isSelf) {
|
||||
setFingerprintStatus('trusted')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have a known fingerprint in the local registry
|
||||
const { fingerprints: known } = await vaultClient!.getKnownFingerprints()
|
||||
if (cancelled) return
|
||||
|
||||
const knownEntry = known[suiteUserId!]
|
||||
if (!knownEntry) {
|
||||
// Never seen — unknown, needs explicit acceptance
|
||||
setFingerprintStatus('unknown')
|
||||
} else if (knownEntry.fingerprint === fp) {
|
||||
// Same fingerprint — use stored status
|
||||
setFingerprintStatus(knownEntry.status as FingerprintStatus)
|
||||
} else {
|
||||
// Different fingerprint — key changed, needs re-verification
|
||||
setFingerprintStatus('unknown')
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setFingerprintStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
return () => { cancelled = true }
|
||||
}, [vaultClient, suiteUserId, isAuthenticated, isAdvanced, isSelf, revision])
|
||||
|
||||
// Derive trust level from fingerprint status
|
||||
let trustLevel: TrustLevel
|
||||
if (!isAuthenticated) {
|
||||
trustLevel = 'anonymous'
|
||||
} else if (!isAdvanced) {
|
||||
// Basic mode: only authentication matters
|
||||
trustLevel = 'authenticated'
|
||||
} else if (fingerprintStatus === 'trusted') {
|
||||
trustLevel = 'verified'
|
||||
} else if (fingerprintStatus === 'refused') {
|
||||
trustLevel = 'refused'
|
||||
} else if (fingerprintStatus === 'no-key' || fingerprintStatus === 'error') {
|
||||
// Authenticated but no vault keys — show as authenticated (blue)
|
||||
trustLevel = 'authenticated'
|
||||
} else {
|
||||
// 'unknown' or 'loading' — has key but not yet verified
|
||||
trustLevel = 'unknown'
|
||||
}
|
||||
|
||||
return { trustLevel, fingerprintStatus, fingerprint }
|
||||
}
|
||||
@@ -0,0 +1,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,142 +0,0 @@
|
||||
import { Button, Dialog, type DialogProps, Text } from '@/primitives'
|
||||
import { VStack, HStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiLockFill, RiShieldCheckFill, RiAlertLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { useVaultClient } from '@/features/encryption'
|
||||
|
||||
export const EncryptionModeDialog = ({
|
||||
onSelect,
|
||||
isForLater = false,
|
||||
...dialogProps
|
||||
}: {
|
||||
onSelect: (mode: ApiEncryptionMode) => void
|
||||
isForLater?: boolean
|
||||
} & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('home', { keyPrefix: 'encryptionModeDialog' })
|
||||
const { hasKeys, client: vaultClient, error: vaultError, isLoading: vaultLoading } = useVaultClient()
|
||||
const vaultUnavailable = !vaultClient && !vaultLoading
|
||||
const canUseAdvanced = !!hasKeys && !vaultUnavailable
|
||||
|
||||
return (
|
||||
<Dialog title={t('title')} isOpen {...dialogProps}>
|
||||
<VStack gap="1rem" alignItems="stretch">
|
||||
<Text variant="sm" className={css({ color: 'greyscale.700' })}>
|
||||
{t('description')}
|
||||
</Text>
|
||||
|
||||
<button
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
backgroundColor: 'white',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'border-color 150ms ease, background-color 150ms ease',
|
||||
_hover: {
|
||||
borderColor: 'primary.500',
|
||||
backgroundColor: 'primary.50',
|
||||
},
|
||||
})}
|
||||
onClick={() => onSelect(ApiEncryptionMode.BASIC)}
|
||||
>
|
||||
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
|
||||
<RiLockFill size={20} color="#2563eb" />
|
||||
</div>
|
||||
<VStack gap="0.25rem" alignItems="flex-start">
|
||||
<Text
|
||||
variant="sm"
|
||||
bold
|
||||
className={css({ color: 'greyscale.900' })}
|
||||
>
|
||||
{t('basic.title')}
|
||||
</Text>
|
||||
<Text variant="sm" className={css({ color: 'greyscale.600' })}>
|
||||
{t('basic.description')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</button>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
backgroundColor: 'white',
|
||||
cursor: canUseAdvanced ? 'pointer' : 'not-allowed',
|
||||
textAlign: 'left',
|
||||
opacity: canUseAdvanced ? 1 : 0.5,
|
||||
transition:
|
||||
'border-color 150ms ease, background-color 150ms ease',
|
||||
_hover: canUseAdvanced
|
||||
? {
|
||||
borderColor: 'green.500',
|
||||
backgroundColor: 'green.50',
|
||||
}
|
||||
: {},
|
||||
})}
|
||||
onClick={() => canUseAdvanced && onSelect(ApiEncryptionMode.ADVANCED)}
|
||||
disabled={!canUseAdvanced}
|
||||
>
|
||||
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
|
||||
<RiShieldCheckFill
|
||||
size={20}
|
||||
color={canUseAdvanced ? '#166534' : '#9ca3af'}
|
||||
/>
|
||||
</div>
|
||||
<VStack gap="0.25rem" alignItems="flex-start">
|
||||
<Text
|
||||
variant="sm"
|
||||
bold
|
||||
className={css({
|
||||
color: canUseAdvanced ? 'greyscale.900' : 'greyscale.400',
|
||||
})}
|
||||
>
|
||||
{t('advanced.title')}
|
||||
</Text>
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
color: canUseAdvanced ? 'greyscale.600' : 'greyscale.400',
|
||||
})}
|
||||
>
|
||||
{t('advanced.description')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</button>
|
||||
{!canUseAdvanced && (
|
||||
<HStack
|
||||
gap="0.4rem"
|
||||
className={css({
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
backgroundColor: vaultUnavailable ? 'red.50' : 'orange.50',
|
||||
borderRadius: '0.375rem',
|
||||
})}
|
||||
>
|
||||
<RiAlertLine
|
||||
size={14}
|
||||
color={vaultUnavailable ? '#dc2626' : '#d97706'}
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<Text variant="note" className={css({ color: vaultUnavailable ? 'red.800' : 'orange.800' })}>
|
||||
{vaultUnavailable
|
||||
? t('advanced.serviceUnavailable')
|
||||
: t('advanced.onboardingRequired')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</div>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ 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 { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { isValidPassphrase } from '@/features/encryption'
|
||||
|
||||
export const JoinMeetingDialog = () => {
|
||||
const { t } = useTranslation('home')
|
||||
@@ -31,18 +31,15 @@ export const JoinMeetingDialog = () => {
|
||||
const input = data.roomId as string
|
||||
const parsed = parseInput(input)
|
||||
|
||||
// If URL already has a hash, navigate directly with it
|
||||
if (parsed.hash) {
|
||||
navigateTo('room', parsed.roomId)
|
||||
window.location.hash = parsed.hash
|
||||
navigateTo('room', parsed.roomId, { hash: parsed.hash })
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the room uses basic encryption (needs passphrase)
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const room = await fetchRoom({ roomId: parsed.roomId })
|
||||
if (room.encryption_mode === ApiEncryptionMode.BASIC) {
|
||||
if (room.encryption_mode === 'basic') {
|
||||
setRoomId(parsed.roomId)
|
||||
setStep('passphrase')
|
||||
return
|
||||
@@ -56,32 +53,42 @@ export const JoinMeetingDialog = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePassphraseSubmit = (data: { passphrase?: FormDataEntryValue }) => {
|
||||
const handlePassphraseSubmit = (data: {
|
||||
passphrase?: FormDataEntryValue
|
||||
}) => {
|
||||
const passphrase = (data.passphrase as string).trim()
|
||||
navigateTo('room', roomId)
|
||||
window.location.hash = passphrase
|
||||
navigateTo('room', roomId, { hash: passphrase })
|
||||
}
|
||||
|
||||
const validateRoomId = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const { roomId: id } = parseInput(trimmed)
|
||||
return !isRoomValid(id) ? (
|
||||
<>
|
||||
<p>{t('joinInputError')}</p>
|
||||
<Ul>
|
||||
<li>{window.location.origin}/uio-azer-jkl</li>
|
||||
<li>uio-azer-jkl</li>
|
||||
<li>uioazerjkl</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')}>
|
||||
<Form
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
submitLabel={t('joinPassphraseSubmit')}
|
||||
>
|
||||
<P
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('joinPassphraseDescription', {
|
||||
@@ -122,7 +129,12 @@ export const JoinMeetingDialog = () => {
|
||||
isRequired
|
||||
name="passphrase"
|
||||
label={t('joinPassphraseLabel')}
|
||||
errorMessage={t('joinPassphraseError')}
|
||||
validate={(value: string) => {
|
||||
const v = (value || '').trim()
|
||||
if (!v) return t('joinPassphraseError')
|
||||
if (!isValidPassphrase(v)) return t('joinPassphraseInvalidFormat')
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
|
||||
<P
|
||||
@@ -141,7 +153,10 @@ export const JoinMeetingDialog = () => {
|
||||
|
||||
return (
|
||||
<Dialog title={t('joinMeeting')}>
|
||||
<Form onSubmit={handleRoomSubmit} submitLabel={isLoading ? '...' : 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"
|
||||
|
||||
@@ -13,12 +13,11 @@ import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRo
|
||||
// fixme - duplication with the InviteDialog
|
||||
export const LaterMeetingDialog = ({
|
||||
room,
|
||||
hash,
|
||||
...dialogProps
|
||||
}: { room: null | ApiRoom; hash?: string } & Omit<DialogProps, 'title'>) => {
|
||||
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
||||
|
||||
const roomUrl = room ? `${getRouteUrl('room', room.slug)}${hash ? `#${hash}` : ''}` : null
|
||||
const roomUrl = room ? getRouteUrl('room', room.slug) : null
|
||||
const telephony = useTelephony()
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
@@ -32,7 +31,7 @@ export const LaterMeetingDialog = ({
|
||||
copyRoomToClipboard,
|
||||
isRoomUrlCopied,
|
||||
copyRoomUrlToClipboard,
|
||||
} = useCopyRoomToClipboard(room || undefined, hash)
|
||||
} = useCopyRoomToClipboard(room || undefined)
|
||||
|
||||
return (
|
||||
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DialogTrigger, MenuItem, Menu as RACMenu, Separator as RACSeparator } 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,12 +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, RiLockLine, RiShieldKeyholeLine } from '@remixicon/react'
|
||||
import { RiAddLine, RiLink, RiShieldCrossLine } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { EncryptionModeDialog } from '@/features/home/components/EncryptionModeDialog'
|
||||
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 { generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
|
||||
import { useVaultClient } from '@/features/encryption'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
@@ -152,19 +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 { client: vaultClient } = useVaultClient()
|
||||
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom; hash?: string }>(null)
|
||||
const [encryptionDialogMode, setEncryptionDialogMode] = useState<null | 'instant' | 'later'>(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 () => {
|
||||
@@ -216,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"
|
||||
>
|
||||
@@ -232,45 +258,34 @@ export const Home = () => {
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoom({ room: data })
|
||||
)
|
||||
onAction={async () => {
|
||||
const { room } = await buildRoomBundle()
|
||||
setLaterRoom({ room })
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
{data?.encryption?.enabled && (
|
||||
{encryptionAvailable && (
|
||||
<>
|
||||
<RACSeparator
|
||||
className={css({
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
margin: '0.25rem 0',
|
||||
border: 'none',
|
||||
height: '1px',
|
||||
background: 'greyscale.250',
|
||||
margin: '0.35rem 0',
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => setEncryptionDialogMode('instant')}
|
||||
data-attr="create-option-encrypted-instant"
|
||||
onAction={() => setShowEncryptedConfirm(true)}
|
||||
data-attr="create-option-encrypted"
|
||||
>
|
||||
<RiLockLine size={18} />
|
||||
{t('createMenu.encryptedInstantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => setEncryptionDialogMode('later')}
|
||||
data-attr="create-option-encrypted-later"
|
||||
>
|
||||
<RiShieldKeyholeLine size={18} />
|
||||
{t('createMenu.encryptedLaterOption')}
|
||||
<RiShieldCrossLine size={18} />
|
||||
{t('createMenu.encryptedOption')}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -303,57 +318,35 @@ export const Home = () => {
|
||||
</Columns>
|
||||
<LaterMeetingDialog
|
||||
room={laterRoom?.room ?? null}
|
||||
hash={laterRoom?.hash}
|
||||
onOpenChange={() => setLaterRoom(null)}
|
||||
/>
|
||||
{encryptionDialogMode && (
|
||||
<EncryptionModeDialog
|
||||
onSelect={async (mode) => {
|
||||
const dialogMode = encryptionDialogMode
|
||||
setEncryptionDialogMode(null)
|
||||
const slug = generateRoomId()
|
||||
const hash = mode === ApiEncryptionMode.BASIC ? generatePassphrase() : undefined
|
||||
|
||||
let encryptedSymmetricKey = ''
|
||||
if (mode === ApiEncryptionMode.ADVANCED && vaultClient) {
|
||||
// encryptWithoutKey requires data to encrypt, but we only care about
|
||||
// the generated symmetric key (encryptedKeys), not the encrypted content.
|
||||
// The same symmetric key will be used for all streams (video/audio/chat).
|
||||
const dummyData = new Uint8Array(32).buffer
|
||||
const { publicKey } = await vaultClient.getPublicKey()
|
||||
const { encryptedKeys } = await vaultClient.encryptWithoutKey(
|
||||
dummyData,
|
||||
{ self: publicKey }
|
||||
)
|
||||
const keyBytes = new Uint8Array(encryptedKeys['self'])
|
||||
encryptedSymmetricKey = btoa(String.fromCharCode(...keyBytes))
|
||||
}
|
||||
|
||||
createRoom({
|
||||
slug,
|
||||
username,
|
||||
encryptionMode: mode,
|
||||
encryptedSymmetricKey,
|
||||
}).then((data) => {
|
||||
if (dialogMode === 'instant') {
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
if (hash) {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}#${hash}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setLaterRoom({ room: data, hash })
|
||||
}
|
||||
})
|
||||
}}
|
||||
onOpenChange={() => setEncryptionDialogMode(null)}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
|
||||
+32
-123
@@ -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'
|
||||
@@ -12,117 +13,17 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||
import { NotificationType } from '@/features/notifications'
|
||||
import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
|
||||
import { useParticipantTrustLevel, formatFingerprint } from '@/features/encryption/useParticipantTrustLevel'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
const WaitingParticipantIdentity = ({ participant }: { participant: WaitingParticipant }) => {
|
||||
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
|
||||
const roomData = useRoomData()
|
||||
const [isIdentityOpen, setIsIdentityOpen] = useState(false)
|
||||
const attrs = {
|
||||
is_authenticated: participant.is_authenticated ? 'true' : 'false',
|
||||
suite_user_id: participant.suite_user_id || '',
|
||||
}
|
||||
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(attrs, roomData?.encryption_mode)
|
||||
const badgeTooltip = tBadge(trustLevel)
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack gap="0" alignItems="start">
|
||||
<Button
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={badgeTooltip}
|
||||
aria-label={badgeTooltip}
|
||||
onPress={() => setIsIdentityOpen(true)}
|
||||
className={css({
|
||||
padding: '0.1rem 0.25rem !important',
|
||||
minWidth: 'auto !important',
|
||||
height: 'auto !important',
|
||||
gap: '0.15rem !important',
|
||||
borderRadius: '0.25rem !important',
|
||||
backgroundColor: 'transparent !important',
|
||||
color: 'white !important',
|
||||
cursor: 'pointer',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15) !important',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<EncryptionBadge isEncrypted={true} trustLevel={trustLevel} />
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
maxWidth: '8rem',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
})}
|
||||
>
|
||||
{participant.username}
|
||||
</Text>
|
||||
</Button>
|
||||
{fingerprint && (
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.6rem',
|
||||
color: 'greyscale.100',
|
||||
letterSpacing: '0.03em',
|
||||
paddingLeft: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{formatFingerprint(fingerprint)}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
fontSize: '0.7rem',
|
||||
color: 'greyscale.200',
|
||||
paddingLeft: '0.25rem',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '10rem',
|
||||
})}
|
||||
>
|
||||
{participant.is_authenticated && participant.email
|
||||
? participant.email
|
||||
: tBadge('anonymous')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<EncryptionIdentityDialog
|
||||
isOpen={isIdentityOpen}
|
||||
onOpenChange={setIsIdentityOpen}
|
||||
participantName={participant.username}
|
||||
participantEmail={participant.email}
|
||||
suiteUserId={participant.suite_user_id}
|
||||
isAuthenticated={participant.is_authenticated}
|
||||
encryptionMode={roomData?.encryption_mode}
|
||||
preloadedFingerprint={fingerprint}
|
||||
preloadedFingerprintStatus={fingerprintStatus}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const NOTIFICATION_DISPLAY_DURATION = 10000
|
||||
|
||||
export const WaitingParticipantNotification = () => {
|
||||
const roomData = useRoomData()
|
||||
const encrypted = isEncryptedRoom(roomData)
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
|
||||
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()
|
||||
@@ -136,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) &&
|
||||
@@ -151,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)
|
||||
}
|
||||
}, [
|
||||
@@ -165,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)
|
||||
@@ -174,7 +72,6 @@ export const WaitingParticipantNotification = () => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Hide notification when participants panel is opened
|
||||
if (isParticipantsOpen) {
|
||||
setShowQuickActionsMessage(false)
|
||||
}
|
||||
@@ -202,28 +99,40 @@ export const WaitingParticipantNotification = () => {
|
||||
>
|
||||
{t('one')}
|
||||
</Text>
|
||||
<HStack gap="0.5rem">
|
||||
<HStack gap="0.5rem" alignItems="center">
|
||||
<Avatar
|
||||
name={waitingParticipants[0].username}
|
||||
bgColor={waitingParticipants[0].color}
|
||||
context="list"
|
||||
notification
|
||||
/>
|
||||
{encrypted ? (
|
||||
<WaitingParticipantIdentity participant={waitingParticipants[0]} />
|
||||
) : (
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
maxWidth: '10rem',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
})}
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
maxWidth: '10rem',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
})}
|
||||
>
|
||||
{waitingParticipants[0].username}
|
||||
</Text>
|
||||
{!waitingParticipants[0].is_authenticated && (
|
||||
<VisualOnlyTooltip
|
||||
tooltip={anonymousLabel}
|
||||
ariaLabel={anonymousLabel}
|
||||
>
|
||||
{waitingParticipants[0].username}
|
||||
</Text>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={16} color="#f87171" />
|
||||
</span>
|
||||
</VisualOnlyTooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack gap="0.25rem" marginLeft="auto">
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -13,16 +13,6 @@ export enum ApiAccessLevel {
|
||||
export enum ApiEncryptionMode {
|
||||
NONE = 'none',
|
||||
BASIC = 'basic',
|
||||
ADVANCED = 'advanced',
|
||||
}
|
||||
|
||||
export function isEncryptedRoom(room?: { encryption_mode?: ApiEncryptionMode; encryption_enabled?: boolean } | null): boolean {
|
||||
if (!room) return false
|
||||
// Support both new encryption_mode and legacy encryption_enabled
|
||||
if (room.encryption_mode !== undefined) {
|
||||
return room.encryption_mode !== ApiEncryptionMode.NONE
|
||||
}
|
||||
return !!room.encryption_enabled
|
||||
}
|
||||
|
||||
export type ApiRoom = {
|
||||
@@ -33,7 +23,6 @@ export type ApiRoom = {
|
||||
is_administrable: boolean
|
||||
access_level: ApiAccessLevel
|
||||
encryption_mode: ApiEncryptionMode
|
||||
encrypted_symmetric_key?: string
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: {
|
||||
[key: string]: string | number | boolean | string[]
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom, ApiEncryptionMode } from './ApiRoom'
|
||||
import { ApiEncryptionMode, ApiRoom } from './ApiRoom'
|
||||
|
||||
export interface CreateRoomParams {
|
||||
slug: string
|
||||
callbackId?: string
|
||||
username?: string
|
||||
encryptionMode?: ApiEncryptionMode
|
||||
encryptedSymmetricKey?: string
|
||||
}
|
||||
|
||||
const createRoom = ({
|
||||
@@ -16,16 +15,16 @@ const createRoom = ({
|
||||
callbackId,
|
||||
username = '',
|
||||
encryptionMode = ApiEncryptionMode.NONE,
|
||||
encryptedSymmetricKey = '',
|
||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
const queryParams = username ? `?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,
|
||||
encrypted_symmetric_key: encryptedSymmetricKey,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ export interface EnterRoomParams {
|
||||
roomId: string
|
||||
allowEntry: boolean
|
||||
participantId: string
|
||||
encryptedKey?: string
|
||||
adminEphemeralPublicKey?: string
|
||||
encryptedVaultKey?: string
|
||||
}
|
||||
|
||||
export interface EnterRoomResponse {
|
||||
@@ -19,18 +16,12 @@ export const enterRoom = async ({
|
||||
roomId,
|
||||
allowEntry,
|
||||
participantId,
|
||||
encryptedKey = '',
|
||||
adminEphemeralPublicKey = '',
|
||||
encryptedVaultKey = '',
|
||||
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
||||
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_id: participantId,
|
||||
allow_entry: allowEntry,
|
||||
encrypted_key: encryptedKey,
|
||||
admin_ephemeral_public_key: adminEphemeralPublicKey,
|
||||
encrypted_vault_key: encryptedVaultKey,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,9 +9,6 @@ export type WaitingParticipant = {
|
||||
username: string
|
||||
color: string
|
||||
is_authenticated: boolean
|
||||
email?: string
|
||||
suite_user_id?: string
|
||||
ephemeral_public_key?: string
|
||||
}
|
||||
|
||||
export type WaitingParticipantsResponse = {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
||||
export interface RequestEntryParams {
|
||||
roomId: string
|
||||
username?: string
|
||||
ephemeralPublicKey?: string
|
||||
}
|
||||
|
||||
export enum ApiLobbyStatus {
|
||||
@@ -18,21 +17,16 @@ export enum ApiLobbyStatus {
|
||||
export interface ApiRequestEntry {
|
||||
status: ApiLobbyStatus
|
||||
livekit?: ApiLiveKit
|
||||
encrypted_key?: string
|
||||
admin_ephemeral_public_key?: string
|
||||
encrypted_vault_key?: string
|
||||
}
|
||||
|
||||
export const requestEntry = async ({
|
||||
roomId,
|
||||
username = '',
|
||||
ephemeralPublicKey = '',
|
||||
}: RequestEntryParams) => {
|
||||
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
ephemeral_public_key: ephemeralPublicKey,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,21 +13,18 @@ import {
|
||||
RoomOptions,
|
||||
VideoPresets,
|
||||
} from 'livekit-client'
|
||||
import { setSymmetricKey, getSymmetricKey, getEncryptedVaultKey, generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
|
||||
import { isEncryptedRoom, ApiEncryptionMode } from '../api/ApiRoom'
|
||||
import { VaultE2EEManager } from '@/features/encryption/VaultE2EEManager'
|
||||
import { useVaultClient } from '@/features/encryption'
|
||||
import {
|
||||
getPassphraseFromHash,
|
||||
isValidPassphrase,
|
||||
EncryptionMismatchScreen,
|
||||
} from '@/features/encryption'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { RiLockLine } from '@remixicon/react'
|
||||
import { Center } from '@/styled-system/jsx'
|
||||
import { Text } from '@/primitives'
|
||||
import { QueryAware } from '@/components/QueryAware'
|
||||
import { 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'
|
||||
@@ -95,28 +92,58 @@ export const Conference = ({
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const encryptionEnabled = isEncryptedRoom(data)
|
||||
const { client: vaultClient, hasKeys: vaultHasKeys, error: vaultError, isLoading: vaultLoading } = useVaultClient()
|
||||
// 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
|
||||
|
||||
// Determine which E2EE backend to use based solely on the room's encryption_mode.
|
||||
// Advanced mode always uses VaultClient, basic mode always uses LiveKit Worker+KeyProvider.
|
||||
const useVaultE2EE = data?.encryption_mode === ApiEncryptionMode.ADVANCED
|
||||
const encryptionMismatch:
|
||||
| 'missingPassphrase'
|
||||
| 'unexpectedPassphrase'
|
||||
| null =
|
||||
data === undefined
|
||||
? null
|
||||
: dbSaysEncrypted && !hasValidHash
|
||||
? 'missingPassphrase'
|
||||
: !dbSaysEncrypted && hashPassphrase.length > 0
|
||||
? 'unexpectedPassphrase'
|
||||
: null
|
||||
|
||||
// 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
|
||||
|
||||
// Refs for both approaches (only one is used per session)
|
||||
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
|
||||
const workerRef = useRef<Worker | null>(null)
|
||||
const vaultManagerRef = useRef<VaultE2EEManager | null>(null)
|
||||
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled)
|
||||
// `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 && encryptionEnabled && !useVaultE2EE) {
|
||||
if (!keyProviderRef.current && isEncrypted) {
|
||||
keyProviderRef.current = new ExternalE2EEKeyProvider()
|
||||
}
|
||||
return keyProviderRef.current
|
||||
}
|
||||
|
||||
const getWorker = () => {
|
||||
if (!workerRef.current && encryptionEnabled && !useVaultE2EE && typeof window !== 'undefined') {
|
||||
if (!workerRef.current && isEncrypted && typeof window !== 'undefined') {
|
||||
workerRef.current = new Worker(
|
||||
new URL('livekit-client/e2ee-worker', import.meta.url)
|
||||
)
|
||||
@@ -124,20 +151,13 @@ export const Conference = ({
|
||||
return workerRef.current
|
||||
}
|
||||
|
||||
const getVaultManager = () => {
|
||||
if (!vaultManagerRef.current && useVaultE2EE && vaultClient) {
|
||||
vaultManagerRef.current = new VaultE2EEManager(vaultClient)
|
||||
}
|
||||
return vaultManagerRef.current
|
||||
}
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
const baseOptions: RoomOptions = {
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
publishDefaults: {
|
||||
videoCodec: encryptionEnabled ? undefined : 'vp9',
|
||||
red: !encryptionEnabled,
|
||||
videoCodec: isEncrypted ? undefined : 'vp9',
|
||||
red: !isEncrypted,
|
||||
},
|
||||
videoCaptureDefaults: {
|
||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||
@@ -153,12 +173,7 @@ export const Conference = ({
|
||||
},
|
||||
}
|
||||
|
||||
if (useVaultE2EE) {
|
||||
const vaultManager = getVaultManager()
|
||||
if (vaultManager) {
|
||||
baseOptions.encryption = { e2eeManager: vaultManager }
|
||||
}
|
||||
} else if (encryptionEnabled) {
|
||||
if (isEncrypted) {
|
||||
const worker = getWorker()
|
||||
const keyProvider = getKeyProvider()
|
||||
if (keyProvider && worker) {
|
||||
@@ -168,9 +183,9 @@ export const Conference = ({
|
||||
|
||||
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
|
||||
}, [
|
||||
encryptionEnabled,
|
||||
useVaultE2EE,
|
||||
isEncrypted,
|
||||
userConfig.videoDeviceId,
|
||||
userConfig.videoPublishResolution,
|
||||
userConfig.audioDeviceId,
|
||||
@@ -179,6 +194,15 @@ export const Conference = ({
|
||||
|
||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||
|
||||
const encryptionSetupComplete = !isEncrypted || roomWithE2EE === room
|
||||
// Never let LiveKitRoom connect in an indeterminate state:
|
||||
// 1. `data` must have arrived from the server so we know whether to
|
||||
// show the mismatch screen.
|
||||
// 2. If the URL has a valid hash, the *current* Room must have already
|
||||
// been armed with setKey + setE2EEEnabled — otherwise the camera
|
||||
// goes out in clear.
|
||||
const canConnectMediaWise = data !== undefined && encryptionSetupComplete
|
||||
|
||||
/*
|
||||
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
||||
@@ -193,125 +217,57 @@ export const Conference = ({
|
||||
return livekit_url
|
||||
}, [apiConfig?.livekit])
|
||||
|
||||
// Encryption key setup:
|
||||
// VaultE2EE: admin generates key via vaultClient.encryptWithoutKey(), joiner receives wrapped key
|
||||
// Fallback: admin generates passphrase, joiner receives via lobby DH exchange
|
||||
const isAdmin = mode === 'create' || data?.is_administrable === true
|
||||
const adminPassphraseRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!encryptionEnabled || encryptionSetupComplete) return
|
||||
if (!isEncrypted || roomWithE2EE === room) return
|
||||
|
||||
if (useVaultE2EE) {
|
||||
// Advanced mode: VaultE2EEManager delegates crypto to VaultClient iframe
|
||||
const vaultManager = getVaultManager()
|
||||
if (!vaultManager || !vaultClient) return
|
||||
if (isAdmin) {
|
||||
const existingKey = data?.encrypted_symmetric_key
|
||||
if (existingKey) {
|
||||
const binaryStr = atob(existingKey)
|
||||
const bytes = new Uint8Array(binaryStr.length)
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
|
||||
vaultManager.setEncryptedSymmetricKey(bytes.buffer)
|
||||
}
|
||||
} else {
|
||||
const vaultKey = getEncryptedVaultKey()
|
||||
if (vaultKey) {
|
||||
vaultManager.setEncryptedSymmetricKey(vaultKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Enable E2EE BEFORE connecting — no tracks exist yet so
|
||||
// republishAllTracks() is a no-op. Calling after connection
|
||||
// triggers republish which times out.
|
||||
room.setE2EEEnabled(true).catch((err) => {
|
||||
console.error('[VaultE2EE] E2EE enable failed:', err)
|
||||
})
|
||||
|
||||
setEncryptionSetupComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Basic mode: LiveKit Worker+KeyProvider with passphrase
|
||||
const keyProvider = getKeyProvider()
|
||||
if (!keyProvider) return
|
||||
|
||||
let passphrase: string | null = null
|
||||
|
||||
if (isAdmin) {
|
||||
if (!adminPassphraseRef.current) {
|
||||
const existingHash = window.location.hash.slice(1)
|
||||
if (existingHash) {
|
||||
adminPassphraseRef.current = existingHash
|
||||
} else {
|
||||
adminPassphraseRef.current = generatePassphrase()
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}#${adminPassphraseRef.current}`
|
||||
)
|
||||
}
|
||||
}
|
||||
passphrase = adminPassphraseRef.current
|
||||
setSymmetricKey(new TextEncoder().encode(passphrase))
|
||||
} else {
|
||||
const hashKey = window.location.hash.slice(1)
|
||||
if (hashKey) {
|
||||
passphrase = hashKey
|
||||
setSymmetricKey(new TextEncoder().encode(passphrase))
|
||||
} else {
|
||||
const preExchangedKey = getSymmetricKey()
|
||||
if (preExchangedKey) {
|
||||
passphrase = new TextDecoder().decode(preExchangedKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!passphrase) {
|
||||
console.error('[Encryption] No passphrase available')
|
||||
return
|
||||
}
|
||||
// `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(async () => {
|
||||
// Enable E2EE BEFORE connecting — sets encryptionType=GCM so tracks
|
||||
// are published with encryption metadata from the start.
|
||||
// Also sends 'enable' to the Worker before any frames flow,
|
||||
// eliminating the unencrypted frame window.
|
||||
try {
|
||||
await room.setE2EEEnabled(true)
|
||||
} catch (err) {
|
||||
console.error('[Encryption] E2EE enable failed:', err)
|
||||
}
|
||||
|
||||
setEncryptionSetupComplete(true)
|
||||
.then(() => room.setE2EEEnabled(true))
|
||||
.then(() => {
|
||||
if (!cancelled) setRoomWithE2EE(room)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Encryption] Key setup failed:', 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])
|
||||
|
||||
}, [room, encryptionEnabled, encryptionSetupComplete, isAdmin, useVaultE2EE])
|
||||
|
||||
// In basic encrypted rooms, the passphrase is in the URL hash.
|
||||
// If the user changes the hash (e.g. corrects a typo), reload the page
|
||||
// so the new passphrase is picked up by the encryption setup.
|
||||
useEffect(() => {
|
||||
if (!encryptionEnabled || useVaultE2EE) return
|
||||
const handleHashChange = () => {
|
||||
if (!isEncrypted) return
|
||||
let currentHash = getPassphraseFromHash()
|
||||
const onHashChange = () => {
|
||||
const next = getPassphraseFromHash()
|
||||
if (next === currentHash) return
|
||||
currentHash = next
|
||||
window.location.reload()
|
||||
}
|
||||
window.addEventListener('hashchange', handleHashChange)
|
||||
return () => window.removeEventListener('hashchange', handleHashChange)
|
||||
}, [encryptionEnabled, useVaultE2EE])
|
||||
window.addEventListener('hashchange', onHashChange)
|
||||
return () => window.removeEventListener('hashchange', onHashChange)
|
||||
}, [isEncrypted])
|
||||
|
||||
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
|
||||
@@ -325,20 +281,9 @@ export const Conference = ({
|
||||
.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
|
||||
* FIREFOX + PROXY WORKAROUND — see 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)
|
||||
@@ -363,7 +308,6 @@ export const Conference = ({
|
||||
|
||||
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')}
|
||||
@@ -372,72 +316,23 @@ export const Conference = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Block entry to advanced encrypted rooms when vault service is unavailable
|
||||
if (useVaultE2EE && !vaultLoading && !vaultClient) {
|
||||
if (encryptionMismatch) {
|
||||
return <EncryptionMismatchScreen reason={encryptionMismatch} />
|
||||
}
|
||||
|
||||
if (encryptionSetupError) {
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<CenteredContent withBackButton>
|
||||
<Center>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '400px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '1rem',
|
||||
padding: '2rem',
|
||||
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.08)',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '3.5rem',
|
||||
height: '3.5rem',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#fef2f2',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<RiLockLine size={28} color="#dc2626" />
|
||||
</div>
|
||||
<Text as="h2" className={css({ fontWeight: 700, fontSize: '1.15rem' })}>
|
||||
{t('encryption.error.title')}
|
||||
</Text>
|
||||
<Text as="p" className={css({ fontSize: '0.9rem', color: 'greyscale.700' })}>
|
||||
{t('encryption.error.vaultUnavailable')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: '#fffbeb',
|
||||
border: '1px solid #fde68a',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<Text as="p" className={css({ fontSize: '0.8rem', color: '#92400e' })}>
|
||||
{t('encryption.error.vaultUnavailableHint')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Center>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
<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 (
|
||||
@@ -447,7 +342,7 @@ export const Conference = ({
|
||||
room={room}
|
||||
serverUrl={serverUrl}
|
||||
token={data?.livekit?.token}
|
||||
connect={isConnectionWarmedUp && encryptionSetupComplete}
|
||||
connect={isConnectionWarmedUp && canConnectMediaWise}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={
|
||||
userConfig.videoEnabled && {
|
||||
|
||||
@@ -5,14 +5,18 @@ import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import { Heading, Dialog } from 'react-aria-components'
|
||||
import { Text, text } from '@/primitives/Text'
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiComputerLine,
|
||||
RiFileCopyLine,
|
||||
RiPhoneLine,
|
||||
RiSpam2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { FeaturePill } from '@/features/encryption'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
|
||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||
@@ -41,8 +45,16 @@ const StyledRACDialog = styled(Dialog, {
|
||||
|
||||
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||
const { t: tHome } = useTranslation('home', {
|
||||
keyPrefix: 'connectionDetailsDialog',
|
||||
})
|
||||
const { t: tFeatures } = useTranslation('home', {
|
||||
keyPrefix: 'createEncryptedMeetingDialog',
|
||||
})
|
||||
|
||||
const roomData = useRoomData()
|
||||
const isEncrypted = roomData?.encryption_mode === '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
|
||||
@@ -51,9 +63,12 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
|
||||
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,
|
||||
@@ -72,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
|
||||
@@ -88,8 +103,119 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
<P>{t('description')}</P>
|
||||
{isTelephonyReadyForUse ? (
|
||||
{isEncrypted && !isAdminOrOwner ? (
|
||||
<P>{t('encryptedGuestBody')}</P>
|
||||
) : (
|
||||
<P>{t('description')}</P>
|
||||
)}
|
||||
{isEncrypted && !isAdminOrOwner ? null : isEncrypted ? (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
marginTop: '0.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 0.85rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
>
|
||||
<RiAlertFill
|
||||
size={18}
|
||||
color="#b45309"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{tHome('warning')}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.250',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
flex: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</span>
|
||||
<Button
|
||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size="sm"
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine size={16} />
|
||||
) : (
|
||||
<RiFileCopyLine size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Text
|
||||
margin={false}
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
color: 'greyscale.500',
|
||||
})}
|
||||
>
|
||||
{t('encryptedDisabledHeading')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.4rem',
|
||||
})}
|
||||
>
|
||||
<FeaturePill
|
||||
size="sm"
|
||||
icon={<RiPhoneLine size={13} />}
|
||||
label={tFeatures('features.dialIn')}
|
||||
/>
|
||||
<FeaturePill
|
||||
size="sm"
|
||||
icon={<RiComputerLine size={13} />}
|
||||
label={tFeatures('features.meetingRoom')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : isTelephonyReadyForUse ? (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
|
||||
@@ -32,122 +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, ApiEncryptionMode, isEncryptedRoom as checkEncryptedRoom } from '../api/ApiRoom'
|
||||
import { useVaultClient } from '@/features/encryption'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
|
||||
const AdvancedOnboardingScreen = ({
|
||||
modalOpen,
|
||||
onModalOpenChange,
|
||||
}: {
|
||||
modalOpen: boolean
|
||||
onModalOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
const { client: vaultClient } = useVaultClient()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen || !vaultClient) return
|
||||
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
|
||||
el.innerHTML = ''
|
||||
vaultClient.openOnboarding(el)
|
||||
|
||||
const handleClosed = () => {
|
||||
onModalOpenChange(false)
|
||||
vaultClient.off('interface:closed', handleClosed)
|
||||
}
|
||||
vaultClient.on('interface:closed', handleClosed)
|
||||
|
||||
return () => {
|
||||
vaultClient.off('interface:closed', handleClosed)
|
||||
}
|
||||
}, [modalOpen, vaultClient, onModalOpenChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack alignItems="center" textAlign="center" gap="0.75rem">
|
||||
<RiLockLine size={32} color="#d97706" />
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('advancedOnboarding.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('advancedOnboarding.body')}
|
||||
</Text>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => onModalOpenChange(true)}
|
||||
>
|
||||
{t('advancedOnboarding.button')}
|
||||
</Button>
|
||||
</VStack>
|
||||
{modalOpen && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
})}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onModalOpenChange(false)
|
||||
vaultClient?.closeInterface()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
width: '90%',
|
||||
maxWidth: '550px',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onModalOpenChange(false)
|
||||
vaultClient?.closeInterface()
|
||||
}}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
zIndex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.25rem',
|
||||
color: 'greyscale.500',
|
||||
_hover: { color: 'greyscale.900' },
|
||||
})}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={css({ minHeight: '300px' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import {
|
||||
isValidPassphrase,
|
||||
getPassphraseFromHash,
|
||||
EncryptionMismatchScreen,
|
||||
} from '@/features/encryption'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { useUser } from '@/features/auth'
|
||||
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'
|
||||
@@ -216,31 +109,30 @@ export const Join = ({
|
||||
roomId: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
const { isLoggedIn, user } = useUser()
|
||||
|
||||
// Early fetch to check if the room is encrypted (needed before form submission)
|
||||
// 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 = checkEncryptedRoom(roomInfo)
|
||||
const isBasicEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC
|
||||
const isAdvancedEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.ADVANCED
|
||||
|
||||
// Basic mode: validate the passphrase in the URL hash
|
||||
const hashKey = window.location.hash.slice(1)
|
||||
const hasValidBasicKey = isBasicEncrypted ? (hashKey.length === 48 && /^[a-z0-9]+$/.test(hashKey)) : true
|
||||
|
||||
// Advanced mode: require auth + vault onboarding
|
||||
const { hasKeys: vaultHasKeys, isReady: vaultReady } = useVaultClient()
|
||||
const advancedRequiresLogin = isAdvancedEncrypted && !isLoggedIn
|
||||
const advancedRequiresOnboarding = isAdvancedEncrypted && isLoggedIn && vaultReady && !vaultHasKeys
|
||||
|
||||
// In encrypted rooms, authenticated users must use their OIDC name
|
||||
const isEncryptedRoom = roomInfo?.encryption_mode === '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 || ''
|
||||
const passphrase = getPassphraseFromHash()
|
||||
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: {
|
||||
@@ -312,11 +204,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 {
|
||||
@@ -384,8 +271,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,
|
||||
@@ -425,12 +310,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,
|
||||
@@ -464,17 +343,14 @@ export const Join = ({
|
||||
roomId,
|
||||
username,
|
||||
onAccepted: handleAccepted,
|
||||
encryptionEnabled: isEncryptedRoom,
|
||||
})
|
||||
|
||||
const [advancedOnboardingOpen, setAdvancedOnboardingOpen] = useState(false)
|
||||
const { openLoginHint } = useLoginHint()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
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()
|
||||
}
|
||||
@@ -567,40 +443,11 @@ export const Join = ({
|
||||
)
|
||||
|
||||
default:
|
||||
if (advancedRequiresLogin) {
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center" gap="0.75rem">
|
||||
<RiLockLine size={32} color="#2563eb" />
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('advancedAuth.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('advancedAuth.body')}
|
||||
</Text>
|
||||
<LoginButton proConnectHint={false} />
|
||||
</VStack>
|
||||
)
|
||||
if (unexpectedPassphrase) {
|
||||
return <EncryptionMismatchScreen reason="unexpectedPassphrase" />
|
||||
}
|
||||
if (advancedRequiresOnboarding || advancedOnboardingOpen) {
|
||||
return (
|
||||
<AdvancedOnboardingScreen
|
||||
modalOpen={advancedOnboardingOpen}
|
||||
onModalOpenChange={setAdvancedOnboardingOpen}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (isBasicEncrypted && !hasValidBasicKey) {
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center" gap="0.75rem">
|
||||
<RiLockLine size={32} color="#dc2626" />
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('invalidKey.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('invalidKey.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
if (isEncryptedRoom && !hasValidPassphrase) {
|
||||
return <EncryptionMismatchScreen reason="missingPassphrase" />
|
||||
}
|
||||
return (
|
||||
<Form
|
||||
@@ -617,19 +464,13 @@ export const Join = ({
|
||||
{isNameLocked ? (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
color: 'greyscale.500',
|
||||
fontSize: '0.8rem',
|
||||
})}
|
||||
>
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
|
||||
{t('usernameLabel')}
|
||||
</Text>
|
||||
<div
|
||||
@@ -645,12 +486,7 @@ export const Join = ({
|
||||
})}
|
||||
>
|
||||
<RiLockLine size={14} color="#6b7280" />
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
fontWeight: 500,
|
||||
})}
|
||||
>
|
||||
<Text variant="sm" margin={false}>
|
||||
{lockedName}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -658,15 +494,20 @@ export const Join = ({
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
gap: '0.4rem',
|
||||
})}
|
||||
>
|
||||
<RiInformationLine size={12} color="#9ca3af" />
|
||||
<RiInformationLine
|
||||
size={18}
|
||||
color="#6b7280"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
<Text
|
||||
variant="note"
|
||||
margin={false}
|
||||
className={css({
|
||||
fontSize: '0.7rem',
|
||||
color: 'greyscale.400',
|
||||
fontSize: '0.8rem',
|
||||
color: 'greyscale.500',
|
||||
})}
|
||||
>
|
||||
{t('encryptedNameLocked')}
|
||||
|
||||
@@ -6,10 +6,6 @@ import {
|
||||
ApiLobbyStatus,
|
||||
ApiRequestEntry,
|
||||
} from '../api/requestEntry'
|
||||
import {
|
||||
setSymmetricKey,
|
||||
setEncryptedVaultKey,
|
||||
} from '@/features/encryption/lobbyKeyExchange'
|
||||
|
||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
@@ -18,12 +14,10 @@ export const useLobby = ({
|
||||
roomId,
|
||||
username,
|
||||
onAccepted,
|
||||
encryptionEnabled = false,
|
||||
}: {
|
||||
roomId: string
|
||||
username: string
|
||||
onAccepted: (e: ApiRequestEntry) => void
|
||||
encryptionEnabled?: boolean
|
||||
}) => {
|
||||
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
|
||||
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
@@ -45,27 +39,10 @@ export const useLobby = ({
|
||||
/* eslint-disable @tanstack/query/exhaustive-deps */
|
||||
queryKey: [keys.requestEntry, roomId],
|
||||
queryFn: async () => {
|
||||
const response = await requestEntry({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
const response = await requestEntry({ roomId, username })
|
||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||
|
||||
// Advanced mode: vault-wrapped key
|
||||
if (encryptionEnabled && response.encrypted_vault_key) {
|
||||
console.info('[VaultE2EE] Joiner: received encrypted_vault_key from lobby, length:', response.encrypted_vault_key.length)
|
||||
const binaryStr = atob(response.encrypted_vault_key)
|
||||
const bytes = new Uint8Array(binaryStr.length)
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
bytes[i] = binaryStr.charCodeAt(i)
|
||||
}
|
||||
setEncryptedVaultKey(bytes.buffer)
|
||||
} else if (encryptionEnabled) {
|
||||
console.warn('[VaultE2EE] Joiner: ACCEPTED but no encrypted_vault_key in response', response)
|
||||
}
|
||||
|
||||
onAccepted(response)
|
||||
} else if (response.status === ApiLobbyStatus.DENIED) {
|
||||
clearWaitingTimeout()
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { isEncryptedRoom as checkEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useEnterRoom } from '../api/enterRoom'
|
||||
import {
|
||||
@@ -11,8 +10,6 @@ import {
|
||||
} from '../api/listWaitingParticipants'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { useVaultClient } from '@/features/encryption'
|
||||
import { toastQueue } from '@/features/notifications/components/ToastProvider'
|
||||
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
|
||||
@@ -21,12 +18,9 @@ export const useWaitingParticipants = () => {
|
||||
|
||||
const roomData = useRoomData()
|
||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||
const encrypted = checkEncryptedRoom(roomData)
|
||||
const isAdvancedMode = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
|
||||
|
||||
const room = useRoomContext()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const { client: vaultClient } = useVaultClient()
|
||||
|
||||
const handleDataReceived = useCallback((payload: Uint8Array) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
@@ -63,91 +57,14 @@ export const useWaitingParticipants = () => {
|
||||
|
||||
const { mutateAsync: enterRoom } = useEnterRoom()
|
||||
|
||||
const encryptKeyForAccept = async (participant: WaitingParticipant) => {
|
||||
let encryptedKey = ''
|
||||
let adminEphemeralPublicKey = ''
|
||||
let encryptedVaultKey = ''
|
||||
|
||||
if (isAdvancedMode) {
|
||||
// Advanced mode: re-wrap the existing symmetric key for the joiner.
|
||||
// All steps are mandatory — if any fails, the participant must NOT be accepted
|
||||
// (they would join without a key and see nothing).
|
||||
if (!vaultClient) {
|
||||
throw new Error('Encryption service is not available')
|
||||
}
|
||||
if (!participant.suite_user_id) {
|
||||
throw new Error('Participant has no vault identity — they may not be authenticated')
|
||||
}
|
||||
|
||||
const adminKeyBase64 = roomData?.encrypted_symmetric_key
|
||||
if (!adminKeyBase64) {
|
||||
throw new Error('Admin has no encrypted symmetric key for this room')
|
||||
}
|
||||
|
||||
console.info('[VaultE2EE] Admin: wrapping key for joiner', participant.suite_user_id)
|
||||
const adminKeyBinary = atob(adminKeyBase64)
|
||||
const adminKeyBytes = new Uint8Array(adminKeyBinary.length)
|
||||
for (let i = 0; i < adminKeyBinary.length; i++) adminKeyBytes[i] = adminKeyBinary.charCodeAt(i)
|
||||
|
||||
// Fetch joiner's vault public key
|
||||
const { publicKeys } = await vaultClient.fetchPublicKeys([participant.suite_user_id])
|
||||
const joinerPubKey = publicKeys[participant.suite_user_id]
|
||||
if (!joinerPubKey) {
|
||||
throw new Error(`Could not find encryption public key for participant "${participant.username}"`)
|
||||
}
|
||||
|
||||
// Re-wrap the symmetric key for the joiner using shareKeys
|
||||
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||
adminKeyBytes.buffer,
|
||||
{ [participant.suite_user_id]: joinerPubKey }
|
||||
)
|
||||
const joinerKey = encryptedKeys[participant.suite_user_id]
|
||||
if (!joinerKey) {
|
||||
throw new Error('Key wrapping returned no result — shareKeys failed')
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(joinerKey)
|
||||
encryptedVaultKey = btoa(String.fromCharCode(...bytes))
|
||||
console.info('[VaultE2EE] Admin: key wrapped successfully, length:', encryptedVaultKey.length)
|
||||
}
|
||||
|
||||
return { encryptedKey, adminEphemeralPublicKey, encryptedVaultKey }
|
||||
}
|
||||
|
||||
const handleParticipantEntry = async (
|
||||
participant: WaitingParticipant,
|
||||
allowEntry: boolean
|
||||
) => {
|
||||
let encryptedKey = ''
|
||||
let adminEphemeralPublicKey = ''
|
||||
let encryptedVaultKey = ''
|
||||
|
||||
if (allowEntry) {
|
||||
try {
|
||||
const keys = await encryptKeyForAccept(participant)
|
||||
encryptedKey = keys.encryptedKey
|
||||
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
|
||||
encryptedVaultKey = keys.encryptedVaultKey
|
||||
} catch (err) {
|
||||
console.error('[VaultE2EE] Cannot accept participant:', err)
|
||||
toastQueue.add(
|
||||
{
|
||||
type: 'encryptionError' as NotificationType,
|
||||
message: `Cannot accept ${participant.username}: ${(err as Error).message}`,
|
||||
},
|
||||
{ timeout: 8000 }
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await enterRoom({
|
||||
roomId: roomId,
|
||||
roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
encryptedKey,
|
||||
adminEphemeralPublicKey,
|
||||
encryptedVaultKey,
|
||||
})
|
||||
await refetchWaiting()
|
||||
}
|
||||
@@ -159,39 +76,13 @@ export const useWaitingParticipants = () => {
|
||||
setListEnabled(false)
|
||||
|
||||
await Promise.all(
|
||||
waitingParticipants.map(async (participant) => {
|
||||
let encryptedKey = ''
|
||||
let adminEphemeralPublicKey = ''
|
||||
let encryptedVaultKey = ''
|
||||
|
||||
if (allowEntry) {
|
||||
try {
|
||||
const keys = await encryptKeyForAccept(participant)
|
||||
encryptedKey = keys.encryptedKey
|
||||
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
|
||||
encryptedVaultKey = keys.encryptedVaultKey
|
||||
} catch (err) {
|
||||
console.error('[VaultE2EE] Cannot accept participant:', err)
|
||||
toastQueue.add(
|
||||
{
|
||||
type: 'encryptionError' as NotificationType,
|
||||
message: `Cannot accept ${participant.username}: ${(err as Error).message}`,
|
||||
},
|
||||
{ timeout: 8000 }
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return enterRoom({
|
||||
roomId: roomId,
|
||||
waitingParticipants.map((participant) =>
|
||||
enterRoom({
|
||||
roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
encryptedKey,
|
||||
adminEphemeralPublicKey,
|
||||
encryptedVaultKey,
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await refetchWaiting()
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
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, isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'wouter'
|
||||
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
||||
import { RiLockFill } from '@remixicon/react'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
export const Admin = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||
@@ -168,66 +167,98 @@ export const Admin = () => {
|
||||
>
|
||||
{t('access.description')}
|
||||
</Text>
|
||||
{isEncryptedRoom(readOnlyData) && (
|
||||
<HStack
|
||||
gap="0.5rem"
|
||||
className={css({
|
||||
backgroundColor: 'primary.100',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<RiLockFill size={16} className={css({ flexShrink: 0 })} />
|
||||
<Text
|
||||
variant="note"
|
||||
className={css({ textStyle: 'sm' })}
|
||||
>
|
||||
{t('access.encryptionWarning')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<Field
|
||||
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'),
|
||||
isDisabled: isEncryptedRoom(readOnlyData),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.TRUSTED,
|
||||
label: t('access.levels.trusted.label'),
|
||||
description: t('access.levels.trusted.description'),
|
||||
isDisabled: isEncryptedRoom(readOnlyData),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.RESTRICTED,
|
||||
label: t('access.levels.restricted.label'),
|
||||
description: t('access.levels.restricted.description'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{(() => {
|
||||
const isEncrypted = readOnlyData?.encryption_mode === '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,16 +2,26 @@ 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'
|
||||
|
||||
export const Info = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||
const { t: tHome } = useTranslation('home', {
|
||||
keyPrefix: 'connectionDetailsDialog',
|
||||
})
|
||||
|
||||
const data = useRoomData()
|
||||
const baseRoomUrl = getRouteUrl('room', data?.slug)
|
||||
@@ -20,13 +30,157 @@ export const Info = () => {
|
||||
: baseRoomUrl
|
||||
|
||||
const telephony = useTelephony()
|
||||
const isEncrypted = data?.encryption_mode === '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"
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ScreenShareIcon,
|
||||
useEnsureTrackRef,
|
||||
useFeatureContext,
|
||||
useIsEncrypted,
|
||||
useMaybeLayoutContext,
|
||||
useMaybeTrackRefContext,
|
||||
useParticipantTile,
|
||||
@@ -19,23 +18,11 @@ import {
|
||||
isTrackReferencePinned,
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track, RoomEvent } from 'livekit-client'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { Track } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||
import {
|
||||
EncryptionBadge,
|
||||
EncryptionIdentityDialog,
|
||||
} from '@/features/encryption'
|
||||
import { useParticipantTrustLevel } from '@/features/encryption/useParticipantTrustLevel'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||
import { RiLockFill } from '@remixicon/react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button } from '@/primitives'
|
||||
import { DecryptionFailedTileOverlay } from '@/features/encryption'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
@@ -90,52 +77,6 @@ export const ParticipantTile: (
|
||||
onParticipantClick,
|
||||
trackRef: trackReference,
|
||||
})
|
||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||
const roomData = useRoomData()
|
||||
const isEncryptedRoom = checkEncryptedRoom(roomData)
|
||||
const isAdmin = useIsAdminOrOwner()
|
||||
const [isIdentityOpen, setIsFingerprintOpen] = React.useState(false)
|
||||
const participantAttrs = trackReference.participant.attributes as Record<string, string> | undefined
|
||||
const { trustLevel, fingerprintStatus, fingerprint: participantFingerprint } = useParticipantTrustLevel(participantAttrs, roomData?.encryption_mode, trackReference.participant.isLocal)
|
||||
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
|
||||
const badgeTooltip = tBadge(trustLevel)
|
||||
|
||||
// Track decryption failures via EncryptionError events from LiveKit.
|
||||
// useIsEncrypted returns true when E2EE is enabled, NOT when frames decrypt successfully.
|
||||
// So we listen for actual decryption errors to know when to show the overlay.
|
||||
const room = useRoomContext()
|
||||
const [decryptionFailed, setDecryptionFailed] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isEncryptedRoom || trackReference.participant.isLocal) return
|
||||
|
||||
const participantIdentity = trackReference.participant.identity
|
||||
|
||||
const handleEncryptionError = (_error: Error, participant?: Participant) => {
|
||||
if (participant?.identity === participantIdentity) {
|
||||
setDecryptionFailed(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEncryptionStatusChanged = (encrypted: boolean, participant?: Participant) => {
|
||||
// Clear the error when encryption status confirms frames are decrypting
|
||||
if (participant?.identity === participantIdentity && encrypted) {
|
||||
setDecryptionFailed(false)
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.EncryptionError, handleEncryptionError)
|
||||
room.on(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.EncryptionError, handleEncryptionError)
|
||||
room.off(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged)
|
||||
}
|
||||
}, [room, isEncryptedRoom, trackReference.participant])
|
||||
|
||||
const showDecryptionError =
|
||||
!trackReference.participant.isLocal &&
|
||||
isEncryptedRoom &&
|
||||
decryptionFailed
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
|
||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||
@@ -171,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>) => {
|
||||
@@ -218,65 +158,6 @@ export const ParticipantTile: (
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
{showDecryptionError && !isScreenShare && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: '0 !important',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
>
|
||||
<ParticipantPlaceholder
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '2.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.6rem 1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
maxWidth: '85%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
color: '#f87171',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<RiLockFill size={14} />
|
||||
<span>Decryption failed</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: '#d1d5db',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
Check that you and this person are using the correct
|
||||
meeting link. If they are the only one you can't see,
|
||||
the issue is likely on their side.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!disableMetadata && (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
@@ -331,59 +212,12 @@ export const ParticipantTile: (
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncryptedRoom && !isScreenShare ? (
|
||||
<Button
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={badgeTooltip}
|
||||
aria-label={badgeTooltip}
|
||||
onPress={() => setIsFingerprintOpen(true)}
|
||||
className={css({
|
||||
display: 'inline-flex !important',
|
||||
alignItems: 'center !important',
|
||||
gap: '0.15rem !important',
|
||||
padding: '0.1rem 0.15rem !important',
|
||||
minWidth: 'auto !important',
|
||||
height: 'auto !important',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
borderRadius: '0.25rem !important',
|
||||
backgroundColor: 'transparent !important',
|
||||
color: 'inherit !important',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15) !important',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{(isEncrypted || isEncryptedRoom) && (
|
||||
<EncryptionBadge
|
||||
isEncrypted={true}
|
||||
trustLevel={trustLevel}
|
||||
/>
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{(isEncrypted || isEncryptedRoom) && !isScreenShare && (
|
||||
<EncryptionBadge
|
||||
isEncrypted={true}
|
||||
trustLevel={trustLevel}
|
||||
/>
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
@@ -397,6 +231,11 @@ export const ParticipantTile: (
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
/>
|
||||
)}
|
||||
{!isScreenShare && (
|
||||
<DecryptionFailedTileOverlay
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
<KeyboardShortcutHint>
|
||||
@@ -406,20 +245,6 @@ export const ParticipantTile: (
|
||||
),
|
||||
})}
|
||||
</KeyboardShortcutHint>
|
||||
{isEncryptedRoom && (
|
||||
<EncryptionIdentityDialog
|
||||
isOpen={isIdentityOpen}
|
||||
onOpenChange={setIsFingerprintOpen}
|
||||
participantName={trackReference.participant.name || trackReference.participant.identity}
|
||||
participantEmail={participantAttrs?.email}
|
||||
suiteUserId={participantAttrs?.suite_user_id}
|
||||
isAuthenticated={participantAttrs?.is_authenticated === 'true'}
|
||||
encryptionMode={roomData?.encryption_mode}
|
||||
isSelf={trackReference.participant.isLocal}
|
||||
preloadedFingerprint={participantFingerprint}
|
||||
preloadedFingerprintStatus={fingerprintStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,9 +13,7 @@ import {
|
||||
ScreenRecordingSidePanel,
|
||||
} from '@/features/recording'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { RiLockLine } from '@remixicon/react'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
|
||||
export interface ToolsButtonProps {
|
||||
icon: ReactNode
|
||||
@@ -108,17 +107,15 @@ export const Tools = () => {
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
const roomData = useRoomData()
|
||||
const isEncrypted = roomData?.encryption_mode === '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,
|
||||
@@ -142,9 +139,6 @@ export const Tools = () => {
|
||||
break
|
||||
}
|
||||
|
||||
const roomData = useRoomData()
|
||||
const encrypted = isEncryptedRoom(roomData)
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
@@ -179,23 +173,32 @@ export const Tools = () => {
|
||||
</A>
|
||||
)}
|
||||
</Text>
|
||||
{encrypted && (
|
||||
{isEncrypted && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'start',
|
||||
padding: '0.6rem 0.75rem',
|
||||
backgroundColor: '#fffbeb',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 0.85rem',
|
||||
margin: '0 0.75rem 0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #fde68a',
|
||||
marginBottom: '0.5rem',
|
||||
width: '100%',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
role="alert"
|
||||
>
|
||||
<RiLockLine size={16} color="#d97706" className={css({ flexShrink: 0, marginTop: '0.1rem' })} />
|
||||
<Text variant="note" className={css({ fontSize: '0.8rem', color: '#92400e' })}>
|
||||
{t('encryptedDisabled')}
|
||||
<RiAlertFill size={18} color="#b45309" />
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{t('encryptedBlock')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
@@ -204,8 +207,8 @@ export const Tools = () => {
|
||||
icon={<Icon type="symbols" name="speech_to_text" />}
|
||||
title={t('tools.transcript.title')}
|
||||
description={t('tools.transcript.body')}
|
||||
onPress={() => openTranscript()}
|
||||
isDisabled={encrypted}
|
||||
onPress={openTranscript}
|
||||
isDisabled={isEncrypted}
|
||||
/>
|
||||
)}
|
||||
{isScreenRecordingEnabled && (
|
||||
@@ -213,8 +216,8 @@ export const Tools = () => {
|
||||
icon={<Icon type="symbols" name="mode_standby" />}
|
||||
title={t('tools.screenRecording.title')}
|
||||
description={t('tools.screenRecording.body')}
|
||||
onPress={() => openScreenRecording()}
|
||||
isDisabled={encrypted}
|
||||
onPress={openScreenRecording}
|
||||
isDisabled={isEncrypted}
|
||||
/>
|
||||
)}
|
||||
</Div>
|
||||
|
||||
+8
-6
@@ -6,28 +6,30 @@ 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 { isEncryptedRoom as checkEncryptedRoom } 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 === 'basic'
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
|
||||
// Recording not available in encrypted rooms
|
||||
if (!hasScreenRecordingAccess || checkEncryptedRoom(roomData)) return null
|
||||
if (!hasScreenRecordingAccess) return null
|
||||
|
||||
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')}
|
||||
|
||||
+8
-4
@@ -6,25 +6,29 @@ 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 { isEncryptedRoom as checkEncryptedRoom } 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 === 'basic'
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
|
||||
// Recording/transcription not available in encrypted rooms
|
||||
if (!hasTranscriptAccess || checkEncryptedRoom(roomData)) return null
|
||||
if (!hasTranscriptAccess) return null
|
||||
|
||||
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')}
|
||||
|
||||
+43
-118
@@ -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,13 +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 { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
|
||||
import { useParticipantTrustLevel } from '@/features/encryption/useParticipantTrustLevel'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { isEncryptedRoom as isEncryptedRoomFn } from '@/features/rooms/api/ApiRoom'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { TooltipWrapper } from '@/primitives/TooltipWrapper'
|
||||
|
||||
type MicIndicatorProps = {
|
||||
participant: Participant
|
||||
@@ -104,16 +99,16 @@ export const ParticipantListItem = ({
|
||||
participant,
|
||||
}: ParticipantListItemProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const roomData = useRoomData()
|
||||
const isEncryptedRoom = isEncryptedRoomFn(roomData)
|
||||
const isAdmin = useIsAdminOrOwner()
|
||||
const { isLoggedIn } = useUser()
|
||||
const { t: tEncBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
|
||||
const [isIdentityOpen, setIsFingerprintOpen] = useState(false)
|
||||
const name = participant.name || participant.identity
|
||||
const attrs = participant.attributes as Record<string, string> | undefined
|
||||
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(attrs, roomData?.encryption_mode, isLocal(participant))
|
||||
const badgeTooltip = tEncBadge(trustLevel)
|
||||
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"
|
||||
@@ -134,131 +129,61 @@ export const ParticipantListItem = ({
|
||||
<PinBadge participant={participant} />
|
||||
</div>
|
||||
<VStack gap={0} alignItems="start">
|
||||
{isEncryptedRoom ? (
|
||||
<Button
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={badgeTooltip}
|
||||
aria-label={badgeTooltip}
|
||||
onPress={() => setIsFingerprintOpen(true)}
|
||||
className={css({
|
||||
padding: '0.1rem 0.25rem !important',
|
||||
minWidth: 'auto !important',
|
||||
height: 'auto !important',
|
||||
gap: '0.15rem !important',
|
||||
borderRadius: '0.25rem !important',
|
||||
backgroundColor: 'transparent !important',
|
||||
color: 'greyscale.900 !important',
|
||||
cursor: isEncryptedRoom ? 'pointer' : 'default',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'greyscale.100 !important',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<EncryptionBadge
|
||||
isEncrypted={true}
|
||||
trustLevel={trustLevel}
|
||||
/>
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '120px',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
{isLocal(participant) && (
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({ whiteSpace: 'nowrap', flexShrink: 0 })}
|
||||
>
|
||||
({t('participants.you')})
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<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 is only in JWT for encrypted rooms (backend restriction).
|
||||
Additionally, only show to authenticated users in the UI — anonymous
|
||||
users in encrypted rooms could still extract it from LiveKit signaling
|
||||
but won't see it in the interface. See utils.py for details. */}
|
||||
{isEncryptedRoom && isLoggedIn && (() => {
|
||||
const email = participant.attributes?.is_authenticated === 'true' && participant.attributes?.email
|
||||
? participant.attributes.email
|
||||
: null
|
||||
const label = email || t('participants.anonymous')
|
||||
return (
|
||||
<Button
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={email || undefined}
|
||||
aria-label={label}
|
||||
className={css({
|
||||
padding: '0 !important',
|
||||
minWidth: 'auto !important',
|
||||
height: 'auto !important',
|
||||
backgroundColor: 'transparent !important',
|
||||
color: 'greyscale.500 !important',
|
||||
fontSize: '0.7rem !important',
|
||||
fontWeight: 'normal !important',
|
||||
width: '100%',
|
||||
minW: 0,
|
||||
justifyContent: 'flex-start !important',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span className={css({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
})}>
|
||||
{label}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
})()}
|
||||
{email && (
|
||||
<Text
|
||||
variant="xsNote"
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '180px',
|
||||
})}
|
||||
>
|
||||
{email}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<MicIndicator participant={participant} />
|
||||
<ParticipantMenuButton participant={participant} />
|
||||
</HStack>
|
||||
{isEncryptedRoom && (
|
||||
<EncryptionIdentityDialog
|
||||
isOpen={isIdentityOpen}
|
||||
onOpenChange={setIsFingerprintOpen}
|
||||
participantName={name}
|
||||
participantEmail={attrs?.email}
|
||||
suiteUserId={attrs?.suite_user_id}
|
||||
isAuthenticated={attrs?.is_authenticated === 'true'}
|
||||
encryptionMode={roomData?.encryption_mode}
|
||||
isSelf={isLocal(participant)}
|
||||
preloadedFingerprint={fingerprint}
|
||||
preloadedFingerprintStatus={fingerprintStatus}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
+27
-128
@@ -4,12 +4,8 @@ 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 { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
|
||||
import { useParticipantTrustLevel, formatFingerprint } from '@/features/encryption/useParticipantTrustLevel'
|
||||
import { useState } from 'react'
|
||||
import { RiCloseLine, RiErrorWarningLine } from '@remixicon/react'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
|
||||
export const WaitingParticipantListItem = ({
|
||||
participant,
|
||||
@@ -19,17 +15,7 @@ export const WaitingParticipantListItem = ({
|
||||
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const roomData = useRoomData()
|
||||
const encryptedRoom = isEncryptedRoom(roomData)
|
||||
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
|
||||
const [isIdentityOpen, setIsDialogOpen] = useState(false)
|
||||
// Build attributes-like object for the hook (waiting participants aren't in LiveKit yet)
|
||||
const waitingAttrs = {
|
||||
is_authenticated: participant.is_authenticated ? 'true' : 'false',
|
||||
suite_user_id: participant.suite_user_id || '',
|
||||
}
|
||||
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(waitingAttrs, roomData?.encryption_mode)
|
||||
const badgeTooltip = encryptedRoom ? tBadge(trustLevel) : undefined
|
||||
const anonymousLabel = t('identity.anonymous.tooltip')
|
||||
|
||||
return (
|
||||
<HStack
|
||||
@@ -50,120 +36,46 @@ export const WaitingParticipantListItem = ({
|
||||
})}
|
||||
>
|
||||
<Avatar name={participant.username} bgColor={participant.color} />
|
||||
<VStack gap={0} alignItems="start" className={css({ flex: 1, minWidth: 0 })}>
|
||||
{encryptedRoom ? (
|
||||
<Button
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={badgeTooltip}
|
||||
aria-label={badgeTooltip}
|
||||
onPress={() => setIsDialogOpen(true)}
|
||||
className={css({
|
||||
padding: '0.1rem 0.25rem !important',
|
||||
minWidth: 'auto !important',
|
||||
height: 'auto !important',
|
||||
gap: '0.15rem !important',
|
||||
borderRadius: '0.25rem !important',
|
||||
backgroundColor: 'transparent !important',
|
||||
color: 'greyscale.900 !important',
|
||||
cursor: 'pointer',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'greyscale.100 !important',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<EncryptionBadge
|
||||
isEncrypted={true}
|
||||
trustLevel={trustLevel}
|
||||
/>
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: 0,
|
||||
})}
|
||||
>
|
||||
{participant.username}
|
||||
</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<VStack
|
||||
gap={0}
|
||||
alignItems="start"
|
||||
className={css({ flex: 1, minWidth: 0 })}
|
||||
>
|
||||
<HStack gap="0.2rem" alignItems="center">
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0.1rem 0.25rem',
|
||||
lineHeight: 1.2,
|
||||
})}
|
||||
>
|
||||
{participant.username}
|
||||
</Text>
|
||||
)}
|
||||
{encryptedRoom && fingerprint && (
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
fontSize: '0.6rem',
|
||||
fontFamily: 'monospace',
|
||||
color: 'greyscale.400',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
paddingLeft: '0.25rem',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
})}
|
||||
>
|
||||
{formatFingerprint(fingerprint)}
|
||||
</Text>
|
||||
)}
|
||||
{encryptedRoom && (() => {
|
||||
const email = participant.is_authenticated && participant.email
|
||||
? participant.email
|
||||
: null
|
||||
const label = email || t('participants.anonymous')
|
||||
return (
|
||||
<Button
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={email || undefined}
|
||||
aria-label={label}
|
||||
className={css({
|
||||
padding: '0 0.25rem !important',
|
||||
minWidth: 'auto !important',
|
||||
height: 'auto !important',
|
||||
backgroundColor: 'transparent !important',
|
||||
color: 'greyscale.500 !important',
|
||||
fontSize: '0.7rem !important',
|
||||
fontWeight: 'normal !important',
|
||||
width: '100%',
|
||||
minW: 0,
|
||||
justifyContent: 'flex-start !important',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
})}
|
||||
{!participant.is_authenticated && (
|
||||
<VisualOnlyTooltip
|
||||
tooltip={anonymousLabel}
|
||||
ariaLabel={anonymousLabel}
|
||||
>
|
||||
<span className={css({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
})}>
|
||||
{label}
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={14} color="#dc2626" />
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
})()}
|
||||
</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"
|
||||
@@ -185,19 +97,6 @@ export const WaitingParticipantListItem = ({
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</HStack>
|
||||
{encryptedRoom && (
|
||||
<EncryptionIdentityDialog
|
||||
isOpen={isIdentityOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
participantName={participant.username}
|
||||
participantEmail={participant.email}
|
||||
suiteUserId={participant.suite_user_id}
|
||||
isAuthenticated={participant.is_authenticated}
|
||||
encryptionMode={roomData?.encryption_mode}
|
||||
preloadedFingerprint={fingerprint}
|
||||
preloadedFingerprintStatus={fingerprintStatus}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
|
||||
const COPY_SUCCESS_TIMEOUT = 3000
|
||||
|
||||
export const useCopyRoomToClipboard = (room: ApiRoom | undefined, hashOverride?: string) => {
|
||||
export const useCopyRoomToClipboard = (
|
||||
room: ApiRoom | undefined,
|
||||
hashOverride?: string
|
||||
) => {
|
||||
const telephony = useTelephony()
|
||||
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
|
||||
|
||||
@@ -39,9 +42,14 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined, hashOverride?:
|
||||
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 !== 'basic'
|
||||
)
|
||||
}, [telephony.enabled, room?.pin_code, room?.encryption_mode])
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!roomUrl || !room) return ''
|
||||
|
||||
@@ -42,7 +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 { EncryptedMeetingBanner } from '@/features/encryption/EncryptedMeetingBanner'
|
||||
import { RoomStatusBanner } from '@/features/encryption'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
@@ -277,7 +277,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
onClose={() => setIsShareErrorVisible(false)}
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<EncryptedMeetingBanner />
|
||||
<RoomStatusBanner />
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
|
||||
@@ -15,7 +15,6 @@ import { PopupManager } from '../utils/PopupManager'
|
||||
import { CallbackCreationRoomData } from '../utils/types'
|
||||
import { useSearchParams } from 'wouter'
|
||||
|
||||
|
||||
const popupManager = new PopupManager()
|
||||
|
||||
export const CreateMeetingButton = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { generateRoomId, useCreateRoom } from '../../rooms'
|
||||
@@ -9,68 +9,38 @@ import { VStack } from '@/styled-system/jsx'
|
||||
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
|
||||
import { PopupWindow } from '../utils/PopupWindow'
|
||||
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
|
||||
import { generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
|
||||
import { useVaultClient } from '@/features/encryption'
|
||||
import {
|
||||
RiVideoOnLine,
|
||||
RiLockLine,
|
||||
RiShieldCheckLine,
|
||||
} from '@remixicon/react'
|
||||
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 { client: vaultClient, hasKeys, isReady: vaultReady } = useVaultClient()
|
||||
|
||||
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
const onboardingContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle unauthenticated users by redirecting to login.
|
||||
// Don't send callbackId to parent yet — we need the user to pick
|
||||
// an encryption mode first. The callbackId is sent with createRoom.
|
||||
useEffect(() => {
|
||||
if (isLoggedIn === false) {
|
||||
popupWindow.navigateToAuthentication()
|
||||
}
|
||||
}, [isLoggedIn])
|
||||
|
||||
const handleCreate = useCallback(async (mode: ApiEncryptionMode) => {
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const slug = generateRoomId()
|
||||
const hash =
|
||||
mode === ApiEncryptionMode.BASIC ? generatePassphrase() : undefined
|
||||
|
||||
// For advanced mode, generate the vault key at creation time
|
||||
let encryptedSymmetricKey = ''
|
||||
if (mode === ApiEncryptionMode.ADVANCED && vaultClient) {
|
||||
// encryptWithoutKey requires data to encrypt, but we only care about
|
||||
// the generated symmetric key (encryptedKeys), not the encrypted content.
|
||||
// The same symmetric key will be used for all streams (video/audio/chat).
|
||||
const dummyData = new Uint8Array(32).buffer
|
||||
const { publicKey } = await vaultClient.getPublicKey()
|
||||
const { encryptedKeys } = await vaultClient.encryptWithoutKey(
|
||||
dummyData,
|
||||
{ self: publicKey }
|
||||
)
|
||||
const keyBytes = new Uint8Array(encryptedKeys['self'])
|
||||
encryptedSymmetricKey = btoa(String.fromCharCode(...keyBytes))
|
||||
}
|
||||
|
||||
const roomData = await createRoom({
|
||||
slug,
|
||||
encryptionMode: mode,
|
||||
encryptedSymmetricKey,
|
||||
encryptionMode: ApiEncryptionMode.NONE,
|
||||
})
|
||||
|
||||
popupWindow.sendRoomData({ slug: roomData.slug, hash }, () => {
|
||||
popupWindow.sendRoomData({ slug: roomData.slug }, () => {
|
||||
callbackIdHandler.clear()
|
||||
popupWindow.close()
|
||||
})
|
||||
@@ -78,54 +48,15 @@ export const CreatePopup = () => {
|
||||
console.error('Failed to create meeting room:', error)
|
||||
setIsCreating(false)
|
||||
}
|
||||
}, [createRoom, vaultClient])
|
||||
|
||||
// Handle vault onboarding completion
|
||||
useEffect(() => {
|
||||
if (!vaultClient || !showOnboarding) return
|
||||
|
||||
const handleOnboardingComplete = () => {
|
||||
setShowOnboarding(false)
|
||||
// After onboarding, create the advanced encrypted room
|
||||
handleCreate(ApiEncryptionMode.ADVANCED)
|
||||
}
|
||||
|
||||
const handleInterfaceClosed = () => {
|
||||
setShowOnboarding(false)
|
||||
}
|
||||
|
||||
vaultClient.on('onboarding:complete', handleOnboardingComplete)
|
||||
vaultClient.on('interface:closed', handleInterfaceClosed)
|
||||
|
||||
return () => {
|
||||
vaultClient.off('onboarding:complete', handleOnboardingComplete)
|
||||
vaultClient.off('interface:closed', handleInterfaceClosed)
|
||||
}
|
||||
}, [vaultClient, showOnboarding, handleCreate])
|
||||
|
||||
// Open vault onboarding when container is ready
|
||||
useEffect(() => {
|
||||
if (showOnboarding && vaultClient && onboardingContainerRef.current) {
|
||||
console.info('[CreatePopup] Opening vault onboarding in container', onboardingContainerRef.current)
|
||||
vaultClient.openOnboarding(onboardingContainerRef.current)
|
||||
} else if (showOnboarding) {
|
||||
console.warn('[CreatePopup] Cannot open onboarding:', {
|
||||
vaultClient: !!vaultClient,
|
||||
container: !!onboardingContainerRef.current,
|
||||
})
|
||||
}
|
||||
}, [showOnboarding, vaultClient])
|
||||
|
||||
const handleAdvancedClick = () => {
|
||||
if (hasKeys) {
|
||||
// Already onboarded, create directly
|
||||
handleCreate(ApiEncryptionMode.ADVANCED)
|
||||
} else if (vaultClient) {
|
||||
// Need onboarding first
|
||||
setShowOnboarding(true)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && !isCreating && callbackId) {
|
||||
void handleCreate()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoggedIn])
|
||||
|
||||
if (!isLoggedIn || isCreating) {
|
||||
return (
|
||||
<div
|
||||
@@ -142,29 +73,6 @@ export const CreatePopup = () => {
|
||||
)
|
||||
}
|
||||
|
||||
if (showOnboarding) {
|
||||
return (
|
||||
<div className={css({ position: 'fixed', inset: 0, zIndex: 100, backgroundColor: 'white' })}>
|
||||
<div
|
||||
ref={onboardingContainerRef}
|
||||
className={css({ position: 'absolute', inset: 0 })}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiaryText"
|
||||
size="sm"
|
||||
onPress={() => setShowOnboarding(false)}
|
||||
style={{ position: 'absolute', top: '0.5rem', left: '0.5rem', zIndex: 101 }}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Vault is available if the client was loaded (script + init succeeded).
|
||||
// Auth context (isReady) may not be set yet — the onboarding handles its own auth.
|
||||
const vaultAvailable = !!vaultClient
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -180,53 +88,18 @@ export const CreatePopup = () => {
|
||||
<Text
|
||||
variant="sm"
|
||||
bold
|
||||
className={css({ textAlign: 'center', fontSize: '1.1rem', marginBottom: '0.5rem' })}
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
fontSize: '1.1rem',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{t('title')}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onPress={() => handleCreate(ApiEncryptionMode.NONE)}
|
||||
>
|
||||
<Button variant="primary" fullWidth onPress={handleCreate}>
|
||||
<RiVideoOnLine size={18} />
|
||||
{t('standard')}
|
||||
{t('create')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onPress={() => handleCreate(ApiEncryptionMode.BASIC)}
|
||||
>
|
||||
<RiLockLine size={18} />
|
||||
{t('encrypted')}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'greyscale.100',
|
||||
margin: '0.25rem 0',
|
||||
})}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
isDisabled={!vaultAvailable}
|
||||
onPress={handleAdvancedClick}
|
||||
style={{ opacity: vaultAvailable ? 1 : 0.4, cursor: vaultAvailable ? 'pointer' : 'not-allowed' }}
|
||||
>
|
||||
<RiShieldCheckLine size={18} />
|
||||
{t('advancedEncrypted')}
|
||||
</Button>
|
||||
<Text
|
||||
variant="note"
|
||||
className={css({ color: 'greyscale.500', fontSize: '0.75rem', lineHeight: 1.4 })}
|
||||
>
|
||||
{t('advancedDescription')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -56,11 +56,13 @@ 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
|
||||
const roomUrl = data.room.hash
|
||||
? `${baseUrl}#${data.room.hash}`
|
||||
: baseUrl
|
||||
this.sendRoomData({
|
||||
room: {
|
||||
url: roomUrl,
|
||||
@@ -68,6 +70,7 @@ export class PopupManager {
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', this.messageHandler)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* "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 = (next: boolean) => {
|
||||
if (!user) return
|
||||
void mutateAsync({
|
||||
user: {
|
||||
id: user.id,
|
||||
default_encryption_mode: next
|
||||
? ApiEncryptionMode.BASIC
|
||||
: ApiEncryptionMode.NONE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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 */}
|
||||
|
||||
@@ -8,8 +8,6 @@ import { HStack } from '@/styled-system/jsx'
|
||||
import { useState } from 'react'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
Pick<TabPanelProps, 'id'>
|
||||
@@ -18,10 +16,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { saveUsername } = usePersistentUserChoices()
|
||||
const room = useRoomContext()
|
||||
const roomData = useRoomData()
|
||||
const { user, isLoggedIn, logout } = useUser()
|
||||
const isEncryptedRoom = checkEncryptedRoom(roomData)
|
||||
const isNameLocked = isEncryptedRoom
|
||||
const [name, setName] = useState(room?.localParticipant.name ?? '')
|
||||
const userDisplay =
|
||||
user?.full_name && user?.email
|
||||
@@ -29,10 +24,8 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
: user?.email
|
||||
|
||||
const handleOnSubmit = () => {
|
||||
if (!isNameLocked) {
|
||||
if (room) room.localParticipant.setName(name)
|
||||
saveUsername(name)
|
||||
}
|
||||
if (room) room.localParticipant.setName(name)
|
||||
saveUsername(name)
|
||||
if (onOpenChange) onOpenChange(false)
|
||||
}
|
||||
const handleOnCancel = () => {
|
||||
@@ -42,20 +35,15 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('account.heading')}</H>
|
||||
<div className={isNameLocked ? css({ opacity: 0.5, '& input': { cursor: 'not-allowed' } }) : undefined}>
|
||||
<Field
|
||||
type="text"
|
||||
label={t('account.nameLabel')}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
isDisabled={isNameLocked}
|
||||
isReadOnly={isNameLocked}
|
||||
description={isNameLocked ? t('account.nameLockedEncryption') : undefined}
|
||||
validate={(value) => {
|
||||
return !value ? <p>{t('account.nameError')}</p> : null
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
type="text"
|
||||
label={t('account.nameLabel')}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
validate={(value) => {
|
||||
return !value ? <p>{t('account.nameError')}</p> : null
|
||||
}}
|
||||
/>
|
||||
<H lvl={2}>{t('account.authentication')}</H>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -12,9 +12,6 @@ import { MenuList } from '@/primitives/MenuList'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { useVaultClient } from '@/features/encryption'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const Logo = () => (
|
||||
<img
|
||||
@@ -93,50 +90,6 @@ export const Header = () => {
|
||||
const isTermsOfService = useMatchesRoute('termsOfService')
|
||||
const isRoom = useMatchesRoute('room')
|
||||
const { user, isLoggedIn, logout } = useUser()
|
||||
const { data: config } = useConfig()
|
||||
const { client: vaultClient, hasKeys } = useVaultClient()
|
||||
const encryptionContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [showEncryptionModal, setShowEncryptionModal] = useState(false)
|
||||
// Track whether the vault interface has been injected for the current modal session.
|
||||
// This prevents re-injection when vault events (onboarding:complete, keys-destroyed)
|
||||
// trigger re-renders via hasKeys state changes — which would destroy the iframe mid-flow.
|
||||
const vaultInjectedRef = useRef(false)
|
||||
|
||||
const encryptionRefCallback = useCallback((el: HTMLDivElement | null) => {
|
||||
encryptionContainerRef.current = el
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = encryptionContainerRef.current
|
||||
if (!showEncryptionModal || !el || !vaultClient || vaultInjectedRef.current) return
|
||||
|
||||
vaultInjectedRef.current = true
|
||||
el.innerHTML = ''
|
||||
if (hasKeys) {
|
||||
vaultClient.openSettings(el)
|
||||
} else {
|
||||
vaultClient.openOnboarding(el)
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
setShowEncryptionModal(false)
|
||||
vaultClient.off('interface:closed', handleClosed)
|
||||
}
|
||||
vaultClient.on('interface:closed', handleClosed)
|
||||
|
||||
return () => {
|
||||
vaultClient.off('interface:closed', handleClosed)
|
||||
}
|
||||
}, [showEncryptionModal, vaultClient])
|
||||
|
||||
// Reset injection flag when modal closes
|
||||
useEffect(() => {
|
||||
if (!showEncryptionModal) {
|
||||
vaultInjectedRef.current = false
|
||||
}
|
||||
}, [showEncryptionModal])
|
||||
const isEncryptionEnabled = !!config?.encryption?.enabled
|
||||
const isEncryptionAvailable = isEncryptionEnabled && !!vaultClient
|
||||
const userLabel = user?.full_name || user?.short_name || user?.email
|
||||
const loggedInTooltip = t('loggedInUserTooltip')
|
||||
const loggedInAriaLabel = userLabel
|
||||
@@ -226,27 +179,11 @@ export const Header = () => {
|
||||
</Button>
|
||||
<MenuList
|
||||
variant={'light'}
|
||||
items={[
|
||||
...(isEncryptionEnabled
|
||||
? [
|
||||
{
|
||||
value: 'encryption',
|
||||
label: isEncryptionAvailable
|
||||
? (hasKeys ? t('encryptionSettings') : t('encryptionSetup'))
|
||||
: t('encryptionUnavailable'),
|
||||
isDisabled: !isEncryptionAvailable,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ value: 'logout', label: t('logout') },
|
||||
]}
|
||||
items={[{ value: 'logout', label: t('logout') }]}
|
||||
onAction={(value) => {
|
||||
if (value === 'logout') {
|
||||
logout()
|
||||
}
|
||||
if (value === 'encryption' && isEncryptionAvailable) {
|
||||
setShowEncryptionModal(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
@@ -256,64 +193,6 @@ export const Header = () => {
|
||||
</nav>
|
||||
</HStack>
|
||||
</div>
|
||||
{showEncryptionModal && vaultClient && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
})}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowEncryptionModal(false)
|
||||
vaultClient.closeInterface()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
width: '90%',
|
||||
maxWidth: '550px',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEncryptionModal(false)
|
||||
vaultClient.closeInterface()
|
||||
}}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
zIndex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.25rem',
|
||||
color: 'greyscale.500',
|
||||
_hover: { color: 'greyscale.900' },
|
||||
})}
|
||||
aria-label={t('close')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div
|
||||
ref={encryptionRefCallback}
|
||||
className={css({ minHeight: '300px' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
"proconnectLink": "What is ProConnect?"
|
||||
},
|
||||
"logout": "Logout",
|
||||
"encryptionSetup": "Set up encryption",
|
||||
"encryptionSettings": "Encryption settings",
|
||||
"encryptionUnavailable": "Encryption (unavailable)",
|
||||
"notFound": {
|
||||
"heading": "Verify your meeting code",
|
||||
"body": "Check that you have entered the correct meeting code in the URL. Example:"
|
||||
|
||||
@@ -23,22 +23,28 @@
|
||||
"createMenu": {
|
||||
"laterOption": "Create a meeting for a later date",
|
||||
"instantOption": "Start an instant meeting",
|
||||
"encryptedInstantOption": "Start an encrypted meeting",
|
||||
"encryptedLaterOption": "Create an encrypted meeting for later"
|
||||
"encryptedOption": "Create an encrypted meeting"
|
||||
},
|
||||
"encryptionModeDialog": {
|
||||
"title": "Choose encryption mode",
|
||||
"description": "Select the level of encryption for your meeting.",
|
||||
"basic": {
|
||||
"title": "Basic encryption",
|
||||
"description": "Protects your meeting with a shared passphrase. Accessible to everyone — no setup required."
|
||||
"createEncryptedMeetingDialog": {
|
||||
"title": "Create an encrypted meeting",
|
||||
"description": "Encryption disables these features:",
|
||||
"features": {
|
||||
"dialIn": "Phone dial-in",
|
||||
"meetingRoom": "Meeting room devices",
|
||||
"transcription": "Transcription",
|
||||
"recording": "Recording"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced encryption",
|
||||
"description": "Maximum security — the encryption key never leaves your browser. Requires encryption onboarding for all participants.",
|
||||
"onboardingRequired": "You must complete the encryption setup in your account settings before using advanced encryption.",
|
||||
"serviceUnavailable": "Advanced encryption is currently unavailable. Please try again later or contact your administrator."
|
||||
}
|
||||
"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",
|
||||
@@ -81,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."
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
"toggleOn": "Click to turn on",
|
||||
"usernameHint": "Shown to other participants",
|
||||
"usernameLabel": "Your name",
|
||||
"encryptedNameLocked": "Encrypted meeting — name from your account.",
|
||||
"errors": {
|
||||
"usernameEmpty": "Your name cannot be empty"
|
||||
},
|
||||
@@ -86,15 +85,7 @@
|
||||
"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."
|
||||
},
|
||||
"advancedAuth": {
|
||||
"title": "Authentication required",
|
||||
"body": "This meeting uses advanced encryption. You must be logged in to join."
|
||||
},
|
||||
"advancedOnboarding": {
|
||||
"title": "Encryption setup required",
|
||||
"body": "This meeting uses advanced encryption. You must complete your encryption setup before you can join.",
|
||||
"button": "Set up encryption"
|
||||
}
|
||||
"encryptedNameLocked": "Authenticated identity — you can't change your name in an encrypted meeting."
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
"shareDialog": {
|
||||
@@ -108,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}}",
|
||||
@@ -167,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": {
|
||||
@@ -363,7 +361,6 @@
|
||||
},
|
||||
"moreTools": {
|
||||
"body": "Access more tools to enhance your meetings.",
|
||||
"encryptedDisabled": "Transcription and recording are not available in encrypted meetings. The server cannot access the media content.",
|
||||
"linkAriaLabel": "Open documentation about tools - opens in new window",
|
||||
"moreLink": "Open documentation",
|
||||
"tools": {
|
||||
@@ -375,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": {
|
||||
@@ -495,7 +503,6 @@
|
||||
"access": {
|
||||
"title": "Room access",
|
||||
"description": "These settings will also apply to future occurrences of this meeting.",
|
||||
"encryptionWarning": "This meeting is encrypted. Access must remain restricted so that each participant is approved before receiving the encryption key.",
|
||||
"type": "Meeting access types",
|
||||
"levels": {
|
||||
"public": {
|
||||
@@ -510,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",
|
||||
@@ -587,12 +595,6 @@
|
||||
"button": "Deny",
|
||||
"label": "Deny {{name}} from the meeting",
|
||||
"all": "Deny all"
|
||||
},
|
||||
"trust": {
|
||||
"verified": "Identity verified — fingerprint is trusted.",
|
||||
"refused": "WARNING — This fingerprint has been refused. This person may not be who they claim to be.",
|
||||
"authenticated": "Authenticated identity (ProConnect). Click to check fingerprint.",
|
||||
"anonymous": "Anonymous user — identity not verified. Verify their identity before accepting."
|
||||
}
|
||||
},
|
||||
"moreOptions": "More options"
|
||||
@@ -694,83 +696,31 @@
|
||||
"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": {
|
||||
"banner": "Basic encryption",
|
||||
"bannerStrong": "Advanced encryption",
|
||||
"badge": {
|
||||
"verified": "Verified — fingerprint trusted",
|
||||
"unknown": "Not yet verified — click to check fingerprint",
|
||||
"refused": "Refused — fingerprint previously rejected",
|
||||
"authenticated": "Authenticated via ProConnect",
|
||||
"anonymous": "Unverified identity",
|
||||
"default": "Encrypted"
|
||||
},
|
||||
"bannerModal": {
|
||||
"title": "End-to-end encrypted meeting",
|
||||
"descriptionBasic": "This meeting uses basic end-to-end encryption. Audio and video are encrypted using a shared passphrase embedded in the meeting link. Anyone with the link can join and decrypt the content.",
|
||||
"descriptionAdvanced": "This meeting uses advanced end-to-end encryption. The encryption key is managed by La Suite's encryption service and never leaves your device. All participants must complete encryption onboarding to join.",
|
||||
"guarantees": "What this protects:",
|
||||
"guarantee1": "The server cannot access audio, video, or screen sharing content",
|
||||
"guarantee2": "Only approved participants with the encryption key can view the content",
|
||||
"guarantee3": "Each participant's trust level is visible (verified, authenticated, or anonymous)",
|
||||
"limitations": "Limitations:",
|
||||
"limitation1": "Recording and transcription are not available (the server cannot decrypt content)",
|
||||
"limitation2Basic": "Security depends on keeping the meeting link private",
|
||||
"limitation2Advanced": "All participants must complete encryption onboarding in their account settings before joining",
|
||||
"note": "For the highest level of trust, use advanced encryption and ask participants to verify each other's fingerprints."
|
||||
},
|
||||
"settingUp": {
|
||||
"title": "Securing your connection",
|
||||
"description": "Exchanging encryption keys with the room administrator..."
|
||||
},
|
||||
"error": {
|
||||
"title": "Encryption error",
|
||||
"hint": "The encrypted data could not be decoded. Try leaving and rejoining the meeting, or ask the host to restart the call.",
|
||||
"timeout": "Key exchange timed out",
|
||||
"timeoutHint": "The encryption key could not be received from the host. They may not be in the meeting yet.",
|
||||
"refresh": "Retry",
|
||||
"vaultUnavailable": "This meeting requires advanced encryption, which is currently unavailable.",
|
||||
"vaultUnavailableHint": "This may be a temporary issue. Try refreshing the page. If the problem persists, the encryption service may be undergoing maintenance — please try again later."
|
||||
},
|
||||
"fingerprint": {
|
||||
"title": "Encryption identity",
|
||||
"description": "This shows the encryption identity of this participant. It allows you to verify they are who they claim to be.",
|
||||
"descriptionSelf": "This is your encryption identity as seen by other participants. They can use it to verify that you are who you claim to be.",
|
||||
"descriptionSelfAnonymous": "You joined without signing in. Other participants see your name as self-declared and unverified. Sign in via ProConnect for a verified identity.",
|
||||
"loading": "Checking encryption keys...",
|
||||
"noKey": "This participant has not completed encryption onboarding. Their identity is verified by the authentication server (ProConnect) only — not by a cryptographic public key. They can set up encryption in their account settings.",
|
||||
"noKeyBasicAuthenticated": "This participant is signed in via ProConnect. Their name and email are provided by the authentication server and cannot be falsified.",
|
||||
"noKeyAnonymous": "This participant joined without signing in. Their identity is self-declared and not verified. Only accept anonymous participants if you can confirm their identity through another channel.",
|
||||
"error": "Unable to check encryption keys. The encryption service may be unavailable.",
|
||||
"fingerprintLabel": "Public key fingerprint",
|
||||
"fingerprintHint": "Ask this participant to open their encryption settings and compare the fingerprint shown there with the one above.",
|
||||
"trusted": "Fingerprint verified and trusted",
|
||||
"trustedDescription": "You have previously verified this participant's encryption fingerprint. Their identity is cryptographically confirmed.",
|
||||
"refused": "Fingerprint refused",
|
||||
"refusedDescription": "You previously refused this fingerprint. This person's identity could not be verified.",
|
||||
"unknownDescription": "This fingerprint has not been verified yet. Compare it with the participant (via phone, in person, or another secure channel) to confirm their identity.",
|
||||
"accept": "Trust",
|
||||
"refuse": "Refuse",
|
||||
"changeDecision": "Change my decision",
|
||||
"anonymous": "Unverified identity"
|
||||
},
|
||||
"trustModal": {
|
||||
"title": "Encryption trust level",
|
||||
"intro": "This indicates how \"{{name}}\" was identified before joining the encrypted meeting.",
|
||||
"authenticated": {
|
||||
"title": "Authenticated identity",
|
||||
"description": "This person signed in via ProConnect. Their identity is verified by the authentication server. The encryption key will be exchanged securely via ephemeral key exchange."
|
||||
"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."
|
||||
},
|
||||
"anonymous": {
|
||||
"title": "Anonymous user",
|
||||
"description": "This person is not signed in. Their displayed name is self-declared and could be impersonated. Verify their identity verbally before accepting them. The encryption key will be exchanged via ephemeral key exchange."
|
||||
"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."
|
||||
},
|
||||
"levels": {
|
||||
"title": "Trust levels in encrypted meetings",
|
||||
"verified": "Verified — completed encryption onboarding, identity confirmed by public key.",
|
||||
"authenticated": "Authenticated — signed in via ProConnect, identity verified by server.",
|
||||
"anonymous": "Anonymous — not signed in, name is self-declared. Verify before accepting."
|
||||
}
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
},
|
||||
"createPopup": {
|
||||
"title": "Create a meeting",
|
||||
"standard": "Standard meeting",
|
||||
"encrypted": "Encrypted meeting",
|
||||
"advancedEncrypted": "Advanced encrypted meeting",
|
||||
"advancedDescription": "Maximum security — encryption keys never leave your device. All participants must complete encryption setup before joining. Requires key backup.",
|
||||
"advancedUnavailable": "Encryption service not available"
|
||||
"create": "Create"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,18 @@
|
||||
"youAreNotLoggedIn": "You are not logged in.",
|
||||
"nameLabel": "Your Name",
|
||||
"authentication": "Authentication",
|
||||
"nameError": "Your name cannot be empty",
|
||||
"nameLockedEncryption": "In encrypted meetings, your name comes from your account and cannot be changed."
|
||||
"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",
|
||||
@@ -163,6 +173,7 @@
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "General",
|
||||
"security": "Security",
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibility",
|
||||
"transcription": "Transcription",
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
"proconnectLink": "Qu'est-ce que ProConnect ?"
|
||||
},
|
||||
"logout": "Se déconnecter",
|
||||
"encryptionSetup": "Configurer le chiffrement",
|
||||
"encryptionSettings": "Paramètres de chiffrement",
|
||||
"notFound": {
|
||||
"heading": "Vérifier votre code de réunion",
|
||||
"body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :"
|
||||
|
||||
@@ -23,22 +23,28 @@
|
||||
"createMenu": {
|
||||
"laterOption": "Créer une réunion pour une date ultérieure",
|
||||
"instantOption": "Démarrer une réunion instantanée",
|
||||
"encryptedInstantOption": "Démarrer une réunion chiffrée",
|
||||
"encryptedLaterOption": "Créer une réunion chiffrée pour plus tard"
|
||||
"encryptedOption": "Créer une réunion chiffrée"
|
||||
},
|
||||
"encryptionModeDialog": {
|
||||
"title": "Choisir le mode de chiffrement",
|
||||
"description": "Sélectionnez le niveau de chiffrement pour votre réunion.",
|
||||
"basic": {
|
||||
"title": "Chiffrement basique",
|
||||
"description": "Protège votre réunion avec une phrase secrète partagée. Accessible à tous — aucune configuration requise."
|
||||
"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"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Chiffrement avancé",
|
||||
"description": "Sécurité maximale — la clé de chiffrement ne quitte jamais votre navigateur. Nécessite la configuration du chiffrement pour tous les participants.",
|
||||
"onboardingRequired": "Vous devez compléter la configuration du chiffrement dans les paramètres de votre compte avant d'utiliser le chiffrement avancé.",
|
||||
"serviceUnavailable": "Le chiffrement avancé est actuellement indisponible. Veuillez réessayer plus tard ou contacter votre administrateur."
|
||||
}
|
||||
"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",
|
||||
@@ -81,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."
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
"toggleOn": "Cliquez pour activer",
|
||||
"usernameHint": "Affiché aux autres participants",
|
||||
"usernameLabel": "Votre nom",
|
||||
"encryptedNameLocked": "Réunion chiffrée — nom issu de votre compte.",
|
||||
"errors": {
|
||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||
},
|
||||
@@ -86,15 +85,7 @@
|
||||
"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."
|
||||
},
|
||||
"advancedAuth": {
|
||||
"title": "Authentification requise",
|
||||
"body": "Cette réunion utilise le chiffrement avancé. Vous devez être connecté pour la rejoindre."
|
||||
},
|
||||
"advancedOnboarding": {
|
||||
"title": "Configuration du chiffrement requise",
|
||||
"body": "Cette réunion utilise le chiffrement avancé. Vous devez configurer votre chiffrement avant de pouvoir rejoindre.",
|
||||
"button": "Configurer le chiffrement"
|
||||
}
|
||||
"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": {
|
||||
@@ -108,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}}",
|
||||
@@ -167,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": {
|
||||
@@ -363,7 +361,6 @@
|
||||
},
|
||||
"moreTools": {
|
||||
"body": "Accéder à davantage d'outils pour améliorer vos réunions.",
|
||||
"encryptedDisabled": "La transcription et l'enregistrement ne sont pas disponibles dans les réunions chiffrées. Le serveur ne peut pas accéder au contenu média.",
|
||||
"linkAriaLabel": "Ouvrir la documentation sur les outils - ouvre dans une nouvelle fenêtre",
|
||||
"moreLink": "Ouvrir la documentation",
|
||||
"tools": {
|
||||
@@ -375,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": {
|
||||
@@ -495,7 +503,6 @@
|
||||
"access": {
|
||||
"title": "Accès à la réunion",
|
||||
"description": "Ces paramètres s'appliqueront également aux futures occurrences de cette réunion.",
|
||||
"encryptionWarning": "Cette réunion est chiffrée. L'accès doit rester restreint afin que chaque participant soit approuvé avant de recevoir la clé de chiffrement.",
|
||||
"type": "Type d'accès à la réunion",
|
||||
"levels": {
|
||||
"public": {
|
||||
@@ -510,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",
|
||||
@@ -587,12 +595,6 @@
|
||||
"button": "Refuser",
|
||||
"label": "Refuser {{name}} dans la réunion",
|
||||
"all": "Tout rejeter"
|
||||
},
|
||||
"trust": {
|
||||
"verified": "Identité vérifiée — l'empreinte est de confiance.",
|
||||
"refused": "ATTENTION — Cette empreinte a été refusée. Cette personne n'est peut-être pas celle qu'elle prétend être.",
|
||||
"authenticated": "Identité authentifiée (ProConnect). Cliquez pour vérifier l'empreinte.",
|
||||
"anonymous": "Utilisateur anonyme — identité non vérifiée. Vérifiez son identité avant d'accepter."
|
||||
}
|
||||
},
|
||||
"moreOptions": "Plus d'options"
|
||||
@@ -694,83 +696,31 @@
|
||||
"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": {
|
||||
"banner": "Chiffrement basique",
|
||||
"bannerStrong": "Chiffrement avancé",
|
||||
"badge": {
|
||||
"verified": "Vérifié — empreinte approuvée",
|
||||
"unknown": "Non vérifié — cliquez pour vérifier l'empreinte",
|
||||
"refused": "Refusé — empreinte précédemment rejetée",
|
||||
"authenticated": "Authentifié via ProConnect",
|
||||
"anonymous": "Identité non vérifiée",
|
||||
"default": "Chiffré"
|
||||
},
|
||||
"bannerModal": {
|
||||
"title": "Réunion chiffrée de bout en bout",
|
||||
"descriptionBasic": "Cette réunion utilise le chiffrement de bout en bout basique. L'audio et la vidéo sont chiffrés à l'aide d'une phrase secrète intégrée au lien de la réunion. Toute personne disposant du lien peut rejoindre et déchiffrer le contenu.",
|
||||
"descriptionAdvanced": "Cette réunion utilise le chiffrement de bout en bout avancé. La clé de chiffrement est gérée par le service de chiffrement de La Suite et ne quitte jamais votre appareil. Tous les participants doivent effectuer l'onboarding chiffrement pour rejoindre.",
|
||||
"guarantees": "Ce que cela protège :",
|
||||
"guarantee1": "Le serveur ne peut pas accéder au contenu audio, vidéo ou de partage d'écran",
|
||||
"guarantee2": "Seuls les participants approuvés disposant de la clé de chiffrement peuvent voir le contenu",
|
||||
"guarantee3": "Le niveau de confiance de chaque participant est visible (vérifié, authentifié ou anonyme)",
|
||||
"limitations": "Limitations :",
|
||||
"limitation1": "L'enregistrement et la transcription ne sont pas disponibles (le serveur ne peut pas déchiffrer le contenu)",
|
||||
"limitation2Basic": "La sécurité dépend de la confidentialité du lien de la réunion",
|
||||
"limitation2Advanced": "Tous les participants doivent effectuer l'onboarding chiffrement dans les paramètres de leur compte avant de rejoindre",
|
||||
"note": "Pour le plus haut niveau de confiance, utilisez le chiffrement avancé et demandez aux participants de vérifier mutuellement leurs empreintes."
|
||||
},
|
||||
"settingUp": {
|
||||
"title": "Sécurisation de votre connexion",
|
||||
"description": "Échange des clés de chiffrement avec l'administrateur de la réunion..."
|
||||
},
|
||||
"error": {
|
||||
"title": "Erreur de chiffrement",
|
||||
"hint": "Les données chiffrées n'ont pas pu être décodées. Essayez de quitter et de rejoindre la réunion, ou demandez à l'hôte de relancer l'appel.",
|
||||
"timeout": "Échange de clés expiré",
|
||||
"timeoutHint": "La clé de chiffrement n'a pas pu être reçue de l'hôte. Il n'est peut-être pas encore dans la réunion.",
|
||||
"refresh": "Réessayer",
|
||||
"vaultUnavailable": "Cette réunion nécessite le chiffrement avancé, qui est actuellement indisponible.",
|
||||
"vaultUnavailableHint": "Il peut s'agir d'un problème temporaire. Essayez de rafraîchir la page. Si le problème persiste, le service de chiffrement est peut-être en maintenance — veuillez réessayer plus tard."
|
||||
},
|
||||
"fingerprint": {
|
||||
"title": "Identité chiffrée",
|
||||
"description": "Ceci montre l'identité de chiffrement de ce participant. Cela vous permet de vérifier qu'il est bien celui qu'il prétend être.",
|
||||
"descriptionSelf": "Ceci est votre identité de chiffrement telle que vue par les autres participants. Ils peuvent l'utiliser pour vérifier que vous êtes bien qui vous prétendez être.",
|
||||
"descriptionSelfAnonymous": "Vous avez rejoint sans vous connecter. Les autres participants voient votre nom comme auto-déclaré et non vérifié. Connectez-vous via ProConnect pour une identité vérifiée.",
|
||||
"loading": "Vérification des clés de chiffrement...",
|
||||
"noKey": "Ce participant n'a pas effectué l'onboarding chiffrement. Son identité est vérifiée par le serveur d'authentification (ProConnect) uniquement — pas par une clé publique cryptographique. Il peut configurer le chiffrement dans les paramètres de son compte.",
|
||||
"noKeyBasicAuthenticated": "Ce participant est connecté via ProConnect. Son nom et son email proviennent du serveur d'authentification et ne peuvent pas être falsifiés.",
|
||||
"noKeyAnonymous": "Ce participant a rejoint sans se connecter. Son identité est auto-déclarée et non vérifiée. N'acceptez les participants anonymes que si vous pouvez confirmer leur identité par un autre canal.",
|
||||
"error": "Impossible de vérifier les clés de chiffrement. Le service de chiffrement est peut-être indisponible.",
|
||||
"fingerprintLabel": "Empreinte de la clé publique",
|
||||
"fingerprintHint": "Demandez à ce participant d'ouvrir ses paramètres de chiffrement et de comparer l'empreinte affichée avec celle ci-dessus.",
|
||||
"trusted": "Empreinte vérifiée et approuvée",
|
||||
"trustedDescription": "Vous avez précédemment vérifié l'empreinte de chiffrement de ce participant. Son identité est confirmée cryptographiquement.",
|
||||
"refused": "Empreinte refusée",
|
||||
"refusedDescription": "Vous avez précédemment refusé cette empreinte. L'identité de cette personne n'a pas pu être vérifiée.",
|
||||
"unknownDescription": "Cette empreinte n'a pas encore été vérifiée. Comparez-la avec le participant (par téléphone, en personne ou par un autre canal sécurisé) pour confirmer son identité.",
|
||||
"accept": "Approuver",
|
||||
"refuse": "Refuser",
|
||||
"changeDecision": "Modifier ma décision",
|
||||
"anonymous": "Identité non vérifiée"
|
||||
},
|
||||
"trustModal": {
|
||||
"title": "Niveau de confiance du chiffrement",
|
||||
"intro": "Ceci indique comment « {{name}} » a été identifié avant de rejoindre la réunion chiffrée.",
|
||||
"authenticated": {
|
||||
"title": "Identité authentifiée",
|
||||
"description": "Cette personne s'est connectée via ProConnect. Son identité est vérifiée par le serveur d'authentification. La clé de chiffrement sera échangée de manière sécurisée."
|
||||
"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."
|
||||
},
|
||||
"anonymous": {
|
||||
"title": "Utilisateur anonyme",
|
||||
"description": "Cette personne n'est pas connectée. Son nom affiché est déclaratif et pourrait être usurpé. Vérifiez son identité de vive voix avant de l'accepter. La clé de chiffrement sera échangée via un échange éphémère."
|
||||
"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é."
|
||||
},
|
||||
"levels": {
|
||||
"title": "Niveaux de confiance en réunion chiffrée",
|
||||
"verified": "Vérifié — onboarding chiffrement effectué, identité confirmée par clé publique.",
|
||||
"authenticated": "Authentifié — connecté via ProConnect, identité vérifiée par le serveur.",
|
||||
"anonymous": "Anonyme — non connecté, nom auto-déclaré. Vérifiez avant d'accepter."
|
||||
}
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
},
|
||||
"createPopup": {
|
||||
"title": "Créer une réunion",
|
||||
"standard": "Réunion standard",
|
||||
"encrypted": "Réunion chiffrée",
|
||||
"advancedEncrypted": "Réunion chiffrée avancée",
|
||||
"advancedDescription": "Sécurité maximale — les clés de chiffrement ne quittent jamais votre appareil. Tous les participants doivent configurer le chiffrement avant de rejoindre. Nécessite une sauvegarde des clés.",
|
||||
"advancedUnavailable": "Service de chiffrement non disponible"
|
||||
"create": "Créer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,18 @@
|
||||
"youAreNotLoggedIn": "Vous n'êtes pas connecté.",
|
||||
"nameLabel": "Votre Nom",
|
||||
"authentication": "Authentification",
|
||||
"nameError": "Votre Nom ne peut pas être vide",
|
||||
"nameLockedEncryption": "Dans les réunions chiffrées, votre nom provient de votre compte et ne peut pas être modifié."
|
||||
"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",
|
||||
@@ -163,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,11 @@ 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 target = options?.hash ? `${to}#${options.hash}` : to
|
||||
const { hash: _hash, ...navigateOptions } = options ?? {}
|
||||
void _hash
|
||||
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; isDisabled?: boolean }>
|
||||
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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user