wip improve accesses tips
This commit is contained in:
@@ -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,
|
||||
|
||||
+34
@@ -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';
|
||||
|
||||
+22
@@ -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(),
|
||||
|
||||
+3
@@ -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"
|
||||
|
||||
+53
-12
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+153
-20
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+119
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user