mirror of
https://github.com/suitenumerique/docs.git
synced 2026-10-01 05:55:16 +02:00
wip
This commit is contained in:
+17
-2
@@ -10,9 +10,15 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useUserEncryption } from '../UserEncryptionProvider';
|
||||
|
||||
/**
|
||||
* - `pending_acceptance`: the user is a member but no key was wrapped for them
|
||||
* yet; the document owner gives them one;
|
||||
* - `missing_symmetric_key`: no usable key for the user on this document (no
|
||||
* direct access, or a stored key that is not valid base64).
|
||||
*/
|
||||
export type DocumentEncryptionError =
|
||||
| 'pending_acceptance'
|
||||
| 'missing_symmetric_key'
|
||||
| 'decryption_failed'
|
||||
| null;
|
||||
|
||||
export interface DocumentEncryptionSettings {
|
||||
@@ -25,7 +31,7 @@ export interface DocumentEncryptionSettings {
|
||||
* The current user's encryption-key VERSION this wrapped symmetric key was
|
||||
* produced against (the share-time version stored per access). Passed as the
|
||||
* `keyVersion` argument to VaultClient.decryptWithKey() so the vault selects
|
||||
* the matching private key. Same source KeyMismatchPanel reads:
|
||||
* the matching private key. Same source DecryptionFailurePanel reads:
|
||||
* `doc.accesses_versions_per_user[user.suite_user_id]`.
|
||||
*/
|
||||
keyVersion: number;
|
||||
@@ -47,6 +53,7 @@ export function useDocumentEncryption(
|
||||
isDocumentEncrypted: boolean | undefined,
|
||||
userEncryptedSymmetricKeyBase64: string | undefined,
|
||||
keyVersion: number | undefined,
|
||||
isPendingForUser: boolean | undefined,
|
||||
): {
|
||||
documentEncryptionLoading: boolean;
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null;
|
||||
@@ -101,6 +108,13 @@ export function useDocumentEncryption(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPendingForUser) {
|
||||
setError('pending_acceptance');
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!encryptedSymmetricKey) {
|
||||
setError('missing_symmetric_key');
|
||||
setLoading(false);
|
||||
@@ -114,6 +128,7 @@ export function useDocumentEncryption(
|
||||
encryptionLoading,
|
||||
encryptionSettings,
|
||||
isDocumentEncrypted,
|
||||
isPendingForUser,
|
||||
encryptedSymmetricKey,
|
||||
]);
|
||||
|
||||
|
||||
+36
-5
@@ -107,6 +107,7 @@ export declare class VaultClient {
|
||||
private overlay;
|
||||
private overlayWatchdog;
|
||||
private overlayBootFailure;
|
||||
private overlayReadyFallback;
|
||||
private verifyResolve;
|
||||
private emergencySurfaced;
|
||||
private pendingContext;
|
||||
@@ -223,8 +224,10 @@ export declare class VaultClient {
|
||||
* @param encryptedSymmetricKey - user's encrypted copy of the symmetric key
|
||||
* @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).
|
||||
* unwraps with exactly that retained key: a version this vault does not
|
||||
* hold throws KEY_VERSION_UNAVAILABLE, a wrap that key cannot open throws
|
||||
* WRONG_SECRET_KEY, and content that fails its integrity check under the
|
||||
* unwrapped key throws CONTENT_INTEGRITY_FAILED.
|
||||
* @param encryptedKeyChain - optional chain of wrapped keys for Drive's key hierarchy.
|
||||
* When provided, resolves the chain from entry point to target before decrypting.
|
||||
*/
|
||||
@@ -409,8 +412,8 @@ export declare class VaultClient {
|
||||
off<K extends keyof EncryptionClientEventMap>(event: K, listener: Listener<K>): void;
|
||||
/**
|
||||
* 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:
|
||||
* in it. The layer stays `visibility: hidden` until the app inside reports its
|
||||
* modal painted, 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
|
||||
@@ -422,7 +425,19 @@ export declare class VaultClient {
|
||||
* 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. */
|
||||
/**
|
||||
* The app inside came up: stand the watchdog down and wait for its modal
|
||||
* (`revealOverlay`), for a bounded time. The layer stays hidden meanwhile,
|
||||
* over the product's own loader.
|
||||
*/
|
||||
private overlayBooted;
|
||||
/**
|
||||
* The app inside has its modal in the DOM: show the layer and have the
|
||||
* product drop its loader, in one go, so both land in the same paint. The
|
||||
* layer is transparent and both modals dim the page the same way, so a
|
||||
* moment with the two of them, or with neither, would show as a blink.
|
||||
* Once per opening.
|
||||
*/
|
||||
private revealOverlay;
|
||||
/**
|
||||
* Construct and configure an interface iframe for `path` (sandbox, allow,
|
||||
@@ -519,6 +534,13 @@ export declare class VaultError extends Error {
|
||||
export declare const VaultErrorCode: {
|
||||
/** No key pair stored locally on this device — user must onboard. */
|
||||
readonly MISSING_KEYS: "MISSING_KEYS";
|
||||
/**
|
||||
* This device holds keys, but not the encryption key version the content was
|
||||
* wrapped for: typically a key from before the user reset their encryption,
|
||||
* whose private half is gone. The content must be shared again with the
|
||||
* current key; it is not a reason to onboard.
|
||||
*/
|
||||
readonly KEY_VERSION_UNAVAILABLE: "KEY_VERSION_UNAVAILABLE";
|
||||
/**
|
||||
* AEAD verification failed. Either the ciphertext is for a different
|
||||
* recipient (their wrapped symmetric key was encrypted against another
|
||||
@@ -526,6 +548,15 @@ export declare const VaultErrorCode: {
|
||||
* libsodium's "wrong secret key for the given ciphertext".
|
||||
*/
|
||||
readonly WRONG_SECRET_KEY: "WRONG_SECRET_KEY";
|
||||
/**
|
||||
* The symmetric key was unwrapped, but the content failed its integrity check
|
||||
* under it (the AEAD tag did not verify): the content was damaged or altered
|
||||
* in storage, or encrypted under another key. The vault cannot tell which, so
|
||||
* the name states the check, not a cause. Raised by `decrypt-with-key` for the
|
||||
* content step only, so a product can tell "your key does not open this" from
|
||||
* "this content cannot be trusted". Mirrors VAULT_INTEGRITY_FAILED.
|
||||
*/
|
||||
readonly CONTENT_INTEGRITY_FAILED: "CONTENT_INTEGRITY_FAILED";
|
||||
/** Backup payload is corrupted, truncated, or from an unsupported version. */
|
||||
readonly INVALID_BACKUP: "INVALID_BACKUP";
|
||||
/** BIP-39-style mnemonic input that doesn't checksum. */
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon, StyledLink } from '@/components';
|
||||
|
||||
import type { DecryptionFailure } from '../stores/useProviderStore';
|
||||
|
||||
import { EncryptionEmptyState } from './EncryptionLayout';
|
||||
|
||||
interface Props {
|
||||
failure: DecryptionFailure;
|
||||
}
|
||||
|
||||
/** Shown instead of the editor when an encrypted document cannot be opened. */
|
||||
export const DecryptionFailurePanel = ({ failure }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const copy = {
|
||||
key_unavailable: {
|
||||
title: t('This document was shared with a previous key'),
|
||||
description: t(
|
||||
'It was shared with you under an encryption key you no longer have, most likely from before you reset your encryption. Ask the document owner to remove you from its members and add you again.',
|
||||
),
|
||||
},
|
||||
key_mismatch: {
|
||||
title: t('This document was encrypted with a different key'),
|
||||
description: t(
|
||||
"The copy of this document's key stored for you cannot be opened with your encryption key. Ask the document owner to remove you from its members and add you again.",
|
||||
),
|
||||
},
|
||||
content_integrity: {
|
||||
title: t('This document cannot be decrypted'),
|
||||
description: t(
|
||||
'Your key is correct, but the stored content is damaged or was altered, so it cannot be trusted. Contact the owner of this document or your support.',
|
||||
),
|
||||
},
|
||||
unknown: {
|
||||
title: t('This document could not be decrypted'),
|
||||
description: t(
|
||||
'Something went wrong while decrypting this document. Try again.',
|
||||
),
|
||||
},
|
||||
}[failure];
|
||||
|
||||
return (
|
||||
<EncryptionEmptyState
|
||||
title={copy.title}
|
||||
description={copy.description}
|
||||
actions={
|
||||
<>
|
||||
<StyledLink href="/">
|
||||
<Button
|
||||
size="small"
|
||||
color="neutral"
|
||||
variant="tertiary"
|
||||
icon={<Icon iconName="home" $withThemeInherited />}
|
||||
>
|
||||
{t('Home')}
|
||||
</Button>
|
||||
</StyledLink>
|
||||
{failure === 'unknown' && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
onClick={() => window.location.reload()}
|
||||
icon={<Icon iconName="refresh" $withThemeInherited />}
|
||||
>
|
||||
{t('Retry')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon, StyledLink, Text } from '@/components';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import {
|
||||
fetchRegisteredKeys,
|
||||
useVaultClient,
|
||||
} from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
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
|
||||
* encrypted against a PREVIOUS public key of the current user (reset,
|
||||
* different device without backup restore, etc.) — the ciphertext is
|
||||
* still valid for whoever holds the old key, but the current key
|
||||
* can't unwrap it. The fix is social: someone with access has to
|
||||
* re-share the doc so the symmetric key gets wrapped against the
|
||||
* user's CURRENT public key.
|
||||
*/
|
||||
export const isWrongSecretKeyError = (
|
||||
err: Error | null | undefined,
|
||||
): boolean => {
|
||||
if (!err) {
|
||||
return false;
|
||||
}
|
||||
return (err as VaultError).code === 'WRONG_SECRET_KEY';
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* The doc — used to read the share-time encryption key version from
|
||||
* `doc.accesses_versions_per_user[currentUser.suite_user_id]`.
|
||||
* Docs exposes this as a per-user map on the document. (We may
|
||||
* later collapse it to a single `encryption_public_key_version_for_user`
|
||||
* scalar once we take the same "current user only" approach Drive
|
||||
* does, but keeping it as a map for now matches the existing API.)
|
||||
*/
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly panel shown when the page / websocket surfaces a "wrong
|
||||
* secret key" decryption failure. Explains the key rotation, shows the
|
||||
* share-time key version (from the doc's version map) AND the user's
|
||||
* current key version so whoever re-shares can see the access was
|
||||
* wrapped for an older key and needs re-encryption.
|
||||
*/
|
||||
export const KeyMismatchPanel = ({ doc }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const [currentVersion, setCurrentVersion] = useState<number | null>(null);
|
||||
|
||||
const shareTimeVersion = user?.suite_user_id
|
||||
? (doc.accesses_versions_per_user?.[user.suite_user_id] ?? null)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!vaultClient || !user?.suite_user_id) {
|
||||
return;
|
||||
}
|
||||
const sub = user.suite_user_id;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const { versions } = await fetchRegisteredKeys(vaultClient, [sub]);
|
||||
if (!cancelled) {
|
||||
setCurrentVersion(versions[sub] ?? null);
|
||||
}
|
||||
} catch {
|
||||
// Ignore — we just won't render the version row.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [vaultClient, user?.suite_user_id]);
|
||||
|
||||
const versionChip = (value: number) => (
|
||||
<Text
|
||||
as="span"
|
||||
$css={`
|
||||
font-family: monospace;
|
||||
background: var(--c--contextuals--background--surface--tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
`}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<EncryptionEmptyState
|
||||
title={t('This document was encrypted with a different key')}
|
||||
description={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.',
|
||||
)}
|
||||
actions={
|
||||
<StyledLink href="/">
|
||||
<Button
|
||||
size="small"
|
||||
color="neutral"
|
||||
variant="tertiary"
|
||||
icon={<Icon iconName="home" $withThemeInherited />}
|
||||
>
|
||||
{t('Home')}
|
||||
</Button>
|
||||
</StyledLink>
|
||||
}
|
||||
>
|
||||
{(shareTimeVersion !== null || currentVersion !== null) && (
|
||||
<Text
|
||||
as="div"
|
||||
$size="xs"
|
||||
$variation="secondary"
|
||||
$textAlign="center"
|
||||
$css="display: flex; flex-direction: column; gap: 4px;"
|
||||
>
|
||||
{shareTimeVersion !== null && (
|
||||
<span>
|
||||
{t('Encryption key version at the time it was shared with you:')}{' '}
|
||||
{versionChip(shareTimeVersion)}
|
||||
</span>
|
||||
)}
|
||||
{currentVersion !== null && (
|
||||
<span>
|
||||
{t('Your current encryption key version:')}{' '}
|
||||
{versionChip(currentVersion)}
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</EncryptionEmptyState>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -454,7 +454,7 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
{!isError && blockers.length === 0 && othersWithoutKey.length > 0 && (
|
||||
<Alert type={VariantType.WARNING}>
|
||||
{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}} collaborator(s) have not enabled encryption yet. They will be added as pending and get access once they enable it.',
|
||||
{ count: othersWithoutKey.length },
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './useDocTitleUpdate';
|
||||
export * from './useDocUtils';
|
||||
export * from './useIsCollaborativeEditable';
|
||||
export * from './useTrans';
|
||||
export * from './useEncryptionAccessCopy';
|
||||
|
||||
+14
-3
@@ -2,7 +2,11 @@ import { useEffect } from 'react';
|
||||
|
||||
import { useCollaborationUrl } from '@/core/config';
|
||||
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { Base64, useProviderStore } from '@/docs/doc-management';
|
||||
import {
|
||||
Base64,
|
||||
decryptionFailureOf,
|
||||
useProviderStore,
|
||||
} from '@/docs/doc-management';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
@@ -17,8 +21,13 @@ export const useCollaboration = (
|
||||
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
|
||||
const { user } = useAuth();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const { provider, createProvider, destroyProvider, encryptionTransition } =
|
||||
useProviderStore();
|
||||
const {
|
||||
provider,
|
||||
createProvider,
|
||||
destroyProvider,
|
||||
encryptionTransition,
|
||||
setDecryptionFailure,
|
||||
} = useProviderStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -68,6 +77,7 @@ export const useCollaboration = (
|
||||
setBroadcastProvider(newProvider);
|
||||
})().catch((err) => {
|
||||
console.error('Failed to decrypt document content:', err);
|
||||
setDecryptionFailure(decryptionFailureOf(err));
|
||||
});
|
||||
} else {
|
||||
const newProvider = createProvider(
|
||||
@@ -90,6 +100,7 @@ export const useCollaboration = (
|
||||
documentEncryptionSettings,
|
||||
vaultClient,
|
||||
encryptionTransition,
|
||||
setDecryptionFailure,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Doc } from '../types';
|
||||
|
||||
/**
|
||||
* Title and description explaining why the current user cannot open an
|
||||
* encrypted document they are a member of, shared by the document page and
|
||||
* the share dialog. Being a pending member is told apart from having no
|
||||
* encryption and from having no usable key: each asks something different of
|
||||
* the user.
|
||||
*/
|
||||
export const useEncryptionAccessCopy = (
|
||||
doc: Pick<Doc, 'is_pending_encryption_for_user'> | undefined,
|
||||
needsSetup: boolean,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (doc?.is_pending_encryption_for_user) {
|
||||
return {
|
||||
title: t('Waiting for access'),
|
||||
description: needsSetup
|
||||
? t(
|
||||
'This document was encrypted before you enabled encryption, so its key could not be shared with you. Enable encryption on your account: you will get access the next time the document owner opens it.',
|
||||
)
|
||||
: t(
|
||||
'This document was encrypted before you enabled encryption, so its key could not be shared with you then. You will get access the next time the document owner opens it.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
return {
|
||||
title: t('Encrypted document'),
|
||||
description: t(
|
||||
'This document is encrypted. You must enable encryption on your account to access it.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t('Encrypted document'),
|
||||
description: t(
|
||||
'This encrypted document holds no key for you. Ask the document owner to add you to its members.',
|
||||
),
|
||||
};
|
||||
};
|
||||
+41
-10
@@ -22,6 +22,38 @@ export type SwitchableProvider = RelayProvider | HocuspocusProvider;
|
||||
|
||||
export type EncryptionTransitionType = 'encrypting' | 'removing-encryption';
|
||||
|
||||
/**
|
||||
* Why an encrypted document could not be opened:
|
||||
* - `key_unavailable`: shared for a key version the user no longer holds
|
||||
* (typically from before they reset their encryption);
|
||||
* - `key_mismatch`: the user's key cannot open the copy of the document key
|
||||
* stored for them;
|
||||
* - `content_integrity`: the document key opened, the stored content failed its
|
||||
* integrity check (damaged or altered);
|
||||
* - `unknown`: anything else (vault unreachable mid-way, unexpected error).
|
||||
*/
|
||||
export type DecryptionFailure =
|
||||
| 'key_unavailable'
|
||||
| 'key_mismatch'
|
||||
| 'content_integrity'
|
||||
| 'unknown';
|
||||
|
||||
export const decryptionFailureOf = (err: unknown): DecryptionFailure => {
|
||||
switch ((err as VaultError | null | undefined)?.code) {
|
||||
case 'KEY_VERSION_UNAVAILABLE':
|
||||
return 'key_unavailable';
|
||||
case 'WRONG_SECRET_KEY':
|
||||
return 'key_mismatch';
|
||||
case 'CONTENT_INTEGRITY_FAILED':
|
||||
case 'MALFORMED_CIPHERTEXT':
|
||||
case 'CIPHERTEXT_TOO_SHORT':
|
||||
case 'UNSUPPORTED_CRYPTO_VERSION':
|
||||
return 'content_integrity';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
};
|
||||
|
||||
export interface UseCollaborationStore {
|
||||
createProvider: (
|
||||
providerUrl: string,
|
||||
@@ -43,7 +75,8 @@ export interface UseCollaborationStore {
|
||||
isSynced: boolean;
|
||||
hasLostConnection: boolean;
|
||||
encryptionTransition: EncryptionTransitionType | null;
|
||||
decryptionFailed: boolean;
|
||||
decryptionFailure: DecryptionFailure | null;
|
||||
setDecryptionFailure: (failure: DecryptionFailure) => void;
|
||||
resetLostConnection: () => void;
|
||||
}
|
||||
|
||||
@@ -54,7 +87,7 @@ const defaultValues = {
|
||||
isSynced: false,
|
||||
hasLostConnection: false,
|
||||
encryptionTransition: null,
|
||||
decryptionFailed: false,
|
||||
decryptionFailure: null,
|
||||
};
|
||||
|
||||
function handleEncryptionSystemMessage(
|
||||
@@ -84,6 +117,7 @@ function handleEncryptionSystemMessage(
|
||||
|
||||
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
...defaultValues,
|
||||
setDecryptionFailure: (failure) => set({ decryptionFailure: failure }),
|
||||
createProvider: (wsUrl, storeId, initialDocState, encryptionOptions) => {
|
||||
const isEncrypted = !!encryptionOptions;
|
||||
|
||||
@@ -115,14 +149,11 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
onDecryptError: (err) => {
|
||||
// Match on the stable VaultError code rather than message
|
||||
// text — the SDK guarantees `code === 'WRONG_SECRET_KEY'`
|
||||
// for the AEAD-verification failure branch (libsodium's
|
||||
// "wrong secret key for the given ciphertext").
|
||||
if (
|
||||
(err as VaultError | null | undefined)?.code === 'WRONG_SECRET_KEY'
|
||||
) {
|
||||
set({ decryptionFailed: true });
|
||||
// A key that cannot open the document key makes every message
|
||||
// unreadable; one damaged message alone does not end the session.
|
||||
const failure = decryptionFailureOf(err);
|
||||
if (failure === 'key_unavailable' || failure === 'key_mismatch') {
|
||||
set({ decryptionFailure: failure });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './useDocInvitations';
|
||||
export * from './useUpdateDocAccess';
|
||||
export * from './useUpdateDocInvitation';
|
||||
export * from './useUsers';
|
||||
export * from './useAcceptEncryptionAccess';
|
||||
|
||||
+96
-16
@@ -1,7 +1,11 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { Doc, KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management';
|
||||
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { Access, Doc, KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management';
|
||||
import { fetchRegisteredKeys } from '@/features/docs/doc-collaboration/vault';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
|
||||
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
|
||||
|
||||
@@ -46,21 +50,97 @@ export const acceptEncryptionAccess = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export function useAcceptEncryptionAccess() {
|
||||
/**
|
||||
* Give pending members the document key: those who have enabled encryption
|
||||
* since they were added get it wrapped for their current key, in one vault
|
||||
* call (so one verification prompt for every recipient not yet trusted), and
|
||||
* the others stay pending. Throws when the vault refuses the wrap, e.g. when
|
||||
* the caller declines to verify a recipient: nobody is accepted then.
|
||||
*/
|
||||
export const acceptPendingMembers = async (
|
||||
vaultClient: VaultClient,
|
||||
docId: Doc['id'],
|
||||
settings: DocumentEncryptionSettings,
|
||||
pending: Access[],
|
||||
): Promise<{ accepted: Access[]; notReady: Access[] }> => {
|
||||
const members = pending.flatMap((access) =>
|
||||
access.user?.suite_user_id
|
||||
? [{ access, sub: access.user.suite_user_id }]
|
||||
: [],
|
||||
);
|
||||
const { publicKeys, versions } = await fetchRegisteredKeys(
|
||||
vaultClient,
|
||||
members.map(({ sub }) => sub),
|
||||
);
|
||||
const ready = members.filter(({ sub }) => !!publicKeys[sub]);
|
||||
const notReady = pending.filter(
|
||||
(access) => !ready.some((member) => member.access === access),
|
||||
);
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { accepted: [], notReady };
|
||||
}
|
||||
|
||||
// The vault resolves and trust-checks each recipient key (binding + TOFU);
|
||||
// the labels are display-only, shown if the verification prompt opens.
|
||||
const recipients: Record<string, RecipientLabel> = {};
|
||||
for (const { access, sub } of ready) {
|
||||
recipients[sub] = { email: access.user.email, name: access.user.full_name };
|
||||
}
|
||||
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||
settings.encryptedSymmetricKey,
|
||||
recipients,
|
||||
);
|
||||
|
||||
const accepted: Access[] = [];
|
||||
for (const { access, sub } of ready) {
|
||||
const wrappedKey = encryptedKeys[sub];
|
||||
if (!wrappedKey) {
|
||||
continue;
|
||||
}
|
||||
await acceptEncryptionAccess({
|
||||
docId,
|
||||
accessId: access.id,
|
||||
encrypted_document_symmetric_key_for_user: toBase64(
|
||||
new Uint8Array(wrappedKey),
|
||||
),
|
||||
encryption_public_key_version: versions[sub],
|
||||
});
|
||||
accepted.push(access);
|
||||
}
|
||||
|
||||
return { accepted, notReady };
|
||||
};
|
||||
|
||||
export function useAcceptPendingMembers() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, APIError, AcceptEncryptionAccessParams>({
|
||||
mutationFn: acceptEncryptionAccess,
|
||||
onSuccess: (_data, variables) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESSES, { docId: variables.docId }],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_DOC, { docId: variables.docId }],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
return useCallback(
|
||||
async (
|
||||
vaultClient: VaultClient,
|
||||
docId: Doc['id'],
|
||||
settings: DocumentEncryptionSettings,
|
||||
pending: Access[],
|
||||
) => {
|
||||
try {
|
||||
return await acceptPendingMembers(
|
||||
vaultClient,
|
||||
docId,
|
||||
settings,
|
||||
pending,
|
||||
);
|
||||
} finally {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESSES, { docId }],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_DOC, { id: docId }],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
[queryClient],
|
||||
);
|
||||
}
|
||||
|
||||
+2
-5
@@ -16,7 +16,7 @@ import { useDocInvitationsInfinite, useUpdateDocInvitation } from '../api';
|
||||
import { Invitation } from '../types';
|
||||
|
||||
import { DocRoleDropdown } from './DocRoleDropdown';
|
||||
import { SearchUserRow } from './SearchUserRow';
|
||||
import { SearchUserRow, UserRowStatus } from './SearchUserRow';
|
||||
|
||||
type DocShareInvitationItemProps = {
|
||||
doc: Doc;
|
||||
@@ -92,14 +92,12 @@ export const DocShareInvitationItem = ({
|
||||
|
||||
type DocShareModalInviteUserRowProps = {
|
||||
user: User;
|
||||
suffix?: string;
|
||||
suffixIcon?: string;
|
||||
suffix?: UserRowStatus;
|
||||
onAvatarClick?: () => void;
|
||||
};
|
||||
export const DocShareModalInviteUserRow = ({
|
||||
user,
|
||||
suffix,
|
||||
suffixIcon,
|
||||
onAvatarClick,
|
||||
}: DocShareModalInviteUserRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -112,7 +110,6 @@ export const DocShareModalInviteUserRow = ({
|
||||
<SearchUserRow
|
||||
user={user}
|
||||
suffix={suffix}
|
||||
suffixIcon={suffixIcon}
|
||||
onAvatarClick={onAvatarClick}
|
||||
right={
|
||||
<BoxButton
|
||||
|
||||
+13
-9
@@ -17,14 +17,13 @@ import { useDocAccesses, useUpdateDocAccess } from '../api';
|
||||
import { useWhoAmI } from '../hooks/';
|
||||
|
||||
import { DocRoleDropdown } from './DocRoleDropdown';
|
||||
import { SearchUserRow } from './SearchUserRow';
|
||||
import { SearchUserRow, UserRowStatus } from './SearchUserRow';
|
||||
|
||||
type Props = {
|
||||
doc?: Doc;
|
||||
access: Access;
|
||||
isInherited?: boolean;
|
||||
suffix?: string;
|
||||
suffixIcon?: string;
|
||||
suffix?: UserRowStatus;
|
||||
onAvatarClick?: () => void;
|
||||
};
|
||||
export const DocShareMemberItem = ({
|
||||
@@ -32,7 +31,6 @@ export const DocShareMemberItem = ({
|
||||
access,
|
||||
isInherited = false,
|
||||
suffix,
|
||||
suffixIcon,
|
||||
onAvatarClick,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -78,7 +76,6 @@ export const DocShareMemberItem = ({
|
||||
alwaysShowRight={true}
|
||||
user={access.user}
|
||||
suffix={suffix}
|
||||
suffixIcon={suffixIcon}
|
||||
onAvatarClick={onAvatarClick}
|
||||
right={
|
||||
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
|
||||
@@ -139,9 +136,6 @@ export const QuickSearchGroupMember = ({
|
||||
group={membersData}
|
||||
renderElement={(access) => {
|
||||
const uid = access.user.suite_user_id;
|
||||
const hasNoEncryptionKey =
|
||||
doc.is_encrypted &&
|
||||
(!uid || !doc.accesses_versions_per_user?.[uid]);
|
||||
|
||||
// On an encrypted document, a member's avatar opens their
|
||||
// encryption identity (fingerprint, trust decision); not one's own.
|
||||
@@ -158,7 +152,17 @@ export const QuickSearchGroupMember = ({
|
||||
<DocShareMemberItem
|
||||
doc={doc}
|
||||
access={access}
|
||||
suffix={hasNoEncryptionKey ? t('No encryption') : undefined}
|
||||
suffix={
|
||||
doc.is_encrypted && access.is_pending_encryption
|
||||
? {
|
||||
label: t('Waiting for encryption'),
|
||||
hint: t(
|
||||
'Added before they enabled encryption. They get access once they have, the next time the document owner opens it.',
|
||||
),
|
||||
icon: 'schedule',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onAvatarClick={identityOf}
|
||||
/>
|
||||
);
|
||||
|
||||
+17
-18
@@ -26,7 +26,7 @@ import {
|
||||
fetchRegisteredKeys,
|
||||
useVaultClient,
|
||||
} from '@/docs/doc-collaboration/vault';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { Doc, useEncryptionAccessCopy } from '@/docs/doc-management';
|
||||
import { User, useAuth } from '@/features/auth';
|
||||
import {
|
||||
EncryptionEmptyState,
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { QuickSearchGroupMember } from './DocShareMember';
|
||||
import { DocShareModalFooter } from './DocShareModalFooter';
|
||||
import { PendingEncryptionSection } from './PendingEncryptionSection';
|
||||
import { UserRowStatus } from './SearchUserRow';
|
||||
|
||||
const ShareModalStyle = createGlobalStyle`
|
||||
.--docs--doc-share-modal [cmdk-item] {
|
||||
@@ -101,6 +102,12 @@ export const DocShareModal = ({
|
||||
needsDerivation && user?.suite_user_id
|
||||
? doc.accesses_versions_per_user?.[user.suite_user_id]
|
||||
: undefined,
|
||||
needsDerivation ? doc.is_pending_encryption_for_user : undefined,
|
||||
);
|
||||
const accessCopy = useEncryptionAccessCopy(
|
||||
doc,
|
||||
encryptionError === 'missing_private_key' ||
|
||||
encryptionError === 'missing_public_key',
|
||||
);
|
||||
const effectiveEncryptionSettings =
|
||||
documentEncryptionSettings ?? derivedEncryptionSettings ?? null;
|
||||
@@ -268,21 +275,8 @@ export const DocShareModal = ({
|
||||
{isEncryptionDeriving && <Loading />}
|
||||
{!isEncryptionDeriving && derivedEncryptionError && (
|
||||
<EncryptionEmptyState
|
||||
title={t('Encrypted document')}
|
||||
description={
|
||||
encryptionError === 'missing_private_key' ||
|
||||
encryptionError === 'missing_public_key'
|
||||
? t(
|
||||
'This document is encrypted. You must enable encryption on your account to access 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.',
|
||||
)
|
||||
: t(
|
||||
'You do not have the correct encryption key to decrypt this document. Ask the document owner to share it with you again.',
|
||||
)
|
||||
}
|
||||
title={accessCopy.title}
|
||||
description={accessCopy.description}
|
||||
/>
|
||||
)}
|
||||
{!isEncryptionDeriving && !derivedEncryptionError && (
|
||||
@@ -548,9 +542,14 @@ const QuickSearchInviteInputSection = ({
|
||||
};
|
||||
|
||||
const getUserSuffix = useCallback(
|
||||
(user: User): string | undefined => {
|
||||
(user: User): UserRowStatus | undefined => {
|
||||
if (hasNoKey(user)) {
|
||||
return t('No encryption');
|
||||
return {
|
||||
label: t('No encryption'),
|
||||
hint: t(
|
||||
'This person has not enabled encryption yet, so they cannot be added to an encrypted document.',
|
||||
),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
+53
-48
@@ -9,10 +9,9 @@ import {
|
||||
fetchRegisteredKeys,
|
||||
useVaultClient,
|
||||
} from '@/features/docs/doc-collaboration/vault';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
import type { Access, Doc } from '@/features/docs/doc-management';
|
||||
|
||||
import { useAcceptEncryptionAccess } from '../api/useAcceptEncryptionAccess';
|
||||
import { useAcceptPendingMembers } from '../api/useAcceptEncryptionAccess';
|
||||
|
||||
interface Props {
|
||||
doc: Doc;
|
||||
@@ -27,9 +26,9 @@ interface Props {
|
||||
* Two sub-states per row, driven by an upfront public-key probe:
|
||||
* - invitee HAS a public key → Accept button actionable. One click
|
||||
* re-wraps the document key against their key and PATCHes the row.
|
||||
* - invitee has NO public key yet → no button, just a hint saying we're
|
||||
* waiting for them to complete onboarding. This prevents the
|
||||
* "click Accept, get a cryptic error" loop.
|
||||
* - invitee has NO public key yet → no button, a "Waiting for encryption"
|
||||
* chip; one line under the heading explains it for every such row. This
|
||||
* prevents the "click Accept, get a cryptic error" loop.
|
||||
*/
|
||||
export const PendingEncryptionSection = ({
|
||||
doc,
|
||||
@@ -38,7 +37,7 @@ export const PendingEncryptionSection = ({
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const { mutateAsync: acceptMutation } = useAcceptEncryptionAccess();
|
||||
const acceptPendingMembers = useAcceptPendingMembers();
|
||||
|
||||
const [inFlight, setInFlight] = useState<Set<string>>(new Set());
|
||||
const [errorByAccessId, setErrorByAccessId] = useState<
|
||||
@@ -111,9 +110,8 @@ export const PendingEncryptionSection = ({
|
||||
}
|
||||
|
||||
const handleAccept = async (access: Access) => {
|
||||
const recipient = access.user;
|
||||
const sub = recipient?.suite_user_id;
|
||||
if (!sub || !recipient || !vaultClient || !documentEncryptionSettings) {
|
||||
const sub = access.user?.suite_user_id;
|
||||
if (!sub || !vaultClient || !documentEncryptionSettings) {
|
||||
return;
|
||||
}
|
||||
setInFlight((prev) => new Set(prev).add(access.id));
|
||||
@@ -123,35 +121,21 @@ export const PendingEncryptionSection = ({
|
||||
return copy;
|
||||
});
|
||||
try {
|
||||
const { publicKeys, versions } = await fetchRegisteredKeys(vaultClient, [
|
||||
sub,
|
||||
]);
|
||||
const userPublicKey = publicKeys[sub];
|
||||
if (!userPublicKey) {
|
||||
const { accepted, notReady } = await acceptPendingMembers(
|
||||
vaultClient,
|
||||
doc.id,
|
||||
documentEncryptionSettings,
|
||||
[access],
|
||||
);
|
||||
if (notReady.length > 0) {
|
||||
setHasPublicKeyBySub((m) => ({ ...m, [sub]: false }));
|
||||
throw new Error(
|
||||
t("This user still hasn't completed their encryption onboarding."),
|
||||
);
|
||||
}
|
||||
// The vault resolves + trust-checks the recipient key (binding + TOFU);
|
||||
// the fetched userPublicKey above only gates on completed onboarding. The
|
||||
// label (email/name) is display-only, shown if the trust modal opens.
|
||||
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
{ [sub]: { email: recipient.email, name: recipient.full_name } },
|
||||
);
|
||||
const wrappedKey = encryptedKeys[sub];
|
||||
if (!wrappedKey) {
|
||||
if (accepted.length === 0) {
|
||||
throw new Error(t('Failed to wrap the document key for this user.'));
|
||||
}
|
||||
await acceptMutation({
|
||||
docId: doc.id,
|
||||
accessId: access.id,
|
||||
encrypted_document_symmetric_key_for_user: toBase64(
|
||||
new Uint8Array(wrappedKey),
|
||||
),
|
||||
encryption_public_key_version: versions[sub],
|
||||
});
|
||||
} catch (err) {
|
||||
setErrorByAccessId((prev) => ({
|
||||
...prev,
|
||||
@@ -166,26 +150,41 @@ export const PendingEncryptionSection = ({
|
||||
}
|
||||
};
|
||||
|
||||
const canAcceptAccess = (access: Access) => {
|
||||
const sub = access.user?.suite_user_id;
|
||||
return (
|
||||
!!sub &&
|
||||
!!documentEncryptionSettings &&
|
||||
hasPublicKeyBySub[sub] === true &&
|
||||
!probing
|
||||
);
|
||||
};
|
||||
const someoneWaiting =
|
||||
!probing && pending.some((access) => !canAcceptAccess(access));
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="--docs--pending-encryption"
|
||||
$margin={{ horizontal: 'base', bottom: 'sm' }}
|
||||
$gap="xs"
|
||||
>
|
||||
<Text $size="xs" $weight="700" $variation="secondary">
|
||||
{t('Action needed')}
|
||||
</Text>
|
||||
<Box $gap="4xs">
|
||||
<Text $size="xs" $weight="700" $variation="secondary">
|
||||
{t('Action needed')}
|
||||
</Text>
|
||||
{someoneWaiting && (
|
||||
<Text $size="xs" $variation="tertiary">
|
||||
{t(
|
||||
'Members who have not enabled encryption yet get access once they do.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box $gap="xs">
|
||||
{pending.map((access) => {
|
||||
const sub = access.user?.suite_user_id;
|
||||
const isBusy = inFlight.has(access.id);
|
||||
const error = errorByAccessId[access.id];
|
||||
const hasPublicKey = sub ? hasPublicKeyBySub[sub] : false;
|
||||
const canAccept =
|
||||
!!sub &&
|
||||
!!documentEncryptionSettings &&
|
||||
hasPublicKey === true &&
|
||||
!probing;
|
||||
const canAccept = canAcceptAccess(access);
|
||||
const name = access.user?.full_name || access.user?.email || '';
|
||||
|
||||
return (
|
||||
@@ -213,12 +212,17 @@ export const PendingEncryptionSection = ({
|
||||
{access.user.email}
|
||||
</Text>
|
||||
)}
|
||||
{!canAccept && !probing && (
|
||||
<Text $size="xs" $variation="secondary">
|
||||
{t(
|
||||
'Waiting for them to enable encryption. You will be able to accept them once they have.',
|
||||
)}
|
||||
</Text>
|
||||
{canAccept && (
|
||||
<Box $direction="row" $align="center" $gap="4xs">
|
||||
<Icon
|
||||
iconName="verified_user"
|
||||
$size="14px"
|
||||
$theme="success"
|
||||
/>
|
||||
<Text $size="xs" $weight="500" $theme="success">
|
||||
{t('Encryption enabled')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Text $size="xs" $theme="error">
|
||||
@@ -245,6 +249,7 @@ export const PendingEncryptionSection = ({
|
||||
$radius="4px"
|
||||
$height="24px"
|
||||
$background="var(--c--contextuals--background--surface--tertiary)"
|
||||
title={t('Waiting for them to enable encryption')}
|
||||
>
|
||||
<Icon iconName="schedule" $size="sm" $variation="tertiary" />
|
||||
<Text
|
||||
@@ -253,7 +258,7 @@ export const PendingEncryptionSection = ({
|
||||
$variation="tertiary"
|
||||
$css="white-space: nowrap;"
|
||||
>
|
||||
{t('Pending encryption')}
|
||||
{t('Waiting for encryption')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
+37
-13
@@ -1,3 +1,4 @@
|
||||
import { Tooltip } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, BoxButton, Icon, Text } from '@/components';
|
||||
@@ -8,15 +9,20 @@ import {
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { User, UserAvatar } from '@/features/auth';
|
||||
|
||||
export type UserRowStatus = {
|
||||
label: string;
|
||||
hint: string;
|
||||
/** Material icon; the crossed shield by default. */
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
alwaysShowRight?: boolean;
|
||||
right?: QuickSearchItemContentProps['right'];
|
||||
isInvitation?: boolean;
|
||||
/** A short status ("No encryption") shown as an icon with the text as tooltip. */
|
||||
suffix?: string;
|
||||
/** Material icon for the suffix; the crossed shield by default. */
|
||||
suffixIcon?: string;
|
||||
/** A short status next to the name, with a sentence explaining it on hover. */
|
||||
suffix?: UserRowStatus;
|
||||
/** Makes the avatar a button (the person's encryption identity). */
|
||||
onAvatarClick?: () => void;
|
||||
};
|
||||
@@ -27,7 +33,6 @@ export const SearchUserRow = ({
|
||||
alwaysShowRight = false,
|
||||
isInvitation = false,
|
||||
suffix,
|
||||
suffixIcon = 'gpp_bad',
|
||||
onAvatarClick,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -76,14 +81,33 @@ export const SearchUserRow = ({
|
||||
{hasFullName ? user.full_name : user.email}
|
||||
</Text>
|
||||
{suffix && (
|
||||
<Icon
|
||||
iconName={suffixIcon}
|
||||
$size="sm"
|
||||
$theme="neutral"
|
||||
$variation="tertiary"
|
||||
aria-label={suffix}
|
||||
title={suffix}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<Text $textAlign="center">{suffix.hint}</Text>}
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="4xs"
|
||||
aria-label={`${suffix.label}. ${suffix.hint}`}
|
||||
>
|
||||
<Icon
|
||||
iconName={suffix.icon ?? 'gpp_bad'}
|
||||
$size="14px"
|
||||
$theme="neutral"
|
||||
$variation="tertiary"
|
||||
/>
|
||||
<Text
|
||||
$size="xs"
|
||||
$weight="500"
|
||||
$theme="neutral"
|
||||
$variation="tertiary"
|
||||
$css="white-space: nowrap;"
|
||||
>
|
||||
{suffix.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
{hasFullName && (
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './useTranslatedShareSettings';
|
||||
export * from './useWhoAmI';
|
||||
export * from './useAutoAcceptPendingMembers';
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import type { Doc } from '@/docs/doc-management';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
import {
|
||||
KEY_LIST_DOC_ACCESSES,
|
||||
useAcceptPendingMembers,
|
||||
useDocAccesses,
|
||||
} from '../api';
|
||||
|
||||
// Pending sets already tried in this tab, per document: a declined
|
||||
// verification prompt must not come back each time the page re-renders or the
|
||||
// document is reopened. The share dialog keeps a manual Accept per member.
|
||||
const attempted = new Set<string>();
|
||||
|
||||
/**
|
||||
* When someone who can manage members opens an encrypted document they can
|
||||
* read, give its key to every pending member who has enabled encryption since
|
||||
* they were added. Without this a pending member waits for someone to open
|
||||
* the share dialog and click Accept.
|
||||
*/
|
||||
export const useAutoAcceptPendingMembers = (
|
||||
doc: Doc | undefined,
|
||||
settings: DocumentEncryptionSettings | null,
|
||||
) => {
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const acceptPendingMembers = useAcceptPendingMembers();
|
||||
const enabled =
|
||||
!!doc?.is_encrypted && !!doc.abilities.accesses_manage && !!settings;
|
||||
const docId = doc?.id ?? '';
|
||||
const { data: accesses } = useDocAccesses(
|
||||
{ docId },
|
||||
{ queryKey: [KEY_LIST_DOC_ACCESSES, { docId }], enabled },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !doc || !settings || !vaultClient || !accesses) {
|
||||
return;
|
||||
}
|
||||
const pending = accesses.filter(
|
||||
(access) => access.is_pending_encryption && access.document.id === doc.id,
|
||||
);
|
||||
const key = `${doc.id}:${pending
|
||||
.map((access) => access.id)
|
||||
.sort()
|
||||
.join(',')}`;
|
||||
if (pending.length === 0 || attempted.has(key)) {
|
||||
return;
|
||||
}
|
||||
attempted.add(key);
|
||||
acceptPendingMembers(vaultClient, doc.id, settings, pending).catch(
|
||||
(err: unknown) => {
|
||||
console.warn('Could not give pending members the document key:', err);
|
||||
},
|
||||
);
|
||||
}, [enabled, doc, settings, vaultClient, accesses, acceptPendingMembers]);
|
||||
};
|
||||
@@ -1007,7 +1007,7 @@
|
||||
"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.",
|
||||
"{{count}} collaborator(s) have not enabled encryption yet. They will be added as pending and get access once they enable it.": "{{count}} collaborateur(s) n'ont pas encore activé le chiffrement. Ils seront ajoutés en attente et auront accès dès qu'ils l'auront activé.",
|
||||
"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",
|
||||
@@ -1018,8 +1018,6 @@
|
||||
"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",
|
||||
@@ -1031,12 +1029,27 @@
|
||||
"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",
|
||||
"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",
|
||||
"Verify the identity of {{name}}": "Vérifier l'identité de {{name}}"
|
||||
"Verify the identity of {{name}}": "Vérifier l'identité de {{name}}",
|
||||
"This document was shared with a previous key": "Ce document a été partagé avec une ancienne clé",
|
||||
"It was shared with you under an encryption key you no longer have, most likely from before you reset your encryption. Ask the document owner to remove you from its members and add you again.": "Il vous a été partagé avec une clé de chiffrement que vous n'avez plus, sans doute d'avant la réinitialisation de votre chiffrement. Demandez au propriétaire du document de vous retirer de ses membres puis de vous ajouter à nouveau.",
|
||||
"The copy of this document's key stored for you cannot be opened with your encryption key. Ask the document owner to remove you from its members and add you again.": "La copie de la clé de ce document enregistrée pour vous ne peut pas être ouverte avec votre clé de chiffrement. Demandez au propriétaire du document de vous retirer de ses membres puis de vous ajouter à nouveau.",
|
||||
"This document cannot be decrypted": "Ce document ne peut pas être déchiffré",
|
||||
"Your key is correct, but the stored content is damaged or was altered, so it cannot be trusted. Contact the owner of this document or your support.": "Votre clé est la bonne, mais le contenu enregistré est endommagé ou a été modifié : il n'est pas fiable. Contactez le propriétaire de ce document ou votre support.",
|
||||
"This document could not be decrypted": "Ce document n'a pas pu être déchiffré",
|
||||
"Something went wrong while decrypting this document. Try again.": "Une erreur est survenue lors du déchiffrement de ce document. Réessayez.",
|
||||
"Waiting for access": "En attente d'accès",
|
||||
"This encrypted document holds no key for you. Ask the document owner to add you to its members.": "Ce document chiffré ne contient aucune clé pour vous. Demandez au propriétaire du document de vous ajouter à ses membres.",
|
||||
"Members who have not enabled encryption yet get access once they do.": "Les membres qui n'ont pas encore activé le chiffrement auront accès dès qu'ils l'auront fait.",
|
||||
"Encryption enabled": "Chiffrement activé",
|
||||
"Waiting for them to enable encryption": "En attente de l'activation du chiffrement par cette personne",
|
||||
"Waiting for encryption": "En attente de chiffrement",
|
||||
"This document was encrypted before you enabled encryption, so its key could not be shared with you. Enable encryption on your account: you will get access the next time the document owner opens it.": "Ce document a été chiffré avant que vous activiez le chiffrement, sa clé n'a donc pas pu vous être partagée. Activez le chiffrement sur votre compte : vous aurez accès la prochaine fois que le propriétaire du document l'ouvrira.",
|
||||
"This document was encrypted before you enabled encryption, so its key could not be shared with you then. You will get access the next time the document owner opens it.": "Ce document a été chiffré avant que vous activiez le chiffrement, sa clé n'a donc pas pu vous être partagée à ce moment-là. Vous aurez accès la prochaine fois que le propriétaire du document l'ouvrira.",
|
||||
"Added before they enabled encryption. They get access once they have, the next time the document owner opens it.": "Ajouté avant d'avoir activé le chiffrement. Cette personne aura accès une fois le chiffrement activé, la prochaine fois que le propriétaire du document l'ouvrira.",
|
||||
"This person has not enabled encryption yet, so they cannot be added to an encrypted document.": "Cette personne n'a pas encore activé le chiffrement, elle ne peut donc pas être ajoutée à un document chiffré."
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useCollaboration,
|
||||
useDoc,
|
||||
useDocStore,
|
||||
useEncryptionAccessCopy,
|
||||
useProviderStore,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management/';
|
||||
@@ -30,8 +31,9 @@ import {
|
||||
useUserEncryption,
|
||||
} from '@/features/docs/doc-collaboration';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { DecryptionFailurePanel } from '@/features/docs/doc-management/components/DecryptionFailurePanel';
|
||||
import { EncryptionEmptyState } from '@/features/docs/doc-management/components/EncryptionLayout';
|
||||
import { KeyMismatchPanel } from '@/features/docs/doc-management/components/KeyMismatchPanel';
|
||||
import { useAutoAcceptPendingMembers } from '@/features/docs/doc-share';
|
||||
import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
import { MainLayout } from '@/layouts';
|
||||
@@ -83,7 +85,7 @@ const DocPage = ({ id }: DocProps) => {
|
||||
encryptionTransition,
|
||||
clearEncryptionTransition,
|
||||
provider,
|
||||
decryptionFailed,
|
||||
decryptionFailure,
|
||||
} = useProviderStore();
|
||||
const { isSkeletonVisible, setIsSkeletonVisible } = useSkeletonStore();
|
||||
const {
|
||||
@@ -97,6 +99,10 @@ const DocPage = ({ id }: DocProps) => {
|
||||
staleTime: 0,
|
||||
queryKey: [KEY_DOC, { id }],
|
||||
retryDelay: 1000,
|
||||
// A pending member is let in by an owner opening the document elsewhere:
|
||||
// look again now and then so the page opens by itself once they have.
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.is_pending_encryption_for_user ? 15_000 : false,
|
||||
retry: (failureCount, error) => {
|
||||
if (error.status == 403 || error.status == 401 || error.status == 404) {
|
||||
return false;
|
||||
@@ -123,6 +129,16 @@ const DocPage = ({ id }: DocProps) => {
|
||||
user?.suite_user_id
|
||||
? doc?.accesses_versions_per_user?.[user.suite_user_id]
|
||||
: undefined,
|
||||
doc?.is_pending_encryption_for_user,
|
||||
);
|
||||
const needsSetup =
|
||||
encryptionError === 'missing_private_key' ||
|
||||
encryptionError === 'missing_public_key';
|
||||
const accessCopy = useEncryptionAccessCopy(doc, needsSetup);
|
||||
// Only once the content decrypted: the key then provably opens.
|
||||
useAutoAcceptPendingMembers(
|
||||
doc,
|
||||
provider ? documentEncryptionSettings : null,
|
||||
);
|
||||
const { setCurrentDoc } = useDocStore();
|
||||
const { addTask } = useBroadcastStore();
|
||||
@@ -301,8 +317,8 @@ const DocPage = ({ id }: DocProps) => {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (doc.is_encrypted && decryptionFailed) {
|
||||
return <KeyMismatchPanel doc={doc} />;
|
||||
if (doc.is_encrypted && decryptionFailure) {
|
||||
return <DecryptionFailurePanel failure={decryptionFailure} />;
|
||||
}
|
||||
|
||||
if (doc.is_encrypted && vaultClientError) {
|
||||
@@ -339,27 +355,11 @@ const DocPage = ({ id }: DocProps) => {
|
||||
}
|
||||
|
||||
if (doc.is_encrypted && (encryptionError || documentEncryptionError)) {
|
||||
const needsSetup =
|
||||
encryptionError === 'missing_private_key' ||
|
||||
encryptionError === 'missing_public_key';
|
||||
|
||||
return (
|
||||
<>
|
||||
<EncryptionEmptyState
|
||||
title={t('Encrypted document')}
|
||||
description={
|
||||
needsSetup
|
||||
? t(
|
||||
'This document is encrypted. You must enable encryption on your account to access 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.',
|
||||
)
|
||||
: t(
|
||||
'You do not have the correct encryption key to decrypt this document. Ask the document owner to share it with you again.',
|
||||
)
|
||||
}
|
||||
title={accessCopy.title}
|
||||
description={accessCopy.description}
|
||||
actions={
|
||||
<>
|
||||
<StyledLink href="/">
|
||||
|
||||
Reference in New Issue
Block a user