diff --git a/src/frontend/apps/impress/src/features/auth/components/AccountMenu.tsx b/src/frontend/apps/impress/src/features/auth/components/AccountMenu.tsx
new file mode 100644
index 000000000..cdfb70017
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/auth/components/AccountMenu.tsx
@@ -0,0 +1,126 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { css } from 'styled-components';
+
+import { Box, DropdownMenu, DropdownMenuOption, Icon } from '@/components';
+import {
+ exportPublicKeyAsBase64,
+ useUserEncryption,
+} from '@/docs/doc-collaboration';
+
+import { useAuth } from '../hooks';
+import { gotoLogout } from '../utils';
+
+import { ModalEncryptionOnboarding } from './ModalEncryptionOnboarding';
+import { ModalEncryptionSettings } from './ModalEncryptionSettings';
+
+export const AccountMenu = () => {
+ const { t } = useTranslation();
+ const { user } = useAuth();
+ const { encryptionSettings } = useUserEncryption();
+
+ const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
+ const [localPublicKeyBase64, setLocalPublicKeyBase64] = useState<
+ string | null
+ >(null);
+
+ useEffect(() => {
+ if (encryptionSettings?.userPublicKey) {
+ exportPublicKeyAsBase64(encryptionSettings.userPublicKey).then(
+ setLocalPublicKeyBase64,
+ );
+ } else {
+ setLocalPublicKeyBase64(null);
+ }
+ }, [encryptionSettings]);
+
+ const hasEncryptionSetup = !!user?.encryption_public_key;
+
+ const hasMismatch =
+ localPublicKeyBase64 !== null &&
+ user?.encryption_public_key !== null &&
+ localPublicKeyBase64 !== user?.encryption_public_key;
+
+ const encryptionOption: DropdownMenuOption = useMemo(() => {
+ if (hasEncryptionSetup) {
+ return {
+ label: t('Encryption settings'),
+ icon: hasMismatch ? (
+
+ ) : (
+ 'lock'
+ ),
+ callback: () => setIsSettingsOpen(true),
+ showSeparator: true,
+ };
+ }
+
+ return {
+ label: t('Enable encryption'),
+ icon: 'lock_open',
+ callback: () => setIsOnboardingOpen(true),
+ showSeparator: true,
+ };
+ }, [hasEncryptionSetup, hasMismatch, t]);
+
+ const options: DropdownMenuOption[] = useMemo(
+ () => [
+ encryptionOption,
+ {
+ label: t('Logout'),
+ icon: 'logout',
+ callback: gotoLogout,
+ },
+ ],
+ [encryptionOption, t],
+ );
+
+ return (
+ <>
+ div {
+ gap: 0.2rem;
+ display: flex;
+ }
+ `}
+ >
+
+ {hasMismatch && (
+
+ )}
+ {t('My account')}
+
+
+
+ setIsOnboardingOpen(false)}
+ />
+
+ setIsSettingsOpen(false)}
+ onRequestReOnboard={() => {
+ setIsSettingsOpen(false);
+ setIsOnboardingOpen(true);
+ }}
+ />
+ >
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/auth/components/ButtonLogin.tsx b/src/frontend/apps/impress/src/features/auth/components/ButtonLogin.tsx
index b4666f47a..541b6fb1b 100644
--- a/src/frontend/apps/impress/src/features/auth/components/ButtonLogin.tsx
+++ b/src/frontend/apps/impress/src/features/auth/components/ButtonLogin.tsx
@@ -2,17 +2,16 @@ import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
-import { Box, BoxButton } from '@/components';
-import { useCunninghamTheme } from '@/cunningham';
+import { BoxButton } from '@/components';
import ProConnectImg from '../assets/button-proconnect.svg';
import { useAuth } from '../hooks';
-import { gotoLogin, gotoLogout } from '../utils';
+import { gotoLogin } from '../utils';
+import { AccountMenu } from './AccountMenu';
export const ButtonLogin = () => {
const { t } = useTranslation();
const { authenticated } = useAuth();
- const { colorsTokens } = useCunninghamTheme();
if (!authenticated) {
return (
@@ -28,26 +27,7 @@ export const ButtonLogin = () => {
);
}
- return (
-
-
-
- );
+ return ;
};
export const ProConnectButton = () => {
diff --git a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx
new file mode 100644
index 000000000..c2612c524
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx
@@ -0,0 +1,582 @@
+import {
+ Alert,
+ Button,
+ Modal,
+ ModalSize,
+ VariantType,
+ useToastProvider,
+} from '@gouvfr-lasuite/cunningham-react';
+import { Badge, Spinner } from '@gouvfr-lasuite/ui-kit';
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Box, ButtonCloseModal, Icon, Text } from '@/components';
+import { useUserUpdate } from '@/core/api/useUserUpdate';
+import {
+ derivePublicJwkFromPrivate,
+ exportPrivateKeyAsJwk,
+ exportPublicKeyAsBase64,
+ generateUserKeyPair,
+ getEncryptionDB,
+ importPrivateKeyFromJwk,
+ importPublicKeyFromJwk,
+ jwkToPassphrase,
+ passphraseToJwk,
+ useUserEncryption,
+} from '@/docs/doc-collaboration';
+
+import { useAuth } from '../hooks';
+
+type OnboardingStep =
+ | 'explanation'
+ | 'existing-key-choice'
+ | 'generating'
+ | 'restore'
+ | 'backup';
+
+interface ModalEncryptionOnboardingProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess?: () => void;
+}
+
+export const ModalEncryptionOnboarding = ({
+ isOpen,
+ onClose,
+ onSuccess,
+}: ModalEncryptionOnboardingProps) => {
+ const { t } = useTranslation();
+ const { toast } = useToastProvider();
+ const { user } = useAuth();
+ const { refreshEncryption } = useUserEncryption();
+ const { mutateAsync: updateUser } = useUserUpdate();
+
+ const hasExistingBackendKey = !!user?.encryption_public_key;
+
+ const [step, setStep] = useState('explanation');
+ const [isPending, setIsPending] = useState(false);
+ const [backupPassphrase, setBackupPassphrase] = useState(null);
+ const [restoreInput, setRestoreInput] = useState('');
+ const [restoreError, setRestoreError] = useState(null);
+
+ const prevIsOpenRef = useRef(isOpen);
+ useEffect(() => {
+ if (isOpen && !prevIsOpenRef.current) {
+ setStep(hasExistingBackendKey ? 'existing-key-choice' : 'explanation');
+ setBackupPassphrase(null);
+ setShowPassphrase(false);
+ setRestoreInput('');
+ setRestoreError(null);
+ }
+ prevIsOpenRef.current = isOpen;
+ }, [isOpen, hasExistingBackendKey]);
+
+ const handleClose = () => {
+ if (isPending) {
+ return;
+ }
+
+ onClose();
+ };
+
+ const generateAndStoreKeys = async () => {
+ if (!user) {
+ return;
+ }
+
+ setIsPending(true);
+
+ try {
+ const userKeyPair = await generateUserKeyPair();
+
+ const encryptionDatabase = await getEncryptionDB();
+
+ // TODO: it should use transaction
+ // encryptionDatabase.transaction
+ await encryptionDatabase.put(
+ 'privateKey',
+ userKeyPair.privateKey,
+ `user:${user.id}`,
+ );
+ await encryptionDatabase.put(
+ 'publicKey',
+ userKeyPair.publicKey,
+ `user:${user.id}`,
+ );
+
+ const publicKeyBase64 = await exportPublicKeyAsBase64(
+ userKeyPair.publicKey,
+ );
+
+ await updateUser({
+ id: user.id,
+ encryption_public_key: publicKeyBase64,
+ });
+
+ // Generate backup passphrase
+ const privateJwk = await exportPrivateKeyAsJwk(userKeyPair.privateKey);
+
+ setBackupPassphrase(jwkToPassphrase(privateJwk));
+
+ refreshEncryption();
+
+ setStep('backup');
+ } catch (error) {
+ console.error('Key generation failed:', error);
+
+ toast(
+ t('Failed to generate encryption keys. Please try again.'),
+ VariantType.ERROR,
+ );
+ } finally {
+ setIsPending(false);
+ }
+ };
+
+ const handleRestoreKeys = async () => {
+ if (!user || !restoreInput.trim()) {
+ return;
+ }
+
+ setIsPending(true);
+ setRestoreError(null);
+
+ try {
+ const privateJwk = passphraseToJwk(restoreInput.trim());
+ const privateKey = await importPrivateKeyFromJwk(privateJwk);
+
+ const publicJwk = derivePublicJwkFromPrivate(privateJwk);
+ const publicKey = await importPublicKeyFromJwk(publicJwk);
+
+ // Verify restored public key matches the backend
+ const restoredPublicKeyBase64 = await exportPublicKeyAsBase64(publicKey);
+
+ if (
+ user.encryption_public_key &&
+ restoredPublicKeyBase64 !== user.encryption_public_key
+ ) {
+ setRestoreError(
+ t(
+ 'The restored key does not match the one registered on your account. If you want to restore an older key, you must first remove encryption from your account settings (including the server key), then re-enable encryption using this backup.',
+ ),
+ );
+ setIsPending(false);
+
+ return;
+ }
+
+ const encryptionDatabase = await getEncryptionDB();
+ await encryptionDatabase.put('privateKey', privateKey, `user:${user.id}`);
+ await encryptionDatabase.put('publicKey', publicKey, `user:${user.id}`);
+
+ refreshEncryption();
+
+ toast(t('Encryption keys restored successfully.'), VariantType.SUCCESS, {
+ duration: 4000,
+ });
+
+ handleClose();
+ onSuccess?.();
+ } catch (error) {
+ console.error('Key restoration failed:', error);
+
+ setRestoreError(
+ t('Invalid backup data. Please check your passphrase and try again.'),
+ );
+ } finally {
+ setIsPending(false);
+ }
+ };
+
+ const handleBackupThirdParty = () => {
+ alert(t('Third-party backup is not implemented yet.'));
+ };
+
+ const handleCopyPassphrase = async () => {
+ if (!backupPassphrase) {
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(backupPassphrase);
+ toast(t('Passphrase copied to clipboard.'), VariantType.SUCCESS, {
+ duration: 2000,
+ });
+ } catch {
+ toast(t('Failed to copy to clipboard.'), VariantType.ERROR);
+ }
+ };
+
+ const handleBackupDone = () => {
+ toast(t('Encryption has been enabled.'), VariantType.SUCCESS, {
+ duration: 4000,
+ });
+ handleClose();
+ onSuccess?.();
+ };
+
+ const renderExplanation = () => (
+
+
+
+
+ {t(
+ 'Encryption keys will be stored locally on this device. If these keys are lost (browser data cleared, device lost), you will permanently lose the ability to decrypt your documents.',
+ )}
+
+
+ {t(
+ 'After enabling encryption, you will be prompted to back up your keys. Please do so carefully using a password manager with two-factor authentication (2FA), or by printing your backup.',
+ )}
+
+
+
+
+ );
+
+ const renderExistingKeyChoice = () => (
+
+
+
+
+ {t('Previous encryption setup detected')}
+
+
+ {t(
+ 'Your account already has an encryption key registered. This could be from a previous setup on this device (with storage cleared) or from another device.',
+ )}
+
+
+
+
+
+
+
+
+ {t('Restore from backup')}
+
+ {t('Recommended')}
+
+
+ {t(
+ 'If you have a backup of your keys, you can restore them on this device.',
+ )}
+
+
+
+
+
+
+
+ {t('or')}
+
+
+
+
+
+
+ {t('Start fresh')}
+
+
+ {t(
+ 'Creating new keys will invalidate your old ones. Documents where you are the sole member will become permanently undecryptable. Documents shared with others will require them to unshare and reshare after you have your new key.',
+ )}
+
+
+
+
+
+ );
+
+ const renderRestore = () => (
+
+
+ {t(
+ 'Paste your backup passphrase below to restore your encryption keys on this device.',
+ )}
+
+
+ ) =>
+ setRestoreInput(e.target.value)
+ }
+ placeholder={t('Paste your backup passphrase here...')}
+ />
+
+ {restoreError && {restoreError}}
+
+ );
+
+ const [showPassphrase, setShowPassphrase] = useState(false);
+
+ const renderBackup = () => (
+
+
+
+
+ {t('Keys generated successfully!')}
+
+
+ {t(
+ 'Please back up your private key using one of the methods below. Without this backup, you will lose access to your encrypted documents if your browser data is cleared.',
+ )}
+
+
+
+
+
+
+
+
+ {t('Save passphrase')}
+
+ {t('Recommended')}
+
+
+ {t(
+ 'Copy this passphrase and store it in a password manager with 2FA enabled, or print it and keep it in a safe place.',
+ )}
+
+ {showPassphrase ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {t('Third-party backup')}
+
+
+
+ {t(
+ 'Send your encrypted key to a trusted third-party server for recovery.',
+ )}
+
+
+
+
+ );
+
+ const getStepContent = () => {
+ switch (step) {
+ case 'explanation':
+ return renderExplanation();
+ case 'existing-key-choice':
+ return renderExistingKeyChoice();
+ case 'generating':
+ return (
+
+
+ {t('Generating encryption keys...')}
+
+ );
+ case 'restore':
+ return renderRestore();
+ case 'backup':
+ return renderBackup();
+ }
+ };
+
+ const getRightActions = () => {
+ switch (step) {
+ case 'explanation':
+ return (
+ <>
+
+
+ >
+ );
+ case 'existing-key-choice':
+ return (
+
+ );
+ case 'restore':
+ return (
+ <>
+
+
+ >
+ );
+ case 'backup':
+ return (
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ const getTitle = () => {
+ switch (step) {
+ case 'backup':
+ return t('Back up your encryption keys');
+ case 'restore':
+ return t('Restore encryption keys');
+ default:
+ return t('Enable encryption');
+ }
+ };
+
+ return (
+
+
+ {getTitle()}
+
+ {step !== 'backup' && (
+
+ )}
+
+ }
+ >
+ {getStepContent()}
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx
new file mode 100644
index 000000000..944790814
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx
@@ -0,0 +1,366 @@
+import {
+ Alert,
+ Button,
+ Checkbox,
+ Input,
+ Modal,
+ ModalSize,
+ VariantType,
+ useToastProvider,
+} from '@gouvfr-lasuite/cunningham-react';
+import { useEffect, useRef, useState } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+
+import { Box, ButtonCloseModal, Icon, Text } from '@/components';
+import { useUserUpdate } from '@/core/api/useUserUpdate';
+import {
+ exportPublicKeyAsBase64,
+ getEncryptionDB,
+ useKeyFingerprint,
+ useUserEncryption,
+} from '@/docs/doc-collaboration';
+import { Badge, Spinner } from '@gouvfr-lasuite/ui-kit';
+
+import { useAuth } from '../hooks';
+
+type SettingsView = 'main' | 'confirm-remove';
+
+interface ModalEncryptionSettingsProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onRequestReOnboard: () => void;
+}
+
+export const ModalEncryptionSettings = ({
+ isOpen,
+ onClose,
+ onRequestReOnboard,
+}: ModalEncryptionSettingsProps) => {
+ const { t } = useTranslation();
+ const { toast } = useToastProvider();
+ const { user } = useAuth();
+ const { encryptionSettings, refreshEncryption } = useUserEncryption();
+ const { mutateAsync: updateUser } = useUserUpdate();
+
+ const backendFingerprint = useKeyFingerprint(user?.encryption_public_key);
+ const [localPublicKeyBase64, setLocalPublicKeyBase64] = useState<
+ string | null
+ >(null);
+ const localFingerprint = useKeyFingerprint(localPublicKeyBase64);
+
+ const [view, setView] = useState('main');
+ const [confirmInput, setConfirmInput] = useState('');
+ const [isPending, setIsPending] = useState(false);
+ const [alsoRemoveFromServer, setAlsoRemoveFromServer] = useState(false);
+
+ const prevIsOpenRef = useRef(isOpen);
+ useEffect(() => {
+ if (isOpen && !prevIsOpenRef.current) {
+ setView('main');
+ setConfirmInput('');
+ setAlsoRemoveFromServer(false);
+ }
+ prevIsOpenRef.current = isOpen;
+ }, [isOpen]);
+
+ useEffect(() => {
+ if (encryptionSettings?.userPublicKey) {
+ exportPublicKeyAsBase64(encryptionSettings.userPublicKey).then(
+ setLocalPublicKeyBase64,
+ );
+ } else {
+ setLocalPublicKeyBase64(null);
+ }
+ }, [encryptionSettings]);
+
+ const hasMismatch =
+ localPublicKeyBase64 !== null &&
+ user?.encryption_public_key !== null &&
+ localPublicKeyBase64 !== user?.encryption_public_key;
+
+ const handleClose = () => {
+ if (isPending) {
+ return;
+ }
+ onClose();
+ };
+
+ const normalizedConfirmInput = confirmInput.trim().toUpperCase();
+ const normalizedBackendFingerprint = backendFingerprint?.toUpperCase() ?? '';
+ const fingerprintMatches =
+ !!normalizedBackendFingerprint &&
+ normalizedConfirmInput === normalizedBackendFingerprint;
+
+ const canConfirmRemoval = fingerprintMatches;
+
+ const handleRemoveEncryption = async () => {
+ if (!user || !canConfirmRemoval) {
+ return;
+ }
+
+ setIsPending(true);
+
+ try {
+ if (alsoRemoveFromServer) {
+ await updateUser({
+ id: user.id,
+ encryption_public_key: null,
+ });
+ }
+
+ const encryptionDatabase = await getEncryptionDB();
+ await encryptionDatabase.delete('privateKey', `user:${user.id}`);
+ await encryptionDatabase.delete('publicKey', `user:${user.id}`);
+
+ refreshEncryption();
+
+ toast(
+ alsoRemoveFromServer
+ ? t('Encryption has been fully removed from your account.')
+ : t('Local encryption keys have been removed from this device.'),
+ VariantType.SUCCESS,
+ {
+ duration: 4000,
+ },
+ );
+
+ handleClose();
+ } catch (error) {
+ console.error('Failed to remove encryption:', error);
+ toast(
+ t('Failed to remove encryption. Please try again.'),
+ VariantType.ERROR,
+ );
+ } finally {
+ setIsPending(false);
+ }
+ };
+
+ const handleReOnboard = () => {
+ handleClose();
+ onRequestReOnboard();
+ };
+
+ const renderMain = () => (
+
+ {hasMismatch && (
+
+
+
+ {t('Key mismatch detected')}
+
+
+ {t(
+ 'The encryption key on this device does not match the one registered on your account. This may happen if you set up encryption on another device or if your local data was modified.',
+ )}
+
+
+ {t(
+ 'It will lead to unexpected behavior since when other people is sharing a document with you they will use the public key stored on the server, and so according to your current local public key your device will not be able to decrypt the document.',
+ )}
+
+
+
+
+ )}
+
+
+ {t('Your public key fingerprint on the server')}
+
+ {backendFingerprint || '...'}
+
+
+
+ {localFingerprint && (
+
+
+ {t('Your public key fingerprint on this current device')}
+
+
+ {localFingerprint}
+
+
+ )}
+
+ {!encryptionSettings && user?.encryption_public_key && (
+
+
+
+ {t('No local keys on this device')}
+
+
+ {t(
+ 'Your account has a public key registered on the server, but no encryption keys were found on this device. You will not be able to decrypt documents until you restore your keys from a backup.',
+ )}
+
+
+
+
+ )}
+
+ );
+
+ const renderConfirmRemove = () => (
+
+
+
+ {t(
+ 'This will delete your local encryption keys from this device. You will no longer be able to decrypt documents from this browser unless you restore your keys from a backup.',
+ )}
+
+
+
+
+ {
+ setAlsoRemoveFromServer((prev) => !prev);
+ setConfirmInput('');
+ }}
+ />
+
+ {t(
+ 'If enabled, other users will no longer find this current public key to share new documents with you.',
+ )}
+
+
+
+
+
+
+ To confirm, type your public key fingerprint:{' '}
+ {backendFingerprint}
+
+
+ setConfirmInput(e.target.value)}
+ state={confirmInput && !fingerprintMatches ? 'error' : 'default'}
+ text={
+ confirmInput && !fingerprintMatches
+ ? t('Fingerprint does not match')
+ : undefined
+ }
+ />
+
+
+ );
+
+ const getRightActions = () => {
+ if (view === 'main') {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+ };
+
+ return (
+
+
+ {view === 'main'
+ ? t('Encryption settings')
+ : t('Remove encryption')}
+
+
+
+ }
+ >
+ {view === 'main' ? renderMain() : renderConfirmRemove()}
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/auth/components/index.ts b/src/frontend/apps/impress/src/features/auth/components/index.ts
index 26ebaf2e8..ea64e28ad 100644
--- a/src/frontend/apps/impress/src/features/auth/components/index.ts
+++ b/src/frontend/apps/impress/src/features/auth/components/index.ts
@@ -1,3 +1,6 @@
+export * from './AccountMenu';
export * from './Auth';
export * from './ButtonLogin';
+export * from './ModalEncryptionOnboarding';
+export * from './ModalEncryptionSettings';
export * from './UserAvatar';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx
index 2f3319606..f2b83c00b 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx
@@ -1,4 +1,4 @@
-import { createContext, useContext } from 'react';
+import { createContext, useCallback, useContext, useState } from 'react';
import { useAuth } from '@/features/auth';
@@ -12,12 +12,14 @@ interface UserEncryptionContextValue {
userPublicKey: CryptoKey;
} | null;
encryptionError: EncryptionError;
+ refreshEncryption: () => void;
}
const UserEncryptionContext = createContext({
encryptionLoading: true,
encryptionSettings: null,
encryptionError: null,
+ refreshEncryption: () => {},
});
export const UserEncryptionProvider = ({
@@ -26,10 +28,17 @@ export const UserEncryptionProvider = ({
children: React.ReactNode;
}) => {
const { user } = useAuth();
- const value = useEncryption(user?.id);
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
+ const encryptionValue = useEncryption(user?.id, refreshTrigger);
+
+ const refreshEncryption = useCallback(() => {
+ setRefreshTrigger((prev) => prev + 1);
+ }, []);
return (
-
+
{children}
);
diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryption-backup.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryption-backup.ts
new file mode 100644
index 000000000..fadb16877
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryption-backup.ts
@@ -0,0 +1,69 @@
+import { userKeyPairAlgorithm } from './encryption';
+
+export async function exportPrivateKeyAsJwk(
+ privateKey: CryptoKey,
+): Promise {
+ return await crypto.subtle.exportKey('jwk', privateKey);
+}
+
+export async function importPrivateKeyFromJwk(
+ jwk: JsonWebKey,
+): Promise {
+ return await crypto.subtle.importKey(
+ 'jwk',
+ jwk,
+ { name: userKeyPairAlgorithm, hash: 'SHA-256' },
+ true,
+ ['decrypt'],
+ );
+}
+
+export async function importPublicKeyFromJwk(
+ jwk: JsonWebKey,
+): Promise {
+ return await crypto.subtle.importKey(
+ 'jwk',
+ jwk,
+ { name: userKeyPairAlgorithm, hash: 'SHA-256' },
+ true,
+ ['encrypt'],
+ );
+}
+
+export async function exportPublicKeyAsBase64(
+ publicKey: CryptoKey,
+): Promise {
+ const rawPublicKey = await crypto.subtle.exportKey('spki', publicKey);
+
+ return Buffer.from(new Uint8Array(rawPublicKey)).toString('base64');
+}
+
+// Derive a public JWK from a private JWK by removing private fields.
+export function derivePublicJwkFromPrivate(privateJwk: JsonWebKey): JsonWebKey {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { d, p, q, dp, dq, qi, ...publicJwk } = privateJwk;
+
+ return { ...publicJwk, key_ops: ['encrypt'] };
+}
+
+/**
+ * Serialize a JWK to a compact passphrase-like string.
+ * This is a base64url encoding of the full JWK JSON - not a mnemonic,
+ * but compact enough to be stored in a password manager.
+ */
+export function jwkToPassphrase(jwk: JsonWebKey): string {
+ const json = JSON.stringify(jwk);
+ const base64 = Buffer.from(json).toString('base64');
+
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+/**
+ * Deserialize a passphrase string back to a JWK.
+ */
+export function passphraseToJwk(passphrase: string): JsonWebKey {
+ const base64 = passphrase.replace(/-/g, '+').replace(/_/g, '/');
+ const json = Buffer.from(base64, 'base64').toString('utf-8');
+
+ return JSON.parse(json) as JsonWebKey;
+}
diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryption.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryption.ts
index e520dae4b..14ca4d44f 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryption.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryption.ts
@@ -1,5 +1,5 @@
-const userKeyPairAlgorithm = 'RSA-OAEP';
-const documentSymmetricKeyAlgorithm = 'AES-GCM';
+export const userKeyPairAlgorithm = 'RSA-OAEP';
+export const documentSymmetricKeyAlgorithm = 'AES-GCM';
export async function generateUserKeyPair(): Promise {
return await crypto.subtle.generateKey(
@@ -79,6 +79,7 @@ export async function encryptContent(
const result = new Uint8Array(iv.length + ciphertext.byteLength);
result.set(iv);
result.set(new Uint8Array(ciphertext), iv.length);
+
return result;
}
@@ -113,6 +114,7 @@ export async function computeKeyFingerprint(
const hex = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
+
return hex
.slice(0, 16)
.replace(/(.{4})/g, '$1 ')
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 b80813466..4b951e7e9 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
@@ -73,8 +73,8 @@ export function useDocumentEncryption(
if (!cancelled) {
setSettings({ documentSymmetricKey: symmetricKey });
}
- } catch (err) {
- console.error(err);
+ } catch (error) {
+ console.error(error);
if (!cancelled) {
setError('decryption_failed');
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 359addc8b..c89885453 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
@@ -7,7 +7,10 @@ export type EncryptionError =
| 'missing_public_key'
| null;
-export function useEncryption(userId?: string): {
+export function useEncryption(
+ userId?: string,
+ refreshTrigger?: number,
+): {
encryptionLoading: boolean;
encryptionSettings: {
userId: string;
@@ -83,8 +86,8 @@ export function useEncryption(userId?: string): {
userPublicKey: userPublicKey,
});
}
- } catch (err) {
- console.error(err);
+ } catch (error) {
+ console.error(error);
if (!cancelled) {
setSettings(null);
@@ -101,7 +104,7 @@ export function useEncryption(userId?: string): {
return () => {
cancelled = true;
};
- }, [userId, enableEncryption]);
+ }, [userId, enableEncryption, refreshTrigger]);
return {
encryptionLoading: loading,
diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/usePublicKeyRegistry.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/usePublicKeyRegistry.tsx
index 31f262d62..0bf941e60 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/usePublicKeyRegistry.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/usePublicKeyRegistry.tsx
@@ -1,9 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
-import {
- STORE_KNOWN_PUBLIC_KEYS,
- getEncryptionDB,
-} from '../encryptionDB';
+import { STORE_KNOWN_PUBLIC_KEYS, getEncryptionDB } from '../encryptionDB';
export interface PublicKeyMismatch {
userId: string;
@@ -11,6 +8,13 @@ export interface PublicKeyMismatch {
currentKey: string;
}
+// module-level listener set to keep all hook instances in sync
+const registryListeners = new Set<() => void>();
+
+function notifyRegistryUpdated() {
+ registryListeners.forEach((fn) => fn());
+}
+
/**
* TOFU (Trust On First Use) public key registry.
*
@@ -19,6 +23,8 @@ export interface PublicKeyMismatch {
* flagged as a mismatch.
* - The caller can accept a new key via `acceptNewKey(userId)`, which updates
* the locally stored key.
+ *
+ * All instances stay in sync via a module-level listener set.
*/
export function usePublicKeyRegistry(
accessesPublicKeysPerUser: Record | undefined,
@@ -26,6 +32,18 @@ export function usePublicKeyRegistry(
) {
const [mismatches, setMismatches] = useState([]);
const [loading, setLoading] = useState(true);
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
+
+ // listen for updates from other hook instances
+ useEffect(() => {
+ const handler = () => setRefreshTrigger((prev) => prev + 1);
+
+ registryListeners.add(handler);
+
+ return () => {
+ registryListeners.delete(handler);
+ };
+ }, []);
useEffect(() => {
if (!accessesPublicKeysPerUser) {
@@ -67,8 +85,8 @@ export function usePublicKeyRegistry(
if (!cancelled) {
setMismatches(newMismatches);
}
- } catch (err) {
- console.error('usePublicKeyRegistry: failed to check keys', err);
+ } catch (error) {
+ console.error('usePublicKeyRegistry: failed to check keys', error);
} finally {
if (!cancelled) {
setLoading(false);
@@ -82,7 +100,7 @@ export function usePublicKeyRegistry(
return () => {
cancelled = true;
};
- }, [accessesPublicKeysPerUser, currentUserId]);
+ }, [accessesPublicKeysPerUser, currentUserId, refreshTrigger]);
const acceptNewKey = useCallback(
async (userId: string) => {
@@ -99,6 +117,9 @@ export function usePublicKeyRegistry(
);
setMismatches((prev) => prev.filter((m) => m.userId !== userId));
+
+ // notify other instances to re-check
+ notifyRegistryUpdated();
},
[mismatches],
);
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 0606ebba4..7fbf54b52 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
@@ -12,13 +12,19 @@ export {
useDocumentEncryption,
type DocumentEncryptionError,
} from './hook/useDocumentEncryption';
-export {
- useEncryption,
- type EncryptionError,
-} from './hook/useEncryption';
+export { useEncryption, type EncryptionError } from './hook/useEncryption';
export {
UserEncryptionProvider,
useUserEncryption,
} from './UserEncryptionProvider';
export { useKeyFingerprint } from './hook/useKeyFingerprint';
export { usePublicKeyRegistry } from './hook/usePublicKeyRegistry';
+export {
+ exportPrivateKeyAsJwk,
+ importPrivateKeyFromJwk,
+ importPublicKeyFromJwk,
+ exportPublicKeyAsBase64,
+ derivePublicJwkFromPrivate,
+ jwkToPassphrase,
+ passphraseToJwk,
+} from './encryption-backup';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
index eb90993f6..95b32356b 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
@@ -77,7 +77,7 @@ export const DocToolBox = ({
const modalShare = useModal();
const { hasMismatches: hasKeyWarnings } = usePublicKeyRegistry(
- doc.accesses_public_keys_per_user,
+ doc.is_encrypted ? doc.accesses_public_keys_per_user : undefined,
encryptionSettings?.userId,
);
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx
index 8c79a80d8..fd0d9043a 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx
@@ -1,30 +1,23 @@
import {
+ Alert,
Button,
- Loader,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
-import { backendUrl } from '@/api';
-import { useState } from 'react';
-
import { Box, ButtonCloseModal, Icon, Text, TextErrors } from '@/components';
-import { useUserUpdate } from '@/core/api/useUserUpdate';
import {
encryptContent,
generateSymmetricKey,
- generateUserKeyPair,
- getEncryptionDB,
prepareEncryptedSymmetricKeysForUsers,
useUserEncryption,
} from '@/docs/doc-collaboration';
import { createDocAttachment } from '@/docs/doc-editor/api';
-import { toBase64 } from '@/docs/doc-editor';
import { useAuth } from '@/features/auth';
import {
Doc,
@@ -133,7 +126,6 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
useProviderStore();
const { user } = useAuth();
const { encryptionSettings } = useUserEncryption();
- const { mutateAsync: updateUser } = useUserUpdate();
const [isPending, setIsPending] = useState(false);
@@ -170,8 +162,13 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
);
}, [accesses, doc.accesses_public_keys_per_user]);
+ const hasEncryptionKeys = !!encryptionSettings;
+
const canEncrypt =
- isRestricted && !hasPendingInvitations && membersWithoutKey.length === 0;
+ hasEncryptionKeys &&
+ isRestricted &&
+ !hasPendingInvitations &&
+ membersWithoutKey.length === 0;
const handleClose = () => {
if (isPending) {
@@ -181,57 +178,13 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
};
const handleEncrypt = async () => {
- if (!provider || !user || isPending || !canEncrypt) {
+ if (!provider || !user || isPending || !canEncrypt || !encryptionSettings) {
return;
}
setIsPending(true);
try {
- let currentUserPublicKeyFromThisOnboardingSession: ArrayBuffer | null =
- null;
-
- // Perform the onboarding if that's the first time using encryption on this device
- if (!encryptionSettings) {
- // TODO: trigger the onboarding, either by creating or retrieving a key from another device
- // TODO: probably the logic should be at a device key level, not user one?
-
- const userKeyPair = await generateUserKeyPair();
-
- const encryptionDatabase = await getEncryptionDB();
-
- // TODO: it should use transaction
- // encryptionDatabase.transaction
- await encryptionDatabase.put(
- 'privateKey',
- userKeyPair.privateKey,
- `user:${user.id}`,
- );
- await encryptionDatabase.put(
- 'publicKey',
- userKeyPair.publicKey,
- `user:${user.id}`,
- );
-
- const rawPublicKey = await crypto.subtle.exportKey(
- 'spki',
- userKeyPair.publicKey,
- );
-
- // TODO: it should throw if the backend has already a public key (so the user can with concious forget the old one (but here he did the onboarding already so... it was probably a new device))
- await updateUser({
- id: user.id,
- encryption_public_key: toBase64(new Uint8Array(rawPublicKey)),
- });
-
- currentUserPublicKeyFromThisOnboardingSession = rawPublicKey;
-
- // TODO: should check encryptionSettings will update, otherwise hard refresh is needed
- window.location.reload();
-
- return;
- }
-
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_STARTED);
const documentSymmetricKey = await generateSymmetricKey();
@@ -249,13 +202,6 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
)) {
usersPublicKeys[userId] = Buffer.from(publicKey, 'base64').buffer;
}
-
- // if the onboarding has been done directly in this encryption flow, the backend has not yet told the frontend
- // about the current user key, so just patching the mapping with this new public key
- if (currentUserPublicKeyFromThisOnboardingSession) {
- usersPublicKeys[user.id] =
- currentUserPublicKeyFromThisOnboardingSession;
- }
} else {
// if it has been not provided it's weird because it should only happen for people not authenticated
throw new Error(`"accesses_public_keys_per_user" should be provided`);
@@ -391,23 +337,51 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
{!isError && (
-
- {t(
- 'Encrypting a document ensures that only authorized members can read its content. Before proceeding, the following conditions must be met:',
- )}
-
+
+
+
+ {t(
+ 'Encrypting a document ensures that only authorized members can read its content. Keep in mind before proceeding any access will then require its user to do the encryption onboarding, with the complication of ensuring keys backups.',
+ )}
+
+
+
- {/* TODO: warning about encryption */}
- {/* TODO: if no public key for current user, provide an onboarding */}
+
+ {t('Here the conditions that must be met:')}
+
+
+
+
+ {hasEncryptionKeys
+ ? t('Encryption is enabled on your account')
+ : t(
+ 'You must enable encryption from your account menu first',
+ )}
+
+
+
-
+
{isRestricted
? t('Document access is private')
: t(
@@ -426,11 +400,12 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
{!hasPendingInvitations
? t('No pending invitations')
@@ -446,12 +421,15 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
}
$size="sm"
$theme={
- membersWithoutKey.length === 0 ? 'success' : 'danger'
+ membersWithoutKey.length === 0 ? 'success' : 'error'
}
/>
{membersWithoutKey.length === 0
? t('All members have encryption enabled')
@@ -472,14 +450,6 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
)}
-
- {!canEncrypt && (
-
- {t(
- 'Please resolve the issues above before encrypting the document.',
- )}
-
- )}
)}
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx
index fa3f6c6ce..d34390e74 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx
@@ -10,7 +10,6 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
-import { backendUrl } from '@/api';
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import { decryptContent } from '@/docs/doc-collaboration';
import { createDocAttachment } from '@/docs/doc-editor/api';
@@ -254,16 +253,12 @@ export const ModalRemoveDocEncryption = ({
}
>
-
+
{!isError && (
-
-
- TODO: warning about removing encryption
+
+ {t(
+ 'Removing encryption will decrypt the document and make it accessible without encryption keys. The document content will be stored in plain text on the server.',
+ )}
)}
diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx
index 8900b343f..103783de1 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx
@@ -144,15 +144,23 @@ export const QuickSearchGroupMember = ({
group={membersData}
renderElement={(access) => {
const hasMismatch = keyMismatchUserIds?.has(access.user.id);
+ const hasNoEncryptionKey =
+ doc.is_encrypted && !access.user.encryption_public_key;
+
+ let suffix: string | undefined;
+ if (hasMismatch) {
+ suffix = t('DIFFERENT PUBLIC KEY, PLEASE VERIFY');
+ } else if (hasNoEncryptionKey) {
+ suffix = t(
+ 'ENCRYPTION DISABLED - consider removing this member since unable to read the document',
+ );
+ }
+
return (
setMismatchUserId(access.user.id)
diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx
index 7caf88bf0..28f5ddef8 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx
@@ -105,7 +105,7 @@ export const DocShareModal = ({
: null;
const { mismatches: keyMismatches, acceptNewKey } = usePublicKeyRegistry(
- doc.accesses_public_keys_per_user,
+ doc.is_encrypted ? doc.accesses_public_keys_per_user : undefined,
user?.id,
);
const keyMismatchUserIds = useMemo(
@@ -283,7 +283,7 @@ export const DocShareModal = ({
encryptionError === 'missing_public_key') && (
{t(
- 'This usually happens when you switch to a new device or browser without restoring your encryption backup.',
+ 'This usually happens when you switch to a new device or browser without restoring your encryption backup, please go to your "Encryption Settings" to fix it.',
)}
)}
diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
index 6c1067239..e9183551d 100644
--- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
+++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
@@ -37,7 +37,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
const shareModal = useModal();
const { user } = useAuth();
const { hasMismatches: hasKeyWarning } = usePublicKeyRegistry(
- doc.accesses_public_keys_per_user,
+ doc.is_encrypted ? doc.accesses_public_keys_per_user : undefined,
user?.id,
);
const isPublic = doc.link_reach === LinkReach.PUBLIC;
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 060c76737..ba6c2962b 100644
--- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
+++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
@@ -306,7 +306,7 @@ const DocPage = ({ id }: DocProps) => {
encryptionError === 'missing_public_key') && (
{t(
- 'This usually happens when you switch to a new device or browser without restoring your encryption backup.',
+ 'This usually happens when you switch to a new device or browser without restoring your encryption backup, please go to your "Encryption Settings" to fix it.',
)}
)}