wip advanced

This commit is contained in:
Thomas Ramé
2026-04-02 18:52:11 +02:00
parent 316008016c
commit d7ce25b1b5
35 changed files with 865 additions and 166 deletions
+20 -3
View File
@@ -60,7 +60,7 @@
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.e2e",
"email": "user.test@chromium.test",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": "true",
@@ -74,7 +74,7 @@
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
"email": "user.test@webkit.test",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": "true",
@@ -88,7 +88,7 @@
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
"email": "user.test@firefox.test",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": "true",
@@ -845,6 +845,23 @@
"offline_access",
"microprofile-jwt"
]
},
{
"clientId": "encryption",
"name": "Encryption Service",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": [
"http://encryption.localhost:7200/auth/callback"
],
"webOrigins": [
"http://encryption.localhost:7200",
"http://data.encryption.localhost:7200"
],
"protocol": "openid-connect",
"fullScopeAllowed": true
}
],
"clientScopes": [
+31 -4
View File
@@ -30,8 +30,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
fields = ["id", "sub", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "sub", "email", "full_name", "short_name"]
class UserLightSerializer(serializers.ModelSerializer):
@@ -74,6 +74,23 @@ class ResourceAccessSerializerMixin:
raise PermissionDenied(
"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):
@@ -98,7 +115,7 @@ class ResourceAccessSerializer(
class Meta:
model = models.ResourceAccess
fields = ["id", "user", "resource", "role"]
fields = ["id", "user", "resource", "role", "encrypted_symmetric_key"]
read_only_fields = ["id"]
def update(self, instance, validated_data):
@@ -202,12 +219,22 @@ class RoomSerializer(serializers.ModelSerializer):
username=username,
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
encryption_mode=instance.encryption_mode,
)
else:
del output["pin_code"]
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
@@ -289,7 +316,7 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
username = serializers.CharField(required=True, allow_blank=True)
ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
+15
View File
@@ -287,10 +287,12 @@ class RoomViewSet(
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"):
@@ -319,6 +321,12 @@ class RoomViewSet(
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,
)
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room,
@@ -403,6 +411,13 @@ 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:
@@ -0,0 +1,23 @@
"""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",
),
),
]
+9
View File
@@ -332,6 +332,15 @@ 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"
+3 -1
View File
@@ -127,7 +127,7 @@ class LobbyService:
key=settings.LOBBY_COOKIE_NAME,
value=participant_id,
httponly=True,
secure=True,
secure=not settings.DEBUG,
samesite="Lax",
)
@@ -193,6 +193,7 @@ class LobbyService:
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
encryption_mode=room.encryption_mode,
)
return participant, livekit_config
@@ -235,6 +236,7 @@ class LobbyService:
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
encryption_mode=room.encryption_mode,
)
return participant, livekit_config
+8 -1
View File
@@ -66,6 +66,7 @@ def generate_token(
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
participant_id: Optional[str] = None,
encryption_mode: str = 'none',
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -92,11 +93,15 @@ def generate_token(
if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES
# In encrypted rooms, authenticated users cannot change their name/metadata
# to prevent identity spoofing in the LiveKit room.
can_update_metadata = encryption_mode == 'none' or user.is_anonymous
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=is_admin_or_owner,
can_update_own_metadata=True,
can_update_own_metadata=can_update_metadata,
can_publish=bool(sources),
can_publish_sources=sources,
can_subscribe=True,
@@ -150,6 +155,7 @@ def generate_livekit_config(
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
encryption_mode: str = 'none',
) -> dict:
"""Generate LiveKit configuration for room access.
@@ -182,6 +188,7 @@ def generate_livekit_config(
sources=sources,
is_admin_or_owner=is_admin_or_owner,
participant_id=participant_id,
encryption_mode=encryption_mode,
),
}
+1 -1
View File
@@ -561,7 +561,7 @@ class Base(Configuration):
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
)
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
default=["given_name", "usual_name"],
default=["given_name", "usual_name", "family_name"],
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None,
)
@@ -18,6 +18,7 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useUser } from '@/features/auth'
import { useConfig } from '@/api/useConfig'
export interface VaultClientContextValue {
@@ -75,6 +76,7 @@ export function VaultClientProvider({
}) {
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)
@@ -171,11 +173,24 @@ export function VaultClientProvider({
return
}
// For now, mark as ready without auth context.
// Auth context will be set when we have a suite_user_id.
setIsReady(true)
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])
}, [clientInitialized, (user as Record<string, unknown>)?.sub])
const refreshKeyState = useCallback(async () => {
const client = clientRef.current
@@ -198,7 +213,7 @@ export function VaultClientProvider({
return (
<VaultClientContext.Provider
value={{
client: isReady ? clientRef.current : null,
client: clientInitialized ? clientRef.current : null,
isReady,
isLoading,
error,
@@ -119,6 +119,19 @@ export function getEncryptedVaultKey(): ArrayBuffer | null {
return _encryptedVaultKey
}
/**
* 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
/**
* Derive a shared secret from X25519 ECDH, then derive an encryption key via BLAKE2b.
*/
@@ -13,11 +13,12 @@ import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRo
// fixme - duplication with the InviteDialog
export const LaterMeetingDialog = ({
room,
hash,
...dialogProps
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
}: { room: null | ApiRoom; hash?: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
const roomUrl = room && getRouteUrl('room', room?.slug)
const roomUrl = room ? `${getRouteUrl('room', room.slug)}${hash ? `#${hash}` : ''}` : null
const telephony = useTelephony()
const [isHovered, setIsHovered] = useState(false)
@@ -31,7 +32,7 @@ export const LaterMeetingDialog = ({
copyRoomToClipboard,
isRoomUrlCopied,
copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(room || undefined)
} = useCopyRoomToClipboard(room || undefined, hash)
return (
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
+36 -6
View File
@@ -11,6 +11,8 @@ import { RiAddLine, RiLink, RiLockLine } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { EncryptionModeDialog } from '@/features/home/components/EncryptionModeDialog'
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'
@@ -157,7 +159,8 @@ export const Home = () => {
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const { client: vaultClient } = useVaultClient()
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom; hash?: string }>(null)
const [encryptionDialogMode, setEncryptionDialogMode] = useState<null | 'instant' | 'later'>(null)
const [redirectFailed, setRedirectFailed] = useState(false)
@@ -232,7 +235,7 @@ export const Home = () => {
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoom(data)
setLaterRoom({ room: data })
)
}}
data-attr="create-option-later"
@@ -295,25 +298,52 @@ export const Home = () => {
</RightColumn>
</Columns>
<LaterMeetingDialog
room={laterRoom}
room={laterRoom?.room ?? null}
hash={laterRoom?.hash}
onOpenChange={() => setLaterRoom(null)}
/>
{encryptionDialogMode && (
<EncryptionModeDialog
onSelect={(mode) => {
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 (encryptionDialogMode === 'instant') {
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(data)
setLaterRoom({ room: data, hash })
}
})
}}
@@ -33,6 +33,7 @@ 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[]
@@ -8,6 +8,7 @@ export interface CreateRoomParams {
callbackId?: string
username?: string
encryptionMode?: ApiEncryptionMode
encryptedSymmetricKey?: string
}
const createRoom = ({
@@ -15,13 +16,16 @@ const createRoom = ({
callbackId,
username = '',
encryptionMode = ApiEncryptionMode.NONE,
encryptedSymmetricKey = '',
}: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
const queryParams = username ? `?username=${encodeURIComponent(username)}` : ''
return fetchApi(`rooms/${queryParams}`, {
method: 'POST',
body: JSON.stringify({
name: slug,
callback_id: callbackId,
encryption_mode: encryptionMode,
encrypted_symmetric_key: encryptedSymmetricKey,
}),
})
}
@@ -13,7 +13,7 @@ import {
RoomOptions,
VideoPresets,
} from 'livekit-client'
import { setSymmetricKey, getSymmetricKey, getEncryptedVaultKey } from '@/features/encryption/lobbyKeyExchange'
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'
@@ -208,16 +208,19 @@ export const Conference = ({
const setupVaultKey = async () => {
try {
if (isAdmin) {
// Admin: generate a symmetric key via VaultClient
const dummyData = new Uint8Array(32).buffer
const { publicKey } = await vaultClient.getPublicKey()
const { encryptedKeys } = await vaultClient.encryptWithoutKey(
dummyData,
{ self: publicKey }
)
const encryptedSymmetricKey = encryptedKeys['self']
vaultManager.setEncryptedSymmetricKey(encryptedSymmetricKey)
console.info('[VaultE2EE] Admin: symmetric key generated')
// Admin: check if we already have a key (refresh/rejoin case)
const existingKey = data?.encrypted_symmetric_key
if (existingKey) {
// Decode base64 to ArrayBuffer
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)
console.info('[VaultE2EE] Admin: restored key from backend')
} else {
console.error('[VaultE2EE] Admin: no encrypted symmetric key found — was the room created with advanced mode?')
return
}
} else {
// Joiner: use the vault-wrapped key received from admin via lobby
const vaultKey = getEncryptedVaultKey()
@@ -264,9 +267,7 @@ export const Conference = ({
if (existingHash) {
adminPassphraseRef.current = existingHash
} else {
adminPassphraseRef.current = Array.from(crypto.getRandomValues(new Uint8Array(24)))
.map((b) => b.toString(36).padStart(2, '0'))
.join('')
adminPassphraseRef.current = generatePassphrase()
// Set the hash in the URL (without triggering navigation)
window.history.replaceState(
window.history.state,
@@ -33,6 +33,118 @@ 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 { useLoginHint } from '@/hooks/useLoginHint'
import { useUser } from '@/features/auth'
import { RiInformationLine, RiLockLine } from '@remixicon/react'
@@ -115,11 +227,16 @@ export const Join = ({
})
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 BASIC_KEY_LENGTH = 48
const hashKey = window.location.hash.slice(1)
const hasValidBasicKey = isBasicEncrypted ? (hashKey.length === BASIC_KEY_LENGTH && /^[a-z0-9]+$/.test(hashKey)) : true
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 isNameLocked = isEncryptedRoom && !!isLoggedIn
@@ -350,6 +467,7 @@ export const Join = ({
encryptionEnabled: isEncryptedRoom,
})
const [advancedOnboardingOpen, setAdvancedOnboardingOpen] = useState(false)
const { openLoginHint } = useLoginHint()
const handleSubmit = async () => {
@@ -449,6 +567,28 @@ 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 (advancedRequiresOnboarding || advancedOnboardingOpen) {
return (
<AdvancedOnboardingScreen
modalOpen={advancedOnboardingOpen}
onModalOpenChange={setAdvancedOnboardingOpen}
/>
)
}
if (isBasicEncrypted && !hasValidBasicKey) {
return (
<VStack alignItems="center" textAlign="center" gap="0.75rem">
@@ -69,21 +69,29 @@ export const useWaitingParticipants = () => {
let encryptedVaultKey = ''
if (isAdvancedMode && vaultClient && participant.suite_user_id) {
// Advanced mode: wrap the symmetric key for the joiner's vault public key
// Advanced mode: re-wrap the existing symmetric key for the joiner
try {
// Get the admin's own encrypted symmetric key from the room data
const adminKeyBase64 = roomData?.encrypted_symmetric_key
if (!adminKeyBase64) {
console.error('[VaultE2EE] Admin has no encrypted symmetric key')
return { encryptedKey, adminEphemeralPublicKey, encryptedVaultKey }
}
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) {
// Get admin's encrypted symmetric key from the VaultE2EEManager
// and re-wrap it for the joiner
const adminPubKey = (await vaultClient.getPublicKey()).publicKey
const { encryptedKeys } = await vaultClient.encryptWithoutKey(
new Uint8Array(32).buffer,
{ [participant.suite_user_id]: joinerPubKey, self: adminPubKey }
// 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) {
// Encode as base64 for transport via REST API
const bytes = new Uint8Array(joinerKey)
encryptedVaultKey = btoa(String.fromCharCode(...bytes))
}
@@ -5,18 +5,22 @@ import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { 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 hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
if (!hasScreenRecordingAccess) return null
// Recording not available in encrypted rooms
if (!hasScreenRecordingAccess || checkEncryptedRoom(roomData)) return null
return (
<MenuItem
@@ -5,17 +5,21 @@ import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { 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 hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
if (!hasTranscriptAccess) return null
// Recording/transcription not available in encrypted rooms
if (!hasTranscriptAccess || checkEncryptedRoom(roomData)) return null
return (
<MenuItem
@@ -4,21 +4,109 @@ 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, RiShieldCheckLine, RiAlertLine } from '@remixicon/react'
import {
RiCloseLine,
RiShieldCheckFill,
RiShieldCheckLine,
RiAlertLine,
RiErrorWarningLine,
} from '@remixicon/react'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { EncryptionTrustModal } from '@/features/encryption'
import { useState } from 'react'
import { FingerprintDialog } from '@/features/encryption'
import { useVaultClient } from '@/features/encryption'
import { useState, useEffect } from 'react'
type FingerprintBadgeStatus = 'loading' | 'trusted' | 'refused' | 'unknown' | 'no-key'
const EncryptionTrustIndicator = ({
isAuthenticated,
participantName,
participant,
}: {
isAuthenticated: boolean
participantName: string
participant: WaitingParticipant
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
const [isModalOpen, setIsModalOpen] = useState(false)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const { client: vaultClient } = useVaultClient()
const [fpStatus, setFpStatus] = useState<FingerprintBadgeStatus>('loading')
useEffect(() => {
if (!vaultClient || !participant.suite_user_id) {
setFpStatus(participant.is_authenticated ? 'loading' : 'no-key')
return
}
let cancelled = false
async function check() {
try {
const { publicKeys } = await vaultClient!.fetchPublicKeys([participant.suite_user_id!])
if (cancelled) return
if (!publicKeys[participant.suite_user_id!]) {
setFpStatus('no-key')
return
}
const { results } = await vaultClient!.checkFingerprints(
{ [participant.suite_user_id!]: '' }
)
if (cancelled) return
const result = results.find((r) => r.userId === participant.suite_user_id)
setFpStatus(result?.status ?? 'unknown')
} catch {
if (!cancelled) setFpStatus('no-key')
}
}
check()
return () => { cancelled = true }
}, [vaultClient, participant.suite_user_id, participant.is_authenticated])
const getBadge = () => {
switch (fpStatus) {
case 'trusted':
return {
icon: <RiShieldCheckFill size={16} color="#22c55e" />,
bg: '#f0fdf4',
tooltip: t('trust.verified'),
}
case 'refused':
return {
icon: <RiErrorWarningLine size={16} color="#ef4444" />,
bg: '#fef2f2',
tooltip: t('trust.refused'),
}
case 'unknown':
return {
icon: <RiShieldCheckLine size={16} color="#3b82f6" />,
bg: '#eff6ff',
tooltip: participant.is_authenticated
? t('trust.authenticated')
: t('trust.anonymous'),
}
case 'no-key':
return {
icon: <RiAlertLine size={16} color="#f59e0b" />,
bg: '#fffbeb',
tooltip: participant.is_authenticated
? t('trust.authenticated')
: t('trust.anonymous'),
}
default:
return {
icon: participant.is_authenticated
? <RiShieldCheckLine size={16} color="#3b82f6" />
: <RiAlertLine size={16} color="#f59e0b" />,
bg: participant.is_authenticated ? '#eff6ff' : '#fffbeb',
tooltip: participant.is_authenticated
? t('trust.authenticated')
: t('trust.anonymous'),
}
}
}
const badge = getBadge()
return (
<>
@@ -26,40 +114,28 @@ const EncryptionTrustIndicator = ({
variant="tertiaryText"
size="sm"
square
tooltip={
isAuthenticated
? t('trust.authenticated')
: t('trust.anonymous')
}
aria-label={
isAuthenticated
? t('trust.authenticated')
: t('trust.anonymous')
}
onPress={() => setIsModalOpen(true)}
tooltip={badge.tooltip}
aria-label={badge.tooltip}
onPress={() => setIsDialogOpen(true)}
className={css({
padding: '0.15rem !important',
minWidth: 'auto !important',
width: '1.5rem !important',
height: '1.5rem !important',
borderRadius: '50% !important',
backgroundColor: isAuthenticated
? '#eff6ff !important'
: '#fffbeb !important',
backgroundColor: `${badge.bg} !important`,
flexShrink: 0,
})}
>
{isAuthenticated ? (
<RiShieldCheckLine size={16} color="#3b82f6" />
) : (
<RiAlertLine size={16} color="#f59e0b" />
)}
{badge.icon}
</Button>
<EncryptionTrustModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
participantName={participantName}
isAuthenticated={isAuthenticated}
<FingerprintDialog
isOpen={isDialogOpen}
onOpenChange={setIsDialogOpen}
participantName={participant.username}
participantEmail={participant.email}
suiteUserId={participant.suite_user_id}
isAuthenticated={participant.is_authenticated}
/>
</>
)
@@ -96,10 +172,7 @@ export const WaitingParticipantListItem = ({
>
<Avatar name={participant.username} bgColor={participant.color} />
{encryptedRoom && (
<EncryptionTrustIndicator
isAuthenticated={participant.is_authenticated}
participantName={participant.username}
/>
<EncryptionTrustIndicator participant={participant} />
)}
<div
className={css({
@@ -7,7 +7,7 @@ import { getRouteUrl } from '@/navigation/getRouteUrl'
const COPY_SUCCESS_TIMEOUT = 3000
export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
export const useCopyRoomToClipboard = (room: ApiRoom | undefined, hashOverride?: string) => {
const telephony = useTelephony()
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
@@ -32,8 +32,12 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
}, [isRoomUrlCopied])
const roomUrl = useMemo(() => {
return room?.slug ? getRouteUrl('room', room.slug) : ''
}, [room?.slug])
if (!room?.slug) return ''
const base = getRouteUrl('room', room.slug)
// In basic encrypted mode, the passphrase is in the URL hash
const hash = hashOverride ? `#${hashOverride}` : window.location.hash
return hash ? `${base}${hash}` : base
}, [room?.slug, hashOverride])
const hasTelephonyInfo = useMemo(() => {
return telephony.enabled && room?.pin_code
@@ -15,6 +15,7 @@ import { PopupManager } from '../utils/PopupManager'
import { CallbackCreationRoomData } from '../utils/types'
import { useSearchParams } from 'wouter'
const popupManager = new PopupManager()
export const CreateMeetingButton = () => {
@@ -39,18 +40,24 @@ export const CreateMeetingButton = () => {
const { data } = useRoomCreationCallback({ callbackId })
const [basicHash, setBasicHash] = useState<string | undefined>(undefined)
const roomUrl = useMemo(() => {
if (room?.slug) return getRouteUrl('room', room.slug)
}, [room])
if (!room?.slug) return undefined
const base = getRouteUrl('room', room.slug)
return basicHash ? `${base}#${basicHash}` : base
}, [room, basicHash])
useEffect(() => {
if (!data?.room?.slug) return
setRoom(data.room)
setCallbackId(undefined)
setIsPending(false)
const url = getRouteUrl('room', data.room.slug)
popupManager.sendRoomData({
room: {
url: getRouteUrl('room', data.room.slug),
url,
...data.room,
},
})
@@ -61,6 +68,7 @@ export const CreateMeetingButton = () => {
(id) => setCallbackId(id),
(data) => {
setRoom(data)
if (data.hash) setBasicHash(data.hash)
setIsPending(false)
}
)
@@ -68,6 +76,18 @@ export const CreateMeetingButton = () => {
return () => popupManager.cleanup()
}, [])
// Communicate iframe height to parent for proper sizing
useEffect(() => {
const observer = new ResizeObserver(() => {
window.parent.postMessage(
{ type: 'RESIZE', data: { height: document.body.scrollHeight } },
'*'
)
})
observer.observe(document.body)
return () => observer.disconnect()
}, [])
const resetState = () => {
setRoom(undefined)
setCallbackId(undefined)
@@ -1,10 +1,21 @@
import { useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { generateRoomId, useCreateRoom } from '../../rooms'
import { useUser } from '@/features/auth'
import { Spinner } from '@/primitives/Spinner'
import { Button, Text } from '@/primitives'
import { VStack } from '@/styled-system/jsx'
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
import { PopupWindow } from '../utils/PopupWindow'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
import { useVaultClient } from '@/features/encryption'
import {
RiVideoOnLine,
RiLockLine,
RiShieldCheckLine,
} from '@remixicon/react'
const callbackIdHandler = new CallbackIdHandler()
const popupWindow = new PopupWindow()
@@ -12,53 +23,147 @@ const popupWindow = new PopupWindow()
export const CreatePopup = () => {
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
*
* When redirecting to authentication, the window.location change breaks the connection
* between this popup and its parent window. We need to send the callbackId to the parent
* before redirecting so it can re-establish connection after authentication completes.
* This prevents the popup from becoming orphaned and ensures state consistency.
*/
// 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) {
// redirection loses the connection to the manager
// prevent it passing an async callback id
popupWindow.sendCallbackId(callbackId, () => {
popupWindow.navigateToAuthentication()
popupWindow.navigateToAuthentication()
}
}, [isLoggedIn])
const handleCreate = useCallback(async (mode: ApiEncryptionMode) => {
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,
})
popupWindow.sendRoomData({ slug: roomData.slug, hash }, () => {
callbackIdHandler.clear()
popupWindow.close()
})
} catch (error) {
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,
})
}
}, [isLoggedIn, callbackId])
}, [showOnboarding, vaultClient])
/**
* Automatically create meeting room once user is authenticated
* This effect will trigger either immediately if the user is already logged in,
* or after successful authentication and return to this popup
*/
useEffect(() => {
const createMeetingRoom = async () => {
try {
const slug = generateRoomId()
const roomData = await createRoom({
slug,
callbackId,
})
// Send room data back to parent window and clean up resources
popupWindow.sendRoomData(roomData, () => {
callbackIdHandler.clear()
popupWindow.close()
})
} catch (error) {
console.error('Failed to create meeting room:', error)
}
const handleAdvancedClick = () => {
if (hasKeys) {
// Already onboarded, create directly
handleCreate(ApiEncryptionMode.ADVANCED)
} else if (vaultClient) {
// Need onboarding first
setShowOnboarding(true)
}
if (isLoggedIn && callbackId) {
createMeetingRoom()
}
}, [isLoggedIn, callbackId, createRoom])
}
if (!isLoggedIn || isCreating) {
return (
<div
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
width: '100%',
})}
>
<Spinner />
</div>
)
}
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
@@ -68,9 +173,61 @@ export const CreatePopup = () => {
alignItems: 'center',
height: '100%',
width: '100%',
padding: '2rem',
})}
>
<Spinner />
<VStack gap="0.75rem" alignItems="stretch" maxWidth="22rem" width="100%">
<Text
variant="sm"
bold
className={css({ textAlign: 'center', fontSize: '1.1rem', marginBottom: '0.5rem' })}
>
{t('title')}
</Text>
<Button
variant="primary"
fullWidth
onPress={() => handleCreate(ApiEncryptionMode.NONE)}
>
<RiVideoOnLine size={18} />
{t('standard')}
</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>
)
}
@@ -59,9 +59,11 @@ export class PopupManager {
case PopupMessageType.ROOM_DATA:
if (!data?.room) return
onRoomData(data.room)
const baseUrl = getRouteUrl('room', data.room.slug)
const roomUrl = data.room.hash ? `${baseUrl}#${data.room.hash}` : baseUrl
this.sendRoomData({
room: {
url: getRouteUrl('room', data.room.slug),
url: roomUrl,
...data.room,
},
})
@@ -27,7 +27,7 @@ export class PopupWindow {
public sendRoomData(data: CallbackCreationRoomData, callback?: () => void) {
this.sendMessageToManager(
PopupMessageType.ROOM_DATA,
{ room: { slug: data.slug } },
{ room: { slug: data.slug, hash: data.hash } },
callback
)
}
@@ -1,10 +1,12 @@
export type CallbackCreationRoomData = {
slug: string
hash?: string
}
export enum ClientMessageType {
ROOM_CREATED = 'ROOM_CREATED',
STATE_CLEAR = 'STATE_CLEAR',
RESIZE = 'RESIZE',
}
export interface PopupMessageData {
@@ -8,6 +8,8 @@ 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'>
@@ -16,7 +18,10 @@ 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 && !!isLoggedIn
const [name, setName] = useState(room?.localParticipant.name ?? '')
const userDisplay =
user?.full_name && user?.email
@@ -24,8 +29,10 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
: user?.email
const handleOnSubmit = () => {
if (room) room.localParticipant.setName(name)
saveUsername(name)
if (!isNameLocked) {
if (room) room.localParticipant.setName(name)
saveUsername(name)
}
if (onOpenChange) onOpenChange(false)
}
const handleOnCancel = () => {
@@ -40,6 +47,8 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
label={t('account.nameLabel')}
value={name}
onChange={setName}
isDisabled={isNameLocked}
description={isNameLocked ? t('account.nameLockedEncryption') : undefined}
validate={(value) => {
return !value ? <p>{t('account.nameError')}</p> : null
}}
+79 -25
View File
@@ -14,7 +14,7 @@ import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useVaultClient } from '@/features/encryption'
import { useConfig } from '@/api/useConfig'
import { useRef } from 'react'
import { useRef, useState } from 'react'
const Logo = () => (
<img
@@ -96,6 +96,7 @@ export const Header = () => {
const { data: config } = useConfig()
const { client: vaultClient, hasKeys } = useVaultClient()
const encryptionContainerRef = useRef<HTMLDivElement | null>(null)
const [showEncryptionModal, setShowEncryptionModal] = useState(false)
const isEncryptionAvailable = !!config?.encryption?.enabled && !!vaultClient
const userLabel = user?.full_name || user?.email
const loggedInTooltip = t('loggedInUserTooltip')
@@ -203,30 +204,8 @@ export const Header = () => {
if (value === 'logout') {
logout()
}
if (value === 'encryption' && vaultClient) {
// Create a container for the VaultClient interface
const container = document.createElement('div')
container.style.position = 'fixed'
container.style.inset = '0'
container.style.zIndex = '9999'
document.body.appendChild(container)
encryptionContainerRef.current = container
if (hasKeys) {
vaultClient.openSettings(container)
} else {
vaultClient.openOnboarding(container)
}
// Listen for close
vaultClient.on('interface:closed', () => {
if (encryptionContainerRef.current) {
document.body.removeChild(
encryptionContainerRef.current
)
encryptionContainerRef.current = null
}
})
if (value === 'encryption') {
setShowEncryptionModal(true)
}
}}
/>
@@ -237,6 +216,81 @@ 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={(el) => {
if (!el) return
// Clear previous content and re-inject
el.innerHTML = ''
encryptionContainerRef.current = el
if (hasKeys) {
vaultClient.openSettings(el)
} else {
vaultClient.openOnboarding(el)
}
const handleClosed = () => {
setShowEncryptionModal(false)
encryptionContainerRef.current = null
vaultClient.off('interface:closed', handleClosed)
}
vaultClient.on('interface:closed', handleClosed)
}}
className={css({ minHeight: '300px' })}
/>
</div>
</div>
)}
</>
)
}
+12 -1
View File
@@ -85,6 +85,15 @@
"invalidKey": {
"title": "Invalid meeting link",
"body": "This encrypted meeting requires a valid encryption key in the URL. Please ask the meeting organizer for the correct link."
},
"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"
}
},
"leaveRoomPrompt": "This will make you leave the meeting.",
@@ -578,7 +587,9 @@
"all": "Deny all"
},
"trust": {
"authenticated": "Authenticated identity (ProConnect). Encryption key will be exchanged securely.",
"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."
}
},
+8
View File
@@ -6,5 +6,13 @@
"resetLabel": "Reset",
"participantLimit": "Up to 150 participants.",
"popupBlocked": "Popup was blocked. Please allow popups for this site."
},
"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"
}
}
+2 -1
View File
@@ -5,7 +5,8 @@
"youAreNotLoggedIn": "You are not logged in.",
"nameLabel": "Your Name",
"authentication": "Authentication",
"nameError": "Your name cannot be empty"
"nameError": "Your name cannot be empty",
"nameLockedEncryption": "In encrypted meetings, your name comes from your account and cannot be changed."
},
"preferences": {
"title": "Preferences",
+13
View File
@@ -19,6 +19,19 @@
"encryptedInstantOption": "Démarrer une réunion chiffrée",
"encryptedLaterOption": "Créer une réunion chiffrée pour plus tard"
},
"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."
},
"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é."
}
},
"laterMeetingDialog": {
"heading": "Vos informations de connexion",
"description": "Partagez ces informations avec les invités. Ils pourront rejoindre la réunion sans avoir besoin de se connecter. Cette réunion est permanente et peut être réutilisée.",
+16 -1
View File
@@ -81,6 +81,19 @@
"timeoutInvite": {
"title": "Vous ne pouvez pas participer à cet appel",
"body": "Personne n'a répondu à votre demande de participation à l'appel"
},
"invalidKey": {
"title": "Lien de réunion invalide",
"body": "Cette réunion chiffrée nécessite une clé de chiffrement valide dans l'URL. Veuillez demander le lien correct à l'organisateur de la réunion."
},
"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"
}
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
@@ -574,7 +587,9 @@
"all": "Tout rejeter"
},
"trust": {
"authenticated": "Identité authentifiée (ProConnect). La clé de chiffrement sera échangée de manière sécurisée.",
"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."
}
},
+8
View File
@@ -6,5 +6,13 @@
"resetLabel": "Réinitialiser",
"participantLimit": "Jusqu'à 150 participants.",
"popupBlocked": "La fenêtre pop-up a été bloquée. Veuillez autoriser les pop-ups pour ce site."
},
"createPopup": {
"title": "Créer une réunion",
"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"
}
}
+2 -1
View File
@@ -5,7 +5,8 @@
"youAreNotLoggedIn": "Vous n'êtes pas connecté.",
"nameLabel": "Votre Nom",
"authentication": "Authentification",
"nameError": "Votre Nom ne peut pas être vide"
"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é."
},
"preferences": {
"title": "Préférences",