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>