This commit is contained in:
Thomas Ramé
2026-03-05 00:46:40 +01:00
parent 579ff98a5a
commit 1eba8b77c0
10 changed files with 128 additions and 84 deletions
@@ -22,6 +22,7 @@ export interface PublicKeyMismatch {
*/
export function usePublicKeyRegistry(
accessesPublicKeysPerUser: Record<string, string> | undefined,
currentUserId?: string,
) {
const [mismatches, setMismatches] = useState<PublicKeyMismatch[]>([]);
const [loading, setLoading] = useState(true);
@@ -43,6 +44,13 @@ export function usePublicKeyRegistry(
for (const [userId, currentKey] of Object.entries(
accessesPublicKeysPerUser!,
)) {
// 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}`,
@@ -74,7 +82,7 @@ export function usePublicKeyRegistry(
return () => {
cancelled = true;
};
}, [accessesPublicKeysPerUser]);
}, [accessesPublicKeysPerUser, currentUserId]);
const acceptNewKey = useCallback(
async (userId: string) => {
@@ -43,15 +43,6 @@ export const BoutonShare = ({
return null;
}
const warningIcon = hasKeyWarning ? (
<Icon
iconName="warning"
$color="var(--c--theme--colors--warning-600)"
$size="sm"
aria-label={t('Public key mismatch detected')}
/>
) : null;
if (hasAccesses) {
return (
<Box
@@ -66,13 +57,13 @@ export const BoutonShare = ({
}
`}
>
{warningIcon}
<Button
aria-label={t('Share button')}
variant="secondary"
color={hasKeyWarning ? 'warning' : undefined}
icon={
<Icon
iconName="group"
iconName={hasKeyWarning ? 'warning' : 'group'}
$color="inherit"
variant="filled"
disabled={isDisabled}
@@ -89,17 +80,17 @@ export const BoutonShare = ({
}
return (
<Box $direction="row" $align="center" $gap="4px">
{warningIcon}
<Button
color="brand"
variant="tertiary"
onClick={open}
size="medium"
disabled={isDisabled}
>
{t('Share')}
</Button>
</Box>
<Button
color={hasKeyWarning ? 'warning' : 'brand'}
variant="tertiary"
icon={
hasKeyWarning ? <Icon iconName="warning" $color="inherit" /> : undefined
}
onClick={open}
size="medium"
disabled={isDisabled}
>
{t('Share')}
</Button>
);
};
@@ -80,6 +80,7 @@ export const DocToolBox = ({
const { hasMismatches: hasKeyWarnings } = usePublicKeyRegistry(
doc.accesses_public_keys_per_user,
encryptionSettings?.userId,
);
const { isSmallMobile, isMobile } = useResponsiveStore();
@@ -239,10 +240,25 @@ export const DocToolBox = ({
$gap={spacingsTokens['2xs']}
>
{doc.is_encrypted && (
<>
[chiffrement activé]
{/* TODO */}
</>
<Box
$direction="row"
$align="center"
$gap="5px"
$css={css`
margin-right: 5px;
`}
>
<Icon
iconName="lock"
$size="md"
$color={colorsTokens['success-600']}
/>
<span
style={{ fontSize: '0.8rem', color: colorsTokens['success-600'] }}
>
{t('Encrypted')}
</span>
</Box>
)}
<BoutonShare
@@ -332,18 +348,18 @@ export const DocToolBox = ({
)}
{isModalRemoveEncryptionOpen &&
documentEncryptionSettings?.documentSymmetricKey && (
<ModalRemoveDocEncryption
doc={doc}
symmetricKey={documentEncryptionSettings.documentSymmetricKey}
onClose={() => setIsModalRemoveEncryptionOpen(false)}
onSuccess={() => {
//
// TODO: probably it should make an hard refresh to get the setup
// but it should before register content in database with clean accesses, and broadcast the information through websocket
//
}}
/>
)}
<ModalRemoveDocEncryption
doc={doc}
symmetricKey={documentEncryptionSettings.documentSymmetricKey}
onClose={() => setIsModalRemoveEncryptionOpen(false)}
onSuccess={() => {
//
// TODO: probably it should make an hard refresh to get the setup
// but it should before register content in database with clean accesses, and broadcast the information through websocket
//
}}
/>
)}
{selectHistoryModal.isOpen && (
<ModalSelectVersion
onClose={() => selectHistoryModal.close()}
@@ -95,10 +95,9 @@
stroke="white"
stroke-width="1.21628"
/>
<path
d="M17.9595 16.5L18.9325 17.473V18.2028H16.8648V20.5137L16.4999 20.8786L16.1351 20.5137V18.2028H14.0674V17.473L15.0404 16.5V13.8242H14.5539V13.0944H18.446V13.8242H17.9595V16.5Z"
fill="white"
/>
<g transform="translate(16.5 16.5) scale(0.27)">
<path transform="translate(-12 -12)" d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" fill="white"/>
</g>
</g>
<defs>
<linearGradient

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDate } from '@/hooks/useDate';
import { useResponsiveStore } from '@/stores';
@@ -59,23 +59,24 @@ export const SimpleDocItem = ({
$css={css`
background-color: transparent;
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
position: relative;
`}
$padding={`${spacingsTokens['3xs']} 0`}
data-testid={isPinned ? `doc-pinned-${doc.id}` : undefined}
aria-hidden="true"
>
{isPinned ? (
<PinnedDocumentIcon
aria-hidden="true"
data-testid="doc-pinned-icon"
color="var(--c--contextuals--content--semantic--info--tertiary)"
/>
) : isEncrypted ? (
{isEncrypted ? (
<EncryptedDocumentIcon
aria-hidden="true"
data-testid="doc-encryption-icon"
color="var(--c--contextuals--content--semantic--info--tertiary)"
/>
) : isPinned ? (
<PinnedDocumentIcon
aria-hidden="true"
data-testid="doc-pinned-icon"
color="var(--c--contextuals--content--semantic--info--tertiary)"
/>
) : isChild ? (
<ChildDocument
aria-hidden="true"
@@ -91,6 +92,18 @@ export const SimpleDocItem = ({
color="var(--c--contextuals--content--semantic--info--tertiary)"
/>
)}
{isPinned && isEncrypted && (
<Icon
iconName="push_pin"
$size="12px"
$color="var(--c--contextuals--content--semantic--info--tertiary)"
style={{
position: 'absolute',
bottom: 0,
right: -4,
}}
/>
)}
</Box>
<Box $justify="center" $overflow="auto">
<Text
@@ -148,7 +148,11 @@ export const QuickSearchGroupMember = ({
<DocShareMemberItem
doc={doc}
access={access}
suffix={hasMismatch ? t('DIFFERENT PUBLIC KEY') : undefined}
suffix={
hasMismatch
? t('DIFFERENT PUBLIC KEY, PLEASE VERIFY')
: undefined
}
onSuffixClick={
hasMismatch
? () => setMismatchUserId(access.user.id)
@@ -1,8 +1,4 @@
import {
Button,
Modal,
ModalSize,
} from '@gouvfr-lasuite/cunningham-react';
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -24,7 +20,7 @@ import {
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 { User, useAuth } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
import { isValidEmail } from '@/utils';
@@ -80,8 +76,10 @@ export const DocShareModal = ({
const queryClient = useQueryClient();
const { isDesktop } = useResponsiveStore();
const { user } = useAuth();
const { mismatches: keyMismatches, acceptNewKey } = usePublicKeyRegistry(
doc.accesses_public_keys_per_user,
user?.id,
);
const keyMismatchUserIds = useMemo(
() => new Set(keyMismatches.map((m) => m.userId)),
@@ -448,10 +446,10 @@ const QuickSearchInviteInputSection = ({
const getUserSuffix = useCallback(
(user: User): string | undefined => {
if (keyMismatchUserIds?.has(user.id)) {
return t('DIFFERENT PUBLIC KEY');
return t('DIFFERENT PUBLIC KEY, PLEASE VERIFY');
}
if (isEncrypted && !user.encryption_public_key) {
return t(`(chiffrement non activé)`);
return t(`(encryption not enabled)`);
}
return undefined;
},
@@ -526,25 +524,28 @@ const QuickSearchInviteInputSection = ({
</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}
/>
);
})()}
{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>
);
};
@@ -82,7 +82,9 @@ export const SearchUserRow = ({
</Text>
)}
{fingerprint && (
<Badge style={{ gap: '0.3rem', margin: '5px 0' }}>
<Badge
style={{ width: 'fit-content', gap: '0.3rem', margin: '5px 0' }}
>
<Text
$size="xs"
$weight="600"
@@ -7,8 +7,10 @@ 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';
@@ -33,6 +35,11 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
const { flexLeft, flexRight } = useResponsiveDocGrid();
const { spacingsTokens } = useCunninghamTheme();
const shareModal = useModal();
const { user } = useAuth();
const { hasMismatches: hasKeyWarning } = usePublicKeyRegistry(
doc.accesses_public_keys_per_user,
user?.id,
);
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isShared = isPublic || isAuthenticated;
@@ -181,6 +188,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
doc={doc}
handleClick={handleShareClick}
disabled={isInTrashbin}
hasKeyWarning={hasKeyWarning}
/>
)}
{isInTrashbin ? (
@@ -8,11 +8,13 @@ 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;
@@ -44,14 +46,14 @@ export const DocsGridItemSharedButton = ({
event.stopPropagation();
handleClick();
}}
color="brand"
color={hasKeyWarning ? 'warning' : 'brand'}
variant="secondary"
size="nano"
icon={
<Icon
$theme="brand"
$theme={hasKeyWarning ? 'warning' : 'brand'}
$variation="secondary"
iconName="group"
iconName={hasKeyWarning ? 'warning' : 'group'}
disabled={disabled}
variant="filled"
/>