From 7a55e31a732346bf11b4ed77d4d54dfa2d621f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Thu, 5 Mar 2026 10:26:34 +0100 Subject: [PATCH] wip use a context instead for global usage --- src/backend/core/api/viewsets.py | 8 + .../apps/impress/src/core/AppProvider.tsx | 5 +- .../UserEncryptionProvider.tsx | 39 +++ .../hook/useDocumentEncryption.tsx | 9 +- .../features/docs/doc-collaboration/index.ts | 4 + .../doc-editor/__tests__/DocEditor.spec.tsx | 5 +- .../docs/doc-editor/components/DocEditor.tsx | 7 - .../__tests__/DocToolBoxLicence.spec.tsx | 4 +- .../docs/doc-header/components/DocHeader.tsx | 7 - .../docs/doc-header/components/DocToolBox.tsx | 13 +- .../components/ModalEncryptDoc.tsx | 8 +- .../doc-share/components/DocShareModal.tsx | 286 +++++++++++------- .../components/DocShareModalFooter.tsx | 8 +- .../docs/docs-grid/components/DocsGrid.tsx | 9 +- .../impress/src/pages/docs/[id]/index.tsx | 10 +- 15 files changed, 258 insertions(+), 164 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 0b5b7608..6f5a0565 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1385,6 +1385,14 @@ class DocumentViewSet( # Check permissions first document = self.get_object() + if document.is_encrypted: + raise drf.exceptions.ValidationError( + { + "detail": "Visibility cannot be changed for encrypted documents. " + "Encrypted documents must remain restricted.", + } + ) + # Deserialize and validate the data serializer = serializers.LinkDocumentSerializer( document, data=request.data, partial=True diff --git a/src/frontend/apps/impress/src/core/AppProvider.tsx b/src/frontend/apps/impress/src/core/AppProvider.tsx index 79059491..23f9c72a 100644 --- a/src/frontend/apps/impress/src/core/AppProvider.tsx +++ b/src/frontend/apps/impress/src/core/AppProvider.tsx @@ -9,6 +9,7 @@ import { useEffect } from 'react'; import { useCunninghamTheme } from '@/cunningham'; import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth'; +import { UserEncryptionProvider } from '@/features/docs/doc-collaboration'; import { useResponsiveStore } from '@/stores/'; import { ConfigProvider } from './config/'; @@ -74,7 +75,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) { - {children} + + {children} + diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx new file mode 100644 index 00000000..2f331960 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx @@ -0,0 +1,39 @@ +import { createContext, useContext } from 'react'; + +import { useAuth } from '@/features/auth'; + +import { EncryptionError, useEncryption } from './hook/useEncryption'; + +interface UserEncryptionContextValue { + encryptionLoading: boolean; + encryptionSettings: { + userId: string; + userPrivateKey: CryptoKey; + userPublicKey: CryptoKey; + } | null; + encryptionError: EncryptionError; +} + +const UserEncryptionContext = createContext({ + encryptionLoading: true, + encryptionSettings: null, + encryptionError: null, +}); + +export const UserEncryptionProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const { user } = useAuth(); + const value = useEncryption(user?.id); + + return ( + + {children} + + ); +}; + +export const useUserEncryption = (): UserEncryptionContextValue => + useContext(UserEncryptionContext); diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx index bbf05f6a..b8081346 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx @@ -2,18 +2,14 @@ import { useEffect, useState } from 'react'; import { decryptSymmetricKey } from '@/docs/doc-collaboration/encryption'; +import { useUserEncryption } from '../UserEncryptionProvider'; + export type DocumentEncryptionError = | 'missing_symmetric_key' | 'decryption_failed' | null; export function useDocumentEncryption( - encryptionLoading: boolean, - encryptionSettings: { - userId: string; - userPrivateKey: CryptoKey; - userPublicKey: CryptoKey; - } | null, isDocumentEncrypted: boolean | undefined, userEncryptedSymmetricKey: string | undefined, ): { @@ -23,6 +19,7 @@ export function useDocumentEncryption( } | null; documentEncryptionError: DocumentEncryptionError; } { + const { encryptionLoading, encryptionSettings } = useUserEncryption(); const [loading, setLoading] = useState(true); const [settings, setSettings] = useState<{ documentSymmetricKey: CryptoKey; diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts index b1b8f460..0606ebba 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts @@ -16,5 +16,9 @@ export { useEncryption, type EncryptionError, } from './hook/useEncryption'; +export { + UserEncryptionProvider, + useUserEncryption, +} from './UserEncryptionProvider'; export { useKeyFingerprint } from './hook/useKeyFingerprint'; export { usePublicKeyRegistry } from './hook/usePublicKeyRegistry'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx index e70ebbad..0013659d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx @@ -78,7 +78,7 @@ describe('DocEditor', () => { }, } as any; - const { rerender } = render(, { + const { rerender } = render(, { wrapper: AppWrapper, }); @@ -90,7 +90,7 @@ describe('DocEditor', () => { // Rerender with same doc to check that event is not tracked again rerender( - , + , ); expect(TrackEventMock).toHaveBeenNthCalledWith(1, { @@ -107,7 +107,6 @@ describe('DocEditor', () => { id: 'test-doc-id-2', computed_link_reach: LinkReach.RESTRICTED, }} - encryptionSettings={null} documentEncryptionSettings={null} />, ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index 87c9ebf6..b91484f0 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -76,11 +76,6 @@ export const DocEditorContainer = ({ interface DocEditorProps { doc: Doc; - encryptionSettings: { - userId: string; - userPrivateKey: CryptoKey; - userPublicKey: CryptoKey; - } | null; documentEncryptionSettings: { documentSymmetricKey: CryptoKey; } | null; @@ -88,7 +83,6 @@ interface DocEditorProps { export const DocEditor = ({ doc, - encryptionSettings, documentEncryptionSettings, }: DocEditorProps) => { const { isDesktop } = useResponsiveStore(); @@ -145,7 +139,6 @@ export const DocEditor = ({ docHeader={ } diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx index 3577e557..54029d7f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx @@ -37,7 +37,7 @@ describe('DocToolBox - Licence', () => { const { DocToolBox } = await import('../components/DocToolBox'); - render(, { + render(, { wrapper: AppWrapper, }); const optionsButton = await screen.findByLabelText('Export the document'); @@ -55,7 +55,7 @@ describe('DocToolBox - Licence', () => { const { DocToolBox } = await import('../components/DocToolBox'); - render(, { + render(, { wrapper: AppWrapper, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx index e3fa778d..bba76ad2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx @@ -20,11 +20,6 @@ import { DocToolBox } from './DocToolBox'; interface DocHeaderProps { doc: Doc; - encryptionSettings: { - userId: string; - userPrivateKey: CryptoKey; - userPublicKey: CryptoKey; - } | null; documentEncryptionSettings?: { documentSymmetricKey: CryptoKey; } | null; @@ -32,7 +27,6 @@ interface DocHeaderProps { export const DocHeader = ({ doc, - encryptionSettings, documentEncryptionSettings, }: DocHeaderProps) => { const { spacingsTokens } = useCunninghamTheme(); @@ -80,7 +74,6 @@ export const DocHeader = ({ {!isDeletedDoc && ( )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 4df2590e..1181d7ba 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -31,7 +31,10 @@ import { useDocUtils, useDuplicateDoc, } from '@/docs/doc-management'; -import { usePublicKeyRegistry } from '@/docs/doc-collaboration'; +import { + usePublicKeyRegistry, + useUserEncryption, +} from '@/docs/doc-collaboration'; import { DocShareModal } from '@/docs/doc-share'; import { KEY_LIST_DOC_VERSIONS, @@ -47,11 +50,6 @@ const ModalExport = Export?.ModalExport; interface DocToolBoxProps { doc: Doc; - encryptionSettings: { - userId: string; - userPrivateKey: CryptoKey; - userPublicKey: CryptoKey; - } | null; documentEncryptionSettings?: { documentSymmetricKey: CryptoKey; } | null; @@ -59,7 +57,6 @@ interface DocToolBoxProps { export const DocToolBox = ({ doc, - encryptionSettings, documentEncryptionSettings, }: DocToolBoxProps) => { const { t } = useTranslation(); @@ -69,6 +66,7 @@ export const DocToolBox = ({ const { isChild, isTopRoot } = useDocUtils(doc); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); + const { encryptionSettings } = useUserEncryption(); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); const [isModalExportOpen, setIsModalExportOpen] = useState(false); @@ -336,7 +334,6 @@ export const DocToolBox = ({ {isModalEncryptOpen && ( setIsModalEncryptOpen(false)} onSuccess={() => { // diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx index a1199c4e..fae97510 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx @@ -21,6 +21,7 @@ import { generateUserKeyPair, getEncryptionDB, prepareEncryptedSymmetricKeysForUsers, + useUserEncryption, } from '@/docs/doc-collaboration'; import { createDocAttachment } from '@/docs/doc-editor/api'; import { toBase64 } from '@/docs/doc-editor'; @@ -121,18 +122,12 @@ const encryptRemoteAttachments = async ( interface ModalEncryptDocProps { doc: Doc; - encryptionSettings: { - userId: string; - userPrivateKey: CryptoKey; - userPublicKey: CryptoKey; - } | null; onClose: () => void; onSuccess?: (doc: Doc) => void; } export const ModalEncryptDoc = ({ doc, - encryptionSettings, onClose, onSuccess, }: ModalEncryptDocProps) => { @@ -140,6 +135,7 @@ export const ModalEncryptDoc = ({ const { toast } = useToastProvider(); const { provider } = useProviderStore(); const { user } = useAuth(); + const { encryptionSettings } = useUserEncryption(); const { mutateAsync: updateUser } = useUserUpdate(); const [isPending, setIsPending] = useState(false); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx index e8cdd0f1..7caf88bf 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx @@ -10,6 +10,7 @@ import { ButtonCloseModal, HorizontalSeparator, Icon, + Loading, Text, } from '@/components'; import { @@ -17,7 +18,11 @@ import { QuickSearchData, QuickSearchGroup, } from '@/components/quick-search/'; -import { usePublicKeyRegistry } from '@/docs/doc-collaboration'; +import { + useDocumentEncryption, + usePublicKeyRegistry, + useUserEncryption, +} from '@/docs/doc-collaboration'; import type { PublicKeyMismatch } from '@/docs/doc-collaboration/hook/usePublicKeyRegistry'; import { Doc } from '@/docs/doc-management'; import { User, useAuth } from '@/features/auth'; @@ -77,6 +82,28 @@ export const DocShareModal = ({ const { isDesktop } = useResponsiveStore(); const { user } = useAuth(); + + // When document encryption settings exist they should be passed as prop, on it will use this fallback + // that's because in some cases we want them to only be computed at this step (to avoid computing just when listed in a list) + const needsDerivation = !documentEncryptionSettings; + const { encryptionLoading, encryptionError } = useUserEncryption(); + const { + documentEncryptionLoading, + documentEncryptionSettings: derivedEncryptionSettings, + documentEncryptionError, + } = useDocumentEncryption( + needsDerivation ? doc.is_encrypted : undefined, + needsDerivation ? doc.encrypted_document_symmetric_key_for_user : undefined, + ); + const effectiveEncryptionSettings = + documentEncryptionSettings ?? derivedEncryptionSettings ?? null; + const isEncryptionDeriving = + needsDerivation && (encryptionLoading || documentEncryptionLoading); + const derivedEncryptionError = + needsDerivation && doc.is_encrypted + ? encryptionError || documentEncryptionError + : null; + const { mismatches: keyMismatches, acceptNewKey } = usePublicKeyRegistry( doc.accesses_public_keys_per_user, user?.id, @@ -240,125 +267,168 @@ export const DocShareModal = ({ > {liveAnnouncement} - + {isEncryptionDeriving && } + {!isEncryptionDeriving && derivedEncryptionError && ( + + + + {t('Encryption keys unavailable')} + + + {t( + 'This is an encrypted document, but your current device does not have the required encryption keys to decrypt it.', + )} + + {(encryptionError === 'missing_private_key' || + encryptionError === 'missing_public_key') && ( + + {t( + 'This usually happens when you switch to a new device or browser without restoring your encryption backup.', + )} + + )} + {documentEncryptionError === 'missing_symmetric_key' && ( + + {t( + 'You do not have access to this encrypted document. Ask the document owner to share it with you again.', + )} + + )} + {documentEncryptionError === 'decryption_failed' && ( + + {t( + 'Your encryption keys could not decrypt this document. This may happen if your keys were recreated. Ask the document owner to share it with you again.', + )} + + )} + + )} + {!isEncryptionDeriving && !derivedEncryptionError && ( - - {canShare && selectedUsers.length > 0 && ( - - { - setUserQuery(''); - setInputValue(''); - setSelectedUsers([]); - }} - /> - - )} - {!canViewAccesses && } - - - - {!canViewAccesses && ( - - + + {canShare && selectedUsers.length > 0 && ( + - {t( - 'You can view this document but need additional access to see its members or modify settings.', - )} - - - - )} - {canViewAccesses && ( - { - setInputValue(str); - onFilter(str); - }} - inputValue={inputValue} - showInput={canShare} - loading={searchUsersQuery.isLoading} - placeholder={t('Type a name or email')} - > - {showInheritedShareContent && ( - access.document.id !== doc.id, - ) ?? [] - } + { + setUserQuery(''); + setInputValue(''); + setSelectedUsers([]); + }} /> - )} - {showMemberSection && isRootDoc && ( - - - - + )} + {!canViewAccesses && ( + + )} + + + + {!canViewAccesses && ( + + + {t( + 'You can view this document but need additional access to see its members or modify settings.', + )} + + + + )} + {canViewAccesses && ( + { + setInputValue(str); + onFilter(str); + }} + inputValue={inputValue} + showInput={canShare} + loading={searchUsersQuery.isLoading} + placeholder={t('Type a name or email')} + > + {showInheritedShareContent && ( + access.document.id !== doc.id, + ) ?? [] + } + /> + )} + {showMemberSection && isRootDoc && ( + + + + + + )} + + {!showMemberSection && canShare && ( + - - )} + )} + + )} + + - {!showMemberSection && canShare && ( - - )} - + + {showFooter && ( + )} - - - {showFooter && } - - + )} ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx index 7dc0c8f5..df1c1609 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx @@ -27,8 +27,12 @@ export const DocShareModalFooter = ({ > - - + {!doc.is_encrypted && ( + <> + + + + )} page.results.length > 0); diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 1e2c6569..6c9ec097 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -23,7 +23,7 @@ import { import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth'; import { useDocumentEncryption, - useEncryption, + useUserEncryption, } from '@/features/docs/doc-collaboration'; import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/'; import { useSkeletonStore } from '@/features/skeletons'; @@ -93,17 +93,14 @@ const DocPage = ({ id }: DocProps) => { }, ); - const { authenticated, user } = useAuth(); + const { authenticated } = useAuth(); const [doc, setDoc] = useState(); - const { encryptionLoading, encryptionSettings, encryptionError } = - useEncryption(user?.id); + const { encryptionLoading, encryptionError } = useUserEncryption(); const { documentEncryptionLoading, documentEncryptionSettings, documentEncryptionError, } = useDocumentEncryption( - encryptionLoading, - encryptionSettings, doc?.is_encrypted, doc?.encrypted_document_symmetric_key_for_user, ); @@ -357,7 +354,6 @@ const DocPage = ({ id }: DocProps) => {