From 608239e4121bfebb8e2c9402ea5fbc354489409c Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Mon, 8 Dec 2025 01:19:52 +0100 Subject: [PATCH] feat: Refactor frontend authentication and tenant switching to use global state --- frontend/src/app/useDocumentsSearch.ts | 4 +- frontend/src/documents/data/useAuthManager.ts | 18 +-- .../documents/data/useDocumentMutations.ts | 23 +--- .../documents/data/useDocumentsWorkspace.ts | 113 +++++++----------- .../src/documents/data/useTenantManager.ts | 65 ++++------ .../features/folders/useFolderTreeActions.ts | 19 --- .../features/upload/useDocumentUploads.ts | 17 --- .../documents/features/upload/useFileDrop.ts | 9 -- frontend/src/settings/usePasskeys.ts | 8 +- 9 files changed, 82 insertions(+), 194 deletions(-) diff --git a/frontend/src/app/useDocumentsSearch.ts b/frontend/src/app/useDocumentsSearch.ts index 981a140..be3588f 100644 --- a/frontend/src/app/useDocumentsSearch.ts +++ b/frontend/src/app/useDocumentsSearch.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useAppState } from '../lib/store/appState'; import { TAG_FILTER_UNTAGGED } from './workspaceUtils'; import { listDocuments } from '../lib/api/apiClient'; import type { Identifier } from '../types/identifiers'; @@ -15,7 +16,6 @@ import useNotifyApiError from '../hooks/useNotifyApiError'; interface UseDocumentsSearchArgs { api: ApiClient; - token?: string | null; selectedFolder?: Identifier | 'root' | null; locationPathname?: string; isDocumentsRoute?: boolean; @@ -65,7 +65,6 @@ interface UseDocumentsSearchResult { const useDocumentsSearch = ({ api, - token, selectedFolder, locationPathname, isDocumentsRoute, @@ -75,6 +74,7 @@ const useDocumentsSearch = ({ setSearchIncludeDescendants, documentsManager, }: UseDocumentsSearchArgs): UseDocumentsSearchResult => { + const { token } = useAppState(); const [searchQuery, setSearchQuery] = useState(''); const [activeTagFilters, setActiveTagFilters] = useState([]); const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); diff --git a/frontend/src/documents/data/useAuthManager.ts b/frontend/src/documents/data/useAuthManager.ts index 5be9fba..a87667c 100644 --- a/frontend/src/documents/data/useAuthManager.ts +++ b/frontend/src/documents/data/useAuthManager.ts @@ -3,15 +3,9 @@ import type { MutableRefObject } from 'react'; import { clearAuthToken, logoutSession, refreshSession, setAuthToken } from '../../lib/api/apiClient'; import { useStatusToast } from '../../lib/context/StatusToastContext'; -type AppStatus = string; +import { useAppDispatch, useAppState } from '../../lib/store/appState'; -type AppDispatch = (action: { type: string;[key: string]: unknown }) => void; - -interface UseAuthManagerArgs { - token?: string | null; - appStatus: AppStatus; - appDispatch: AppDispatch; -} +interface UseAuthManagerArgs { } interface UseAuthManagerResult { tokenRef: MutableRefObject; @@ -19,11 +13,9 @@ interface UseAuthManagerResult { handleLogout: () => Promise; } -const useAuthManager = ({ - token, - appStatus, - appDispatch, -}: UseAuthManagerArgs): UseAuthManagerResult => { +const useAuthManager = (_: UseAuthManagerArgs = {}): UseAuthManagerResult => { + const { token, status: appStatus } = useAppState(); + const appDispatch = useAppDispatch(); const tokenRef = useRef(token); const initialRefreshAttemptedRef = useRef(Boolean(token)); const { showToast } = useStatusToast(); diff --git a/frontend/src/documents/data/useDocumentMutations.ts b/frontend/src/documents/data/useDocumentMutations.ts index 94c97bd..89bf71b 100644 --- a/frontend/src/documents/data/useDocumentMutations.ts +++ b/frontend/src/documents/data/useDocumentMutations.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { useStatusToast } from '../../lib/context/StatusToastContext'; + import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils'; import { getEntryId, isDocumentEntry } from '../../app/entryKey'; import { @@ -74,7 +75,6 @@ interface DocumentTagExtras { import useNotifyApiError from '../../hooks/useNotifyApiError'; interface UseDocumentMutationsArgs { - token?: string | null; documentLookup: Map; folderLabelMap: Map; ensureFolderData: EnsureFolderData; @@ -143,7 +143,6 @@ const normalizeDocumentId = (value: unknown): DocumentId | null => { }; const useDocumentMutations = ({ - token, documentLookup, folderLabelMap, ensureFolderData, @@ -338,10 +337,6 @@ const useDocumentMutations = ({ const handleThumbnailRegeneration = useCallback( async (documentId: DocumentId) => { - if (!token) { - showToast('Log in to manage assets.', 'error'); - return; - } try { await queueDocumentReanalysis(documentId, { force: true }); showToast('Document re-analysis queued.', 'info'); @@ -351,7 +346,7 @@ const useDocumentMutations = ({ notifyApiError(error, message); } }, - [token, refreshCurrentFolder, notifyApiError, showToast], + [refreshCurrentFolder, notifyApiError, showToast], ); const handleDocumentsDelete = useCallback( @@ -360,11 +355,6 @@ const useDocumentMutations = ({ return false; } - if (!token) { - showToast('Log in to manage documents.', 'error'); - return false; - } - try { await Promise.all(documentIds.map((documentId) => trashDocument(documentId))); @@ -386,8 +376,6 @@ const useDocumentMutations = ({ } }, [ - token, - removeDocumentsFromCaches, previewDocumentId, closeDocumentPreview, @@ -625,12 +613,6 @@ const useDocumentMutations = ({ const handleFolderDelete = useCallback( async (folderId?: FolderId, { showMessage = true }: MessageOptions = {}) => { - if (!token) { - if (showMessage) { - showToast('Log in to manage folders.', 'error'); - } - return false; - } if (!folderId || folderId === 'root') { if (showMessage) { showToast('The root folder cannot be removed.', 'error'); @@ -695,7 +677,6 @@ const useDocumentMutations = ({ } }, [ - token, ensureFolderData, selectedFolder, folderNodes, diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index 693c51a..7cd6554 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -145,15 +145,8 @@ const useDocumentsWorkspace = ({ : []; const { showToast } = useStatusToast(); const notifyApiError = useNotifyApiError(); - const [creatingFolder, setCreatingFolder] = useState(false); - - const { tokenRef, handleLogout } = useAuthManager({ - token, - appStatus, - appDispatch, - }); - + const { handleLogout } = useAuthManager({}); const tagRemovalCursorActiveRef = useRef(false); const tenantIdRef = useRef(currentTenantId); @@ -287,63 +280,59 @@ const useDocumentsWorkspace = ({ folderId: FolderNodeId, options: { includeDocuments?: boolean } = {} ) => { - try { - const path = folderId === 'root' ? 'root' : folderId; - const includeDocuments = options.includeDocuments ?? true; - const params: Record = { - include_documents: includeDocuments, - sort: activeSortFieldRef.current, - dir: activeSortDirectionRef.current, - }; - const data = await listFolderContents(path, params); + const path = folderId === 'root' ? 'root' : folderId; + const includeDocuments = options.includeDocuments ?? true; + const params: Record = { + include_documents: includeDocuments, + sort: activeSortFieldRef.current, + dir: activeSortDirectionRef.current, + }; - // Only update UI state if we are fetching for the currently selected folder - if (folderId === selectedFolder) { - // Update documents state if included - if (includeDocuments) { - setDocuments((data.documents || []) as Document[]); - } - setCurrentSubfolders((data.subfolders || []) as any[]); + const data = await listFolderContents(path, params); - // Update selection state based on new documents - if (includeDocuments) { - const docs = (data.documents || []) as Document[]; - const subfolders = (data.subfolders || []) as any[]; + // Only update UI state if we are fetching for the currently selected folder + if (folderId === selectedFolder) { + // Update documents state if included + if (includeDocuments) { + setDocuments((data.documents || []) as Document[]); + } + setCurrentSubfolders((data.subfolders || []) as any[]); - const availableDocKeys = docs - .map((doc) => createDocumentEntryKey(doc?.id as Identifier)) - .filter(Boolean); - const availableDocKeySet = new Set(availableDocKeys); - const availableFolderKeys = new Set( - subfolders - .map((folder) => createFolderEntryKey(folder?.id as Identifier)) - .filter(Boolean), - ); + // Update selection state based on new documents + if (includeDocuments) { + const docs = (data.documents || []) as Document[]; + const subfolders = (data.subfolders || []) as any[]; - setSelectedEntries((previous) => { - const previousFolderKeys = previous - .filter(isFolderEntry) - .filter((key) => availableFolderKeys.has(key)); - const previousDocKeys = previous.filter(isDocumentEntry); - const nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key)); - const mergedSelection = [...previousFolderKeys, ...nextDocKeys]; - return mergedSelection; - }); - } + const availableDocKeys = docs + .map((doc) => createDocumentEntryKey(doc?.id as Identifier)) + .filter(Boolean); + const availableDocKeySet = new Set(availableDocKeys); + const availableFolderKeys = new Set( + subfolders + .map((folder) => createFolderEntryKey(folder?.id as Identifier)) + .filter(Boolean), + ); + + setSelectedEntries((previous) => { + const previousFolderKeys = previous + .filter(isFolderEntry) + .filter((key) => availableFolderKeys.has(key)); + const previousDocKeys = previous.filter(isDocumentEntry); + const nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key)); + const mergedSelection = [...previousFolderKeys, ...nextDocKeys]; + return mergedSelection; + }); } - return data; // Return data for consumers (e.g. useDocumentMutations) - } catch (error) { - notifyApiError(error, 'Failed to fetch folder contents'); - throw error; } + return data; // Return data for consumers (e.g. useDocumentMutations) + }, [ activeSortFieldRef, activeSortDirectionRef, setDocuments, setSelectedEntries, - notifyApiError, setCurrentSubfolders, selectedFolder, ] @@ -351,9 +340,11 @@ const useDocumentsWorkspace = ({ useEffect(() => { if (selectedFolder) { - ensureFolderData(selectedFolder); + ensureFolderData(selectedFolder).catch((error) => { + notifyApiError(error, 'Failed to fetch folder contents'); + }); } - }, [selectedFolder, documentsSortField, documentsSortDirection, ensureFolderData]); + }, [selectedFolder, documentsSortField, documentsSortDirection, ensureFolderData, notifyApiError]); const { searchQuery, @@ -369,7 +360,6 @@ const useDocumentsWorkspace = ({ documentsFilterValue, } = useDocumentsSearch({ api: apiClient, - token, selectedFolder, locationPathname: location.pathname, isDocumentsRoute, @@ -542,9 +532,7 @@ const useDocumentsWorkspace = ({ refreshPasskeys, registerPasskey, revokePasskey, - } = usePasskeys({ - token, - }); + } = usePasskeys({}); const resolveTargetDocumentIds = useCallback( (candidateIds) => { @@ -585,7 +573,6 @@ const useDocumentsWorkspace = ({ resetUploadsState, handleFileSelection, } = useDocumentUploads({ - token, selectedFolder, currentFolderName, ensureFolderData, @@ -709,7 +696,6 @@ const useDocumentsWorkspace = ({ handleDocumentIssuedUpdate, handleTagRemove, } = useDocumentMutations({ - token, documentLookup, folderLabelMap, ensureFolderData, @@ -749,7 +735,6 @@ const useDocumentsWorkspace = ({ handleFolderDelete, folderClickHandlers, } = useFolderTreeActions({ - token, folderNodes, setFolderNodes, selectedFolder, @@ -1078,13 +1063,7 @@ const useDocumentsWorkspace = ({ const { handleTenantSelect } = useTenantManager({ currentTenantId, - resetWorkspaceState, - refreshTags, - refreshCorrespondents, - loadFolder, handleDocumentsViewModeChange, - tokenRef, - tenantIdRef, }); const sessionContext = { diff --git a/frontend/src/documents/data/useTenantManager.ts b/frontend/src/documents/data/useTenantManager.ts index 047e1ad..b94971c 100644 --- a/frontend/src/documents/data/useTenantManager.ts +++ b/frontend/src/documents/data/useTenantManager.ts @@ -1,68 +1,65 @@ -import { MutableRefObject, useCallback } from 'react'; +import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import type { FolderId, TenantId } from '../../types/identifiers'; +import type { TenantId } from '../../types/identifiers'; import { useStatusToast } from '../../lib/context/StatusToastContext'; import { useAppDispatch } from '../../lib/store/appState'; import { api, listTenants, switchTenant } from '../../lib/api/apiClient'; +import useNotifyApiError from '../../hooks/useNotifyApiError'; + interface TenantOption { id?: TenantId; name?: string; } -import useNotifyApiError from '../../hooks/useNotifyApiError'; - interface UseTenantManagerOptions { currentTenantId: TenantId | null; - resetWorkspaceState: () => void; - refreshTags: () => Promise; - refreshCorrespondents: () => Promise; - loadFolder: (folderId: FolderId, options?: { preserveSearch?: boolean }) => Promise; handleDocumentsViewModeChange: (mode: string) => void; - tokenRef?: MutableRefObject; - tenantIdRef?: MutableRefObject; } const useTenantManager = ({ currentTenantId, - resetWorkspaceState, - refreshTags, - refreshCorrespondents, - loadFolder, handleDocumentsViewModeChange, - tokenRef, - tenantIdRef, }: UseTenantManagerOptions) => { const { showToast } = useStatusToast(); const notifyApiError = useNotifyApiError(); const navigate = useNavigate(); const appDispatch = useAppDispatch(); + const handleTenantSelect = useCallback( async (tenantOption: TenantOption | null, { refreshOnly = false }: { refreshOnly?: boolean } = {}) => { const requestedTenantId = tenantOption?.id ?? null; + + // 1. Guard Clauses if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) { return; } try { + // 2. Refresh Logic if (refreshOnly) { const data = await listTenants(); - appDispatch({ - type: 'SET_TENANTS', - tenants: data, - }); + appDispatch({ type: 'SET_TENANTS', tenants: data }); return; } + // 3. Switch Logic const data = await switchTenant(requestedTenantId); + if (!data?.access_token) { throw new Error('Missing access token in tenant switch response.'); } - appDispatch({ type: 'LOGOUT' }); - resetWorkspaceState(); + // 4. Reset UI to safe state BEFORE updating global auth + // This prevents old components from reacting to state changes. + + + // 5. Update Global State IMMEDIATELY + // Don't wait for navigation. Data consistency comes first. + handleDocumentsViewModeChange('list'); + api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`; appDispatch({ type: 'LOGIN_SUCCESS', @@ -70,26 +67,16 @@ const useTenantManager = ({ tenant: data.tenant || null, }); - api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`; - if (tokenRef) { - tokenRef.current = data.access_token; - } - if (tenantIdRef) { - tenantIdRef.current = data?.tenant?.id ?? null; - } - if (Array.isArray(data?.tenants)) { appDispatch({ type: 'SET_TENANTS', tenants: data.tenants }); } - handleDocumentsViewModeChange('list'); - navigate('/documents', { replace: true }); - - await Promise.all([refreshTags(), refreshCorrespondents()]); - await loadFolder('root', { preserveSearch: false }); - const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant'; showToast(`Switched to ${tenantLabel}.`, 'info'); + + // 5. Handle UI/Navigation changes AFTER state is secure + navigate('/documents', { replace: true }); + } catch (error) { notifyApiError(error, 'Failed to switch tenant.'); } @@ -98,15 +85,9 @@ const useTenantManager = ({ appDispatch, currentTenantId, handleDocumentsViewModeChange, - loadFolder, navigate, notifyApiError, - refreshCorrespondents, - refreshTags, - resetWorkspaceState, showToast, - tenantIdRef, - tokenRef, ], ); diff --git a/frontend/src/documents/features/folders/useFolderTreeActions.ts b/frontend/src/documents/features/folders/useFolderTreeActions.ts index 330068f..7ee912e 100644 --- a/frontend/src/documents/features/folders/useFolderTreeActions.ts +++ b/frontend/src/documents/features/folders/useFolderTreeActions.ts @@ -33,7 +33,6 @@ interface FolderClickHandlers { import useNotifyApiError from '../../../hooks/useNotifyApiError'; interface UseFolderTreeActionsOptions { - token?: string | null; folderNodes: Map; setFolderNodes: (updater: (prev: Map) => Map) => void; selectedFolder: FolderKey; @@ -49,7 +48,6 @@ interface UseFolderTreeActionsOptions { } const useFolderTreeActions = ({ - token, folderNodes, setFolderNodes, selectedFolder, @@ -181,10 +179,6 @@ const useFolderTreeActions = ({ const handleFolderRename = useCallback( async (folderId: FolderKey, nextName: string) => { - if (!token) { - showToast('Log in to rename folders.', 'error'); - return false; - } const trimmed = nextName?.trim?.() || ''; if (!trimmed) { showToast('Folder name cannot be empty.', 'error'); @@ -214,16 +208,11 @@ const useFolderTreeActions = ({ notifyApiError, setFolderNodes, showToast, - token, ], ); const handleFolderCreate = useCallback( async (name: string, parentId?: FolderKey | null) => { - if (!token) { - showToast('Log in to create folders.', 'error'); - return false; - } if (!name.trim()) { showToast('Folder name cannot be empty.', 'error'); return false; @@ -291,18 +280,11 @@ const useFolderTreeActions = ({ setCreatingFolder, setFolderNodes, showToast, - token, ], ); const handleFolderDelete = useCallback( async (folderId: FolderKey, { showMessage = true }: MessageOptions = {}) => { - if (!token) { - if (showMessage) { - showToast('Log in to manage folders.', 'error'); - } - return false; - } if (!folderId || folderId === 'root') { if (showMessage) { showToast('The root folder cannot be removed.', 'error'); @@ -352,7 +334,6 @@ const useFolderTreeActions = ({ } }, [ - token, folderNodes, notifyApiError, selectedFolder, diff --git a/frontend/src/documents/features/upload/useDocumentUploads.ts b/frontend/src/documents/features/upload/useDocumentUploads.ts index c19d096..7c5c32e 100644 --- a/frontend/src/documents/features/upload/useDocumentUploads.ts +++ b/frontend/src/documents/features/upload/useDocumentUploads.ts @@ -91,7 +91,6 @@ const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] = }; interface UseDocumentUploadsArgs { - token?: string | null; selectedFolder?: FolderId; currentFolderName?: string | null; ensureFolderData: (folderId: FolderId, options?: { [key: string]: unknown }) => Promise; @@ -121,7 +120,6 @@ interface UseDocumentUploadsResult { import useNotifyApiError from '../../../hooks/useNotifyApiError'; const useDocumentUploads = ({ - token, selectedFolder, currentFolderName, ensureFolderData, @@ -370,19 +368,6 @@ const useDocumentUploads = ({ const queueItems = appendQueueItems(entries, targetFolderId); - if (!token) { - queueItems.forEach((item) => { - const patch = { - status: 'error', - error: 'Please log in before uploading.', - code: null, - }; - updateQueueItem(item.id, patch); - Object.assign(item, patch); - }); - return; - } - try { folderPathCacheRef.current.clear(); @@ -461,7 +446,6 @@ const useDocumentUploads = ({ } }, [ - token, ensureFolderPathOnServer, uploadFile, refreshCurrentFolder, @@ -497,7 +481,6 @@ const useDocumentUploads = ({ useFileDrop({ shellRef, - token, currentFolderName, selectedFolder, handleFileDrop, diff --git a/frontend/src/documents/features/upload/useFileDrop.ts b/frontend/src/documents/features/upload/useFileDrop.ts index 0b02fae..2a2afb0 100644 --- a/frontend/src/documents/features/upload/useFileDrop.ts +++ b/frontend/src/documents/features/upload/useFileDrop.ts @@ -9,7 +9,6 @@ interface DropOverlayState { interface UseFileDropOptions { shellRef: MutableRefObject; - token?: string | null; currentFolderName: string | null; selectedFolder: FolderId; handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderId) => Promise; @@ -21,7 +20,6 @@ interface UseFileDropOptions { const useFileDrop = ({ shellRef, - token, currentFolderName, selectedFolder, handleFileDrop, @@ -32,12 +30,6 @@ const useFileDrop = ({ }: UseFileDropOptions) => { useEffect(() => { - if (!token) { - setDropOverlayState((prev) => ({ ...prev, active: false })); - dragCounterRef.current = 0; - return undefined; - } - const handleDragEnter = (event: DragEvent) => { if (!hasFiles(event)) return; event.preventDefault(); @@ -86,7 +78,6 @@ const useFileDrop = ({ setDropOverlayState((prev) => ({ ...prev, active: false })); }; }, [ - token, handleFileDrop, currentFolderName, defaultFolderName, diff --git a/frontend/src/settings/usePasskeys.ts b/frontend/src/settings/usePasskeys.ts index 511c205..855fe1a 100644 --- a/frontend/src/settings/usePasskeys.ts +++ b/frontend/src/settings/usePasskeys.ts @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react'; +import { useAppState } from '../lib/store/appState'; import { useStatusToast } from '../lib/context/StatusToastContext'; /* global PublicKeyCredentialCreationOptions, CredentialCreationOptions */ @@ -69,9 +70,7 @@ type RevokePasskeyResult = import useNotifyApiError from '../hooks/useNotifyApiError'; -interface UsePasskeysArgs { - token?: string | null; -} +interface UsePasskeysArgs { } interface UsePasskeysResult { passkeys: PasskeyRecord[]; @@ -87,7 +86,8 @@ interface UsePasskeysResult { ) => Promise; } -const usePasskeys = ({ token }: UsePasskeysArgs): UsePasskeysResult => { +const usePasskeys = (_: UsePasskeysArgs = {}): UsePasskeysResult => { + const { token } = useAppState(); const [passkeys, setPasskeys] = useState([]); const [passkeysSupported, setPasskeysSupported] = useState(null); const [passkeysLoading, setPasskeysLoading] = useState(false);