From 41d1d98c44c8342cc69796e39f1c01a132a40c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Thu, 24 Sep 2026 22:29:14 +0200 Subject: [PATCH] fu --- .../auth/components/EncryptionHostBody.tsx | 66 +++--------------- .../components/ModalEncryptionOnboarding.tsx | 67 +++++++++---------- .../components/ModalEncryptionSettings.tsx | 61 ++++++++--------- .../doc-collaboration/vault/client-sdk.d.ts | 66 ++++++++++-------- .../apps/impress/src/pages/globals.css | 33 +-------- 5 files changed, 108 insertions(+), 185 deletions(-) diff --git a/src/frontend/apps/impress/src/features/auth/components/EncryptionHostBody.tsx b/src/frontend/apps/impress/src/features/auth/components/EncryptionHostBody.tsx index 3702ed815..f500f6e48 100644 --- a/src/frontend/apps/impress/src/features/auth/components/EncryptionHostBody.tsx +++ b/src/frontend/apps/impress/src/features/auth/components/EncryptionHostBody.tsx @@ -1,63 +1,23 @@ -import { Button, ModalSize } from '@gouvfr-lasuite/cunningham-react'; -import { useEffect, useState } from 'react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { Loading } from '@/components'; import { useVaultClient } from '@/features/docs/doc-collaboration/vault'; import { EncryptionModalContent } from '@/features/docs/doc-management/components/EncryptionLayout'; -/** - * The size the interface asks for its host modal: small (350px) by default, the - * design system's medium one when the shown screen needs the room. Resets to - * small whenever the modal closes, so the next opening starts at the default. - */ -export const useInterfaceModalSize = (isOpen: boolean): ModalSize => { - const { client } = useVaultClient(); - const [size, setSize] = useState(ModalSize.SMALL); - - useEffect(() => { - if (!client) { - return; - } - - const handleSize = ({ size: wanted }: { size: 'small' | 'medium' }) => { - setSize(wanted === 'medium' ? ModalSize.MEDIUM : ModalSize.SMALL); - }; - - client.on('interface:size', handleSize); - - return () => { - client.off('interface:size', handleSize); - }; - }, [client]); - - useEffect(() => { - if (!isOpen) { - setSize(ModalSize.SMALL); - } - }, [isOpen]); - - return size; -}; - interface EncryptionHostBodyProps { - /** Receives the element the interface iframe is mounted into. */ - hostRef: (element: HTMLDivElement | null) => void; onClose: () => void; } /** - * The body of a modal hosting the encryption interface. The interface can only - * be opened once the SDK script has loaded from the vault domain: until then a - * loader, and if that load failed an explanation with a retry, instead of the - * empty host the interface would otherwise never fill. + * What the product shows while the encryption interface comes up. The interface + * draws its own modal over the page once its SDK has loaded it, so this modal + * only carries a loader until then, or, if the SDK script itself could not be + * loaded from the vault domain, an explanation with a retry. */ -export const EncryptionHostBody = ({ - hostRef, - onClose, -}: EncryptionHostBodyProps) => { +export const EncryptionHostBody = ({ onClose }: EncryptionHostBodyProps) => { const { t } = useTranslation(); - const { client, isLoading, error } = useVaultClient(); + const { error } = useVaultClient(); if (error) { return ( @@ -81,15 +41,5 @@ export const EncryptionHostBody = ({ ); } - if (!client || isLoading) { - return ; - } - - return ( -
- ); + return ; }; diff --git a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx index c5c6b93fe..ee781aa2f 100644 --- a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx +++ b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx @@ -1,22 +1,19 @@ /** - * Encryption onboarding modal — delegates to the centralized encryption service. + * Encryption onboarding — delegates to the centralized encryption service. * - * Opens the encryption service's interface iframe which handles everything: - * key generation, backup, restore, device transfer, and server registration. - * The product (Docs) doesn't manage public keys — it only stores fingerprints - * on document accesses for UI purposes. + * The service's interface handles everything (key generation, backup, restore, + * device transfer, server registration) and draws its own modal over the page; + * Docs only shows a loader until it is on screen. The product doesn't manage + * public keys — it only stores fingerprints on document accesses for UI purposes. */ -import { Modal } from '@gouvfr-lasuite/cunningham-react'; +import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useUserEncryption } from '@/docs/doc-collaboration'; import { useVaultClient } from '@/features/docs/doc-collaboration/vault'; -import { - EncryptionHostBody, - useInterfaceModalSize, -} from './EncryptionHostBody'; +import { EncryptionHostBody } from './EncryptionHostBody'; interface ModalEncryptionOnboardingProps { isOpen: boolean; @@ -32,28 +29,26 @@ export const ModalEncryptionOnboarding = ({ const { t } = useTranslation(); const { client: vaultClient, refreshKeyState } = useVaultClient(); const { refreshEncryption } = useUserEncryption(); - const onboardingOpenedRef = useRef(false); - const [containerEl, setContainerEl] = useState(null); + const openedRef = useRef(false); + const [ready, setReady] = useState(false); + // Open once the SDK is there; the interface then draws its own modal. useEffect(() => { - if ( - !isOpen || - !vaultClient || - !containerEl || - onboardingOpenedRef.current - ) { + if (!isOpen || !vaultClient || openedRef.current) { return; } - onboardingOpenedRef.current = true; - vaultClient.openOnboarding(containerEl); - }, [isOpen, vaultClient, containerEl]); + openedRef.current = true; + vaultClient.openOnboarding(); + }, [isOpen, vaultClient]); useEffect(() => { if (!vaultClient) { return; } + const handleReady = () => setReady(true); + const handleComplete = async () => { // The encryption service registered the public key on its central server. // Docs doesn't need to store it — just refresh the vault key state. @@ -63,47 +58,47 @@ export const ModalEncryptionOnboarding = ({ }; const handleClosed = () => { - onboardingOpenedRef.current = false; + openedRef.current = false; + setReady(false); onClose(); }; + vaultClient.on('interface:ready', handleReady); vaultClient.on('onboarding:complete', handleComplete); vaultClient.on('interface:closed', handleClosed); return () => { + vaultClient.off('interface:ready', handleReady); vaultClient.off('onboarding:complete', handleComplete); vaultClient.off('interface:closed', handleClosed); }; }, [vaultClient, refreshKeyState, refreshEncryption, onSuccess, onClose]); - // The modal's close control only ASKS the interface to close: it may hold an - // unsaved recovery phrase and answer with its own confirmation. The modal goes - // away on 'interface:closed', which the interface emits once really done. + // Closing the loader: nothing is at stake before the interface is on screen, + // so the frame is torn down outright (the interface's own close control takes + // over from there, with its confirmations). const handleClose = useCallback(() => { - if (vaultClient) { - vaultClient.requestClose(); - } else { - onClose(); - } + vaultClient?.closeInterface(); + openedRef.current = false; + onClose(); }, [vaultClient, onClose]); useEffect(() => { if (!isOpen) { - onboardingOpenedRef.current = false; + openedRef.current = false; + setReady(false); } }, [isOpen]); - const size = useInterfaceModalSize(isOpen); - return ( - + ); }; diff --git a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx index 5cffe4d5f..95d6d68d9 100644 --- a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx +++ b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx @@ -1,20 +1,18 @@ /** - * Encryption settings modal — delegates to the centralized encryption service. + * Encryption settings — delegates to the centralized encryption service. * - * Opens the encryption service's settings interface iframe which handles: - * fingerprint display, key deletion, device transfer export, and server key management. + * The service's settings interface (fingerprint, key deletion, device transfer, + * emergency access) draws its own modal over the page; Docs only shows a loader + * until it is on screen. */ -import { Modal } from '@gouvfr-lasuite/cunningham-react'; -import { useCallback, useEffect, useState } from 'react'; +import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useUserEncryption } from '@/docs/doc-collaboration'; import { useVaultClient } from '@/features/docs/doc-collaboration/vault'; -import { - EncryptionHostBody, - useInterfaceModalSize, -} from './EncryptionHostBody'; +import { EncryptionHostBody } from './EncryptionHostBody'; interface ModalEncryptionSettingsProps { isOpen: boolean; @@ -29,27 +27,28 @@ export const ModalEncryptionSettings = ({ const { t } = useTranslation(); const { client: vaultClient, refreshKeyState } = useVaultClient(); const { refreshEncryption } = useUserEncryption(); - const [containerEl, setContainerEl] = useState(null); - const [settingsOpened, setSettingsOpened] = useState(false); + const openedRef = useRef(false); + const [ready, setReady] = useState(false); - // Open the vault's settings interface when container is mounted useEffect(() => { - if (!isOpen || !vaultClient || !containerEl || settingsOpened) { + if (!isOpen || !vaultClient || openedRef.current) { return; } - setSettingsOpened(true); - vaultClient.openSettings(containerEl); - }, [isOpen, vaultClient, containerEl, settingsOpened]); + openedRef.current = true; + vaultClient.openSettings(); + }, [isOpen, vaultClient]); - // Listen for interface close and key changes useEffect(() => { if (!vaultClient) { return; } + const handleReady = () => setReady(true); + const handleClosed = () => { - setSettingsOpened(false); + openedRef.current = false; + setReady(false); void refreshKeyState().then(() => refreshEncryption()); onClose(); }; @@ -58,43 +57,41 @@ export const ModalEncryptionSettings = ({ void refreshKeyState().then(() => refreshEncryption()); }; + vaultClient.on('interface:ready', handleReady); vaultClient.on('interface:closed', handleClosed); vaultClient.on('keys-destroyed', handleKeysDestroyed); return () => { + vaultClient.off('interface:ready', handleReady); vaultClient.off('interface:closed', handleClosed); vaultClient.off('keys-destroyed', handleKeysDestroyed); }; }, [vaultClient, refreshKeyState, refreshEncryption, onClose]); - // The modal's close control only ASKS the interface to close: it may hold an - // unsaved recovery phrase and answer with its own confirmation. The modal goes - // away on 'interface:closed', which the interface emits once really done. + // Closing the loader: nothing is at stake before the interface is on screen, + // so the frame is torn down outright. const handleClose = useCallback(() => { - if (vaultClient) { - vaultClient.requestClose(); - } else { - onClose(); - } + vaultClient?.closeInterface(); + openedRef.current = false; + onClose(); }, [vaultClient, onClose]); useEffect(() => { if (!isOpen) { - setSettingsOpened(false); + openedRef.current = false; + setReady(false); } }, [isOpen]); - const size = useInterfaceModalSize(isOpen); - return ( - + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/client-sdk.d.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/client-sdk.d.ts index 6d355f3d6..874522481 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/client-sdk.d.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/client-sdk.d.ts @@ -20,13 +20,11 @@ export declare interface EncryptionClientEventMap { /** Fired when the user cancels or closes the interface */ [MSG_INTERFACE_CLOSED]: void; /** - * Fired when the shown screen wants a modal of another width: the product - * switches its modal between the design system's small (350px) and medium - * (600px) sizes. Emitted on every screen change, so a product can also ignore it. + * Fired when the interface opened by an open* call is on screen: the product + * dismisses the loader it showed meanwhile. Not fired for the overlays the SDK + * opens on its own (verify recipients, the emergency prompt). */ - 'interface:size': { - size: 'small' | 'medium'; - }; + 'interface:ready': void; /** Fired on errors from the vault or the interface */ error: Error; /** Fired when keys changed from another tab/product (via BroadcastChannel) */ @@ -106,11 +104,11 @@ export declare class VaultClient { private theme; private lang; private authContext; - private verifyOverlay; + private overlay; + private overlayWatchdog; + private overlayBootFailure; private verifyResolve; - private emergencyOverlay; private emergencySurfaced; - private emergencyWatchdog; private pendingContext; constructor(options: EncryptionClientOptions); /** @@ -357,41 +355,42 @@ export declare class VaultClient { formatFingerprint(fingerprint: string): string; /** * Open the encryption interface for onboarding (key generation + backup). - * The product provides a container element where the interface iframe will be mounted. - * The product is responsible for showing/hiding this container (e.g. in a modal). * - * Listen to 'onboarding:complete' and 'interface:closed' events for results. + * Every open* call lays a transparent, full-viewport layer over the page and + * loads the interface in it; the interface draws its own modal there (card, + * backdrop, close control, width), so the product shows nothing but a loader + * until 'interface:ready', and unmounts that loader on 'interface:closed'. + * Listen to 'onboarding:complete' for the result. */ - openOnboarding(container: HTMLElement): void; + openOnboarding(): void; /** * Open the encryption interface for key backup/export. */ - openBackup(container: HTMLElement): void; + openBackup(): void; /** * Open the encryption interface for key restoration from backup. */ - openRestore(container: HTMLElement): void; + openRestore(): void; /** * Open the encryption settings (view fingerprint, delete keys). */ - openSettings(container: HTMLElement): void; + openSettings(): void; /** * Open device approval: enroll this device from another, or approve a new one. */ - openDeviceApproval(container: HTMLElement): void; + openDeviceApproval(): void; /** * Open the emergency-access (trusted contacts) management screen: designate * contacts, accept a designation, follow or refuse a running recovery. */ - openEmergencyAccess(container: HTMLElement): void; + openEmergencyAccess(): void; /** * Open the per-recipient profile: the recipient's current trust decision, their * identity fingerprint (for out-of-band comparison), and Trust / Refuse actions. - * Opened explicitly by the product (e.g. clicking a person in its share UI), so - * it mounts in a product-provided container like the other open* methods. + * Opened explicitly by the product (e.g. clicking a person in its share UI). * `userId` is the recipient's OIDC sub, like every id a product passes. */ - openRecipientProfile(container: HTMLElement, userId: string, label: RecipientLabel): void; + openRecipientProfile(userId: string, label: RecipientLabel): void; /** * Ask the interface to close, from the product's own close control (the X of * the modal hosting the iframe). The interface owns the decision: mid-backup @@ -408,7 +407,23 @@ export declare class VaultClient { closeInterface(): void; on(event: K, listener: Listener): void; off(event: K, listener: Listener): void; - private openInterface; + /** + * Lay the transparent full-viewport layer over the page and load the interface + * in it. The layer stays `visibility: hidden` until the app inside asks for + * its context (the proof it came up), and that is the only right way to hide it: + * - `display: none` would stop the iframe laying out, so the app inside could + * mount at zero size; + * - `opacity: 0` would keep the layer in the hit-test, so this full-viewport + * element would silently swallow every click on the product underneath; + * - `visibility: hidden` still loads and lays the iframe out, but drops it + * from hit-testing, so clicks pass through to the product until the reveal. + * A watchdog tears down a page that never comes up: reported to the product + * (an 'error' then 'interface:closed', so its loader goes) for a flow it asked + * for, silently for the overlays the SDK opens on its own. + */ + private openOverlay; + /** The app inside mounted: show the layer, stand the watchdog down, tell the product. */ + private revealOverlay; /** * Construct and configure an interface iframe for `path` (sandbox, allow, * theme/lang hash, context handshake). Mounting is left to the caller so the @@ -458,14 +473,7 @@ export declare class VaultClient { * load-bearing channel for all of this is email. */ private surfaceEmergencyPending; - /** - * The interface app mounted. Reveal the overlay we kept hidden and stand the - * watchdog down. No-op for every other flow (the product owns their container). - */ - private revealEmergencyOverlay; - private teardownEmergencyOverlay; private completeVerify; - private teardownVerifyOverlay; /** * Run a recipient-bearing operation, and on UNTRUSTED_RECIPIENT open the shared * verify modal for the ORIGINAL recipients (full labeled map; the interface diff --git a/src/frontend/apps/impress/src/pages/globals.css b/src/frontend/apps/impress/src/pages/globals.css index df6830a36..6c2c13537 100644 --- a/src/frontend/apps/impress/src/pages/globals.css +++ b/src/frontend/apps/impress/src/pages/globals.css @@ -20,40 +20,13 @@ body > #__next > .c__app > div:has(> .c__loader) { box-sizing: border-box; } -/* The modals hosting the encryption service's screens use the design system's - 350px "small" modal; Cunningham's small is 300px. */ -.c__modal--small:has(.--docs--encryption-host), +/* Docs' own encryption cards (encrypt a document, remove encryption, key + mismatch) use the design system's small modal widened to 350px, the width the + encryption service's own screens use. */ .c__modal--small:has(.--docs--encryption-modal) { width: 350px; } -/* The interface pads its own screens: the modal gives its iframe the bare box. */ -.c__modal:has(.--docs--encryption-host) .c__modal__scroller { - padding: 0; -} - -/* The design system's close control is a zero-height sticky spot inside the - scroller: while the modal does not scroll it sits in the top padding, and once - it scrolls it pins elsewhere, so the cross jumps and the content shows through - around it. Anchoring it to the modal box itself keeps it in the same corner - in every case, and a halo in the modal's colour masks what scrolls under it. */ -.c__modal:has(.--docs--encryption-host) .c__modal__close, -.c__modal:has(.--docs--encryption-modal) .c__modal__close { - position: absolute; - top: 0.4rem; - right: 0.4rem; - height: auto; - z-index: 1; -} - -.c__modal:has(.--docs--encryption-host) .c__modal__close .c__button, -.c__modal:has(.--docs--encryption-modal) .c__modal__close .c__button { - top: 0; - right: 0; - background: var(--c--components--modal--background-color); - box-shadow: 0 0 0 4px var(--c--components--modal--background-color); -} - main ::-webkit-scrollbar, .ReactModalPortal ::-webkit-scrollbar { width: 20px;