This commit is contained in:
Thomas Ramé
2026-09-24 23:07:02 +02:00
parent 8c6205dcf0
commit 40af663aa8
15 changed files with 145 additions and 469 deletions
@@ -6,7 +6,6 @@ const DB_VERSION = 1;
// Store names
export const STORE_PRIVATE_KEY = 'privateKey';
export const STORE_PUBLIC_KEY = 'publicKey';
export const STORE_KNOWN_PUBLIC_KEYS = 'knownPublicKeys';
let dbPromise: Promise<IDBPDatabase> | null = null;
@@ -24,9 +23,6 @@ export function getEncryptionDB(): Promise<IDBPDatabase> {
if (!db.objectStoreNames.contains(STORE_PUBLIC_KEY)) {
db.createObjectStore(STORE_PUBLIC_KEY);
}
if (!db.objectStoreNames.contains(STORE_KNOWN_PUBLIC_KEYS)) {
db.createObjectStore(STORE_KNOWN_PUBLIC_KEYS);
}
},
});
}
@@ -1,36 +0,0 @@
import { useEffect, useState } from 'react';
import { useVaultClient } from '../vault';
/**
* Computes a SHA-256 fingerprint of a base64-encoded public key.
* Returns a formatted hex string like "A1B2 C3D4 E5F6 7890", or null
* if the key is not provided or still computing.
*/
export function useKeyFingerprint(
base64Key: string | null | undefined,
): string | null {
const { client: vaultClient } = useVaultClient();
const [fingerprint, setFingerprint] = useState<string | null>(null);
useEffect(() => {
if (!base64Key || !vaultClient) {
setFingerprint(null);
return;
}
let cancelled = false;
const raw = Uint8Array.from(atob(base64Key), (c) => c.charCodeAt(0));
void vaultClient.computeKeyFingerprint(raw.buffer).then((fp) => {
if (!cancelled) {
setFingerprint(vaultClient.formatFingerprint(fp));
}
});
return () => {
cancelled = true;
};
}, [base64Key, vaultClient]);
return fingerprint;
}
@@ -1,132 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { STORE_KNOWN_PUBLIC_KEYS, getEncryptionDB } from '../encryptionDB';
export interface PublicKeyMismatch {
userId: string;
knownKey: string;
currentKey: string;
}
// module-level listener set to keep all hook instances in sync
const registryListeners = new Set<() => void>();
function notifyRegistryUpdated() {
registryListeners.forEach((fn) => fn());
}
/**
* TOFU (Trust On First Use) public key registry.
*
* - On first encounter, a user's public key is stored locally in IndexedDB.
* - On subsequent encounters, if the key differs from the stored one, it is
* flagged as a mismatch.
* - The caller can accept a new key via `acceptNewKey(userId)`, which updates
* the locally stored key.
*
* All instances stay in sync via a module-level listener set.
*/
export function usePublicKeyRegistry(
accessesPublicKeysPerUser: Record<string, string> | undefined,
currentUserId?: string,
) {
const [mismatches, setMismatches] = useState<PublicKeyMismatch[]>([]);
const [loading, setLoading] = useState(true);
const [refreshTrigger, setRefreshTrigger] = useState(0);
// listen for updates from other hook instances
useEffect(() => {
const handler = () => setRefreshTrigger((prev) => prev + 1);
registryListeners.add(handler);
return () => {
registryListeners.delete(handler);
};
}, []);
useEffect(() => {
if (!accessesPublicKeysPerUser) {
setMismatches([]);
setLoading(false);
return;
}
let cancelled = false;
const accesses = accessesPublicKeysPerUser;
async function checkKeys() {
try {
const db = await getEncryptionDB();
const newMismatches: PublicKeyMismatch[] = [];
for (const [userId, currentKey] of Object.entries(accesses)) {
// Skip the current user — they know about their own key changes
if (currentUserId && userId === currentUserId) {
// Still store the key so it stays up to date locally
await db.put(STORE_KNOWN_PUBLIC_KEYS, currentKey, `user:${userId}`);
continue;
}
const knownKey: string | undefined = await db.get(
STORE_KNOWN_PUBLIC_KEYS,
`user:${userId}`,
);
if (!knownKey) {
// First time seeing this user's key — trust on first use
await db.put(STORE_KNOWN_PUBLIC_KEYS, currentKey, `user:${userId}`);
} else if (knownKey !== currentKey) {
newMismatches.push({ userId, knownKey, currentKey });
}
}
if (!cancelled) {
setMismatches(newMismatches);
}
} catch (error) {
console.error('usePublicKeyRegistry: failed to check keys', error);
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
setLoading(true);
void checkKeys();
return () => {
cancelled = true;
};
}, [accessesPublicKeysPerUser, currentUserId, refreshTrigger]);
const acceptNewKey = useCallback(
async (userId: string) => {
const mismatch = mismatches.find((m) => m.userId === userId);
if (!mismatch) {
return;
}
const db = await getEncryptionDB();
await db.put(
STORE_KNOWN_PUBLIC_KEYS,
mismatch.currentKey,
`user:${userId}`,
);
setMismatches((prev) => prev.filter((m) => m.userId !== userId));
// notify other instances to re-check
notifyRegistryUpdated();
},
[mismatches],
);
return {
mismatches,
hasMismatches: mismatches.length > 0,
loading,
acceptNewKey,
};
}
@@ -16,8 +16,6 @@ export {
UserEncryptionProvider,
useUserEncryption,
} from './UserEncryptionProvider';
export { useKeyFingerprint } from './hook/useKeyFingerprint';
export { usePublicKeyRegistry } from './hook/usePublicKeyRegistry';
export {
exportPrivateKeyAsJwk,
importPrivateKeyFromJwk,
@@ -10,7 +10,6 @@ import { Doc } from '@/docs/doc-management';
interface BoutonShareProps {
displayNbAccess: boolean;
doc: Doc;
hasKeyWarning?: boolean;
isDisabled?: boolean;
isHidden?: boolean;
open: () => void;
@@ -19,7 +18,6 @@ interface BoutonShareProps {
export const BoutonShare = ({
displayNbAccess,
doc,
hasKeyWarning,
isDisabled,
isHidden,
open,
@@ -60,10 +58,9 @@ export const BoutonShare = ({
<Button
aria-label={t('Share button')}
variant="secondary"
color={hasKeyWarning ? 'warning' : undefined}
icon={
<Icon
iconName={hasKeyWarning ? 'warning' : 'group'}
iconName="group"
$color="inherit"
variant="filled"
disabled={isDisabled}
@@ -81,11 +78,8 @@ export const BoutonShare = ({
return (
<Button
color={hasKeyWarning ? 'warning' : 'brand'}
color="brand"
variant="tertiary"
icon={
hasKeyWarning ? <Icon iconName="warning" $color="inherit" /> : undefined
}
onClick={open}
size="medium"
disabled={isDisabled}
@@ -14,10 +14,6 @@ import {
IconOptions,
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
usePublicKeyRegistry,
useUserEncryption,
} from '@/docs/doc-collaboration';
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
import { useVaultClient } from '@/docs/doc-collaboration/vault';
import Export from '@/docs/doc-export/';
@@ -66,7 +62,6 @@ export const DocToolBox = ({
const { isChild, isTopRoot } = useDocUtils(doc);
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { encryptionSettings } = useUserEncryption();
const { isEnabled: isEncryptionEnabled } = useVaultClient();
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
@@ -77,11 +72,6 @@ export const DocToolBox = ({
const selectHistoryModal = useModal();
const modalShare = useModal();
const { hasMismatches: hasKeyWarnings } = usePublicKeyRegistry(
undefined,
encryptionSettings?.userId,
);
const { isSmallMobile, isMobile } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
const { mutate: duplicateDoc } = useDuplicateDoc({
@@ -243,7 +233,6 @@ export const DocToolBox = ({
open={modalShare.open}
isHidden={isSmallMobile}
displayNbAccess={doc.abilities.accesses_view}
hasKeyWarning={hasKeyWarnings}
/>
{!isSmallMobile && ModalExport && (
@@ -71,6 +71,11 @@ export function useCreateDocAccess() {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
// The document carries the per-member key versions the share dialog
// reads, so it has to come back with the new member in it.
void queryClient.invalidateQueries({
queryKey: [KEY_DOC],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.docId}`);
@@ -94,11 +94,13 @@ type DocShareModalInviteUserRowProps = {
user: User;
suffix?: string;
suffixIcon?: string;
onAvatarClick?: () => void;
};
export const DocShareModalInviteUserRow = ({
user,
suffix,
suffixIcon,
onAvatarClick,
}: DocShareModalInviteUserRowProps) => {
const { t } = useTranslation();
return (
@@ -111,6 +113,7 @@ export const DocShareModalInviteUserRow = ({
user={user}
suffix={suffix}
suffixIcon={suffixIcon}
onAvatarClick={onAvatarClick}
right={
<BoxButton
className="right-hover"
@@ -2,21 +2,21 @@ import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { QuickSearchData } from '@/components/quick-search';
import { QuickSearchGroup } from '@/components/quick-search/QuickSearchGroup';
import { useCunninghamTheme } from '@/cunningham';
import { PublicKeyMismatch } from '@/docs/doc-collaboration/hook/usePublicKeyRegistry';
import { useVaultClient } from '@/docs/doc-collaboration/vault';
import { Access, Doc, Role } from '@/docs/doc-management/';
import { useAuth } from '@/features/auth';
import { useDocAccesses, useUpdateDocAccess } from '../api';
import { useWhoAmI } from '../hooks/';
import { DocRoleDropdown } from './DocRoleDropdown';
import { ModalKeyMismatch } from './ModalKeyMismatch';
import { SearchUserRow } from './SearchUserRow';
type Props = {
@@ -25,7 +25,7 @@ type Props = {
isInherited?: boolean;
suffix?: string;
suffixIcon?: string;
onSuffixClick?: () => void;
onAvatarClick?: () => void;
};
export const DocShareMemberItem = ({
doc,
@@ -33,7 +33,7 @@ export const DocShareMemberItem = ({
isInherited = false,
suffix,
suffixIcon,
onSuffixClick,
onAvatarClick,
}: Props) => {
const { t } = useTranslation();
const { isLastOwner } = useWhoAmI(access);
@@ -79,7 +79,7 @@ export const DocShareMemberItem = ({
user={access.user}
suffix={suffix}
suffixIcon={suffixIcon}
onSuffixClick={onSuffixClick}
onAvatarClick={onAvatarClick}
right={
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
<DocRoleDropdown
@@ -104,19 +104,14 @@ export const DocShareMemberItem = ({
interface QuickSearchGroupMemberProps {
doc: Doc;
keyMismatchUserIds?: Set<string>;
keyMismatches?: PublicKeyMismatch[];
acceptNewKey?: (userId: string) => Promise<void>;
}
export const QuickSearchGroupMember = ({
doc,
keyMismatchUserIds,
keyMismatches,
acceptNewKey,
}: QuickSearchGroupMemberProps) => {
const { t } = useTranslation();
const [mismatchUserId, setMismatchUserId] = useState<string | null>(null);
const { user: me } = useAuth();
const { client: vaultClient } = useVaultClient();
const membersQuery = useDocAccesses({
docId: doc.id,
});
@@ -144,52 +139,31 @@ export const QuickSearchGroupMember = ({
group={membersData}
renderElement={(access) => {
const uid = access.user.suite_user_id;
const hasMismatch = uid ? keyMismatchUserIds?.has(uid) : false;
const hasNoEncryptionKey =
doc.is_encrypted &&
(!uid || !doc.accesses_versions_per_user?.[uid]);
let suffix: string | undefined;
let suffixIcon: string | undefined;
if (hasMismatch) {
suffix = t('Verify key');
suffixIcon = 'gpp_maybe';
} else if (hasNoEncryptionKey) {
suffix = t('No encryption');
suffixIcon = 'gpp_bad';
}
// On an encrypted document, a member's avatar opens their
// encryption identity (fingerprint, trust decision); not one's own.
const identityOf =
doc.is_encrypted && vaultClient && uid && uid !== me?.suite_user_id
? () =>
vaultClient.openRecipientProfile(uid, {
email: access.user.email,
name: access.user.full_name || undefined,
})
: undefined;
return (
<DocShareMemberItem
doc={doc}
access={access}
suffix={suffix}
suffixIcon={suffixIcon}
onSuffixClick={
hasMismatch && uid ? () => setMismatchUserId(uid) : undefined
}
suffix={hasNoEncryptionKey ? t('No encryption') : undefined}
onAvatarClick={identityOf}
/>
);
}}
/>
{mismatchUserId &&
(() => {
const mismatch = keyMismatches?.find(
(m) => m.userId === mismatchUserId,
);
return (
<ModalKeyMismatch
onClose={() => setMismatchUserId(null)}
onAcceptKey={
acceptNewKey
? () => void acceptNewKey(mismatchUserId)
: undefined
}
knownKey={mismatch?.knownKey}
currentKey={mismatch?.currentKey}
/>
);
})()}
</Box>
);
};
@@ -19,11 +19,13 @@ import {
} from '@/components/quick-search/';
import {
useDocumentEncryption,
usePublicKeyRegistry,
useUserEncryption,
} from '@/docs/doc-collaboration';
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
import type { PublicKeyMismatch } from '@/docs/doc-collaboration/hook/usePublicKeyRegistry';
import {
fetchRegisteredKeys,
useVaultClient,
} from '@/docs/doc-collaboration/vault';
import { Doc } from '@/docs/doc-management';
import { User, useAuth } from '@/features/auth';
import {
@@ -54,7 +56,6 @@ import {
} from './DocShareInvitation';
import { QuickSearchGroupMember } from './DocShareMember';
import { DocShareModalFooter } from './DocShareModalFooter';
import { ModalKeyMismatch } from './ModalKeyMismatch';
import { PendingEncryptionSection } from './PendingEncryptionSection';
const ShareModalStyle = createGlobalStyle`
@@ -110,15 +111,6 @@ export const DocShareModal = ({
? encryptionError || documentEncryptionError
: null;
const { mismatches: keyMismatches, acceptNewKey } = usePublicKeyRegistry(
undefined,
user?.suite_user_id ?? undefined,
);
const keyMismatchUserIds = useMemo(
() => new Set(keyMismatches.map((m) => m.userId)),
[keyMismatches],
);
/**
* The modal content height is calculated based on the viewport height.
* The formula is:
@@ -395,25 +387,16 @@ export const DocShareModal = ({
<Box $padding={{ horizontal: 'base' }}>
<QuickSearchGroupAccessRequest doc={doc} />
<QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember
doc={doc}
keyMismatchUserIds={keyMismatchUserIds}
keyMismatches={keyMismatches}
acceptNewKey={acceptNewKey}
/>
<QuickSearchGroupMember doc={doc} />
</Box>
)}
{!showMemberSection && canShare && (
<QuickSearchInviteInputSection
doc={doc}
searchUsersRawData={searchUsersQuery.data}
onSelect={onSelect}
userQuery={userQuery}
isEncrypted={doc.is_encrypted}
keyMismatchUserIds={keyMismatchUserIds}
keyMismatches={keyMismatches}
acceptNewKey={acceptNewKey}
/>
)}
</QuickSearch>
@@ -434,29 +417,69 @@ export const DocShareModal = ({
};
interface QuickSearchInviteInputSectionProps {
doc: Doc;
onSelect: (usr: User) => void;
searchUsersRawData: User[] | undefined;
userQuery: string;
isEncrypted: boolean;
keyMismatchUserIds?: Set<string>;
keyMismatches?: PublicKeyMismatch[];
acceptNewKey?: (userId: string) => Promise<void>;
}
const QuickSearchInviteInputSection = ({
doc,
onSelect,
searchUsersRawData,
userQuery,
isEncrypted,
keyMismatchUserIds,
keyMismatches,
acceptNewKey,
}: QuickSearchInviteInputSectionProps) => {
const { t } = useTranslation();
const { client: vaultClient } = useVaultClient();
const [showNoKeyModal, setShowNoKeyModal] = useState(false);
const [mismatchUser, setMismatchUser] = useState<User | null>(null);
// Subs of the search results that hold a registered encryption key, from
// the directory; null until known (or when the lookup failed), in which
// case nobody is refused here and the invitation itself reports.
const [registeredSubs, setRegisteredSubs] = useState<Set<string> | null>(
null,
);
useEffect(() => {
if (!isEncrypted || !vaultClient) {
setRegisteredSubs(null);
return;
}
const subs = (searchUsersRawData ?? [])
.map((user) => user.suite_user_id)
.filter((sub): sub is string => !!sub);
if (subs.length === 0) {
setRegisteredSubs(new Set());
return;
}
let cancelled = false;
setRegisteredSubs(null);
fetchRegisteredKeys(vaultClient, subs)
.then(({ publicKeys }) => {
if (!cancelled) {
setRegisteredSubs(new Set(Object.keys(publicKeys)));
}
})
.catch(() => {
if (!cancelled) {
setRegisteredSubs(null);
}
});
return () => {
cancelled = true;
};
}, [isEncrypted, vaultClient, searchUsersRawData]);
const hasNoKey = useCallback(
(user: User) =>
isEncrypted &&
(!user.suite_user_id ||
(registeredSubs !== null && !registeredSubs.has(user.suite_user_id))),
[isEncrypted, registeredSubs],
);
const showEncryptedInviteWarning = useMemo(() => {
const users = searchUsersRawData || [];
@@ -469,21 +492,13 @@ const QuickSearchInviteInputSection = ({
const handleSelect = useCallback(
(user: User) => {
if (
isEncrypted &&
(!user.suite_user_id ||
!doc.accesses_versions_per_user?.[user.suite_user_id])
) {
if (hasNoKey(user)) {
setShowNoKeyModal(true);
return;
}
if (user.suite_user_id && keyMismatchUserIds?.has(user.suite_user_id)) {
setMismatchUser(user);
return;
}
onSelect(user);
},
[isEncrypted, doc.accesses_versions_per_user, keyMismatchUserIds, onSelect],
[hasNoKey, onSelect],
);
const searchUserData: QuickSearchData<User> = useMemo(() => {
@@ -518,21 +533,28 @@ const QuickSearchInviteInputSection = ({
};
}, [handleSelect, searchUsersRawData, t, userQuery, isEncrypted]);
// On an encrypted document, a person's avatar opens their encryption
// identity (fingerprint, trust decision), registered or not.
const identityOf = (user: User): (() => void) | undefined => {
const sub = user.suite_user_id;
if (!isEncrypted || !vaultClient || !sub) {
return undefined;
}
return () =>
vaultClient.openRecipientProfile(sub, {
email: user.email,
name: user.full_name || undefined,
});
};
const getUserSuffix = useCallback(
(user: User): string | undefined => {
if (user.suite_user_id && keyMismatchUserIds?.has(user.suite_user_id)) {
return t('Verify key');
}
if (
isEncrypted &&
(!user.suite_user_id ||
!doc.accesses_versions_per_user?.[user.suite_user_id])
) {
if (hasNoKey(user)) {
return t('No encryption');
}
return undefined;
},
[isEncrypted, doc.accesses_versions_per_user, keyMismatchUserIds, t],
[hasNoKey, t],
);
return (
@@ -547,11 +569,7 @@ const QuickSearchInviteInputSection = ({
<DocShareModalInviteUserRow
user={user}
suffix={getUserSuffix(user)}
suffixIcon={
user.suite_user_id && keyMismatchUserIds?.has(user.suite_user_id)
? 'gpp_maybe'
: 'gpp_bad'
}
onAvatarClick={identityOf(user)}
/>
)}
/>
@@ -588,31 +606,6 @@ const QuickSearchInviteInputSection = ({
/>
</Modal>
)}
{mismatchUser &&
(() => {
const mismatch = keyMismatches?.find(
(m) => m.userId === mismatchUser.suite_user_id,
);
return (
<ModalKeyMismatch
onClose={() => setMismatchUser(null)}
onAcceptKey={
acceptNewKey
? () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
void acceptNewKey(mismatchUser.suite_user_id!).then(
() => {
onSelect(mismatchUser);
},
);
}
: undefined
}
knownKey={mismatch?.knownKey}
currentKey={mismatch?.currentKey}
/>
);
})()}
</Box>
);
};
@@ -1,107 +0,0 @@
import {
Alert,
Button,
Modal,
ModalSize,
VariantType,
} from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { useKeyFingerprint } from '@/docs/doc-collaboration';
import { EncryptionModalContent } from '@/features/docs/doc-management/components/EncryptionLayout';
interface ModalKeyMismatchProps {
onClose: () => void;
onAcceptKey?: () => void;
knownKey?: string;
currentKey?: string;
}
const Fingerprint = ({ label, value }: { label: string; value: string }) => (
<Box $gap="3xs">
<Text $size="xs" $weight="600" $variation="secondary">
{label}
</Text>
<Text
$size="sm"
$weight="700"
$css="font-family: monospace; letter-spacing: 0.08em; overflow-wrap: anywhere;"
>
{value}
</Text>
</Box>
);
export const ModalKeyMismatch = ({
onClose,
onAcceptKey,
knownKey,
currentKey,
}: ModalKeyMismatchProps) => {
const { t } = useTranslation();
const knownFingerprint = useKeyFingerprint(knownKey);
const currentFingerprint = useKeyFingerprint(currentKey);
return (
<Modal
isOpen
closeOnClickOutside
onClose={onClose}
size={ModalSize.SMALL}
aria-label={t('Verify encryption key')}
>
<EncryptionModalContent
title={t('Verify encryption key')}
description={t(
'We recommend verifying with this person directly (for example on a call) that they really changed their encryption key before proceeding.',
)}
actionsLayout="row"
actions={
<>
<Button variant="bordered" color="error" onClick={onClose}>
{t("Don't trust")}
</Button>
{onAcceptKey && (
<Button
onClick={() => {
onAcceptKey();
onClose();
}}
>
{t('Trust')}
</Button>
)}
</>
}
>
<Alert type={VariantType.WARNING}>
{t(
"This person's encryption key has changed. Verify it before continuing.",
)}
</Alert>
{(knownFingerprint || currentFingerprint) && (
<Box
$gap="xs"
$padding="xs"
$radius="4px"
$css="border: 1px solid var(--c--contextuals--border--surface--primary);"
>
{knownFingerprint && (
<Fingerprint
label={t('Previously known:')}
value={knownFingerprint}
/>
)}
{currentFingerprint && (
<Fingerprint
label={t('Current key:')}
value={currentFingerprint}
/>
)}
</Box>
)}
</EncryptionModalContent>
</Modal>
);
};
@@ -1,4 +1,6 @@
import { Box, Icon, Text } from '@/components';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Icon, Text } from '@/components';
import {
QuickSearchItemContent,
QuickSearchItemContentProps,
@@ -11,11 +13,12 @@ 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. */
/** A short status ("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. */
/** Material icon for the suffix; the crossed shield by default. */
suffixIcon?: string;
onSuffixClick?: () => void;
/** Makes the avatar a button (the person's encryption identity). */
onAvatarClick?: () => void;
};
export const SearchUserRow = ({
@@ -24,9 +27,10 @@ export const SearchUserRow = ({
alwaysShowRight = false,
isInvitation = false,
suffix,
suffixIcon = 'gpp_maybe',
onSuffixClick,
suffixIcon = 'gpp_bad',
onAvatarClick,
}: Props) => {
const { t } = useTranslation();
const hasFullName = !!user.full_name;
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
@@ -41,10 +45,31 @@ export const SearchUserRow = ({
$gap={spacingsTokens['xs']}
className="--docs--search-user-row"
>
<UserAvatar
fullName={user.full_name || user.email}
background={isInvitation ? colorsTokens['gray-400'] : undefined}
/>
{onAvatarClick ? (
<BoxButton
aria-label={t('Verify the identity of {{name}}', {
name: user.full_name || user.email,
})}
title={t('Verify the identity of {{name}}', {
name: user.full_name || user.email,
})}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onAvatarClick();
}}
>
<UserAvatar
fullName={user.full_name || user.email}
background={isInvitation ? colorsTokens['gray-400'] : undefined}
/>
</BoxButton>
) : (
<UserAvatar
fullName={user.full_name || user.email}
background={isInvitation ? colorsTokens['gray-400'] : undefined}
/>
)}
<Box $direction="column">
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
<Text $size="sm" $weight="500">
@@ -54,19 +79,10 @@ export const SearchUserRow = ({
<Icon
iconName={suffixIcon}
$size="sm"
$theme={onSuffixClick ? 'warning' : 'neutral'}
$variation={onSuffixClick ? undefined : 'tertiary'}
$theme="neutral"
$variation="tertiary"
aria-label={suffix}
title={suffix}
{...(onSuffixClick && {
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
onSuffixClick();
},
role: 'button',
tabIndex: 0,
style: { cursor: 'pointer' },
})}
/>
)}
</Box>
@@ -7,10 +7,8 @@ import { css } from 'styled-components';
import { Box, Icon, StyledLink, Text } from '@/components';
import { useConfig } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { usePublicKeyRegistry } from '@/docs/doc-collaboration';
import { Doc, LinkReach, SimpleDocItem } from '@/docs/doc-management';
import { DocShareModal } from '@/docs/doc-share';
import { useAuth } from '@/features/auth';
import { useDate } from '@/hooks';
import { useResponsiveStore } from '@/stores';
@@ -35,11 +33,6 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
const { flexLeft, flexRight } = useResponsiveDocGrid();
const { spacingsTokens } = useCunninghamTheme();
const shareModal = useModal();
const { user } = useAuth();
const { hasMismatches: hasKeyWarning } = usePublicKeyRegistry(
undefined,
user?.id,
);
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isShared = isPublic || isAuthenticated;
@@ -188,7 +181,6 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
doc={doc}
handleClick={handleShareClick}
disabled={isInTrashbin}
hasKeyWarning={hasKeyWarning}
/>
)}
{isInTrashbin ? (
@@ -8,13 +8,11 @@ type Props = {
doc: Doc;
handleClick: () => void;
disabled: boolean;
hasKeyWarning?: boolean;
};
export const DocsGridItemSharedButton = ({
doc,
handleClick,
disabled,
hasKeyWarning,
}: Props) => {
const { t } = useTranslation();
const sharedCount = doc.nb_accesses_direct;
@@ -46,14 +44,14 @@ export const DocsGridItemSharedButton = ({
event.stopPropagation();
handleClick();
}}
color={hasKeyWarning ? 'warning' : 'brand'}
color="brand"
variant="secondary"
size="nano"
icon={
<Icon
$theme={hasKeyWarning ? 'warning' : 'brand'}
$theme="brand"
$variation="secondary"
iconName={hasKeyWarning ? 'warning' : 'group'}
iconName="group"
disabled={disabled}
variant="filled"
/>
@@ -1026,24 +1026,17 @@
"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"
"Encryption settings": "Paramètres de chiffrement",
"Verify the identity of {{name}}": "Vérifier l'identité de {{name}}"
}
},
"it": {