diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py
index 1a542105..495853d1 100644
--- a/src/backend/core/api/viewsets.py
+++ b/src/backend/core/api/viewsets.py
@@ -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:
diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py
index 6964ec68..1b15f8eb 100644
--- a/src/backend/core/utils.py
+++ b/src/backend/core/utils.py
@@ -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:
diff --git a/src/frontend/src/components/Avatar.tsx b/src/frontend/src/components/Avatar.tsx
index 168fa953..c4bd1073 100644
--- a/src/frontend/src/components/Avatar.tsx
+++ b/src/frontend/src/components/Avatar.tsx
@@ -57,7 +57,7 @@ export const Avatar = ({
style,
...props
}: AvatarProps) => {
- const initial = name?.trim()?.charAt(0) ?? ''
+ const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? ''
return (
{t('account.authentication')}
{isLoggedIn ? (
<>
diff --git a/src/frontend/src/layout/Header.tsx b/src/frontend/src/layout/Header.tsx
index 1bfb09f4..7254c312 100644
--- a/src/frontend/src/layout/Header.tsx
+++ b/src/frontend/src/layout/Header.tsx
@@ -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 = () => {
{
if (value === 'logout') {
logout()
}
- if (value === 'encryption') {
+ if (value === 'encryption' && isEncryptionAvailable) {
setShowEncryptionModal(true)
}
}}
diff --git a/src/frontend/src/locales/en/global.json b/src/frontend/src/locales/en/global.json
index 132f4be2..02923b97 100644
--- a/src/frontend/src/locales/en/global.json
+++ b/src/frontend/src/locales/en/global.json
@@ -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:"
diff --git a/src/frontend/src/locales/en/home.json b/src/frontend/src/locales/en/home.json
index 4cc6f360..c88dfe29 100644
--- a/src/frontend/src/locales/en/home.json
+++ b/src/frontend/src/locales/en/home.json
@@ -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": {
diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json
index e60fc973..bb6ff9df 100644
--- a/src/frontend/src/locales/en/rooms.json
+++ b/src/frontend/src/locales/en/rooms.json
@@ -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",
diff --git a/src/frontend/src/locales/fr/home.json b/src/frontend/src/locales/fr/home.json
index 1999b24d..7fba427f 100644
--- a/src/frontend/src/locales/fr/home.json
+++ b/src/frontend/src/locales/fr/home.json
@@ -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": {
diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json
index dddcb6c8..1523a168 100644
--- a/src/frontend/src/locales/fr/rooms.json
+++ b/src/frontend/src/locales/fr/rooms.json
@@ -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",
diff --git a/src/frontend/src/primitives/TooltipWrapper.tsx b/src/frontend/src/primitives/TooltipWrapper.tsx
index 86ac105f..94393152 100644
--- a/src/frontend/src/primitives/TooltipWrapper.tsx
+++ b/src/frontend/src/primitives/TooltipWrapper.tsx
@@ -25,7 +25,7 @@ export const TooltipWrapper = ({
children: ReactNode
} & TooltipWrapperProps) => {
return tooltip ? (
-
+
{children}
{tooltip}