This commit is contained in:
Thomas Ramé
2026-09-24 22:29:14 +02:00
parent 5533449c5b
commit 41d1d98c44
5 changed files with 108 additions and 185 deletions
@@ -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>(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 <Loading $minHeight="120px" />;
}
return (
<div
ref={hostRef}
className="--docs--encryption-host"
style={{ minHeight: '120px' }}
/>
);
return <Loading $minHeight="120px" />;
};
@@ -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<HTMLDivElement | null>(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 (
<Modal
isOpen={isOpen}
isOpen={isOpen && !ready}
closeOnClickOutside={false}
onClose={handleClose}
size={size}
size={ModalSize.SMALL}
aria-label={t('Encryption')}
>
<EncryptionHostBody hostRef={setContainerEl} onClose={onClose} />
<EncryptionHostBody onClose={handleClose} />
</Modal>
);
};
@@ -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<HTMLDivElement | null>(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 (
<Modal
isOpen={isOpen}
isOpen={isOpen && !ready}
closeOnClickOutside={false}
onClose={handleClose}
size={size}
size={ModalSize.SMALL}
aria-label={t('Encryption')}
>
<EncryptionHostBody hostRef={setContainerEl} onClose={onClose} />
<EncryptionHostBody onClose={handleClose} />
</Modal>
);
};
@@ -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<K extends keyof EncryptionClientEventMap>(event: K, listener: Listener<K>): void;
off<K extends keyof EncryptionClientEventMap>(event: K, listener: Listener<K>): 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
@@ -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;