mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-07 01:13:21 +00:00
wip advanced
This commit is contained in:
+20
-3
@@ -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": [
|
||||
|
||||
@@ -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='')
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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
-1
@@ -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
-1
@@ -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
|
||||
|
||||
+109
-36
@@ -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
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user