mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 11:58:53 +00:00
wip
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
+95
-46
@@ -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>
|
||||
|
||||
+111
-173
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user