mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-28 12:35:10 +02:00
wip
This commit is contained in:
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
-36
@@ -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;
|
||||
}
|
||||
-132
@@ -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}`);
|
||||
|
||||
+3
@@ -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"
|
||||
|
||||
+20
-46
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+73
-80
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
-107
@@ -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>
|
||||
);
|
||||
};
|
||||
+37
-21
@@ -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 ? (
|
||||
|
||||
+3
-5
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user