diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx index 5ff76dad..bbf05f6a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx @@ -1,7 +1,11 @@ import { useEffect, useState } from 'react'; import { decryptSymmetricKey } from '@/docs/doc-collaboration/encryption'; -import assert from 'assert'; + +export type DocumentEncryptionError = + | 'missing_symmetric_key' + | 'decryption_failed' + | null; export function useDocumentEncryption( encryptionLoading: boolean, @@ -17,11 +21,13 @@ export function useDocumentEncryption( documentEncryptionSettings: { documentSymmetricKey: CryptoKey; } | null; + documentEncryptionError: DocumentEncryptionError; } { const [loading, setLoading] = useState(true); const [settings, setSettings] = useState<{ documentSymmetricKey: CryptoKey; } | null>(null); + const [error, setError] = useState(null); useEffect(() => { let cancelled = false; @@ -35,24 +41,27 @@ export function useDocumentEncryption( } else if (encryptionLoading || isDocumentEncrypted === undefined) { setLoading(true); setSettings(null); + setError(null); return; } else if (isDocumentEncrypted === false) { setLoading(false); setSettings(null); + setError(null); return; } - // TODO: - // TODO: if encrypted but there is no encrypted symmetric key for this user, we should display an error - // TODO: (maybe reuse the catch handler below?) - // TODO: - assert( - userEncryptedSymmetricKey, - 'document encrypted symmetric key must exist', - ); + if (!userEncryptedSymmetricKey) { + if (!cancelled) { + setError('missing_symmetric_key'); + setSettings(null); + setLoading(false); + } + return; + } try { setLoading(true); + setError(null); const userEncryptedSymmetricKeyArrayBuffer = Buffer.from( userEncryptedSymmetricKey, @@ -70,18 +79,14 @@ export function useDocumentEncryption( } catch (err) { console.error(err); - // - // TODO: this should display a global error since if encryption needed it should able - // to retrieve information (except if onboarding needed, but still...) - // - // maybe this should be a return value so the parent knows where to set the CTA - // - if (!cancelled) { + setError('decryption_failed'); setSettings(null); } } finally { - setLoading(false); + if (!cancelled) { + setLoading(false); + } } } @@ -95,5 +100,6 @@ export function useDocumentEncryption( return { documentEncryptionLoading: loading, documentEncryptionSettings: settings, + documentEncryptionError: error, }; } diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useEncryption.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useEncryption.tsx index 8c3b45a0..359addc8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useEncryption.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useEncryption.tsx @@ -2,6 +2,11 @@ import { useEffect, useState } from 'react'; import { getEncryptionDB } from '../encryptionDB'; +export type EncryptionError = + | 'missing_private_key' + | 'missing_public_key' + | null; + export function useEncryption(userId?: string): { encryptionLoading: boolean; encryptionSettings: { @@ -9,6 +14,7 @@ export function useEncryption(userId?: string): { userPrivateKey: CryptoKey; userPublicKey: CryptoKey; } | null; + encryptionError: EncryptionError; } { const [loading, setLoading] = useState(true); const [settings, setSettings] = useState<{ @@ -16,6 +22,7 @@ export function useEncryption(userId?: string): { userPrivateKey: CryptoKey; userPublicKey: CryptoKey; } | null>(null); + const [error, setError] = useState(null); const enableEncryption: boolean = true; // TODO: this could be toggled for instances not needing encryption to save some requests @@ -27,15 +34,18 @@ export function useEncryption(userId?: string): { if (!userId) { setLoading(true); setSettings(null); + setError(null); return; } else if (enableEncryption === false) { setLoading(false); setSettings(null); + setError(null); return; } try { setLoading(true); + setError(null); // We must first retrieve user keys locally const encryptionDatabase = await getEncryptionDB(); @@ -46,7 +56,11 @@ export function useEncryption(userId?: string): { ); if (!userPrivateKey) { - throw new Error('user has no local private key (needs onboarding)'); + if (!cancelled) { + setError('missing_private_key'); + setSettings(null); + } + return; } const userPublicKey = await encryptionDatabase.get( @@ -55,7 +69,11 @@ export function useEncryption(userId?: string): { ); if (!userPublicKey) { - throw new Error('user is missing his public key'); + if (!cancelled) { + setError('missing_public_key'); + setSettings(null); + } + return; } if (!cancelled) { @@ -68,18 +86,13 @@ export function useEncryption(userId?: string): { } catch (err) { console.error(err); - // - // TODO: this should display a global error since if encryption needed it should able - // to retrieve information (except if onboarding needed, but still...) - // - // maybe this should be a return value so the parent knows where to set the CTA - // - if (!cancelled) { setSettings(null); } } finally { - setLoading(false); + if (!cancelled) { + setLoading(false); + } } } @@ -90,5 +103,9 @@ export function useEncryption(userId?: string): { }; }, [userId, enableEncryption]); - return { encryptionLoading: loading, encryptionSettings: settings }; + return { + encryptionLoading: loading, + encryptionSettings: settings, + encryptionError: error, + }; } diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts index ce92d015..b1b8f460 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts @@ -8,7 +8,13 @@ export { encryptSymmetricKey, } from './encryption'; export { getEncryptionDB } from './encryptionDB'; -export { useDocumentEncryption } from './hook/useDocumentEncryption'; -export { useEncryption } from './hook/useEncryption'; +export { + useDocumentEncryption, + type DocumentEncryptionError, +} from './hook/useDocumentEncryption'; +export { + useEncryption, + type EncryptionError, +} from './hook/useEncryption'; export { useKeyFingerprint } from './hook/useKeyFingerprint'; export { usePublicKeyRegistry } from './hook/usePublicKeyRegistry'; diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 6ae4daeb..1e2c6569 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -5,7 +5,9 @@ import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Box, Icon, Loading, TextErrors } from '@/components'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; + +import { Box, Icon, Loading, StyledLink, Text, TextErrors } from '@/components'; import { DEFAULT_QUERY_RETRY } from '@/core'; import { DocEditor } from '@/docs/doc-editor'; import { @@ -93,14 +95,18 @@ const DocPage = ({ id }: DocProps) => { const { authenticated, user } = useAuth(); const [doc, setDoc] = useState(); - const { encryptionLoading, encryptionSettings } = useEncryption(user?.id); - const { documentEncryptionLoading, documentEncryptionSettings } = - useDocumentEncryption( - encryptionLoading, - encryptionSettings, - doc?.is_encrypted, - doc?.encrypted_document_symmetric_key_for_user, - ); + const { encryptionLoading, encryptionSettings, encryptionError } = + useEncryption(user?.id); + const { + documentEncryptionLoading, + documentEncryptionSettings, + documentEncryptionError, + } = useDocumentEncryption( + encryptionLoading, + encryptionSettings, + doc?.is_encrypted, + doc?.encrypted_document_symmetric_key_for_user, + ); const { setCurrentDoc } = useDocStore(); const { addTask } = useBroadcastStore(); const queryClient = useQueryClient(); @@ -245,6 +251,98 @@ const DocPage = ({ id }: DocProps) => { return ; } + if (doc.is_encrypted && (encryptionError || documentEncryptionError)) { + return ( + + + + {t('Encryption keys unavailable')} + + + + {t( + 'This is an encrypted document, but your current device does not have the required encryption keys to decrypt it.', + )} + + + {(encryptionError === 'missing_private_key' || + encryptionError === 'missing_public_key') && ( + + {t( + 'This usually happens when you switch to a new device or browser without restoring your encryption backup.', + )} + + )} + + {documentEncryptionError === 'missing_symmetric_key' && ( + + {t( + 'You do not have access to this encrypted document. Ask the document owner to share it with you again.', + )} + + )} + + {documentEncryptionError === 'decryption_failed' && ( + + {t( + 'Your encryption keys could not decrypt this document. This may happen if your keys were recreated. Ask the document owner to share it with you again.', + )} + + )} + + + {(encryptionError === 'missing_private_key' || + encryptionError === 'missing_public_key') && ( + + + + {t('Restore from backup (recommended)')} + + + {t( + 'If you have previously exported your encryption backup, you can restore it in your account settings to regain access to all your encrypted documents.', + )} + + + + + {t('Recreate encryption keys (not recommended)')} + + + {t( + 'Creating new encryption keys means you will lose access to all previously encrypted documents. Document owners will need to share them with you again.', + )} + + + + )} + + + + + + ); + } + return ( <>