diff --git a/src/frontend/apps/impress/src/assets/encryption/document-encrypted.svg b/src/frontend/apps/impress/src/assets/encryption/document-encrypted.svg
new file mode 100644
index 000000000..9e748eb96
--- /dev/null
+++ b/src/frontend/apps/impress/src/assets/encryption/document-encrypted.svg
@@ -0,0 +1,89 @@
+
diff --git a/src/frontend/apps/impress/src/assets/encryption/document-encrypting.svg b/src/frontend/apps/impress/src/assets/encryption/document-encrypting.svg
new file mode 100644
index 000000000..7df0e620d
--- /dev/null
+++ b/src/frontend/apps/impress/src/assets/encryption/document-encrypting.svg
@@ -0,0 +1,90 @@
+
diff --git a/src/frontend/apps/impress/src/assets/encryption/document-shield-check.png b/src/frontend/apps/impress/src/assets/encryption/document-shield-check.png
new file mode 100644
index 000000000..7fc9a7696
Binary files /dev/null and b/src/frontend/apps/impress/src/assets/encryption/document-shield-check.png differ
diff --git a/src/frontend/apps/impress/src/assets/encryption/document-shield-x.png b/src/frontend/apps/impress/src/assets/encryption/document-shield-x.png
new file mode 100644
index 000000000..73ee07526
Binary files /dev/null and b/src/frontend/apps/impress/src/assets/encryption/document-shield-x.png differ
diff --git a/src/frontend/apps/impress/src/assets/encryption/shield-check.png b/src/frontend/apps/impress/src/assets/encryption/shield-check.png
new file mode 100644
index 000000000..54165863c
Binary files /dev/null and b/src/frontend/apps/impress/src/assets/encryption/shield-check.png differ
diff --git a/src/frontend/apps/impress/src/assets/encryption/shield-x.png b/src/frontend/apps/impress/src/assets/encryption/shield-x.png
new file mode 100644
index 000000000..a3d68ba42
Binary files /dev/null and b/src/frontend/apps/impress/src/assets/encryption/shield-x.png differ
diff --git a/src/frontend/apps/impress/src/features/auth/components/AccountMenu.tsx b/src/frontend/apps/impress/src/features/auth/components/AccountMenu.tsx
index 465b7e3fa..1f8aa3b13 100644
--- a/src/frontend/apps/impress/src/features/auth/components/AccountMenu.tsx
+++ b/src/frontend/apps/impress/src/features/auth/components/AccountMenu.tsx
@@ -30,7 +30,7 @@ export const AccountMenu = () => {
if (hasEncryptionSetup) {
return {
label: t('Encryption settings'),
- icon: 'lock',
+ icon: 'verified_user',
callback: () => setIsSettingsOpen(true),
showSeparator: true,
};
@@ -38,7 +38,7 @@ export const AccountMenu = () => {
return {
label: t('Enable encryption'),
- icon: 'lock_open',
+ icon: 'add_moderator',
callback: () => setIsOnboardingOpen(true),
showSeparator: true,
};
diff --git a/src/frontend/apps/impress/src/features/auth/components/EncryptionHostBody.tsx b/src/frontend/apps/impress/src/features/auth/components/EncryptionHostBody.tsx
new file mode 100644
index 000000000..3702ed815
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/auth/components/EncryptionHostBody.tsx
@@ -0,0 +1,95 @@
+import { Button, ModalSize } from '@gouvfr-lasuite/cunningham-react';
+import { useEffect, useState } from '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.
+ */
+export const EncryptionHostBody = ({
+ hostRef,
+ onClose,
+}: EncryptionHostBodyProps) => {
+ const { t } = useTranslation();
+ const { client, isLoading, error } = useVaultClient();
+
+ if (error) {
+ return (
+
+
+
+ >
+ }
+ />
+ );
+ }
+
+ if (!client || isLoading) {
+ 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 6d509956f..c5c6b93fe 100644
--- a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx
+++ b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx
@@ -6,13 +6,18 @@
* The product (Docs) doesn't manage public keys — it only stores fingerprints
* on document accesses for UI purposes.
*/
-import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
+import { Modal } from '@gouvfr-lasuite/cunningham-react';
import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
-import { Box } from '@/components';
import { useUserEncryption } from '@/docs/doc-collaboration';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
+import {
+ EncryptionHostBody,
+ useInterfaceModalSize,
+} from './EncryptionHostBody';
+
interface ModalEncryptionOnboardingProps {
isOpen: boolean;
onClose: () => void;
@@ -24,6 +29,7 @@ export const ModalEncryptionOnboarding = ({
onClose,
onSuccess,
}: ModalEncryptionOnboardingProps) => {
+ const { t } = useTranslation();
const { client: vaultClient, refreshKeyState } = useVaultClient();
const { refreshEncryption } = useUserEncryption();
const onboardingOpenedRef = useRef(false);
@@ -70,10 +76,15 @@ export const ModalEncryptionOnboarding = ({
};
}, [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.
const handleClose = useCallback(() => {
- vaultClient?.closeInterface();
- onboardingOpenedRef.current = false;
- onClose();
+ if (vaultClient) {
+ vaultClient.requestClose();
+ } else {
+ onClose();
+ }
}, [vaultClient, onClose]);
useEffect(() => {
@@ -82,20 +93,17 @@ export const ModalEncryptionOnboarding = ({
}
}, [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 9eaf03e5d..5cffe4d5f 100644
--- a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx
+++ b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx
@@ -4,13 +4,18 @@
* Opens the encryption service's settings interface iframe which handles:
* fingerprint display, key deletion, device transfer export, and server key management.
*/
-import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
+import { Modal } from '@gouvfr-lasuite/cunningham-react';
import { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
-import { Box } from '@/components';
import { useUserEncryption } from '@/docs/doc-collaboration';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
+import {
+ EncryptionHostBody,
+ useInterfaceModalSize,
+} from './EncryptionHostBody';
+
interface ModalEncryptionSettingsProps {
isOpen: boolean;
onClose: () => void;
@@ -21,6 +26,7 @@ export const ModalEncryptionSettings = ({
isOpen,
onClose,
}: ModalEncryptionSettingsProps) => {
+ const { t } = useTranslation();
const { client: vaultClient, refreshKeyState } = useVaultClient();
const { refreshEncryption } = useUserEncryption();
const [containerEl, setContainerEl] = useState(null);
@@ -61,10 +67,15 @@ export const ModalEncryptionSettings = ({
};
}, [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.
const handleClose = useCallback(() => {
- vaultClient?.closeInterface();
- setSettingsOpened(false);
- onClose();
+ if (vaultClient) {
+ vaultClient.requestClose();
+ } else {
+ onClose();
+ }
}, [vaultClient, onClose]);
useEffect(() => {
@@ -73,20 +84,17 @@ export const ModalEncryptionSettings = ({
}
}, [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 0f1ffbb37..6d355f3d6 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
@@ -19,6 +19,14 @@ 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.
+ */
+ 'interface:size': {
+ size: 'small' | 'medium';
+ };
/** Fired on errors from the vault or the interface */
error: Error;
/** Fired when keys changed from another tab/product (via BroadcastChannel) */
@@ -218,8 +226,7 @@ export declare class VaultClient {
* @param keyVersion - the recipient's encryption-key VERSION this wrap was
* produced against, as stored by the product on the access row. The vault
* unwraps with exactly that retained key (a version this device no longer
- * holds throws WRONG_SECRET_KEY). For Drive chains it is the version of the
- * ENTRY-point key; the chain links themselves are symmetric.
+ * holds throws WRONG_SECRET_KEY).
* @param encryptedKeyChain - optional chain of wrapped keys for Drive's key hierarchy.
* When provided, resolves the chain from entry point to target before decrypting.
*/
@@ -385,6 +392,16 @@ export declare class VaultClient {
* `userId` is the recipient's OIDC sub, like every id a product passes.
*/
openRecipientProfile(container: HTMLElement, 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
+ * it shows its "cancel setup?" confirmation instead of closing, so a product
+ * must NOT unmount its modal here. It waits for the 'interface:closed' event,
+ * which fires once the interface has really closed (for this request or any
+ * other reason). With no interface open this is a no-op that still emits
+ * 'interface:closed', so a product's close handler stays uniform.
+ */
+ requestClose(): void;
/**
* Close the interface iframe if it is open.
*/
@@ -526,6 +543,10 @@ export declare const VaultErrorCode: {
readonly IFRAME_REQUIRED: "IFRAME_REQUIRED";
/** Ciphertext / encrypted-key payload too short to be valid (truncated). */
readonly CIPHERTEXT_TOO_SHORT: "CIPHERTEXT_TOO_SHORT";
+ /** Ciphertext structure is inconsistent (e.g. a wrong length field): corrupt, not merely truncated. */
+ readonly MALFORMED_CIPHERTEXT: "MALFORMED_CIPHERTEXT";
+ /** A field cannot be represented in the canonical signed-payload encoding (length or number out of range). */
+ readonly INVALID_CANONICAL_PAYLOAD: "INVALID_CANONICAL_PAYLOAD";
/** Blob's leading version byte doesn't match a format this build can decode. */
readonly UNSUPPORTED_CRYPTO_VERSION: "UNSUPPORTED_CRYPTO_VERSION";
/** A signature public key didn't have the expected Ed25519 length. */
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 2659b0cca..46dcc5a6c 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
@@ -12,6 +12,7 @@ import {
DropdownMenuOption,
Icon,
IconOptions,
+ Text,
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
@@ -149,7 +150,7 @@ export const DocToolBox = ({
},
{
label: t('Encrypt document'),
- icon: 'https',
+ icon: 'add_moderator',
disabled: !doc.abilities.accesses_manage,
callback: () => {
setIsModalEncryptOpen(true);
@@ -158,8 +159,8 @@ export const DocToolBox = ({
showSeparator: isTopRoot ? true : false,
},
{
- label: t('Remove document encryption'),
- icon: 'no_encryption',
+ label: t('Remove encryption'),
+ icon: 'remove_moderator',
disabled: !doc.abilities.accesses_manage,
callback: () => {
setIsModalRemoveEncryptionOpen(true);
@@ -242,21 +243,15 @@ export const DocToolBox = ({
-
-
+
+
{t('Encrypted')}
-
+
)}
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/EncryptionLayout.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/EncryptionLayout.tsx
new file mode 100644
index 000000000..399a4414a
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/EncryptionLayout.tsx
@@ -0,0 +1,165 @@
+import type { StaticImageData } from 'next/image';
+import { ReactNode } from 'react';
+import { css } from 'styled-components';
+
+import DocumentEncrypted from '@/assets/encryption/document-encrypted.svg';
+import DocumentEncrypting from '@/assets/encryption/document-encrypting.svg';
+import documentShieldCheck from '@/assets/encryption/document-shield-check.png';
+import documentShieldX from '@/assets/encryption/document-shield-x.png';
+import shieldCheck from '@/assets/encryption/shield-check.png';
+import shieldX from '@/assets/encryption/shield-x.png';
+import { Box, Text } from '@/components';
+
+const MODAL_ILLUSTRATIONS = {
+ 'shield-check': shieldCheck,
+ 'shield-x': shieldX,
+ 'document-shield-check': documentShieldCheck,
+ 'document-shield-x': documentShieldX,
+} satisfies Record;
+
+const STATE_ILLUSTRATIONS = {
+ 'document-encrypted': DocumentEncrypted,
+ 'document-encrypting': DocumentEncrypting,
+};
+
+export type ModalIllustration = keyof typeof MODAL_ILLUSTRATIONS;
+export type StateIllustration = keyof typeof STATE_ILLUSTRATIONS;
+
+interface EncryptionModalContentProps {
+ illustration?: ModalIllustration;
+ title: ReactNode;
+ titleId?: string;
+ description?: ReactNode;
+ children?: ReactNode;
+ actions?: ReactNode;
+ actionsLayout?: 'stack' | 'row';
+}
+
+/**
+ * The content of an encryption modal: illustration, title, description, body
+ * and stacked full-width actions. Rendered inside a Cunningham small modal
+ * (widened to 350px by `globals.css`) with the modal's own close control.
+ */
+export const EncryptionModalContent = ({
+ illustration,
+ title,
+ titleId,
+ description,
+ children,
+ actions,
+ actionsLayout = 'stack',
+}: EncryptionModalContentProps) => (
+
+ {illustration && (
+
+
+
+ )}
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {children && {children}}
+ {actions && (
+ .c__button {
+ width: 100%;
+ justify-content: center;
+ }
+ `
+ }
+ >
+ {actions}
+
+ )}
+
+);
+
+interface EncryptionEmptyStateProps {
+ illustration?: StateIllustration;
+ title: ReactNode;
+ description?: ReactNode;
+ children?: ReactNode;
+ actions?: ReactNode;
+}
+
+/**
+ * A compact centered state on a page or in a panel: small illustration, bold
+ * title, short description and text-link actions.
+ */
+export const EncryptionEmptyState = ({
+ illustration = 'document-encrypted',
+ title,
+ description,
+ children,
+ actions,
+}: EncryptionEmptyStateProps) => {
+ const Illustration = STATE_ILLUSTRATIONS[illustration];
+
+ return (
+
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {children}
+ {actions && (
+
+ {actions}
+
+ )}
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/KeyMismatchPanel.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/KeyMismatchPanel.tsx
index c880227e7..ce6fc8b76 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-management/components/KeyMismatchPanel.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/KeyMismatchPanel.tsx
@@ -1,7 +1,8 @@
+import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Box, Icon, Text } from '@/components';
+import { Icon, StyledLink, Text } from '@/components';
import { useAuth } from '@/features/auth';
import {
fetchRegisteredKeys,
@@ -10,6 +11,8 @@ import {
import type { Doc } from '../types';
+import { EncryptionEmptyState } from './EncryptionLayout';
+
/**
* True when the SDK threw a `VaultError` carrying the
* `WRONG_SECRET_KEY` code. In docs this means the document was
@@ -79,55 +82,61 @@ export const KeyMismatchPanel = ({ doc }: Props) => {
};
}, [vaultClient, user?.suite_user_id]);
+ const versionChip = (value: number) => (
+
+ {value}
+
+ );
+
return (
-
-
-
- {t('This document was encrypted with a different key')}
-
-
-
- {t(
- 'The document was encrypted for you at a time when you were using a different encryption key — possibly before you reset your keys or switched device without restoring a backup. Your current key can no longer decrypt it. Ask an owner or administrator of this document to remove you from the access list and add you back so it gets re-encrypted for your current key.',
- )}
-
-
+
+ }
+ >
+ {t('Home')}
+
+
+ }
+ >
{(shareTimeVersion !== null || currentVersion !== null) && (
-
+
{shareTimeVersion !== null && (
-
+
{t('Encryption key version at the time it was shared with you:')}{' '}
-
- {shareTimeVersion}
-
-
+ {versionChip(shareTimeVersion)}
+
)}
{currentVersion !== null && (
-
+
{t('Your current encryption key version:')}{' '}
-
- {currentVersion}
-
-
+ {versionChip(currentVersion)}
+
)}
-
+
)}
-
+
);
};
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 ff5a21fb9..231d67fc8 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
@@ -11,7 +11,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
-import { Box, ButtonCloseModal, Icon, Text, TextErrors } from '@/components';
+import { TextErrors } from '@/components';
import { useUserEncryption } from '@/docs/doc-collaboration';
import { createDocAttachment } from '@/docs/doc-editor/api';
import { useAuth } from '@/features/auth';
@@ -35,6 +35,8 @@ import { useDocAccesses } from '@/features/docs/doc-share/api/useDocAccesses';
import { useDocInvitations } from '@/features/docs/doc-share/api/useDocInvitations';
import { useKeyboardAction } from '@/hooks';
+import { EncryptionModalContent } from './EncryptionLayout';
+
/**
* encrypt existing unencrypted attachments and return:
* - a modified Yjs state with URLs pointing to new encrypted files
@@ -372,185 +374,94 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
const handleCloseKeyDown = keyboardAction(handleClose);
const handleEncryptKeyDown = keyboardAction(handleEncrypt);
+ // Only the conditions that are NOT met are worth a word: the modal describes
+ // what encrypting does, and warns about what still blocks it.
+ const blockers: string[] = [];
+ if (!hasEncryptionKeys) {
+ blockers.push(t('You must enable encryption from your account menu first'));
+ }
+ if (!isRestricted) {
+ blockers.push(
+ t('Document must be set to private (currently {{reach}})', {
+ reach:
+ effectiveReach === LinkReach.PUBLIC ? t('public') : t('connected'),
+ }),
+ );
+ }
+ if (hasPendingInvitations) {
+ blockers.push(t('Pending invitations must be resolved first'));
+ }
+
return (
-
-
- >
- }
- size={ModalSize.MEDIUM}
- title={
-
-
- {t('Encrypt document')}
-
-
-
- }
+ size={ModalSize.SMALL}
+ aria-label={t('Encrypt document')}
>
-
- {!isError && (
-
-
-
-
- {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.',
- )}
-
-
-
+
+
+
+ >
+ }
+ >
+ {!isError && blockers.length > 0 && (
+
+ {blockers.length === 1 ? (
+ blockers[0]
+ ) : (
+
+ {blockers.map((blocker) => (
+
{blocker}
+ ))}
+
+ )}
+
+ )}
-
- {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(
- 'Document must be set to private (currently {{reach}})',
- {
- reach:
- effectiveReach === LinkReach.PUBLIC
- ? t('public')
- : t('connected'),
- },
- )}
-
-
-
-
-
-
- {!hasPendingInvitations
- ? t('No pending invitations')
- : t('Pending invitations must be resolved first')}
-
-
-
-
-
-
-
- {othersWithoutKey.length === 0
- ? t('All members have encryption enabled')
- : t(
- '{{count}} member(s) haven’t completed encryption onboarding yet. They will be added as pending and won’t be able to decrypt the document until another validated collaborator accepts them from the share dialog.',
- { count: othersWithoutKey.length },
- )}
-
-
- {othersWithoutKey.length > 0 && (
-
- {othersWithoutKey.map((access) => (
-
- {access.user.full_name || access.user.email}
-
- ))}
-
- )}
-
-
-
+ {!isError && blockers.length === 0 && othersWithoutKey.length > 0 && (
+
+ {t(
+ '{{count}} collaborator(s) have not enabled encryption yet. They will be added as pending and cannot open the document until someone accepts them from the share dialog.',
+ { count: othersWithoutKey.length },
+ )}
+
)}
{isError && }
-
+
);
};
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 442b06791..464c99c8b 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,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
-import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
+import { TextErrors } from '@/components';
import { createDocAttachment } from '@/docs/doc-editor/api';
import { useAuth } from '@/features/auth';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
@@ -25,6 +25,8 @@ import {
} from '@/features/docs/doc-management';
import { useKeyboardAction } from '@/hooks';
+import { EncryptionModalContent } from './EncryptionLayout';
+
/**
* Decrypt existing encrypted attachments using the vault and upload decrypted copies.
*/
@@ -188,47 +190,39 @@ export const ModalRemoveDocEncryption = ({
isOpen
closeOnClickOutside
onClose={handleClose}
- size={ModalSize.MEDIUM}
- rightActions={
- <>
-
-
- >
- }
- title={
-
-
- {t('Remove document encryption')}
-
-
-
- }
- hideCloseButton
+ size={ModalSize.SMALL}
+ aria-label={t('Remove encryption')}
>
-
-
- {t(
- 'This will permanently remove encryption from this document. All content will be stored in plain text.',
- )}
-
+
+
+
+ >
+ }
+ >
{isError && error && }
-
+
);
};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx
index da35a0707..a3ebf3779 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx
@@ -93,10 +93,12 @@ export const DocShareInvitationItem = ({
type DocShareModalInviteUserRowProps = {
user: User;
suffix?: string;
+ suffixIcon?: string;
};
export const DocShareModalInviteUserRow = ({
user,
suffix,
+ suffixIcon,
}: DocShareModalInviteUserRowProps) => {
const { t } = useTranslation();
return (
@@ -108,6 +110,7 @@ export const DocShareModalInviteUserRow = ({
void;
};
export const DocShareMemberItem = ({
@@ -31,6 +32,7 @@ export const DocShareMemberItem = ({
access,
isInherited = false,
suffix,
+ suffixIcon,
onSuffixClick,
}: Props) => {
const { t } = useTranslation();
@@ -76,6 +78,7 @@ export const DocShareMemberItem = ({
alwaysShowRight={true}
user={access.user}
suffix={suffix}
+ suffixIcon={suffixIcon}
onSuffixClick={onSuffixClick}
right={
@@ -147,12 +150,13 @@ export const QuickSearchGroupMember = ({
(!uid || !doc.accesses_versions_per_user?.[uid]);
let suffix: string | undefined;
+ let suffixIcon: string | undefined;
if (hasMismatch) {
- suffix = t('DIFFERENT PUBLIC KEY, PLEASE VERIFY');
+ suffix = t('Verify key');
+ suffixIcon = 'gpp_maybe';
} else if (hasNoEncryptionKey) {
- suffix = t(
- 'ENCRYPTION DISABLED - consider removing this member since unable to read the document',
- );
+ suffix = t('No encryption');
+ suffixIcon = 'gpp_bad';
}
return (
@@ -160,6 +164,7 @@ export const QuickSearchGroupMember = ({
doc={doc}
access={access}
suffix={suffix}
+ suffixIcon={suffixIcon}
onSuffixClick={
hasMismatch && uid ? () => setMismatchUserId(uid) : undefined
}
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 9ee654aa9..b8127dd3c 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
@@ -9,7 +9,6 @@ import {
Box,
ButtonCloseModal,
HorizontalSeparator,
- Icon,
Loading,
Text,
} from '@/components';
@@ -27,6 +26,10 @@ import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/u
import type { PublicKeyMismatch } from '@/docs/doc-collaboration/hook/usePublicKeyRegistry';
import { Doc } from '@/docs/doc-management';
import { User, useAuth } from '@/features/auth';
+import {
+ EncryptionEmptyState,
+ EncryptionModalContent,
+} from '@/features/docs/doc-management/components/EncryptionLayout';
import { useResponsiveStore } from '@/stores';
import { isValidEmail } from '@/utils';
@@ -272,39 +275,23 @@ export const DocShareModal = ({
{isEncryptionDeriving && }
{!isEncryptionDeriving && derivedEncryptionError && (
-
-
-
- {t('Encryption keys unavailable')}
-
-
- {t(
- 'This is an encrypted document, but your current device does not have the required encryption keys to decrypt it.',
- )}
-
- {(encryptionError === 'missing_private_key' ||
- encryptionError === 'missing_public_key') && (
-
- {t(
- 'This usually happens when you switch to a new device or browser without restoring your encryption backup, please go to your "Encryption Settings" to fix it.',
- )}
-
- )}
- {documentEncryptionError === 'missing_symmetric_key' && (
-
- {t(
- 'You do not have access to this encrypted document. Ask the document owner to share it with you again.',
- )}
-
- )}
- {documentEncryptionError === 'decryption_failed' && (
-
- {t(
- 'Your encryption keys could not decrypt this document. This may happen if your keys were recreated. Ask the document owner to share it with you again.',
- )}
-
- )}
-
+
)}
{!isEncryptionDeriving && !derivedEncryptionError && (
{
if (user.suite_user_id && keyMismatchUserIds?.has(user.suite_user_id)) {
- return t('DIFFERENT PUBLIC KEY, PLEASE VERIFY');
+ return t('Verify key');
}
if (
isEncrypted &&
(!user.suite_user_id ||
!doc.accesses_versions_per_user?.[user.suite_user_id])
) {
- return t(`(encryption not enabled)`);
+ return t('No encryption');
}
return undefined;
},
@@ -560,6 +547,11 @@ const QuickSearchInviteInputSection = ({
)}
/>
@@ -580,37 +572,20 @@ const QuickSearchInviteInputSection = ({
closeOnClickOutside
onClose={() => setShowNoKeyModal(false)}
size={ModalSize.SMALL}
- rightActions={
-
- }
- title={
-
-
- {t('Encryption required')}
-
- }
+ aria-label={t('Encryption required')}
>
-
-
- {t(
- 'This user has not enabled encryption on their account yet. It is not possible to share encrypted content with them.',
- )}
-
-
- {t(
- 'Please ask them to enable encryption in their account settings first.',
- )}
-
-
+ setShowNoKeyModal(false)}>
+ {t('Understood')}
+
+ }
+ />
)}
{mismatchUser &&
diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/ModalKeyMismatch.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/ModalKeyMismatch.tsx
index 633698066..a1fdea2c2 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-share/components/ModalKeyMismatch.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/ModalKeyMismatch.tsx
@@ -1,8 +1,15 @@
-import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
+import {
+ Alert,
+ Button,
+ Modal,
+ ModalSize,
+ VariantType,
+} from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
-import { Box, Icon, Text } from '@/components';
+import { Box, Text } from '@/components';
import { useKeyFingerprint } from '@/docs/doc-collaboration';
+import { EncryptionModalContent } from '@/features/docs/doc-management/components/EncryptionLayout';
interface ModalKeyMismatchProps {
onClose: () => void;
@@ -11,6 +18,21 @@ interface ModalKeyMismatchProps {
currentKey?: string;
}
+const Fingerprint = ({ label, value }: { label: string; value: string }) => (
+
+
+ {label}
+
+
+ {value}
+
+
+);
+
export const ModalKeyMismatch = ({
onClose,
onAcceptKey,
@@ -26,94 +48,60 @@ export const ModalKeyMismatch = ({
isOpen
closeOnClickOutside
onClose={onClose}
- size={ModalSize.MEDIUM}
- rightActions={
- <>
-
- {onAcceptKey && (
-
- )}
- >
- }
- title={
-
-
- {t('Public key change detected')}
-
- }
+ size={ModalSize.SMALL}
+ aria-label={t('Verify encryption key')}
>
-
-
+
+
+ {onAcceptKey && (
+
+ )}
+ >
+ }
+ >
+
{t(
- "This user's encryption public key has changed since you last interacted with them.",
+ "This person's encryption key has changed. Verify it before continuing.",
)}
-
-
- {t(
- 'This could mean the user has regenerated their encryption keys, but it could also indicate that their account has been compromised.',
- )}
-
-
- {t(
- 'We recommend verifying with this person directly (e.g. via video call) that they have indeed changed their encryption key before proceeding.',
- )}
-
+
{(knownFingerprint || currentFingerprint) && (
{knownFingerprint && (
-
-
- {t('Previously known:')}
-
-
- {knownFingerprint}
-
-
+
)}
{currentFingerprint && (
-
-
- {t('Current key:')}
-
-
- {currentFingerprint}
-
-
+
)}
)}
-
+
);
};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/PendingEncryptionSection.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/PendingEncryptionSection.tsx
index 41bc413b4..1a5b1e531 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-share/components/PendingEncryptionSection.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/PendingEncryptionSection.tsx
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
+import { UserAvatar } from '@/features/auth';
import type { DocumentEncryptionSettings } from '@/features/docs/doc-collaboration/hook/useDocumentEncryption';
import {
fetchRegisteredKeys,
@@ -167,24 +168,14 @@ export const PendingEncryptionSection = ({
return (
-
-
-
- {t('Users pending encryption access ({{count}})', {
- count: pending.length,
- })}
-
-
-
+
+ {t('Action needed')}
+
+
{pending.map((access) => {
const sub = access.user?.suite_user_id;
const isBusy = inFlight.has(access.id);
@@ -195,45 +186,76 @@ export const PendingEncryptionSection = ({
!!documentEncryptionSettings &&
hasPublicKey === true &&
!probing;
+ const name = access.user?.full_name || access.user?.email || '';
+
return (
-
-
- {access.user?.full_name || access.user?.email}
-
- {access.user?.email && access.user?.full_name && (
-
- {access.user.email}
+
+
+
+
+ {name}
- )}
- {!canAccept && !probing && (
-
- {t(
- "Waiting for this user to complete their encryption onboarding. You'll be able to accept them once they have.",
- )}
-
- )}
- {error && (
-
- {error}
-
- )}
+ {access.user?.email && access.user?.full_name && (
+
+ {access.user.email}
+
+ )}
+ {!canAccept && !probing && (
+
+ {t(
+ 'Waiting for them to enable encryption. You will be able to accept them once they have.',
+ )}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
- {canAccept && (
+ {canAccept ? (
+ ) : (
+
+
+
+ {t('Pending encryption')}
+
+
)}
);
diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx
index ab277ebf1..5d9020aca 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx
@@ -1,4 +1,4 @@
-import { Box, Text } from '@/components';
+import { Box, Icon, Text } from '@/components';
import {
QuickSearchItemContent,
QuickSearchItemContentProps,
@@ -11,7 +11,10 @@ type Props = {
alwaysShowRight?: boolean;
right?: QuickSearchItemContentProps['right'];
isInvitation?: boolean;
+ /** A short status ("Verify key", "No encryption") shown as an icon with the text as tooltip. */
suffix?: string;
+ /** Material icon for the suffix; the shield-with-question mark by default. */
+ suffixIcon?: string;
onSuffixClick?: () => void;
};
@@ -21,6 +24,7 @@ export const SearchUserRow = ({
alwaysShowRight = false,
isInvitation = false,
suffix,
+ suffixIcon = 'gpp_maybe',
onSuffixClick,
}: Props) => {
const hasFullName = !!user.full_name;
@@ -47,10 +51,13 @@ export const SearchUserRow = ({
{hasFullName ? user.full_name : user.email}
{suffix && (
- {
e.stopPropagation();
@@ -58,14 +65,9 @@ export const SearchUserRow = ({
},
role: 'button',
tabIndex: 0,
- style: {
- cursor: 'pointer',
- textDecoration: 'underline',
- },
+ style: { cursor: 'pointer' },
})}
- >
- {suffix}
-
+ />
)}
{hasFullName && (
diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json
index 3e9cb6d6c..30ad0ca4f 100644
--- a/src/frontend/apps/impress/src/i18n/translations.json
+++ b/src/frontend/apps/impress/src/i18n/translations.json
@@ -1000,7 +1000,50 @@
"pdf": "pdf",
"{{action}}, current role: {{role}}": "{{action}}, rôle actuel : {{role}}",
"{{name}} added to invite list. Add more members or press Tab to select role and invite.": "{{name}} a été ajouté à la liste d'invitation. Ajoutez plus de membres ou appuyez sur Tab pour sélectionner le rôle et inviter.",
- "{{name}} removed from invite list": "{{name}} a été retiré de la liste d'invitation"
+ "{{name}} removed from invite list": "{{name}} a été retiré de la liste d'invitation",
+ "Encryption": "Chiffrement",
+ "Remove encryption": "Retirer le chiffrement",
+ "Encrypted": "Chiffré",
+ "Encrypt": "Chiffrer",
+ "The document and its attachments will be encrypted end-to-end. Only people you share it with will be able to access its contents.": "Le document et ses pièces jointes seront chiffrés de bout en bout. Seules les personnes avec qui vous le partagez pourront y accéder.",
+ "The document will be decrypted and stored in plain text on the server.": "Le document sera déchiffré et stocké en clair sur le serveur.",
+ "{{count}} collaborator(s) have not enabled encryption yet. They will be added as pending and cannot open the document until someone accepts them from the share dialog.": "{{count}} collaborateur(s) n'ont pas encore activé le chiffrement. Ils seront ajoutés en attente et ne pourront pas ouvrir le document tant qu'une personne ne les aura pas acceptés depuis la fenêtre de partage.",
+ "You must enable encryption from your account menu first": "Vous devez d'abord activer le chiffrement depuis le menu de votre compte",
+ "Document must be set to private (currently {{reach}})": "Le document doit être privé (actuellement {{reach}})",
+ "Pending invitations must be resolved first": "Les invitations en attente doivent d'abord être traitées",
+ "Encrypted document": "Document chiffré",
+ "Encryption service unavailable": "Service de chiffrement indisponible",
+ "The encryption service could not be loaded. Check your connection and try again.": "Le service de chiffrement n'a pas pu être chargé. Vérifiez votre connexion et réessayez.",
+ "This document is encrypted and the encryption service could not be loaded. Check your connection and try again.": "Ce document est chiffré et le service de chiffrement n'a pas pu être chargé. Vérifiez votre connexion et réessayez.",
+ "Retry": "Réessayer",
+ "Close": "Fermer",
+ "This document is encrypted. You must enable encryption on your account to access it.": "Ce document est chiffré. Vous devez activer le chiffrement sur votre compte pour y accéder.",
+ "You do not have access to this encrypted document. Ask the document owner to share it with you again.": "Vous n'avez pas accès à ce document chiffré. Demandez à son propriétaire de le partager à nouveau avec vous.",
+ "You do not have the correct encryption key to decrypt this document. Ask the document owner to share it with you again.": "Vous n'avez pas la bonne clé de chiffrement pour déchiffrer ce document. Demandez à son propriétaire de le partager à nouveau avec vous.",
+ "Enable encryption": "Activer le chiffrement",
+ "Encryption in progress": "Chiffrement en cours",
+ "Removing encryption": "Retrait du chiffrement",
+ "The document owner is encrypting this document. Please wait.": "Le propriétaire du document est en train de le chiffrer. Veuillez patienter.",
+ "The document owner is removing encryption from this document. Please wait.": "Le propriétaire du document est en train d'en retirer le chiffrement. Veuillez patienter.",
+ "This document was encrypted with a different key": "Ce document a été chiffré avec une autre clé",
+ "Verify key": "Vérifier la clé",
+ "No encryption": "Pas de chiffrement",
+ "Encryption required": "Chiffrement requis",
+ "This person has not enabled encryption yet, so the document cannot be shared with them. Ask them to enable encryption first.": "Cette personne n'a pas encore activé le chiffrement, le document ne peut donc pas être partagé avec elle. Demandez-lui d'activer le chiffrement d'abord.",
+ "Understood": "Compris",
+ "Verify encryption key": "Vérifier la clé de chiffrement",
+ "This person's encryption key has changed. Verify it before continuing.": "La clé de chiffrement de cette personne a changé. Vérifiez-la avant de continuer.",
+ "We recommend verifying with this person directly (for example on a call) that they really changed their encryption key before proceeding.": "Nous vous recommandons de vérifier directement avec cette personne (par exemple lors d'un appel) qu'elle a bien changé sa clé de chiffrement avant de continuer.",
+ "Don't trust": "Ne pas faire confiance",
+ "Trust": "Faire confiance",
+ "Previously known:": "Clé connue :",
+ "Current key:": "Clé actuelle :",
+ "Action needed": "Action requise",
+ "Waiting for them to enable encryption. You will be able to accept them once they have.": "En attente de l'activation du chiffrement par cette personne. Vous pourrez l'accepter une fois que ce sera fait.",
+ "Accept": "Accepter",
+ "Accepting…": "Acceptation…",
+ "Pending encryption": "Chiffrement en attente",
+ "Encryption settings": "Paramètres de chiffrement"
}
},
"it": {
@@ -1449,7 +1492,9 @@
"{{name}} removed from invite list": "{{name}} verwijderd uit de uitnodigingslijst"
}
},
- "pt": { "translation": {} },
+ "pt": {
+ "translation": {}
+ },
"ru": {
"translation": {
"\"{{email}}\" is already invited to the document.": "\"{{email}}\" уже имеет приглашение для этого документа.",
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 e1081c23b..75965a28b 100644
--- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
+++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
@@ -1,12 +1,12 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
-import { Spinner, TreeProvider } from '@gouvfr-lasuite/ui-kit';
+import { TreeProvider } from '@gouvfr-lasuite/ui-kit';
import { useQueryClient } from '@tanstack/react-query';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Box, Icon, Loading, StyledLink, Text, TextErrors } from '@/components';
+import { Box, Icon, Loading, StyledLink, TextErrors } from '@/components';
import { DEFAULT_QUERY_RETRY } from '@/core';
import { DocEditor } from '@/docs/doc-editor';
import {
@@ -19,11 +19,18 @@ import {
useProviderStore,
useTrans,
} from '@/docs/doc-management/';
-import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth';
+import {
+ KEY_AUTH,
+ ModalEncryptionOnboarding,
+ setAuthUrl,
+ useAuth,
+} from '@/features/auth';
import {
useDocumentEncryption,
useUserEncryption,
} from '@/features/docs/doc-collaboration';
+import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
+import { EncryptionEmptyState } from '@/features/docs/doc-management/components/EncryptionLayout';
import { KeyMismatchPanel } from '@/features/docs/doc-management/components/KeyMismatchPanel';
import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
import { useSkeletonStore } from '@/features/skeletons';
@@ -102,6 +109,9 @@ const DocPage = ({ id }: DocProps) => {
const { authenticated, user } = useAuth();
const [doc, setDoc] = useState();
+ const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
+ const { isEnabled: isEncryptionEnabled, error: vaultClientError } =
+ useVaultClient();
const { encryptionLoading, encryptionError } = useUserEncryption();
const {
documentEncryptionLoading,
@@ -295,112 +305,114 @@ const DocPage = ({ id }: DocProps) => {
return ;
}
- if (doc.is_encrypted && (encryptionError || documentEncryptionError)) {
+ if (doc.is_encrypted && vaultClientError) {
return (
-
-
-
- {t('Encryption keys unavailable')}
-
-
-
- {t(
- 'This is an encrypted document, but your current device does not have the required encryption keys to decrypt it.',
- )}
-
-
- {(encryptionError === 'missing_private_key' ||
- encryptionError === 'missing_public_key') && (
-
- {t(
- 'This usually happens when you switch to a new device or browser without restoring your encryption backup, please go to your "Encryption Settings" to fix it.',
- )}
-
- )}
-
- {documentEncryptionError === 'missing_symmetric_key' && (
-
- {t(
- 'You do not have access to this encrypted document. Ask the document owner to share it with you again.',
- )}
-
- )}
-
- {documentEncryptionError === 'decryption_failed' && (
-
- {t(
- 'Your encryption keys could not decrypt this document. This may happen if your keys were recreated. Ask the document owner to share it with you again.',
- )}
-
- )}
-
-
- {(encryptionError === 'missing_private_key' ||
- encryptionError === 'missing_public_key') && (
-
-
-
- {t('Restore from backup (recommended)')}
-
-
- {t(
- 'If you have previously exported your encryption backup, you can restore it in your account settings to regain access to all your encrypted documents.',
- )}
-
-
-
-
- {t('Recreate encryption keys (not recommended)')}
-
-
- {t(
- 'Creating new encryption keys means you will lose access to all previously encrypted documents. Document owners will need to share them with you again.',
- )}
-
-
-
+
+
+ }
+ >
+ {t('Home')}
+
+
+
+ >
+ }
+ />
+ );
+ }
-
- }
- >
- {t('Back to home')}
-
-
-
+ if (doc.is_encrypted && (encryptionError || documentEncryptionError)) {
+ const needsSetup =
+ encryptionError === 'missing_private_key' ||
+ encryptionError === 'missing_public_key';
+
+ return (
+ <>
+
+
+ }
+ >
+ {t('Home')}
+
+
+ {needsSetup && isEncryptionEnabled && (
+
+ )}
+ >
+ }
+ />
+ {isOnboardingOpen && (
+ setIsOnboardingOpen(false)}
+ onSuccess={() => setIsOnboardingOpen(false)}
+ />
+ )}
+ >
);
}
if (encryptionTransition) {
return (
-
-
-
- {encryptionTransition === 'encrypting'
- ? t('Document encryption in progress, please wait...')
- : t('Removing document encryption, please wait...')}
-
-
+
);
}
diff --git a/src/frontend/apps/impress/src/pages/globals.css b/src/frontend/apps/impress/src/pages/globals.css
index 54ff40a24..df6830a36 100644
--- a/src/frontend/apps/impress/src/pages/globals.css
+++ b/src/frontend/apps/impress/src/pages/globals.css
@@ -20,6 +20,40 @@ 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),
+.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;