wip encryption requirements

This commit is contained in:
Thomas Ramé
2026-03-04 23:56:09 +01:00
parent fe34b93249
commit 579ff98a5a
2 changed files with 153 additions and 23 deletions
+27 -3
View File
@@ -2055,6 +2055,14 @@ class DocumentViewSet(
encryptedSymmetricKeyPerUser = serializer.validated_data["encryptedSymmetricKeyPerUser"]
attachment_key_mapping = serializer.validated_data.get("attachmentKeyMapping", {})
# Prevent encryption if the document is not restricted (private)
if document.computed_link_reach != models.LinkReachChoices.RESTRICTED:
raise drf.exceptions.ValidationError({
'non_field_errors':
'Cannot encrypt a document that is not private. '
'Please set the document access to "Restricted" before encrypting.'
})
# Prevent encryption if there are pending invitations
if document.invitations.exists():
raise drf.exceptions.ValidationError({
@@ -2063,9 +2071,25 @@ class DocumentViewSet(
'Please resolve all invitations before encrypting.'
})
# Validate that we have keys for all users with access to this document
# Get all user IDs that have access to this document
document_accesses = models.DocumentAccess.objects.filter(document=document, user__isnull=False)
# Validate that all users with access have an encryption public key
document_accesses = models.DocumentAccess.objects.filter(
document=document, user__isnull=False
).select_related('user')
users_without_public_key = [
access.user.email or str(access.user_id)
for access in document_accesses
if not access.user.encryption_public_key
]
if users_without_public_key:
raise drf.exceptions.ValidationError({
'non_field_errors':
'Cannot encrypt a document when some members have not enabled '
'encryption: ' + ', '.join(users_without_public_key) + '. '
'All members must enable encryption in their account settings first.'
})
# Validate that we have encrypted symmetric keys for all users with access
users_with_access = {str(access.user_id) for access in document_accesses}
# Check that encryptedSymmetricKeyPerUser contains all required users
@@ -6,13 +6,14 @@ import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { backendUrl } from '@/api';
import { useState } from 'react';
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import { Box, ButtonCloseModal, Icon, Text, TextErrors } from '@/components';
import { useUserUpdate } from '@/core/api/useUserUpdate';
import {
encryptContent,
@@ -28,10 +29,14 @@ import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
LinkReach,
extractAttachmentKeysAndMetadata,
getDocLinkReach,
useEncryptDoc,
useProviderStore,
} from '@/features/docs/doc-management';
import { useDocAccesses } from '@/features/docs/doc-share/api/useDocAccesses';
import { useDocInvitations } from '@/features/docs/doc-share/api/useDocInvitations';
import { useKeyboardAction } from '@/hooks';
import { Spinner } from '@gouvfr-lasuite/ui-kit';
@@ -147,8 +152,34 @@ export const ModalEncryptDoc = ({
listInvalidQueries: [KEY_DOC, KEY_LIST_DOC],
});
const { data: invitationsData } = useDocInvitations({
docId: doc.id,
page: 1,
});
const { data: accesses } = useDocAccesses({ docId: doc.id });
const keyboardAction = useKeyboardAction();
const effectiveReach = getDocLinkReach(doc);
const isRestricted = effectiveReach === LinkReach.RESTRICTED;
const hasPendingInvitations = !!invitationsData && invitationsData.count > 0;
const membersWithoutKey = useMemo(() => {
if (!accesses || !doc.accesses_public_keys_per_user) {
return [];
}
const publicKeysMap = doc.accesses_public_keys_per_user;
return accesses.filter(
(access) => access.user && !publicKeysMap[access.user.id],
);
}, [accesses, doc.accesses_public_keys_per_user]);
const canEncrypt =
isRestricted && !hasPendingInvitations && membersWithoutKey.length === 0;
const handleClose = () => {
if (isPending) {
return;
@@ -157,7 +188,7 @@ export const ModalEncryptDoc = ({
};
const handleEncrypt = async () => {
if (!provider || !user || isPending) {
if (!provider || !user || isPending || !canEncrypt) {
return;
}
@@ -315,7 +346,7 @@ export const ModalEncryptDoc = ({
fullWidth
onClick={handleEncrypt}
onKeyDown={handleEncryptKeyDown}
disabled={isPending}
disabled={isPending || !canEncrypt}
icon={
isPending ? (
<div>
@@ -354,24 +385,99 @@ export const ModalEncryptDoc = ({
</Box>
}
>
<Box className="--docs--modal-encrypt-doc">
<Box className="--docs--modal-encrypt-doc" $gap="sm">
{!isError && (
<Text
$size="sm"
$variation="secondary"
$display="inline-block"
as="p"
>
<br />
TODO: warning about encryption
<br />
TODO: accesses for users without public key will be lost (list them)
<br />
TODO: if no public key for current user, provide an onboarding
<br />
TODO: if document public, tell it needs first to be private (add
backend check too)
</Text>
<Box $gap="sm">
<Text $size="sm" $variation="secondary">
{t(
'Encrypting a document ensures that only authorized members can read its content. Before proceeding, the following conditions must be met:',
)}
</Text>
{/* TODO: warning about encryption */}
{/* TODO: if no public key for current user, provide an onboarding */}
<Box $gap="xs">
<Box $direction="row" $align="center" $gap="xs">
<Icon
iconName={isRestricted ? 'check_circle' : 'cancel'}
$size="sm"
$theme={isRestricted ? 'success' : 'danger'}
/>
<Text $size="sm" $weight={isRestricted ? '400' : '600'}>
{isRestricted
? t('Document access is private')
: t(
'Document must be set to private (currently {{reach}})',
{
reach:
effectiveReach === LinkReach.PUBLIC
? t('public')
: t('connected'),
},
)}
</Text>
</Box>
<Box $direction="row" $align="center" $gap="xs">
<Icon
iconName={!hasPendingInvitations ? 'check_circle' : 'cancel'}
$size="sm"
$theme={!hasPendingInvitations ? 'success' : 'danger'}
/>
<Text
$size="sm"
$weight={!hasPendingInvitations ? '400' : '600'}
>
{!hasPendingInvitations
? t('No pending invitations')
: t('Pending invitations must be resolved first')}
</Text>
</Box>
<Box $gap="3xs">
<Box $direction="row" $align="center" $gap="xs">
<Icon
iconName={
membersWithoutKey.length === 0 ? 'check_circle' : 'cancel'
}
$size="sm"
$theme={
membersWithoutKey.length === 0 ? 'success' : 'danger'
}
/>
<Text
$size="sm"
$weight={membersWithoutKey.length === 0 ? '400' : '600'}
>
{membersWithoutKey.length === 0
? t('All members have encryption enabled')
: t(
'{{count}} member(s) have not enabled encryption yet',
{ count: membersWithoutKey.length },
)}
</Text>
</Box>
{membersWithoutKey.length > 0 && (
<Box $margin={{ left: 'sm' }} $gap="3xs">
{membersWithoutKey.map((access) => (
<Text key={access.id} $size="xs" $variation="secondary">
{access.user.full_name || access.user.email}
</Text>
))}
</Box>
)}
</Box>
</Box>
{!canEncrypt && (
<Text $size="xs" $variation="secondary">
{t(
'Please resolve the issues above before encrypting the document.',
)}
</Text>
)}
</Box>
)}
{isError && <TextErrors causes={error.cause} />}