(encryption) add ability to pause encryption to accept sip users or use server features

This commit is contained in:
Thomas Ramé
2026-05-12 20:04:17 +02:00
parent 80c9620368
commit 8812e12849
25 changed files with 957 additions and 261 deletions
+30 -3
View File
@@ -138,15 +138,42 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "is_encrypted"]
fields = [
"id",
"name",
"slug",
"configuration",
"access_level",
"pin_code",
"is_encrypted",
"encryption_paused",
]
read_only_fields = ["id", "slug", "pin_code"]
def validate_is_encrypted(self, value):
"""Encryption is decided at room creation and cannot be flipped afterwards."""
"""is_encrypted is set at creation and is part of the link's identity
(copy-link always carries the hash for an encrypted room). Mid-call
toggling is done via encryption_paused, not by mutating this flag."""
instance = self.instance
if instance and instance.is_encrypted != value:
raise serializers.ValidationError(
"Encryption flag cannot be changed after room creation."
"Encryption mode cannot be changed after room creation. "
"Use encryption_paused to temporarily suspend encryption."
)
return value
def validate_encryption_paused(self, value):
"""encryption_paused only makes sense on encrypted rooms. Allow either
direction (True ↔ False) post-creation: admins suspend E2EE so a
SIP/device caller can join, then resume once they hang up."""
is_encrypted = (
self.instance.is_encrypted
if self.instance is not None
else self.initial_data.get("is_encrypted", False)
)
if value and not is_encrypted:
raise serializers.ValidationError(
"Cannot pause encryption on a non-encrypted room."
)
return value
+9 -1
View File
@@ -314,8 +314,16 @@ class RoomViewSet(
serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid():
print(
"[start-recording] body=",
request.data,
"errors=",
serializer.errors,
flush=True,
)
return drf_response.Response(
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
{"detail": "Invalid request.", "errors": serializer.errors},
status=drf_status.HTTP_400_BAD_REQUEST,
)
mode = serializer.validated_data["mode"]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-05-12 08:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0019_room_is_encrypted_user_default_encryption'),
]
operations = [
migrations.AddField(
model_name='room',
name='encryption_paused',
field=models.BooleanField(default=False, help_text='Temporarily suspend E2EE so external devices can join.', verbose_name='Encryption paused'),
),
]
+43 -2
View File
@@ -399,12 +399,22 @@ class Room(Resource):
# Boolean for now: today the only encryption mode is the passphrase-in-URL
# one. If a follow-up adds a stronger "local-keys" mode (private keys held
# in a vault iframe), this can grow into a CharField with choices like
# `none / passphrase / local_keys`.
# `none / passphrase / local_keys`. Set at creation; never mutated after —
# changing it would change the link's semantics (copy-link / hash carry).
is_encrypted = models.BooleanField(
default=False,
verbose_name=_("Encryption enabled"),
help_text=_("Whether end-to-end encryption is enabled for this room."),
)
# Mid-call admin override: when True on an `is_encrypted` room, the active
# encryption is suspended so a phone/SIP/device caller can join in plaintext.
# The link still carries the hash; toggling back to False resumes E2EE.
# Always False on non-encrypted rooms (enforced by the serializer).
encryption_paused = models.BooleanField(
default=False,
verbose_name=_("Encryption paused"),
help_text=_("Temporarily suspend E2EE so external devices can join."),
)
configuration = models.JSONField(
blank=True,
default=dict,
@@ -430,13 +440,44 @@ 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:
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
previous = None
if self.pk:
try:
previous = Room.objects.only(
"is_encrypted", "encryption_paused"
).get(pk=self.pk)
except Room.DoesNotExist:
previous = None
super().save(*args, **kwargs)
# Sync encryption state to LiveKit room metadata so the SIP gateway can
# decide between bridge mode and placeholder mode. Best-effort: if no
# LiveKit room exists yet, the call is a no-op.
encryption_changed = previous is None or (
previous.is_encrypted != self.is_encrypted
or previous.encryption_paused != self.encryption_paused
)
if encryption_changed:
try:
from core import utils as core_utils # local import: avoid cycle
core_utils.update_room_metadata(
room_name=str(self.pk),
metadata={
"is_encrypted": bool(self.is_encrypted),
"encryption_paused": bool(self.encryption_paused),
},
)
except Exception: # pylint: disable=broad-except
# Metadata sync is advisory — never break Room.save() over it.
pass
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
+33 -21
View File
@@ -194,14 +194,15 @@ class LiveKitEventsService:
When a SIP/phone participant joins an end-to-end encrypted room they
cannot decrypt anything. We:
1. Send an in-band notification (chat-style) to everyone, which
surfaces an admin snackbar and a system message in the chat;
2. Eject the SIP participant — the admin can then disable encryption
from the Security settings and the user can dial back in.
1. Send an in-band notification so admins see a snackbar with an
"Open settings" CTA.
2. Leave the participant connected — the gateway is responsible for
holding them on a placeholder prompt loop ("this meeting is
encrypted, ask the host to disable it") until either the admin
turns encryption off (we update LiveKit room metadata, gateway
reacts) or the user hangs up.
This is the simplest reliable fallback. A future improvement is a
dedicated "audio guard" agent that joins encrypted rooms and plays a
recorded prompt to SIP participants instead of disconnecting them.
See README "Phone / SIP participants" for the full flow.
"""
participant = getattr(data, "participant", None)
@@ -226,7 +227,10 @@ class LiveKitEventsService:
except models.Room.DoesNotExist:
return
if not room.is_encrypted:
# Live encryption: is_encrypted set AND not currently paused.
# If the admin has already paused encryption, the gateway will bridge
# the call normally — no need to surface a blocked notification.
if not room.is_encrypted or room.encryption_paused:
return
# 1. Broadcast a system notice — frontends decode this on the
@@ -245,19 +249,11 @@ class LiveKitEventsService:
"Failed to notify room about blocked external device"
)
# 2. Disconnect the external participant so they don't sit in a
# silent encrypted room. The admin can disable encryption and the
# user can dial in again.
try:
ParticipantsManagement().remove(
room_name=str(room_id), identity=participant.identity
)
except ParticipantsManagementException:
logger.exception(
"Failed to remove external device participant %s from encrypted room %s",
participant.identity,
room_id,
)
# No eject. The gateway reads LiveKit room metadata
# (`{is_encrypted, encryption_paused}` — see `Room.save()`) and stays
# in placeholder-prompt mode for the SIP leg as long as encryption is
# live. When the admin sets encryption_paused=true, the gateway
# transitions to a normal bridge without dropping the call.
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
@@ -276,6 +272,22 @@ class LiveKitEventsService:
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
# Now that the LiveKit room object exists, push the encryption flags
# into its metadata so the SIP gateway can read them as soon as a SIP
# caller joins. Room.save() also tries this on every change, but at
# *creation* time the LiveKit room didn't exist yet — this is the
# first reliable opportunity.
try:
utils.update_room_metadata(
str(room_id),
{
"is_encrypted": bool(room.is_encrypted),
"encryption_paused": bool(room.encryption_paused),
},
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to seed encryption metadata: %s", e)
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.create_dispatch_rule(room)
@@ -0,0 +1,100 @@
/**
* Small banner shown on a participant tile when LiveKit raises an
* EncryptionError for that participant — typically a passphrase/key
* mismatch ("you and they don't share the same encryption key"). The
* avatar stays visible behind the banner.
*
* 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 { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
interface Props {
participant: Participant
}
export function DecryptionFailedTileOverlay({ participant }: Props) {
const { t } = useTranslation('rooms', {
keyPrefix: 'encryption.decryptionFailed',
})
const room = useRoomContext()
const roomData = useRoomData()
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!roomData?.is_encrypted) 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, roomData?.is_encrypted, participant])
if (!failed) return null
return (
<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%',
zIndex: 4,
pointerEvents: 'none',
}}
role="status"
>
<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>
)
}
@@ -90,12 +90,20 @@ interface EncryptionStatusProviderProps {
isEncrypted: boolean
/** Called when the local client should toggle E2EE on/off. */
onPhaseChange?: (phase: EncryptionPhase) => void
/**
* Optional hook to mirror local pause/resume to the room's
* server-side encryption_paused field. When provided, recording and
* transcription pauses also propagate to the SIP gateway (so phone
* callers transition out of placeholder mode automatically).
*/
setServerEncryptionPaused?: (paused: boolean) => Promise<void> | void
}
export function EncryptionStatusProvider({
children,
isEncrypted,
onPhaseChange,
setServerEncryptionPaused,
}: EncryptionStatusProviderProps) {
const room = useRoomContext()
const initialPhase = isEncrypted
@@ -309,6 +317,18 @@ export function EncryptionStatusProvider({
setPauseReason(reason)
setPausedByMe(true)
// Mirror to the server's encryption_paused field if a setter was
// wired through. This is what makes the SIP gateway transition out
// of placeholder mode for recording/transcription too, not just
// for an explicit admin pause from the Admin panel.
if (setServerEncryptionPaused) {
try {
await setServerEncryptionPaused(true)
} catch (err) {
console.error('[encryption] server-side pause failed', err)
}
}
await sendProtocolMessage({
type: 'ENCRYPTION_PAUSED',
reason,
@@ -318,7 +338,7 @@ export function EncryptionStatusProvider({
})
return true
},
[room, sendProtocolMessage, localCanInitiate]
[room, sendProtocolMessage, localCanInitiate, setServerEncryptionPaused]
)
const resumeEncryption = useCallback(async () => {
@@ -329,13 +349,21 @@ export function EncryptionStatusProvider({
setPauseReason(undefined)
setPausedByMe(false)
if (setServerEncryptionPaused) {
try {
await setServerEncryptionPaused(false)
} catch (err) {
console.error('[encryption] server-side resume failed', err)
}
}
await sendProtocolMessage({
type: 'ENCRYPTION_RESUMED',
senderIsAdmin: isParticipantAdmin(room.localParticipant),
senderJoinedAt: room.localParticipant.joinedAt?.getTime() ?? Date.now(),
})
return true
}, [room, sendProtocolMessage, localCanInitiate])
}, [room, sendProtocolMessage, localCanInitiate, setServerEncryptionPaused])
const value = useMemo(
() => ({
@@ -9,10 +9,10 @@ import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { HStack, VStack } from '@/styled-system/jsx'
import { Button, Text } from '@/primitives'
import { ParticipantKind, RemoteParticipant, RoomEvent } from 'livekit-client'
import { useRoomContext } from '@livekit/components-react'
import { ParticipantKind, RemoteParticipant } from 'livekit-client'
import { useRemoteParticipants } from '@livekit/components-react'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useSettingsDialog, SettingsDialogExtendedKey } from '@/features/settings'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { EncryptionPhase, PauseReason } from './encryptionStatusTypes'
import { useEncryptionStatus } from './useEncryptionStatus'
@@ -57,9 +57,9 @@ function useTransient<T>(value: T, displayMs: number) {
export function EncryptionStatusSnackbars() {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.snackbar' })
const { phase, pauseReason, pausedByMe } = useEncryptionStatus()
const room = useRoomContext()
const remoteParticipants = useRemoteParticipants()
const isAdmin = useIsAdminOrOwner()
const { openSettingsDialog } = useSettingsDialog()
const { toggleAdmin, isAdminOpen } = useSidePanel()
const [pauseSignal, setPauseSignal] = useState<{
reason?: PauseReason
@@ -82,32 +82,31 @@ export function EncryptionStatusSnackbars() {
DISPLAY_DURATION_MS
)
const [sipParticipant, setSipParticipant] = useState<string | null>(null)
const [sipDismissed, dismissSip] = useTransient(
sipParticipant,
DISPLAY_DURATION_MS
)
// The SIP-blocked snackbar persists as long as a SIP participant is
// present in the encrypted room — admin needs to either pause encryption
// (Settings → Security → This meeting) or wait for the SIP user to hang
// up. Manual dismiss hides it until a different SIP participant arrives.
//
// useRemoteParticipants re-renders on participant join/leave/state — that
// gives us a reactive participant list without manual event listeners,
// which is more reliable than the prior subscribe-on-mount approach.
const [sipDismissedIdentity, setSipDismissedIdentity] = useState<string | null>(null)
// Detect SIP / phone participants joining an encrypted meeting.
useEffect(() => {
if (!isAdmin) return
if (phase !== EncryptionPhase.ENCRYPTED) return
const isSip = (p: RemoteParticipant) =>
p.kind === ParticipantKind.SIP || p.identity.startsWith('sip_')
const handler = (participant: RemoteParticipant) => {
if (participant.kind === ParticipantKind.SIP) {
setSipParticipant(participant.name || participant.identity)
}
}
room.on(RoomEvent.ParticipantConnected, handler)
room.remoteParticipants.forEach((p) => {
if (p.kind === ParticipantKind.SIP) {
setSipParticipant(p.name || p.identity)
}
})
return () => {
room.off(RoomEvent.ParticipantConnected, handler)
}
}, [room, isAdmin, phase])
const sipParticipant =
isAdmin && phase === EncryptionPhase.ENCRYPTED
? remoteParticipants.find(isSip) ?? null
: null
const sipLabel = sipParticipant
? sipParticipant.name || sipParticipant.identity
: null
const showSipSnack =
sipParticipant !== null &&
sipDismissedIdentity !== sipParticipant.identity
return (
<>
@@ -157,7 +156,7 @@ export function EncryptionStatusSnackbars() {
</HStack>
</SnackbarShell>
)}
{sipDismissed && phase === EncryptionPhase.ENCRYPTED && (
{showSipSnack && phase === EncryptionPhase.ENCRYPTED && (
<SnackbarShell>
<HStack
gap="1rem"
@@ -181,19 +180,29 @@ export function EncryptionStatusSnackbars() {
fontSize: '0.8rem',
})}
>
{t('sipBody', { name: sipDismissed })}
{t('sipBody', { name: sipLabel })}
</Text>
</VStack>
{!isAdminOpen && (
<Button
size="sm"
variant="text"
className={css({ color: 'white !important' })}
onPress={toggleAdmin}
>
{t('openAdmin')}
</Button>
)}
<Button
size="sm"
variant="text"
className={css({ color: 'white !important' })}
onPress={() => {
openSettingsDialog(SettingsDialogExtendedKey.SECURITY)
dismissSip()
}}
onPress={() =>
sipParticipant &&
setSipDismissedIdentity(sipParticipant.identity)
}
>
{t('openSettings')}
{t('dismiss')}
</Button>
</HStack>
</SnackbarShell>
@@ -19,10 +19,11 @@ import {
} from '@remixicon/react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRoomContext } from '@livekit/components-react'
import { RoomEvent } from 'livekit-client'
import { EncryptionPhase } from './encryptionStatusTypes'
import { useEncryptionStatus } from './useEncryptionStatus'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import {
RecordingMode,
useRecordingStatuses,
} from '@/features/recording'
const COLLAPSE_DELAY_MS = 4000
@@ -85,31 +86,26 @@ function StatusPill({ icon, label, background, pulse }: PillProps) {
)
}
function useRecordingStatus() {
const room = useRoomContext()
const [isRecording, setIsRecording] = useState(!!room.isRecording)
useEffect(() => {
const handler = () => setIsRecording(!!room.isRecording)
room.on(RoomEvent.RecordingStatusChanged, handler)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handler)
}
}, [room])
return isRecording
}
export function RoomStatusBanner() {
const { t } = useTranslation('rooms', { keyPrefix: 'roomStatus' })
const { phase, pauseReason } = useEncryptionStatus()
const isRecording = useRecordingStatus()
const roomData = useRoomData()
if (
phase === EncryptionPhase.UNENCRYPTED &&
!isRecording &&
pauseReason !== 'transcript'
) {
// 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
// The encryption pill reflects the room's nature plus its current paused
// state — never disappears just because encryption is temporarily off
// mid-call.
const encryptionCapable = !!roomData?.is_encrypted
const encryptionPaused = !!roomData?.encryption_paused
if (!encryptionCapable && !isRecording && !isTranscribing) {
return null
}
@@ -123,7 +119,7 @@ export function RoomStatusBanner() {
zIndex: 10,
})}
>
{phase === EncryptionPhase.ENCRYPTED && (
{encryptionCapable && !encryptionPaused && (
<StatusPill
key="encrypted"
icon={<RiLockFill size={13} color="white" />}
@@ -131,7 +127,7 @@ export function RoomStatusBanner() {
background="#1e3a5f"
/>
)}
{phase === EncryptionPhase.PAUSED && (
{encryptionCapable && encryptionPaused && (
<StatusPill
key="paused"
icon={<RiLockUnlockFill size={13} color="white" />}
@@ -139,7 +135,7 @@ export function RoomStatusBanner() {
background="#b45309"
/>
)}
{pauseReason === 'transcript' && (
{isTranscribing && (
<StatusPill
key="transcript"
icon={<RiFileTextFill size={13} color="white" />}
@@ -0,0 +1,84 @@
/**
* Small banner shown on top of a SIP/phone participant's tile when the room
* is live-encrypted (encrypted AND not paused). The avatar (LiveKit's
* ParticipantPlaceholder) stays visible behind it; this is just a callout
* near the bottom of the tile so everyone in the room knows why the caller
* isn't producing audio/video. Admins get a CTA to pause encryption.
*
* Detection is kind-then-identity: ParticipantKind.SIP is the canonical
* signal but we also accept identities prefixed `sip_` so the UI keeps
* working if a gateway revision forgets to set the kind enum.
*/
import { useTranslation } from 'react-i18next'
import { Participant, ParticipantKind } from 'livekit-client'
import { RiLockFill } from '@remixicon/react'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
function isSipParticipant(p: Participant): boolean {
if (p.kind === ParticipantKind.SIP) return true
return p.identity.startsWith('sip_')
}
interface Props {
participant: Participant
}
export function SipBlockedTileOverlay({ participant }: Props) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.sipBlocked' })
const room = useRoomData()
const isAdmin = useIsAdminOrOwner()
const liveEncryption =
!!room && !!room.is_encrypted && !room.encryption_paused
if (!liveEncryption) return null
if (!isSipParticipant(participant)) return null
return (
<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.4rem',
maxWidth: '85%',
zIndex: 4,
pointerEvents: 'auto',
}}
role="status"
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
color: '#fbbf24',
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',
}}
>
{isAdmin ? t('bodyAdmin') : t('bodyParticipant')}
</div>
</div>
)
}
@@ -14,3 +14,5 @@ export { PauseEncryptionConfirmDialog } from './PauseEncryptionConfirmDialog'
export { IdentityBadge } from './IdentityBadge'
export { EncryptionMismatchScreen } from './EncryptionMismatchScreen'
export { EncryptionAutoResumeWatcher } from './EncryptionAutoResumeWatcher'
export { SipBlockedTileOverlay } from './SipBlockedTileOverlay'
export { DecryptionFailedTileOverlay } from './DecryptionFailedTileOverlay'
@@ -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 />
</>
@@ -18,6 +18,7 @@ export type ApiRoom = {
is_administrable: boolean
access_level: ApiAccessLevel
is_encrypted: boolean
encryption_paused: boolean
livekit?: ApiLiveKit
configuration?: {
[key: string]: string | number | boolean | string[]
@@ -5,7 +5,9 @@ import { ApiError } from '@/api/ApiError'
export type PatchRoomParams = {
roomId: string
room: Partial<Pick<ApiRoom, 'configuration' | 'access_level'>>
room: Partial<
Pick<ApiRoom, 'configuration' | 'access_level' | 'encryption_paused'>
>
}
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
@@ -10,6 +10,7 @@ import {
ExternalE2EEKeyProvider,
MediaDeviceFailure,
Room,
RoomEvent,
RoomOptions,
VideoPresets,
} from 'livekit-client'
@@ -29,6 +30,7 @@ import { ErrorScreen } from '@/components/ErrorScreen'
import { fetchRoom } from '../api/fetchRoom'
import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { usePatchRoom } from '../api/patchRoom'
import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
@@ -99,8 +101,20 @@ export const Conference = ({
// only signal a hacked server can't fabricate. The DB flag tells us
// whether the room creator *meant* this room to be encrypted — it's used
// to detect mismatches (see below) but never to enable encryption alone.
// encryption_paused is an admin override on an encrypted room: when set,
// E2EE is suspended for this call so external devices can join, but the
// link still carries the hash and the room can be resumed at any time.
//
// Two derived flags:
// encryptionCapable — this room has an encryption key; the Room object
// is constructed with the e2ee worker + key provider regardless of
// whether E2EE is currently active. Stable across mid-call pauses, so
// the Room instance never gets recreated mid-call.
// liveEncryption — encryption is currently active (capable AND not
// paused). Drives room.setE2EEEnabled() and the encryption phase UI.
const hashPassphrase = getPassphraseFromHash()
const dbSaysEncrypted = !!data?.is_encrypted
const isPaused = !!data?.encryption_paused
const hasValidHash = isValidPassphrase(hashPassphrase)
const encryptionMismatch:
@@ -109,29 +123,40 @@ export const Conference = ({
| null =
data === undefined
? null
: dbSaysEncrypted && !hasValidHash
: dbSaysEncrypted && !isPaused && !hasValidHash
? 'missingPassphrase'
: !dbSaysEncrypted && hashPassphrase.length > 0
? 'unexpectedPassphrase'
: null
const isEncrypted = dbSaysEncrypted && hasValidHash
const encryptionCapable = dbSaysEncrypted && hasValidHash
const liveEncryption = encryptionCapable && !isPaused
// Kept as `isEncrypted` so legacy reads below stay readable. Refers to
// the live state — flip-on-pause/flip-on-resume is wired below.
const isEncrypted = liveEncryption
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
const workerRef = useRef<Worker | null>(null)
// Setup is complete once the key provider is wired up; it does NOT need
// to re-run when encryption_paused flips. We toggle the active state of
// E2EE separately via room.setE2EEEnabled.
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(
!isEncrypted
!encryptionCapable
)
const getKeyProvider = () => {
if (!keyProviderRef.current && isEncrypted) {
if (!keyProviderRef.current && encryptionCapable) {
keyProviderRef.current = new ExternalE2EEKeyProvider()
}
return keyProviderRef.current
}
const getWorker = () => {
if (!workerRef.current && isEncrypted && typeof window !== 'undefined') {
if (
!workerRef.current &&
encryptionCapable &&
typeof window !== 'undefined'
) {
workerRef.current = new Worker(
new URL('livekit-client/e2ee-worker', import.meta.url)
)
@@ -144,8 +169,13 @@ export const Conference = ({
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: isEncrypted ? undefined : 'vp9',
red: !isEncrypted,
// VP8 whenever the room is encryption-capable: encryption_paused
// can flip mid-call to let a SIP/phone caller bridge, and the
// gateway's room→SIP GStreamer path only handles VP8 today. Using
// VP8 unconditionally for encryption-capable rooms keeps the Room
// instance stable across pause/resume.
videoCodec: encryptionCapable ? 'vp8' : 'vp8',
red: !encryptionCapable,
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
@@ -161,7 +191,11 @@ export const Conference = ({
},
}
if (isEncrypted) {
// Always wire up the E2EE worker + key provider for an encryption-
// capable room. We toggle whether encryption is *active* with
// room.setE2EEEnabled below; the worker stays around so resume is a
// single API call rather than a Room reconstruction.
if (encryptionCapable) {
const worker = getWorker()
const keyProvider = getKeyProvider()
if (keyProvider && worker) {
@@ -174,7 +208,7 @@ export const Conference = ({
// getKeyProvider/getWorker are stable refs, intentionally not in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isEncrypted,
encryptionCapable,
userConfig.videoDeviceId,
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
@@ -201,7 +235,7 @@ export const Conference = ({
const adminPassphraseRef = useRef<string | null>(null)
useEffect(() => {
if (!isEncrypted || encryptionSetupComplete) return
if (!encryptionCapable || encryptionSetupComplete) return
const keyProvider = getKeyProvider()
if (!keyProvider) return
@@ -236,9 +270,11 @@ export const Conference = ({
.setKey(passphrase)
.then(async () => {
// Enable E2EE BEFORE connecting — sets encryptionType=GCM so tracks
// are published with encryption metadata from the start.
// are published with encryption metadata from the start. If the
// room is currently paused, we set up the worker but leave E2EE
// disabled (resume flips it on without rebuilding the Room).
try {
await room.setE2EEEnabled(true)
await room.setE2EEEnabled(liveEncryption)
} catch (err) {
console.error('[Encryption] E2EE enable failed:', err)
}
@@ -250,18 +286,79 @@ export const Conference = ({
})
// getKeyProvider is a stable ref; not part of deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, isEncrypted, encryptionSetupComplete, isAdmin])
}, [room, encryptionCapable, encryptionSetupComplete, isAdmin])
// Mid-call admin toggle: when the server flips encryption_paused, every
// browser client sees the change as `liveEncryption` flipping. A single
// setE2EEEnabled call is enough — livekit-client internally calls
// republishAllTracks so the SFU forwards tracks in the new mode (plain
// during pause so SIP can bridge; E2EE on resume).
//
// republishAllTracks occasionally drops a track silently when its
// renegotiation hits a transport-state race. We snapshot the local
// mic/camera enabled state before the toggle and re-assert them
// afterwards so the user doesn't lose their camera or microphone over a
// pause/resume cycle.
const prevLiveRef = useRef<boolean | null>(null)
useEffect(() => {
if (!encryptionCapable || !encryptionSetupComplete) return
if (prevLiveRef.current === null) {
prevLiveRef.current = liveEncryption
return // initial setup already covered this value
}
if (prevLiveRef.current === liveEncryption) return
prevLiveRef.current = liveEncryption
void (async () => {
const lp = room.localParticipant
const camWasOn = lp.isCameraEnabled
const micWasOn = lp.isMicrophoneEnabled
try {
await room.setE2EEEnabled(liveEncryption)
} catch (err) {
console.error('[Encryption] mid-call E2EE toggle failed', err)
return
}
// Re-assert track state — republishAllTracks may have silently
// dropped one of them mid-renegotiation.
try {
if (camWasOn && !lp.isCameraEnabled) {
await lp.setCameraEnabled(true)
}
if (micWasOn && !lp.isMicrophoneEnabled) {
await lp.setMicrophoneEnabled(true)
}
} catch (err) {
console.error('[Encryption] track re-assert failed', err)
}
})()
}, [room, encryptionCapable, encryptionSetupComplete, liveEncryption])
// Listen for server-driven metadata updates (admin pausing/resuming from
// a different client) and refresh the room query so liveEncryption above
// reflects the new state.
useEffect(() => {
if (!room) return
const onMetadataChanged = () => {
queryClient.invalidateQueries({ queryKey: fetchKey })
}
room.on(RoomEvent.RoomMetadataChanged, onMetadataChanged)
return () => {
room.off(RoomEvent.RoomMetadataChanged, onMetadataChanged)
}
// fetchKey is derived from roomId; both stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room])
// If the user changes the hash mid-session (e.g. corrects a typo), reload
// so the new passphrase is picked up by the encryption setup.
useEffect(() => {
if (!isEncrypted) return
if (!encryptionCapable) return
const handleHashChange = () => {
window.location.reload()
}
window.addEventListener('hashchange', handleHashChange)
return () => window.removeEventListener('hashchange', handleHashChange)
}, [isEncrypted])
}, [encryptionCapable])
useEffect(() => {
/**
@@ -293,8 +390,13 @@ export const Conference = ({
prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp])
const { mutateAsync: patchRoom } = usePatchRoom()
const setServerEncryptionPaused = async (paused: boolean) => {
await patchRoom({ roomId, room: { encryption_paused: paused } })
}
const handlePhaseChange = (phase: EncryptionPhase) => {
if (!isEncrypted) return
if (!encryptionCapable) return
if (phase === EncryptionPhase.PAUSED) {
void room.setE2EEEnabled(false).catch((err) => {
console.error('[Encryption] E2EE pause failed', err)
@@ -388,6 +490,7 @@ export const Conference = ({
<EncryptionStatusProvider
isEncrypted={isEncrypted}
onPhaseChange={handlePhaseChange}
setServerEncryptionPaused={setServerEncryptionPaused}
>
<VideoConference />
</EncryptionStatusProvider>
@@ -118,8 +118,13 @@ export const Join = ({
})
const isEncryptedRoom = !!roomInfo?.is_encrypted
const isPaused = !!roomInfo?.encryption_paused
// Live encryption is what gates the "needs a passphrase" UX. A paused
// encrypted room accepts hashless joiners (they join in plaintext) and
// re-enforces the passphrase requirement once the admin resumes.
const liveEncryption = isEncryptedRoom && !isPaused
const passphrase = getPassphraseFromHash()
const hasValidPassphrase = isEncryptedRoom
const hasValidPassphrase = liveEncryption
? isValidPassphrase(passphrase)
: true
// If the URL has a passphrase but the room itself is not encrypted, the
@@ -439,7 +444,7 @@ export const Join = ({
if (unexpectedPassphrase) {
return <EncryptionMismatchScreen reason="unexpectedPassphrase" />
}
if (isEncryptedRoom && !hasValidPassphrase) {
if (liveEncryption && !hasValidPassphrase) {
return <EncryptionMismatchScreen reason="missingPassphrase" />
}
return (
@@ -468,7 +473,7 @@ export const Join = ({
autoComplete="name"
maxLength={50}
/>
{isEncryptedRoom && (
{liveEncryption && (
<div
className={css({
display: 'flex',
@@ -2,6 +2,8 @@ import { Div, Field, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Separator as RACSeparator } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { useRecordingStatuses } from '@/features/recording'
import { RecordingMode } from '@/features/recording'
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
@@ -38,6 +40,25 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
// Reasons we block the admin from re-enabling encryption mid-call:
// - a recording is in progress (the recording server needs plaintext)
// - a transcript is being captured (same reason)
// SIP participants aren't a blocker — re-enabling encryption while
// they're present moves them back into placeholder mode, and the admin
// will see the snackbar.
//
// We use `isStarted` from useRecordingStatuses (metadata-driven) rather
// than LK's `useIsRecording`. The metadata transitions through Saving
// immediately when the user clicks stop, so the alert clears right
// away rather than after the 12s LK round-trip.
const screenRec = useRecordingStatuses(RecordingMode.ScreenRecording)
const transcript = useRecordingStatuses(RecordingMode.Transcript)
const resumeBlockedReason = screenRec.isStarted
? t('encryption.blocked.recording')
: transcript.isStarted
? t('encryption.blocked.transcript')
: null
return (
<Div
display="flex"
@@ -132,6 +153,122 @@ export const Admin = () => {
/>
</div>
</div>
{readOnlyData?.is_encrypted && (
<div
className={css({
display: 'flex',
flexDirection: 'column',
marginTop: '1rem',
width: '100%',
})}
>
<RACSeparator
className={css({
border: 'none',
height: '1px',
width: '100%',
background: 'greyscale.250',
})}
/>
<H
lvl={2}
className={css({
fontWeight: 500,
})}
margin="sm"
>
{t('encryption.title')}
</H>
<Text
variant="note"
wrap="balance"
className={css({
textStyle: 'sm',
})}
margin={'md'}
>
{t('encryption.description')}
</Text>
{(() => {
const resumeBlocked =
!!readOnlyData?.encryption_paused && resumeBlockedReason != null
return (
<>
<div
className={css({
opacity: resumeBlocked ? 0.45 : 1,
pointerEvents: resumeBlocked ? 'none' : undefined,
transition: 'opacity 200ms ease',
})}
aria-disabled={resumeBlocked || undefined}
>
<Field
type="switch"
label={t('encryption.toggle.label')}
description={
readOnlyData?.encryption_paused
? t('encryption.toggle.descriptionPaused')
: t('encryption.toggle.descriptionLive')
}
isSelected={!!readOnlyData?.encryption_paused}
isDisabled={resumeBlocked}
onChange={(paused) =>
patchRoom({
roomId,
room: { encryption_paused: paused },
})
.then((room) => {
queryClient.setQueryData([keys.room, roomId], room)
})
.catch((e) => console.error(e))
}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</div>
{resumeBlocked && (
<div
role="alert"
className={css({
display: 'flex',
gap: '0.5rem',
marginTop: '0.75rem',
padding: '0.7rem 0.85rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
color: '#7c2d12',
})}
>
<span
aria-hidden
className={css({
fontSize: '1rem',
lineHeight: '1.2',
})}
>
</span>
<Text
variant="sm"
margin={false}
className={css({
color: '#7c2d12',
fontSize: '0.85rem',
lineHeight: 1.4,
})}
>
{resumeBlockedReason}
</Text>
</div>
)}
</>
)
})()}
</div>
)}
<div
className={css({
display: 'flex',
@@ -22,7 +22,12 @@ import { Track } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { HStack } from '@/styled-system/jsx'
import { IdentityBadge, useEncryptionStatus, EncryptionPhase } from '@/features/encryption'
import {
IdentityBadge,
SipBlockedTileOverlay,
DecryptionFailedTileOverlay,
} from '@/features/encryption'
import { useRoomData } from '../hooks/useRoomData'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
@@ -106,9 +111,13 @@ export const ParticipantTile: (
const isScreenShare = trackReference.source != Track.Source.Camera
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const { phase } = useEncryptionStatus()
// Show the badge in any encryption-capable room — including while the
// room is paused. The badge reflects the *room's nature* (encrypted vs
// unencrypted-by-design), which doesn't change when an admin temporarily
// pauses encryption for SIP/recording/transcription.
const tileRoomData = useRoomData()
const showIdentityBadge =
!isScreenShare && phase !== EncryptionPhase.UNENCRYPTED
!isScreenShare && !!tileRoomData?.is_encrypted
const participantName = getParticipantName(trackReference.participant)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
@@ -240,6 +249,16 @@ export const ParticipantTile: (
hasKeyboardFocus={hasKeyboardFocus}
/>
)}
{!isScreenShare && (
<>
<SipBlockedTileOverlay
participant={trackReference.participant}
/>
<DecryptionFailedTileOverlay
participant={trackReference.participant}
/>
</>
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
<KeyboardShortcutHint>
@@ -0,0 +1,159 @@
/**
* Reusable "create encrypted meetings by default" toggle + confirmation
* dialog. Used both in the in-meeting Security tab and the home-page
* SettingsDialog so the option is reachable from outside a call.
*/
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import {
RiFileTextLine,
RiPhoneLine,
RiRecordCircleLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Button, Dialog, Field, Text } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
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'
export const EncryptionDefaultField = () => {
const { t } = useTranslation('settings', { keyPrefix: 'security' })
const { user, isLoggedIn } = useUser()
const { data: config } = useConfig()
const isFeatureEnabled = !!config?.encryption?.enabled
const [confirmTarget, setConfirmTarget] = useState<boolean | null>(null)
const { mutateAsync, isPending } = useMutation({
mutationFn: updateUserPreferences,
onSuccess: (updatedUser) => {
queryClient.setQueryData([keys.user], updatedUser)
},
})
const isOn = !!user?.default_encryption
const requestToggle = (next: boolean) => {
if (!user) return
if (next) {
setConfirmTarget(true)
return
}
void mutateAsync({ user: { id: user.id, default_encryption: false } })
}
const confirm = async () => {
if (!user || confirmTarget === null) return
await mutateAsync({
user: { id: user.id, default_encryption: confirmTarget },
})
setConfirmTarget(null)
}
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('toggle.label')}
description={t('toggle.description')}
isSelected={isOn}
isDisabled={isPending}
onChange={requestToggle}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
<Dialog
isOpen={confirmTarget !== null}
onOpenChange={(open) => !open && setConfirmTarget(null)}
role="dialog"
type="flex"
title={t('confirmModal.title')}
>
<VStack
alignItems="start"
gap="0.75rem"
className={css({ maxWidth: '24rem' })}
>
<Text variant="sm">{t('confirmModal.description')}</Text>
<VStack gap="0.5rem" alignItems="start">
<ConfirmRow
icon={<RiPhoneLine size={16} />}
label={t('confirmModal.items.phone')}
/>
<ConfirmRow
icon={<RiVideoOnLine size={16} />}
label={t('confirmModal.items.devices')}
/>
<ConfirmRow
icon={<RiFileTextLine size={16} />}
label={t('confirmModal.items.transcription')}
/>
<ConfirmRow
icon={<RiRecordCircleLine size={16} />}
label={t('confirmModal.items.recording')}
/>
</VStack>
<Text
variant="note"
className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}
>
{t('confirmModal.footnote')}
</Text>
<HStack gap="0.5rem" justify="end" className={css({ width: '100%' })}>
<Button
variant="secondary"
onPress={() => setConfirmTarget(null)}
>
{t('confirmModal.cancel')}
</Button>
<Button
variant="primary"
isDisabled={isPending}
onPress={confirm}
>
{t('confirmModal.confirm')}
</Button>
</HStack>
</VStack>
</Dialog>
</>
)
}
const ConfirmRow = ({
icon,
label,
}: {
icon: React.ReactNode
label: string
}) => (
<HStack gap="0.5rem" alignItems="center">
<span className={css({ color: 'greyscale.600' })}>{icon}</span>
<Text variant="sm" margin={false}>
{label}
</Text>
</HStack>
)
@@ -3,6 +3,7 @@ import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { A, Badge, Dialog, type DialogProps, Field, H, P } 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 +47,8 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
i18n.changeLanguage(lang as string)
}}
/>
<H lvl={2}>{t('security.heading')}</H>
<EncryptionDefaultField />
</Dialog>
)
}
@@ -1,143 +1,21 @@
/**
* Security settings tab.
*
* Lets the signed-in user opt in (or out) of having their meetings created
* end-to-end encrypted by default. Confirmation modal lists what becomes
* unavailable while the option is on.
* Security settings tab — user-level encryption preferences only. The
* per-meeting pause control lives in the Admin panel (room moderation),
* since it's a moderation action, not a user preference.
*/
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import {
RiFileTextLine,
RiPhoneLine,
RiRecordCircleLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Button, Dialog, Field, H, P, Text } from '@/primitives'
import { H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { HStack, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
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 { EncryptionDefaultField } from '../EncryptionDefaultField'
export type SecurityTabProps = Pick<TabPanelProps, 'id'>
export const SecurityTab = ({ id }: SecurityTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'security' })
const { user, isLoggedIn } = useUser()
const { data: config } = useConfig()
const isFeatureEnabled = !!config?.encryption?.enabled
const [confirmTarget, setConfirmTarget] = useState<boolean | null>(null)
const { mutateAsync, isPending } = useMutation({
mutationFn: updateUserPreferences,
onSuccess: (updatedUser) => {
queryClient.setQueryData([keys.user], updatedUser)
},
})
const isOn = !!user?.default_encryption
const requestToggle = (next: boolean) => {
if (!user) return
if (next) {
// Turning on requires confirmation (UX of the mockup)
setConfirmTarget(true)
return
}
void mutateAsync({ user: { id: user.id, default_encryption: false } })
}
const confirm = async () => {
if (!user || confirmTarget === null) return
await mutateAsync({
user: { id: user.id, default_encryption: confirmTarget },
})
setConfirmTarget(null)
}
return (
<TabPanel padding="md" flex id={id}>
<H lvl={2}>{t('heading')}</H>
<P last>{t('description')}</P>
{!isLoggedIn ? (
<>
<Text variant="note" margin={false}>
{t('signInRequired')}
</Text>
<LoginButton />
</>
) : !isFeatureEnabled ? (
<Text variant="note" margin={false}>
{t('featureDisabled')}
</Text>
) : (
<Field
type="switch"
label={t('toggle.label')}
description={t('toggle.description')}
isSelected={isOn}
isDisabled={isPending}
onChange={requestToggle}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
)}
<Dialog
isOpen={confirmTarget !== null}
onOpenChange={(open) => !open && setConfirmTarget(null)}
role="dialog"
type="flex"
title={t('confirmModal.title')}
>
<VStack alignItems="start" gap="0.75rem" className={css({ maxWidth: '24rem' })}>
<Text variant="sm">{t('confirmModal.description')}</Text>
<VStack gap="0.5rem" alignItems="start">
<ConfirmRow icon={<RiPhoneLine size={16} />} label={t('confirmModal.items.phone')} />
<ConfirmRow icon={<RiVideoOnLine size={16} />} label={t('confirmModal.items.devices')} />
<ConfirmRow icon={<RiFileTextLine size={16} />} label={t('confirmModal.items.transcription')} />
<ConfirmRow icon={<RiRecordCircleLine size={16} />} label={t('confirmModal.items.recording')} />
</VStack>
<Text
variant="note"
className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}
>
{t('confirmModal.footnote')}
</Text>
<HStack
gap="0.5rem"
justify="end"
className={css({ width: '100%' })}
>
<Button variant="secondary" onPress={() => setConfirmTarget(null)}>
{t('confirmModal.cancel')}
</Button>
<Button variant="primary" isDisabled={isPending} onPress={confirm}>
{t('confirmModal.confirm')}
</Button>
</HStack>
</VStack>
</Dialog>
<EncryptionDefaultField />
</TabPanel>
)
}
const ConfirmRow = ({
icon,
label,
}: {
icon: React.ReactNode
label: string
}) => (
<HStack gap="0.5rem" alignItems="center">
<span className={css({ color: 'greyscale.600' })}>{icon}</span>
<Text variant="sm" margin={false}>
{label}
</Text>
</HStack>
)
+26 -3
View File
@@ -516,6 +516,19 @@
"label": "Share their screen",
"description": "Disabling this option will prevent participants from sharing their screen, and any ongoing screen sharing will be stopped immediately."
}
},
"encryption": {
"title": "Encryption",
"description": "Temporarily pause end-to-end encryption so a phone or other external device can join. The meeting link still carries the encryption key, so you can resume at any time.",
"toggle": {
"label": "Pause encryption",
"descriptionLive": "Encryption is active for this meeting.",
"descriptionPaused": "Encryption is paused — external devices can join in plain audio and video."
},
"blocked": {
"recording": "Encryption can't resume while a recording is in progress. Stop the recording first.",
"transcript": "Encryption can't resume while transcription is running. Stop the transcript first."
}
}
},
"rating": {
@@ -678,7 +691,7 @@
"screenShare": "{{name}}'s screen"
},
"identity": {
"proconnect": "ProConnect",
"proconnect": "Connected",
"anonymous": "Anonymous"
},
"roomStatus": {
@@ -720,9 +733,19 @@
"reasonManual": "An admin turned off encryption for this meeting.",
"reasonSip": "Encryption was paused so a phone participant can join.",
"sipTitle": "A participant can't decrypt this meeting",
"sipBody": "{{name}} joined by phone or another device that cannot decrypt this meeting.",
"openSettings": "Open settings",
"sipBody": "{{name}} joined by phone or another device that cannot decrypt this meeting. Pause encryption from the Admin panel to let them in.",
"openAdmin": "Open admin",
"dismiss": "OK"
},
"sipBlocked": {
"title": "Can't decrypt this caller",
"bodyAdmin": "This participant joined by phone or another device that doesn't support end-to-end encryption. Pause encryption from the Admin panel to bridge their audio and video.",
"bodyParticipant": "This participant joined by phone or another device that doesn't support end-to-end encryption. Ask the host to pause encryption to let them in.",
"openAdmin": "Open admin"
},
"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."
}
}
}
+12 -4
View File
@@ -8,13 +8,13 @@
"nameError": "Your name cannot be empty"
},
"security": {
"heading": "End-to-end encryption",
"description": "When this is on, new meetings you create are end-to-end encrypted by default.",
"heading": "Security",
"signInRequired": "Sign in to set encryption preferences.",
"featureDisabled": "End-to-end encryption is not available on this server.",
"defaultHeading": "New meetings",
"toggle": {
"label": "End-to-end encryption",
"description": "Encrypt audio, video and chat in your meetings. The server cannot read the content while encryption is on."
"label": "Encrypt new meetings by default",
"description": "When on, every new meeting you create starts end-to-end encrypted. You can still pause encryption per-meeting from this same Security panel."
},
"confirmModal": {
"title": "Turn on encryption?",
@@ -28,6 +28,14 @@
"footnote": "You can change this preference at any time.",
"cancel": "Cancel",
"confirm": "Turn on"
},
"thisMeeting": {
"heading": "This meeting",
"toggle": {
"label": "Pause encryption",
"descriptionLive": "Encryption is active. Pause it to let a phone or other external device join — the link still carries the encryption key, so you can resume at any time.",
"descriptionPaused": "Encryption is paused. External devices can join in plain audio and video. Toggle off to resume protection."
}
}
},
"preferences": {
+26 -3
View File
@@ -516,6 +516,19 @@
"label": "Partager leur écran",
"description": "En désactivant cette option, les participants ne pourront plus partager leur écran et tout partage en cours sera immédiatement interrompu."
}
},
"encryption": {
"title": "Chiffrement",
"description": "Mettre temporairement le chiffrement de bout en bout en pause pour qu'un téléphone ou un autre appareil externe puisse rejoindre. Le lien de la réunion porte toujours la clé, vous pouvez réactiver à tout moment.",
"toggle": {
"label": "Mettre le chiffrement en pause",
"descriptionLive": "Le chiffrement est actif pour cette réunion.",
"descriptionPaused": "Le chiffrement est en pause — les appareils externes peuvent rejoindre en audio et vidéo non chiffrés."
},
"blocked": {
"recording": "Le chiffrement ne peut pas être réactivé tant qu'un enregistrement est en cours. Arrêtez l'enregistrement d'abord.",
"transcript": "Le chiffrement ne peut pas être réactivé tant qu'une transcription est en cours. Arrêtez la transcription d'abord."
}
}
},
"rating": {
@@ -678,7 +691,7 @@
"screenShare": "Écran de {{name}}"
},
"identity": {
"proconnect": "ProConnect",
"proconnect": "Connecté",
"anonymous": "Anonyme"
},
"roomStatus": {
@@ -720,9 +733,19 @@
"reasonManual": "Un administrateur a désactivé le chiffrement pour cette réunion.",
"reasonSip": "Le chiffrement a été mis en pause pour permettre à un participant téléphonique de rejoindre.",
"sipTitle": "Un participant ne peut pas déchiffrer cette réunion",
"sipBody": "{{name}} a rejoint par téléphone ou un appareil incompatible avec le chiffrement.",
"openSettings": "Ouvrir les paramètres",
"sipBody": "{{name}} a rejoint par téléphone ou un appareil incompatible avec le chiffrement. Mettez le chiffrement en pause depuis le panneau Admin pour le laisser entrer.",
"openAdmin": "Ouvrir l'admin",
"dismiss": "OK"
},
"sipBlocked": {
"title": "Impossible de déchiffrer ce participant",
"bodyAdmin": "Ce participant a rejoint depuis un téléphone ou un appareil qui ne prend pas en charge le chiffrement de bout en bout. Mettez le chiffrement en pause depuis le panneau Admin pour relayer son audio et sa vidéo.",
"bodyParticipant": "Ce participant a rejoint depuis un téléphone ou un appareil qui ne prend pas en charge le chiffrement de bout en bout. Demandez à l'hôte de mettre le chiffrement en pause pour lui permettre d'entrer.",
"openAdmin": "Ouvrir l'admin"
},
"decryptionFailed": {
"title": "Échec du déchiffrement",
"body": "Vérifiez que vous et cette personne utilisez le même lien de réunion. Si vous êtes le seul à ne pas la voir, le problème vient probablement de son côté."
}
}
}
+12 -4
View File
@@ -8,13 +8,13 @@
"nameError": "Votre Nom ne peut pas être vide"
},
"security": {
"heading": "Chiffrement de bout en bout",
"description": "Lorsque cette option est activée, vos nouvelles réunions sont chiffrées de bout en bout par défaut.",
"heading": "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.",
"defaultHeading": "Nouvelles réunions",
"toggle": {
"label": "Chiffrement de bout en bout",
"description": "Chiffre l'audio, la vidéo et le tchat. Le serveur ne peut pas lire le contenu tant que le chiffrement est actif."
"label": "Chiffrer les nouvelles réunions par défaut",
"description": "Lorsque cette option est activée, chaque nouvelle réunion que vous créez démarre chiffrée de bout en bout. Vous pouvez toujours mettre le chiffrement en pause pour une réunion donnée depuis ce même panneau Sécurité."
},
"confirmModal": {
"title": "Activer le chiffrement ?",
@@ -28,6 +28,14 @@
"footnote": "Vous pouvez modifier cette préférence à tout moment.",
"cancel": "Annuler",
"confirm": "Activer"
},
"thisMeeting": {
"heading": "Cette réunion",
"toggle": {
"label": "Mettre le chiffrement en pause",
"descriptionLive": "Le chiffrement est actif. Mettez-le en pause pour laisser un téléphone ou un autre appareil externe rejoindre — le lien porte toujours la clé de chiffrement, vous pourrez le réactiver à tout moment.",
"descriptionPaused": "Le chiffrement est en pause. Les appareils externes peuvent rejoindre en audio et vidéo non chiffrés. Désactivez pour réactiver la protection."
}
}
},
"preferences": {