This commit is contained in:
Thomas Ramé
2026-04-08 14:29:45 +02:00
parent 6410665c5d
commit b60193065c
23 changed files with 613 additions and 336 deletions
+6
View File
@@ -620,6 +620,12 @@ class RoomViewSet(
room = self.get_object()
if room.encryption_enabled:
return drf_response.Response(
{"error": "Transcription is not available in encrypted rooms."},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
SubtitleService().start_subtitle(room)
except SubtitleException:
+23 -3
View File
@@ -125,9 +125,29 @@ def generate_token(
"is_authenticated": "true" if not user.is_anonymous else "false",
}
# Add identity info for authenticated users (visible to other participants
# for identity verification in encrypted rooms)
if not user.is_anonymous:
# Add identity info for authenticated users in encrypted rooms only.
#
# Email and suite_user_id are included in the JWT attributes for encrypted
# rooms because:
# - Email: allows admins to verify participant identity in the lobby and
# participant list (important for trust decisions in encrypted meetings)
# - suite_user_id: required for vault key exchange in advanced encryption
# (vaultClient.shareKeys needs the recipient's user ID)
#
# These attributes are NOT included in non-encrypted rooms because:
# - Non-encrypted rooms have no waiting room, so anonymous users can join
# freely and would see everyone's email via LiveKit signaling
# - LiveKit JWT attributes are immutable and broadcast to ALL participants
# equally — there is no way to show them only to authenticated users
# at the protocol level
# - The frontend additionally hides email from anonymous users in the UI,
# but this is defense-in-depth, not the primary protection
#
# Future improvement: serve email via a Django API endpoint that checks
# the requester's authentication, removing it from the JWT entirely.
# This would require the backend to call LiveKit's ListParticipants API
# to cross-reference identities with the user database.
if not user.is_anonymous and encryption_mode != 'none':
if user.email:
attributes["email"] = user.email
if user.sub:
+2 -2
View File
@@ -57,7 +57,7 @@ export const Avatar = ({
style,
...props
}: AvatarProps) => {
const initial = name?.trim()?.charAt(0) ?? ''
const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? ''
return (
<div
style={{
@@ -70,7 +70,7 @@ export const Avatar = ({
<span
aria-hidden="true"
className={css({
marginTop: '-0.3rem',
lineHeight: 1,
})}
>
{initial}
@@ -1,16 +1,15 @@
/**
* Per-participant encryption trust badge.
*
* Single icon per trust level, consistent with lobby badges:
* Single icon per trust level:
* - "verified": Green shield — identity confirmed via PKI
* - "authenticated": Blue shield — ProConnect-authenticated, ephemeral key
* - "anonymous": Orange warning — identity not verified, ephemeral key
* - default (no trust level): Lock icon — encrypted, trust level unknown
* - "authenticated": Blue shield — ProConnect-authenticated
* - "anonymous": Orange warning — identity not verified
* - default: Lock icon — encrypted, trust level unknown
*/
import { RiShieldCheckFill, RiAlertFill, RiLockFill } from '@remixicon/react'
import { RiShieldCheckFill, RiErrorWarningFill, RiLockFill } from '@remixicon/react'
import type { TrustLevel } from './types'
import { css } from '@/styled-system/css'
import { TooltipWrapper } from '@/primitives/TooltipWrapper'
import { useTranslation } from 'react-i18next'
interface EncryptionBadgeProps {
@@ -27,42 +26,38 @@ export function EncryptionBadge({
if (!isEncrypted) return null
let icon: React.ReactNode
let tooltip: string
let label: string
switch (trustLevel) {
case 'verified':
icon = <RiShieldCheckFill size={14} color="#22c55e" />
tooltip = t('verified')
label = t('verified')
break
case 'authenticated':
icon = <RiShieldCheckFill size={14} color="#3b82f6" />
tooltip = t('authenticated')
label = t('authenticated')
break
case 'anonymous':
icon = <RiAlertFill size={14} color="#f59e0b" />
tooltip = t('anonymous')
icon = <RiErrorWarningFill size={15} color="#d97706" />
label = t('anonymous')
break
default:
icon = <RiLockFill size={14} />
tooltip = t('default')
label = t('default')
break
}
return (
<TooltipWrapper tooltip={tooltip} tooltipType="instant">
<span
role="img"
aria-label={tooltip}
tabIndex={0}
className={css({
display: 'inline-flex',
alignItems: 'center',
marginRight: '0.25rem',
cursor: 'default',
})}
>
{icon}
</span>
</TooltipWrapper>
<span
aria-label={label}
className={css({
display: 'inline-flex',
alignItems: 'center',
marginRight: '0.15rem',
cursor: 'inherit',
})}
>
{icon}
</span>
)
}
@@ -8,6 +8,7 @@
import { css } from '@/styled-system/css'
import { VStack, HStack } from '@/styled-system/jsx'
import { Dialog, Text, Button } from '@/primitives'
import { Avatar } from '@/components/Avatar'
import {
RiShieldCheckFill,
RiShieldCheckLine,
@@ -26,6 +27,7 @@ interface FingerprintDialogProps {
participantEmail?: string
suiteUserId?: string
isAuthenticated: boolean
encryptionMode?: 'basic' | 'advanced' | 'none'
}
type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error'
@@ -37,15 +39,28 @@ export function FingerprintDialog({
participantEmail,
suiteUserId,
isAuthenticated,
encryptionMode,
}: FingerprintDialogProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' })
const { client: vaultClient } = useVaultClient()
const [status, setStatus] = useState<FingerprintStatus>('loading')
const [fingerprint, setFingerprint] = useState<string | null>(null)
const isBasicMode = encryptionMode !== 'advanced'
useEffect(() => {
if (!isOpen || !vaultClient || !suiteUserId) {
setStatus(isAuthenticated ? 'loading' : 'no-key')
if (!isOpen) return
// In basic mode, no fingerprint check — identity is from ProConnect only
if (isBasicMode) {
setStatus(isAuthenticated ? 'no-key' : 'no-key')
return
}
if (!vaultClient) {
setStatus('error')
return
}
if (!suiteUserId) {
setStatus(isAuthenticated ? 'no-key' : 'no-key')
return
}
@@ -129,20 +144,45 @@ export function FingerprintDialog({
alignItems="start"
className={css({ maxWidth: '22rem' })}
>
<HStack gap="0.5rem">
<Text className={css({ fontWeight: 600 })}>{participantName}</Text>
{participantEmail && (
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{participantEmail}
<HStack gap="0.65rem" className={css({ width: '100%' })}>
<div className={css({ flexShrink: 0, transform: 'scale(0.85)' })}>
<Avatar name={participantName} bgColor="rgb(87, 44, 216)" />
</div>
<VStack gap="0" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.9rem' })}>{participantName}</Text>
<Text variant="note" className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}>
{participantEmail || t('anonymous')}
</Text>
)}
</VStack>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('description')}
</Text>
{status === 'loading' && (
<Text variant="note">{t('loading')}</Text>
)}
{status === 'no-key' && (
{status === 'no-key' && isBasicMode && isAuthenticated && (
<HStack
gap="0.5rem"
className={css({
backgroundColor: '#eff6ff',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #bfdbfe',
})}
>
<RiShieldCheckLine size={20} color="#3b82f6" className={css({ flexShrink: 0 })} />
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('noKeyBasicAuthenticated')}
</Text>
</HStack>
)}
{status === 'no-key' && !(isBasicMode && isAuthenticated) && (
<HStack
gap="0.5rem"
className={css({
@@ -155,7 +195,7 @@ export function FingerprintDialog({
>
<RiAlertLine size={20} color="#f59e0b" className={css({ flexShrink: 0 })} />
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('noKey')}
{isAuthenticated ? t('noKey') : t('noKeyAnonymous')}
</Text>
</HStack>
)}
@@ -188,21 +228,31 @@ export function FingerprintDialog({
</VStack>
{status === 'trusted' && (
<HStack gap="0.5rem" className={css({ color: '#22c55e' })}>
<RiShieldCheckFill size={18} />
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
{t('trusted')}
<VStack gap="0.25rem" alignItems="start">
<HStack gap="0.5rem" className={css({ color: '#22c55e' })}>
<RiShieldCheckFill size={18} />
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
{t('trusted')}
</Text>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('trustedDescription')}
</Text>
</HStack>
</VStack>
)}
{status === 'refused' && (
<HStack gap="0.5rem" className={css({ color: '#ef4444' })}>
<RiCloseLine size={18} />
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
{t('refused')}
<VStack gap="0.25rem" alignItems="start">
<HStack gap="0.5rem" className={css({ color: '#ef4444' })}>
<RiCloseLine size={18} />
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
{t('refused')}
</Text>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('refusedDescription')}
</Text>
</HStack>
</VStack>
)}
{status === 'unknown' && (
@@ -210,6 +260,9 @@ export function FingerprintDialog({
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('unknownDescription')}
</Text>
<Text variant="note" className={css({ fontSize: '0.75rem', fontStyle: 'italic' })}>
{t('fingerprintHint')}
</Text>
<HStack gap="0.5rem">
<Button size="sm" variant="primary" onPress={handleAccept}>
<RiCheckLine size={16} />
@@ -39,17 +39,27 @@ export function determineTrustLevel(
* and cannot be spoofed by clients. It indicates whether the participant
* authenticated via OIDC (ProConnect/Keycloak).
*
* TODO: Once VaultClient integration is complete, also check for registered
* public keys to distinguish "verified" (PKI) from "authenticated" (ephemeral).
* In basic encryption mode, the "verified" level is never returned because
* PKI keys are not used — encryption relies on a shared passphrase, not on
* per-user public keys. The green shield would be misleading.
*
* In advanced encryption mode, "verified" means the participant has completed
* encryption onboarding and their public key is used to encrypt the symmetric key.
*/
export function getTrustLevelFromAttributes(
attributes: Record<string, string> | undefined
attributes: Record<string, string> | undefined,
encryptionMode?: 'basic' | 'advanced' | 'none',
): TrustLevel | null {
if (!attributes) return null
// Check for explicit trust level (set by future PKI integration)
const isAdvanced = encryptionMode === 'advanced'
// Check for explicit trust level (set by PKI integration)
const explicitLevel = attributes[PARTICIPANT_TRUST_ATTR]
if (explicitLevel === 'verified' || explicitLevel === 'authenticated' || explicitLevel === 'anonymous') {
if (explicitLevel === 'verified' && isAdvanced) {
return 'verified'
}
if (explicitLevel === 'authenticated' || explicitLevel === 'anonymous') {
return explicitLevel
}
@@ -15,8 +15,9 @@ export const EncryptionModeDialog = ({
isForLater?: boolean
} & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'encryptionModeDialog' })
const { hasKeys } = useVaultClient()
const canUseAdvanced = !!hasKeys
const { hasKeys, client: vaultClient, error: vaultError, isLoading: vaultLoading } = useVaultClient()
const vaultUnavailable = !vaultClient && !vaultLoading
const canUseAdvanced = !!hasKeys && !vaultUnavailable
return (
<Dialog title={t('title')} isOpen {...dialogProps}>
@@ -118,17 +119,19 @@ export const EncryptionModeDialog = ({
className={css({
marginTop: '0.5rem',
padding: '0.5rem 0.75rem',
backgroundColor: 'orange.50',
backgroundColor: vaultUnavailable ? 'red.50' : 'orange.50',
borderRadius: '0.375rem',
})}
>
<RiAlertLine
size={14}
color="#d97706"
color={vaultUnavailable ? '#dc2626' : '#d97706'}
className={css({ flexShrink: 0 })}
/>
<Text variant="note" className={css({ color: 'orange.800' })}>
{t('advanced.onboardingRequired')}
<Text variant="note" className={css({ color: vaultUnavailable ? 'red.800' : 'orange.800' })}>
{vaultUnavailable
? t('advanced.serviceUnavailable')
: t('advanced.onboardingRequired')}
</Text>
</HStack>
)}
@@ -7,7 +7,7 @@ import { Screen } from '@/layout/Screen'
import { generateRoomId, useCreateRoom } from '@/features/rooms'
import { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { RiAddLine, RiLink, RiLockLine } from '@remixicon/react'
import { RiAddLine, RiLink, RiLockLine, RiShieldKeyholeLine } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { EncryptionModeDialog } from '@/features/home/components/EncryptionModeDialog'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
@@ -269,7 +269,7 @@ export const Home = () => {
onAction={() => setEncryptionDialogMode('later')}
data-attr="create-option-encrypted-later"
>
<RiLockLine size={18} />
<RiShieldKeyholeLine size={18} />
{t('createMenu.encryptedLaterOption')}
</MenuItem>
</>
@@ -20,6 +20,10 @@ import { useVaultClient } from '@/features/encryption'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { RiLockLine } from '@remixicon/react'
import { Center } from '@/styled-system/jsx'
import { Text } from '@/primitives'
import { QueryAware } from '@/components/QueryAware'
import { ErrorScreen } from '@/components/ErrorScreen'
import { fetchRoom } from '../api/fetchRoom'
@@ -92,7 +96,7 @@ export const Conference = ({
})
const encryptionEnabled = isEncryptedRoom(data)
const { client: vaultClient, hasKeys: vaultHasKeys } = useVaultClient()
const { client: vaultClient, hasKeys: vaultHasKeys, error: vaultError, isLoading: vaultLoading } = useVaultClient()
// Determine which E2EE backend to use based solely on the room's encryption_mode.
// Advanced mode always uses VaultClient, basic mode always uses LiveKit Worker+KeyProvider.
@@ -356,6 +360,67 @@ export const Conference = ({
)
}
// Block entry to advanced encrypted rooms when vault service is unavailable
if (useVaultE2EE && !vaultLoading && !vaultClient) {
return (
<Screen layout="centered">
<CenteredContent withBackButton>
<Center>
<div
className={css({
maxWidth: '400px',
backgroundColor: 'white',
borderRadius: '1rem',
padding: '2rem',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.08)',
border: '1px solid',
borderColor: 'greyscale.200',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1rem',
textAlign: 'center',
})}
>
<div
className={css({
width: '3.5rem',
height: '3.5rem',
borderRadius: '50%',
backgroundColor: '#fef2f2',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<RiLockLine size={28} color="#dc2626" />
</div>
<Text as="h2" className={css({ fontWeight: 700, fontSize: '1.15rem' })}>
{t('encryption.error.title')}
</Text>
<Text as="p" className={css({ fontSize: '0.9rem', color: 'greyscale.700' })}>
{t('encryption.error.vaultUnavailable')}
</Text>
<div
className={css({
backgroundColor: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
width: '100%',
})}
>
<Text as="p" className={css({ fontSize: '0.8rem', color: '#92400e' })}>
{t('encryption.error.vaultUnavailableHint')}
</Text>
</div>
</div>
</Center>
</CenteredContent>
</Screen>
)
}
// Some clients (like DINUM) operate in bandwidth-constrained environments
// These settings help ensure successful connections in poor network conditions
const connectOptions = {
@@ -43,7 +43,11 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const roomData = useRoomData()
const roomUrl = getRouteUrl('room', roomData?.slug)
const baseRoomUrl = getRouteUrl('room', roomData?.slug)
// Include the hash (passphrase) for basic encrypted rooms so the full link is visible
const roomUrl = window.location.hash
? `${baseRoomUrl}${window.location.hash}`
: baseRoomUrl
const telephony = useTelephony()
@@ -14,7 +14,10 @@ export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
const baseRoomUrl = getRouteUrl('room', data?.slug)
const roomUrl = window.location.hash
? `${baseRoomUrl}${window.location.hash}`
: baseRoomUrl
const telephony = useTelephony()
@@ -50,7 +53,14 @@ export const Info = () => {
flexDirection: 'column',
})}
>
<Text as="p" variant="xsNote" wrap="pretty">
<Text
as="p"
variant="xsNote"
className={css({
wordBreak: 'break-all',
whiteSpace: 'normal',
})}
>
{roomUrl.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && (
@@ -27,11 +27,15 @@ import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import {
EncryptionBadge,
getTrustLevelFromAttributes,
FingerprintDialog,
} from '@/features/encryption'
import { useRoomData } from '../hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { RiLockFill } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
@@ -89,6 +93,12 @@ export const ParticipantTile: (
const isEncrypted = useIsEncrypted(trackReference.participant)
const roomData = useRoomData()
const isEncryptedRoom = checkEncryptedRoom(roomData)
const isAdmin = useIsAdminOrOwner()
const [isFingerprintOpen, setIsFingerprintOpen] = React.useState(false)
const participantAttrs = trackReference.participant.attributes as Record<string, string> | undefined
const trustLevel = getTrustLevelFromAttributes(participantAttrs, roomData?.encryption_mode)
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const badgeTooltip = trustLevel ? tBadge(trustLevel) : undefined
// Track decryption failures via EncryptionError events from LiveKit.
// useIsEncrypted returns true when E2EE is enabled, NOT when frames decrypt successfully.
@@ -210,14 +220,15 @@ export const ParticipantTile: (
</div>
{showDecryptionError && !isScreenShare && (
<div
style={{
className={css({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 5,
}}
zIndex: '0 !important',
pointerEvents: 'none',
})}
>
<ParticipantPlaceholder
participant={trackReference.participant}
@@ -320,22 +331,59 @@ export const ParticipantTile: (
}}
/>
)}
{(isEncrypted || isEncryptedRoom) && !isScreenShare && (
<EncryptionBadge
isEncrypted={true}
trustLevel={getTrustLevelFromAttributes(
trackReference.participant.attributes as
| Record<string, string>
| undefined
{isEncryptedRoom && isAdmin && !isScreenShare ? (
<Button
variant="greyscale"
size="sm"
tooltip={badgeTooltip}
aria-label={badgeTooltip}
onPress={() => setIsFingerprintOpen(true)}
className={css({
display: 'inline-flex !important',
alignItems: 'center !important',
gap: '0.15rem !important',
padding: '0.1rem 0.15rem !important',
minWidth: 'auto !important',
height: 'auto !important',
position: 'relative',
zIndex: 10,
borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important',
color: 'inherit !important',
'&[data-hovered]': {
backgroundColor: 'rgba(255, 255, 255, 0.15) !important',
},
})}
>
{(isEncrypted || isEncryptedRoom) && (
<EncryptionBadge
isEncrypted={true}
trustLevel={trustLevel}
/>
)}
/>
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</Button>
) : (
<>
{(isEncrypted || isEncryptedRoom) && !isScreenShare && (
<EncryptionBadge
isEncrypted={true}
trustLevel={getTrustLevelFromAttributes(participantAttrs, roomData?.encryption_mode)}
/>
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</>
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</div>
</HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
@@ -358,6 +406,17 @@ export const ParticipantTile: (
),
})}
</KeyboardShortcutHint>
{isEncryptedRoom && isAdmin && (
<FingerprintDialog
isOpen={isFingerprintOpen}
onOpenChange={setIsFingerprintOpen}
participantName={trackReference.participant.name || trackReference.participant.identity}
participantEmail={participantAttrs?.email}
suiteUserId={participantAttrs?.suite_user_id}
isAuthenticated={participantAttrs?.is_authenticated === 'true'}
encryptionMode={roomData?.encryption_mode}
/>
)}
</div>
)
})
@@ -12,12 +12,16 @@ import {
ScreenRecordingSidePanel,
} from '@/features/recording'
import { useConfig } from '@/api/useConfig'
import { useRoomData } from '../hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { RiLockLine } from '@remixicon/react'
export interface ToolsButtonProps {
icon: ReactNode
title: string
description: string
onPress: () => void
isDisabled?: boolean
}
const ToolButton = ({
@@ -25,9 +29,11 @@ const ToolButton = ({
title,
description,
onPress,
isDisabled,
}: ToolsButtonProps) => {
return (
<RACButton
isDisabled={isDisabled}
className={css({
display: 'flex',
flexDirection: 'row',
@@ -39,10 +45,14 @@ const ToolButton = ({
width: 'full',
backgroundColor: 'gray.50',
textAlign: 'start',
'&[data-hovered]': {
'&[data-hovered]:not([data-disabled])': {
backgroundColor: 'primary.50',
cursor: 'pointer',
},
'&[data-disabled]': {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
onPress={onPress}
>
@@ -132,6 +142,9 @@ export const Tools = () => {
break
}
const roomData = useRoomData()
const encrypted = isEncryptedRoom(roomData)
return (
<Div
display="flex"
@@ -166,12 +179,33 @@ export const Tools = () => {
</A>
)}
</Text>
{encrypted && (
<div
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'start',
padding: '0.6rem 0.75rem',
backgroundColor: '#fffbeb',
borderRadius: '0.5rem',
border: '1px solid #fde68a',
marginBottom: '0.5rem',
width: '100%',
})}
>
<RiLockLine size={16} color="#d97706" className={css({ flexShrink: 0, marginTop: '0.1rem' })} />
<Text variant="note" className={css({ fontSize: '0.8rem', color: '#92400e' })}>
{t('encryptedDisabled')}
</Text>
</div>
)}
{isTranscriptEnabled && (
<ToolButton
icon={<Icon type="symbols" name="speech_to_text" />}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
isDisabled={encrypted}
/>
)}
{isScreenRecordingEnabled && (
@@ -180,6 +214,7 @@ export const Tools = () => {
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
isDisabled={encrypted}
/>
)}
</Div>
@@ -25,6 +25,8 @@ import { EncryptionBadge, getTrustLevelFromAttributes, FingerprintDialog } from
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as isEncryptedRoomFn } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useUser } from '@/features/auth'
import { TooltipWrapper } from '@/primitives/TooltipWrapper'
type MicIndicatorProps = {
participant: Participant
@@ -104,9 +106,13 @@ export const ParticipantListItem = ({
const roomData = useRoomData()
const isEncryptedRoom = isEncryptedRoomFn(roomData)
const isAdmin = useIsAdminOrOwner()
const { isLoggedIn } = useUser()
const { t: tEncBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const [isFingerprintOpen, setIsFingerprintOpen] = useState(false)
const name = participant.name || participant.identity
const attrs = participant.attributes as Record<string, string> | undefined
const trustLevel = getTrustLevelFromAttributes(attrs, roomData?.encryption_mode)
const badgeTooltip = trustLevel ? tEncBadge(trustLevel) : undefined
return (
<HStack
role="listitem"
@@ -127,69 +133,111 @@ export const ParticipantListItem = ({
<PinBadge participant={participant} />
</div>
<VStack gap={0} alignItems="start">
<Text
variant="sm"
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
alignItems: 'center',
})}
>
{isEncryptedRoom && (
<span
onClick={isAdmin ? () => setIsFingerprintOpen(true) : undefined}
{isEncryptedRoom ? (
<Button
variant="greyscale"
size="sm"
tooltip={badgeTooltip}
aria-label={badgeTooltip}
onPress={isAdmin ? () => setIsFingerprintOpen(true) : undefined}
className={css({
padding: '0.1rem 0.25rem !important',
minWidth: 'auto !important',
height: 'auto !important',
gap: '0.15rem !important',
borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important',
color: 'greyscale.900 !important',
cursor: isAdmin ? 'pointer' : 'default',
'&[data-hovered]': {
backgroundColor: isAdmin ? 'greyscale.100 !important' : 'transparent !important',
},
})}
>
<EncryptionBadge
isEncrypted={true}
trustLevel={trustLevel}
/>
<Text
variant="sm"
className={css({
cursor: isAdmin ? 'pointer' : 'default',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
})}
>
<EncryptionBadge
isEncrypted={true}
trustLevel={getTrustLevelFromAttributes(attrs)}
/>
</span>
)}
<span
{name}
</Text>
{isLocal(participant) && (
<Text
variant="sm"
className={css({ whiteSpace: 'nowrap', flexShrink: 0 })}
>
({t('participants.you')})
</Text>
)}
</Button>
) : (
<Text
variant="sm"
className={css({
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
maxWidth: '150px',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text>
{isLocal(participant) && ` (${t('participants.you')})`}
</Text>
)}
{getParticipantIsRoomAdmin(participant) && (
<Text variant="xsNote">{t('participants.host')}</Text>
)}
{isEncryptedRoom &&
participant.attributes?.is_authenticated === 'true' &&
participant.attributes?.email && (
<Text
variant="xsNote"
{/* Email is only in JWT for encrypted rooms (backend restriction).
Additionally, only show to authenticated users in the UI — anonymous
users in encrypted rooms could still extract it from LiveKit signaling
but won't see it in the interface. See utils.py for details. */}
{isEncryptedRoom && isLoggedIn && (() => {
const email = participant.attributes?.is_authenticated === 'true' && participant.attributes?.email
? participant.attributes.email
: null
const label = email || t('participants.anonymous')
return (
<Button
variant="greyscale"
size="sm"
tooltip={email || undefined}
aria-label={label}
className={css({
color: 'greyscale.500',
maxWidth: '120px',
padding: '0 !important',
minWidth: 'auto !important',
height: 'auto !important',
backgroundColor: 'transparent !important',
color: 'greyscale.500 !important',
fontSize: '0.7rem !important',
fontWeight: 'normal !important',
width: '100%',
minW: 0,
justifyContent: 'flex-start !important',
'&[data-hovered]': {
backgroundColor: 'transparent !important',
},
})}
>
<span className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
>
{participant.attributes.email}
</Text>
)}
minWidth: 0,
})}>
{label}
</span>
</Button>
)
})()}
</VStack>
</HStack>
<HStack>
@@ -204,6 +252,7 @@ export const ParticipantListItem = ({
participantEmail={attrs?.email}
suiteUserId={attrs?.suite_user_id}
isAuthenticated={attrs?.is_authenticated === 'true'}
encryptionMode={roomData?.encryption_mode}
/>
)}
</HStack>
@@ -1,144 +1,24 @@
import { Button, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { HStack, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Avatar } from '@/components/Avatar'
import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import {
RiCloseLine,
RiShieldCheckFill,
RiShieldCheckLine,
RiAlertLine,
RiErrorWarningLine,
} from '@remixicon/react'
import { RiCloseLine } from '@remixicon/react'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { FingerprintDialog } from '@/features/encryption'
import { useVaultClient } from '@/features/encryption'
import { useState, useEffect } from 'react'
import { EncryptionBadge, getTrustLevelFromAttributes, FingerprintDialog } from '@/features/encryption'
import type { TrustLevel } from '@/features/encryption/types'
import { useState } from 'react'
type FingerprintBadgeStatus = 'loading' | 'trusted' | 'refused' | 'unknown' | 'no-key'
const EncryptionTrustIndicator = ({
participant,
}: {
participant: WaitingParticipant
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
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 (
<>
<Button
variant="tertiaryText"
size="sm"
square
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: `${badge.bg} !important`,
flexShrink: 0,
})}
>
{badge.icon}
</Button>
<FingerprintDialog
isOpen={isDialogOpen}
onOpenChange={setIsDialogOpen}
participantName={participant.username}
participantEmail={participant.email}
suiteUserId={participant.suite_user_id}
isAuthenticated={participant.is_authenticated}
/>
</>
)
function waitingParticipantTrustLevel(
participant: WaitingParticipant,
encryptionMode?: string,
): TrustLevel {
// In basic mode, no PKI — only authenticated or anonymous
// In advanced mode, would check vault keys (but waiting participants haven't joined yet)
if (participant.is_authenticated) return 'authenticated'
return 'anonymous'
}
export const WaitingParticipantListItem = ({
@@ -151,6 +31,10 @@ export const WaitingParticipantListItem = ({
const { t } = useTranslation('rooms')
const roomData = useRoomData()
const encryptedRoom = isEncryptedRoom(roomData)
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const [isDialogOpen, setIsDialogOpen] = useState(false)
const trustLevel = waitingParticipantTrustLevel(participant, roomData?.encryption_mode)
const badgeTooltip = encryptedRoom ? tBadge(trustLevel) : undefined
return (
<HStack
@@ -171,58 +55,101 @@ export const WaitingParticipantListItem = ({
})}
>
<Avatar name={participant.username} bgColor={participant.color} />
{encryptedRoom && (
<EncryptionTrustIndicator participant={participant} />
)}
<div
className={css({
display: 'flex',
flexDirection: 'column',
flex: '1',
minWidth: '0',
})}
>
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
<VStack gap={0} alignItems="start" className={css({ flex: 1, minWidth: 0 })}>
{encryptedRoom ? (
<Button
variant="greyscale"
size="sm"
tooltip={badgeTooltip}
aria-label={badgeTooltip}
onPress={() => setIsDialogOpen(true)}
className={css({
padding: '0.1rem 0.25rem !important',
minWidth: 'auto !important',
height: 'auto !important',
gap: '0.15rem !important',
borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important',
color: 'greyscale.900 !important',
cursor: 'pointer',
'&[data-hovered]': {
backgroundColor: 'greyscale.100 !important',
},
})}
>
<EncryptionBadge
isEncrypted={true}
trustLevel={trustLevel}
/>
<Text
variant="sm"
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
})}
>
{participant.username}
</Text>
</Button>
) : (
<Text
variant="sm"
className={css({
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
display: 'block',
padding: '0.1rem 0.25rem',
})}
>
{participant.username}
</span>
</Text>
{encryptedRoom && participant.email && (
<Text
variant={'sm'}
className={css({
fontSize: '0.7rem',
color: 'greyscale.500',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
})}
>
{participant.email}
</Text>
)}
</div>
{encryptedRoom && (() => {
const email = participant.is_authenticated && participant.email
? participant.email
: null
const label = email || t('participants.anonymous')
return (
<Button
variant="greyscale"
size="sm"
tooltip={email || undefined}
aria-label={label}
className={css({
padding: '0 0.25rem !important',
minWidth: 'auto !important',
height: 'auto !important',
backgroundColor: 'transparent !important',
color: 'greyscale.500 !important',
fontSize: '0.7rem !important',
fontWeight: 'normal !important',
width: '100%',
minW: 0,
justifyContent: 'flex-start !important',
'&[data-hovered]': {
backgroundColor: 'transparent !important',
},
})}
>
<span className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
})}>
{label}
</span>
</Button>
)
})()}
</VStack>
</HStack>
<HStack
gap="0.25rem"
className={css({
flexShrink: '0',
})}
className={css({ flexShrink: '0' })}
>
<Button
size="sm"
@@ -245,6 +172,17 @@ export const WaitingParticipantListItem = ({
<RiCloseLine />
</Button>
</HStack>
{encryptedRoom && (
<FingerprintDialog
isOpen={isDialogOpen}
onOpenChange={setIsDialogOpen}
participantName={participant.username}
participantEmail={participant.email}
suiteUserId={participant.suite_user_id}
isAuthenticated={participant.is_authenticated}
encryptionMode={roomData?.encryption_mode}
/>
)}
</HStack>
)
}
@@ -42,17 +42,20 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('account.heading')}</H>
<Field
type="text"
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
}}
/>
<div className={isNameLocked ? css({ opacity: 0.5, '& input': { cursor: 'not-allowed' } }) : undefined}>
<Field
type="text"
label={t('account.nameLabel')}
value={name}
onChange={setName}
isDisabled={isNameLocked}
isReadOnly={isNameLocked}
description={isNameLocked ? t('account.nameLockedEncryption') : undefined}
validate={(value) => {
return !value ? <p>{t('account.nameError')}</p> : null
}}
/>
</div>
<H lvl={2}>{t('account.authentication')}</H>
{isLoggedIn ? (
<>
+8 -6
View File
@@ -135,7 +135,8 @@ export const Header = () => {
vaultInjectedRef.current = false
}
}, [showEncryptionModal])
const isEncryptionAvailable = !!config?.encryption?.enabled && !!vaultClient
const isEncryptionEnabled = !!config?.encryption?.enabled
const isEncryptionAvailable = isEncryptionEnabled && !!vaultClient
const userLabel = user?.full_name || user?.short_name || user?.email
const loggedInTooltip = t('loggedInUserTooltip')
const loggedInAriaLabel = userLabel
@@ -226,13 +227,14 @@ export const Header = () => {
<MenuList
variant={'light'}
items={[
...(isEncryptionAvailable
...(isEncryptionEnabled
? [
{
value: 'encryption',
label: hasKeys
? t('encryptionSettings')
: t('encryptionSetup'),
label: isEncryptionAvailable
? (hasKeys ? t('encryptionSettings') : t('encryptionSetup'))
: t('encryptionUnavailable'),
isDisabled: !isEncryptionAvailable,
},
]
: []),
@@ -242,7 +244,7 @@ export const Header = () => {
if (value === 'logout') {
logout()
}
if (value === 'encryption') {
if (value === 'encryption' && isEncryptionAvailable) {
setShowEncryptionModal(true)
}
}}
+1
View File
@@ -23,6 +23,7 @@
"logout": "Logout",
"encryptionSetup": "Set up encryption",
"encryptionSettings": "Encryption settings",
"encryptionUnavailable": "Encryption (unavailable)",
"notFound": {
"heading": "Verify your meeting code",
"body": "Check that you have entered the correct meeting code in the URL. Example:"
+2 -1
View File
@@ -36,7 +36,8 @@
"advanced": {
"title": "Advanced encryption",
"description": "Maximum security — the encryption key never leaves your browser. Requires encryption onboarding for all participants.",
"onboardingRequired": "You must complete the encryption setup in your account settings before using advanced encryption."
"onboardingRequired": "You must complete the encryption setup in your account settings before using advanced encryption.",
"serviceUnavailable": "Advanced encryption is currently unavailable. Please try again later or contact your administrator."
}
},
"laterMeetingDialog": {
+16 -5
View File
@@ -363,6 +363,7 @@
},
"moreTools": {
"body": "Access more tools to enhance your meetings.",
"encryptedDisabled": "Transcription and recording are not available in encrypted meetings. The server cannot access the media content.",
"linkAriaLabel": "Open documentation about tools - opens in new window",
"moreLink": "Open documentation",
"tools": {
@@ -556,6 +557,7 @@
"subheading": "In room",
"you": "You",
"unknown": "Unknown participant",
"anonymous": "Unverified identity",
"host": "Host",
"contributors": "Contributors",
"collapsable": {
@@ -724,19 +726,28 @@
"hint": "The encrypted data could not be decoded. Try leaving and rejoining the meeting, or ask the host to restart the call.",
"timeout": "Key exchange timed out",
"timeoutHint": "The encryption key could not be received from the host. They may not be in the meeting yet.",
"refresh": "Retry"
"refresh": "Retry",
"vaultUnavailable": "This meeting requires advanced encryption, which is currently unavailable.",
"vaultUnavailableHint": "This may be a temporary issue. Try refreshing the page. If the problem persists, the encryption service may be undergoing maintenance — please try again later."
},
"fingerprint": {
"title": "Encryption identity",
"description": "This shows the encryption identity of this participant. It allows you to verify they are who they claim to be.",
"loading": "Checking encryption keys...",
"noKey": "This participant has not set up encryption keys. Their identity is verified by the authentication server only, not by a public key.",
"error": "Unable to check encryption keys.",
"noKey": "This participant has not completed encryption onboarding. Their identity is verified by the authentication server (ProConnect) only — not by a cryptographic public key. They can set up encryption in their account settings.",
"noKeyBasicAuthenticated": "This participant is signed in via ProConnect. Their name and email are provided by the authentication server and cannot be falsified.",
"noKeyAnonymous": "This participant joined without signing in. Their identity is self-declared and not verified. Only accept anonymous participants if you can confirm their identity through another channel.",
"error": "Unable to check encryption keys. The encryption service may be unavailable.",
"fingerprintLabel": "Public key fingerprint",
"fingerprintHint": "Ask this participant to open their encryption settings and compare the fingerprint shown there with the one above.",
"trusted": "Fingerprint verified and trusted",
"trustedDescription": "You have previously verified this participant's encryption fingerprint. Their identity is cryptographically confirmed.",
"refused": "Fingerprint refused",
"unknownDescription": "This fingerprint has not been verified yet. Compare it with the participant to ensure their identity.",
"refusedDescription": "You previously refused this fingerprint. This person's identity could not be verified.",
"unknownDescription": "This fingerprint has not been verified yet. Compare it with the participant (via phone, in person, or another secure channel) to confirm their identity.",
"accept": "Trust",
"refuse": "Refuse"
"refuse": "Refuse",
"anonymous": "Unverified identity"
},
"trustModal": {
"title": "Encryption trust level",
+2 -1
View File
@@ -36,7 +36,8 @@
"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é."
"onboardingRequired": "Vous devez compléter la configuration du chiffrement dans les paramètres de votre compte avant d'utiliser le chiffrement avancé.",
"serviceUnavailable": "Le chiffrement avancé est actuellement indisponible. Veuillez réessayer plus tard ou contacter votre administrateur."
}
},
"laterMeetingDialog": {
+16 -5
View File
@@ -363,6 +363,7 @@
},
"moreTools": {
"body": "Accéder à davantage d'outils pour améliorer vos réunions.",
"encryptedDisabled": "La transcription et l'enregistrement ne sont pas disponibles dans les réunions chiffrées. Le serveur ne peut pas accéder au contenu média.",
"linkAriaLabel": "Ouvrir la documentation sur les outils - ouvre dans une nouvelle fenêtre",
"moreLink": "Ouvrir la documentation",
"tools": {
@@ -556,6 +557,7 @@
"subheading": "Dans la réunion",
"you": "Vous",
"unknown": "Participant inconnu",
"anonymous": "Identité non vérifiée",
"contributors": "Contributeurs",
"host": "Organisateur de la réunion",
"collapsable": {
@@ -724,19 +726,28 @@
"hint": "Les données chiffrées n'ont pas pu être décodées. Essayez de quitter et de rejoindre la réunion, ou demandez à l'hôte de relancer l'appel.",
"timeout": "Échange de clés expiré",
"timeoutHint": "La clé de chiffrement n'a pas pu être reçue de l'hôte. Il n'est peut-être pas encore dans la réunion.",
"refresh": "Réessayer"
"refresh": "Réessayer",
"vaultUnavailable": "Cette réunion nécessite le chiffrement avancé, qui est actuellement indisponible.",
"vaultUnavailableHint": "Il peut s'agir d'un problème temporaire. Essayez de rafraîchir la page. Si le problème persiste, le service de chiffrement est peut-être en maintenance — veuillez réessayer plus tard."
},
"fingerprint": {
"title": "Identité chiffrée",
"description": "Ceci montre l'identité de chiffrement de ce participant. Cela vous permet de vérifier qu'il est bien celui qu'il prétend être.",
"loading": "Vérification des clés de chiffrement...",
"noKey": "Ce participant n'a pas configuré de clés de chiffrement. Son identité est vérifiée par le serveur d'authentification uniquement, pas par une clé publique.",
"error": "Impossible de vérifier les clés de chiffrement.",
"noKey": "Ce participant n'a pas effectué l'onboarding chiffrement. Son identité est vérifiée par le serveur d'authentification (ProConnect) uniquement — pas par une clé publique cryptographique. Il peut configurer le chiffrement dans les paramètres de son compte.",
"noKeyBasicAuthenticated": "Ce participant est connecté via ProConnect. Son nom et son email proviennent du serveur d'authentification et ne peuvent pas être falsifiés.",
"noKeyAnonymous": "Ce participant a rejoint sans se connecter. Son identité est auto-déclarée et non vérifiée. N'acceptez les participants anonymes que si vous pouvez confirmer leur identité par un autre canal.",
"error": "Impossible de vérifier les clés de chiffrement. Le service de chiffrement est peut-être indisponible.",
"fingerprintLabel": "Empreinte de la clé publique",
"fingerprintHint": "Demandez à ce participant d'ouvrir ses paramètres de chiffrement et de comparer l'empreinte affichée avec celle ci-dessus.",
"trusted": "Empreinte vérifiée et approuvée",
"trustedDescription": "Vous avez précédemment vérifié l'empreinte de chiffrement de ce participant. Son identité est confirmée cryptographiquement.",
"refused": "Empreinte refusée",
"unknownDescription": "Cette empreinte n'a pas encore été vérifiée. Comparez-la avec le participant pour confirmer son identité.",
"refusedDescription": "Vous avez précédemment refusé cette empreinte. L'identité de cette personne n'a pas pu être vérifiée.",
"unknownDescription": "Cette empreinte n'a pas encore été vérifiée. Comparez-la avec le participant (par téléphone, en personne ou par un autre canal sécurisé) pour confirmer son identité.",
"accept": "Approuver",
"refuse": "Refuser"
"refuse": "Refuser",
"anonymous": "Identité non vérifiée"
},
"trustModal": {
"title": "Niveau de confiance du chiffrement",
@@ -25,7 +25,7 @@ export const TooltipWrapper = ({
children: ReactNode
} & TooltipWrapperProps) => {
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
<TooltipTrigger delay={tooltipType === 'instant' ? 500 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>