mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-28 20:45:08 +02:00
fu
This commit is contained in:
@@ -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" />;
|
||||
};
|
||||
|
||||
+31
-36
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+29
-32
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+37
-29
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user