wip improve accesses tips

This commit is contained in:
Thomas Ramé
2026-03-04 23:22:22 +01:00
parent d685b541c5
commit 205960106b
10 changed files with 456 additions and 35 deletions
+2 -2
View File
@@ -57,8 +57,8 @@ class UserLightSerializer(UserSerializer):
class Meta:
model = models.User
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
fields = ["id", "full_name", "short_name"]
read_only_fields = ["id", "full_name", "short_name"]
class ListDocumentSerializer(serializers.ModelSerializer):
@@ -101,6 +101,25 @@ export async function decryptContent(
return new Uint8Array(arrayBuffer);
}
/**
* Compute a SHA-256 fingerprint of a base64-encoded public key.
* Returns a formatted hex string like "A1B2 C3D4 E5F6 7890".
*/
export async function computeKeyFingerprint(
base64Key: string,
): Promise<string> {
const raw = Uint8Array.from(atob(base64Key), (c) => c.charCodeAt(0));
const hash = await crypto.subtle.digest('SHA-256', raw);
const hex = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return hex
.slice(0, 16)
.replace(/(.{4})/g, '$1 ')
.trim()
.toUpperCase();
}
// prepare encrypted symmetric keys for all users with access to a document
export async function prepareEncryptedSymmetricKeysForUsers(
symmetricKey: CryptoKey,
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
import { computeKeyFingerprint } from '../encryption';
/**
* 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 [fingerprint, setFingerprint] = useState<string | null>(null);
useEffect(() => {
if (!base64Key) {
setFingerprint(null);
return;
}
let cancelled = false;
computeKeyFingerprint(base64Key).then((fp) => {
if (!cancelled) {
setFingerprint(fp);
}
});
return () => {
cancelled = true;
};
}, [base64Key]);
return fingerprint;
}
@@ -1,4 +1,5 @@
export {
computeKeyFingerprint,
decryptContent,
encryptContent,
generateSymmetricKey,
@@ -9,4 +10,5 @@ export {
export { getEncryptionDB } from './encryptionDB';
export { useDocumentEncryption } from './hook/useDocumentEncryption';
export { useEncryption } from './hook/useEncryption';
export { useKeyFingerprint } from './hook/useKeyFingerprint';
export { usePublicKeyRegistry } from './hook/usePublicKeyRegistry';
@@ -78,6 +78,10 @@ export const DocShareAddMemberList = ({
});
}
if (dataError.cause?.[0] && dataError.message === dataError.cause[0]) {
messageError = dataError.cause[0];
}
toast(messageError, VariantType.ERROR, {
duration: 4000,
});
@@ -95,6 +99,24 @@ export const DocShareAddMemberList = ({
};
if (isInvitationMode) {
if (doc.is_encrypted) {
throw Object.assign(
new Error(
t(
'Only registered users with encryption enabled can be added to encrypted documents.',
),
),
{
cause: [
t(
'Only registered users with encryption enabled can be added to encrypted documents.',
),
],
data: { value: user.email, type: OptionType.INVITATION },
},
) as APIErrorUser;
}
return createInvitation({
...payload,
email: user.email.toLowerCase(),
@@ -93,10 +93,12 @@ export const DocShareInvitationItem = ({
type DocShareModalInviteUserRowProps = {
user: User;
suffix?: string;
fingerprintKey?: string | null;
};
export const DocShareModalInviteUserRow = ({
user,
suffix,
fingerprintKey,
}: DocShareModalInviteUserRowProps) => {
const { t } = useTranslation();
return (
@@ -108,6 +110,7 @@ export const DocShareModalInviteUserRow = ({
<SearchUserRow
user={user}
suffix={suffix}
fingerprintKey={fingerprintKey}
right={
<BoxButton
className="right-hover"
@@ -2,19 +2,21 @@ import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useMemo } from 'react';
import { useMemo, useState } 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 { Access, Doc, Role } from '@/docs/doc-management/';
import { useDocAccesses, useUpdateDocAccess } from '../api';
import { useWhoAmI } from '../hooks/';
import { DocRoleDropdown } from './DocRoleDropdown';
import { ModalKeyMismatch } from './ModalKeyMismatch';
import { SearchUserRow } from './SearchUserRow';
type Props = {
@@ -22,12 +24,16 @@ type Props = {
access: Access;
isInherited?: boolean;
suffix?: string;
onSuffixClick?: () => void;
fingerprintKey?: string | null;
};
export const DocShareMemberItem = ({
doc,
access,
isInherited = false,
suffix,
onSuffixClick,
fingerprintKey,
}: Props) => {
const { t } = useTranslation();
const { isLastOwner } = useWhoAmI(access);
@@ -72,6 +78,8 @@ export const DocShareMemberItem = ({
alwaysShowRight={true}
user={access.user}
suffix={suffix}
onSuffixClick={onSuffixClick}
fingerprintKey={fingerprintKey}
right={
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
<DocRoleDropdown
@@ -97,13 +105,18 @@ 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 membersQuery = useDocAccesses({
docId: doc.id,
});
@@ -129,18 +142,46 @@ export const QuickSearchGroupMember = ({
<Box aria-label={t('List members card')} $padding={{ bottom: '3xs' }}>
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem
doc={doc}
access={access}
suffix={
keyMismatchUserIds?.has(access.user.id)
? t('DIFFERENT PUBLIC KEY')
: undefined
}
/>
)}
renderElement={(access) => {
const hasMismatch = keyMismatchUserIds?.has(access.user.id);
return (
<DocShareMemberItem
doc={doc}
access={access}
suffix={hasMismatch ? t('DIFFERENT PUBLIC KEY') : undefined}
onSuffixClick={
hasMismatch
? () => setMismatchUserId(access.user.id)
: undefined
}
fingerprintKey={
doc.is_encrypted
? (doc.accesses_public_keys_per_user?.[access.user.id] ??
access.user.encryption_public_key)
: undefined
}
/>
);
}}
/>
{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>
);
};
@@ -1,17 +1,28 @@
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import {
Button,
Modal,
ModalSize,
} from '@gouvfr-lasuite/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce';
import { Box, ButtonCloseModal, HorizontalSeparator, Text } from '@/components';
import {
Box,
ButtonCloseModal,
HorizontalSeparator,
Icon,
Text,
} from '@/components';
import {
QuickSearch,
QuickSearchData,
QuickSearchGroup,
} from '@/components/quick-search/';
import { usePublicKeyRegistry } from '@/docs/doc-collaboration';
import type { PublicKeyMismatch } from '@/docs/doc-collaboration/hook/usePublicKeyRegistry';
import { Doc } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
@@ -38,6 +49,7 @@ import {
} from './DocShareInvitation';
import { QuickSearchGroupMember } from './DocShareMember';
import { DocShareModalFooter } from './DocShareModalFooter';
import { ModalKeyMismatch } from './ModalKeyMismatch';
const ShareModalStyle = createGlobalStyle`
.--docs--doc-share-modal [cmdk-item] {
@@ -68,7 +80,7 @@ export const DocShareModal = ({
const queryClient = useQueryClient();
const { isDesktop } = useResponsiveStore();
const { mismatches: keyMismatches } = usePublicKeyRegistry(
const { mismatches: keyMismatches, acceptNewKey } = usePublicKeyRegistry(
doc.accesses_public_keys_per_user,
);
const keyMismatchUserIds = useMemo(
@@ -253,7 +265,9 @@ export const DocShareModal = ({
<Box $padding={{ horizontal: 'base' }} $margin={{ top: '12x' }}>
<DocShareAddMemberList
doc={doc}
documentEncryptionSettings={documentEncryptionSettings ?? null}
documentEncryptionSettings={
documentEncryptionSettings ?? null
}
selectedUsers={selectedUsers}
onRemoveUser={onRemoveUser}
afterInvite={() => {
@@ -321,6 +335,8 @@ export const DocShareModal = ({
<QuickSearchGroupMember
doc={doc}
keyMismatchUserIds={keyMismatchUserIds}
keyMismatches={keyMismatches}
acceptNewKey={acceptNewKey}
/>
</Box>
)}
@@ -331,6 +347,9 @@ export const DocShareModal = ({
onSelect={onSelect}
userQuery={userQuery}
isEncrypted={doc.is_encrypted}
keyMismatchUserIds={keyMismatchUserIds}
keyMismatches={keyMismatches}
acceptNewKey={acceptNewKey}
/>
)}
</QuickSearch>
@@ -352,6 +371,9 @@ interface QuickSearchInviteInputSectionProps {
searchUsersRawData: User[] | undefined;
userQuery: string;
isEncrypted: boolean;
keyMismatchUserIds?: Set<string>;
keyMismatches?: PublicKeyMismatch[];
acceptNewKey?: (userId: string) => Promise<void>;
}
const QuickSearchInviteInputSection = ({
@@ -359,8 +381,37 @@ const QuickSearchInviteInputSection = ({
searchUsersRawData,
userQuery,
isEncrypted,
keyMismatchUserIds,
keyMismatches,
acceptNewKey,
}: QuickSearchInviteInputSectionProps) => {
const { t } = useTranslation();
const [showNoKeyModal, setShowNoKeyModal] = useState(false);
const [mismatchUser, setMismatchUser] = useState<User | null>(null);
const showEncryptedInviteWarning = useMemo(() => {
const users = searchUsersRawData || [];
const isEmail = isValidEmail(userQuery);
const hasEmailInUsers = users.some(
(user) => user.email.toLowerCase() === userQuery.toLowerCase(),
);
return isEncrypted && isEmail && !hasEmailInUsers;
}, [searchUsersRawData, userQuery, isEncrypted]);
const handleSelect = useCallback(
(user: User) => {
if (isEncrypted && !user.encryption_public_key) {
setShowNoKeyModal(true);
return;
}
if (keyMismatchUserIds?.has(user.id)) {
setMismatchUser(user);
return;
}
onSelect(user);
},
[isEncrypted, keyMismatchUserIds, onSelect],
);
const searchUserData: QuickSearchData<User> = useMemo(() => {
const users = searchUsersRawData || [];
@@ -378,20 +429,34 @@ const QuickSearchInviteInputSection = ({
(user) => user.email.toLowerCase() === userQuery.toLowerCase(),
);
const showInviteByEmail = isEmail && !hasEmailInUsers && !isEncrypted;
return {
groupName: t('Search user result'),
elements: users,
endActions:
isEmail && !hasEmailInUsers
? [
{
content: <DocShareModalInviteUserRow user={newUser} />,
onSelect: () => void onSelect(newUser),
},
]
: undefined,
endActions: showInviteByEmail
? [
{
content: <DocShareModalInviteUserRow user={newUser} />,
onSelect: () => void handleSelect(newUser),
},
]
: undefined,
};
}, [onSelect, searchUsersRawData, t, userQuery]);
}, [handleSelect, searchUsersRawData, t, userQuery, isEncrypted]);
const getUserSuffix = useCallback(
(user: User): string | undefined => {
if (keyMismatchUserIds?.has(user.id)) {
return t('DIFFERENT PUBLIC KEY');
}
if (isEncrypted && !user.encryption_public_key) {
return t(`(chiffrement non activé)`);
}
return undefined;
},
[isEncrypted, keyMismatchUserIds, t],
);
return (
<Box
@@ -400,18 +465,86 @@ const QuickSearchInviteInputSection = ({
>
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
onSelect={handleSelect}
renderElement={(user) => (
<DocShareModalInviteUserRow
user={user}
suffix={
isEncrypted && !user.encryption_public_key
? t('NO PUBLIC KEY')
: undefined
suffix={getUserSuffix(user)}
fingerprintKey={
isEncrypted ? user.encryption_public_key : undefined
}
/>
)}
/>
{showEncryptedInviteWarning && (
<Text
$variation="secondary"
$size="sm"
$padding={{ horizontal: 'xs', top: '3xs' }}
>
{t(
'Only registered users with encryption enabled can be added to encrypted documents.',
)}
</Text>
)}
{showNoKeyModal && (
<Modal
isOpen
closeOnClickOutside
onClose={() => setShowNoKeyModal(false)}
size={ModalSize.SMALL}
rightActions={
<Button onClick={() => setShowNoKeyModal(false)}>
{t('Understood')}
</Button>
}
title={
<Text
as="h1"
$gap="0.7rem"
$size="h6"
$align="flex-start"
$direction="row"
$margin="0"
>
<Icon iconName="lock" />
{t('Encryption required')}
</Text>
}
>
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
<Text $variation="secondary">
{t(
'This user has not enabled encryption on their account yet. It is not possible to share encrypted content with them.',
)}
</Text>
<Text $variation="secondary">
{t(
'Please ask them to enable encryption in their account settings first.',
)}
</Text>
</Box>
</Modal>
)}
{mismatchUser && (() => {
const mismatch = keyMismatches?.find((m) => m.userId === mismatchUser.id);
return (
<ModalKeyMismatch
onClose={() => setMismatchUser(null)}
onAcceptKey={
acceptNewKey
? () => {
void acceptNewKey(mismatchUser.id).then(() => {
onSelect(mismatchUser);
});
}
: undefined
}
knownKey={mismatch?.knownKey}
currentKey={mismatch?.currentKey}
/>
);
})()}
</Box>
);
};
@@ -0,0 +1,119 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { useKeyFingerprint } from '@/docs/doc-collaboration';
interface ModalKeyMismatchProps {
onClose: () => void;
onAcceptKey?: () => void;
knownKey?: string;
currentKey?: string;
}
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.MEDIUM}
rightActions={
<>
<Button variant="secondary" onClick={onClose}>
{t('Cancel')}
</Button>
{onAcceptKey && (
<Button
color="warning"
onClick={() => {
onAcceptKey();
onClose();
}}
>
{t('I trust this key')}
</Button>
)}
</>
}
title={
<Text
as="h1"
$gap="0.7rem"
$size="h6"
$align="flex-start"
$direction="row"
$margin="0"
>
<Icon iconName="warning" $theme="warning" />
{t('Public key change detected')}
</Text>
}
>
<Box $direction="column" $gap="0.5rem" $margin={{ top: 'sm' }}>
<Text $variation="secondary">
{t(
"This user's encryption public key has changed since you last interacted with them.",
)}
</Text>
<Text $variation="secondary">
{t(
'This could mean the user has regenerated their encryption keys, but it could also indicate that their account has been compromised.',
)}
</Text>
<Text $variation="secondary" $weight="600">
{t(
'We recommend verifying with this person directly (e.g. via video call) that they have indeed changed their encryption key before proceeding.',
)}
</Text>
{(knownFingerprint || currentFingerprint) && (
<Box
$direction="column"
$gap="0.25rem"
$margin={{ top: 'xs' }}
$padding="sm"
$background="#f5f5f5"
$border="1px solid #ddd"
$radius="4px"
>
{knownFingerprint && (
<Box $direction="row" $gap="0.5rem" $align="center">
<Text $size="xs" $weight="600" $variation="secondary">
{t('Previously known:')}
</Text>
<Text
$size="xs"
style={{ fontFamily: 'monospace', letterSpacing: '0.05em' }}
>
{knownFingerprint}
</Text>
</Box>
)}
{currentFingerprint && (
<Box $direction="row" $gap="0.5rem" $align="center">
<Text $size="xs" $weight="600" $variation="secondary">
{t('Current key:')}
</Text>
<Text
$size="xs"
style={{ fontFamily: 'monospace', letterSpacing: '0.05em' }}
>
{currentFingerprint}
</Text>
</Box>
)}
</Box>
)}
</Box>
</Modal>
);
};
@@ -1,9 +1,13 @@
import { Badge } from '@gouvfr-lasuite/ui-kit';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import {
QuickSearchItemContent,
QuickSearchItemContentProps,
} from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { useKeyFingerprint } from '@/docs/doc-collaboration';
import { User, UserAvatar } from '@/features/auth';
type Props = {
@@ -12,6 +16,8 @@ type Props = {
right?: QuickSearchItemContentProps['right'];
isInvitation?: boolean;
suffix?: string;
onSuffixClick?: () => void;
fingerprintKey?: string | null;
};
export const SearchUserRow = ({
@@ -20,9 +26,13 @@ export const SearchUserRow = ({
alwaysShowRight = false,
isInvitation = false,
suffix,
onSuffixClick,
fingerprintKey,
}: Props) => {
const hasFullName = !!user.full_name;
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const fingerprint = useKeyFingerprint(fingerprintKey);
return (
<QuickSearchItemContent
@@ -45,7 +55,23 @@ export const SearchUserRow = ({
{hasFullName ? user.full_name : user.email}
</Text>
{suffix && (
<Text $size="xs" $weight="600" $color={colorsTokens['warning-600']}>
<Text
$size="xs"
$weight="600"
$color={colorsTokens['warning-600']}
{...(onSuffixClick && {
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
onSuffixClick();
},
role: 'button',
tabIndex: 0,
style: {
cursor: 'pointer',
textDecoration: 'underline',
},
})}
>
{suffix}
</Text>
)}
@@ -55,6 +81,28 @@ export const SearchUserRow = ({
{user.email}
</Text>
)}
{fingerprint && (
<Badge style={{ gap: '0.3rem', margin: '5px 0' }}>
<Text
$size="xs"
$weight="600"
$variation="secondary"
style={{ fontSize: '10px' }}
>
{t('Fingerprint')}{' '}
</Text>
<Text
$size="xs"
style={{
fontFamily: 'monospace',
letterSpacing: '0.05em',
fontSize: '10px',
}}
>
{fingerprint}
</Text>
</Badge>
)}
</Box>
</Box>
}