feat: Refactor frontend authentication and tenant switching to use global state

This commit is contained in:
2025-12-08 01:19:52 +01:00
parent 5840c9d1d0
commit 608239e412
9 changed files with 82 additions and 194 deletions
+2 -2
View File
@@ -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<string>('');
const [activeTagFilters, setActiveTagFilters] = useState<Identifier[]>([]);
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState<Identifier[]>([]);
+5 -13
View File
@@ -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<string | null>;
@@ -19,11 +13,9 @@ interface UseAuthManagerResult {
handleLogout: () => Promise<void>;
}
const useAuthManager = ({
token,
appStatus,
appDispatch,
}: UseAuthManagerArgs): UseAuthManagerResult => {
const useAuthManager = (_: UseAuthManagerArgs = {}): UseAuthManagerResult => {
const { token, status: appStatus } = useAppState();
const appDispatch = useAppDispatch();
const tokenRef = useRef<string | null>(token);
const initialRefreshAttemptedRef = useRef(Boolean(token));
const { showToast } = useStatusToast();
@@ -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<DocumentId, Document>;
folderLabelMap: Map<FolderId, string>;
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,
@@ -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<string, unknown> = {
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<string, unknown> = {
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 = {
+23 -42
View File
@@ -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<void>;
refreshCorrespondents: () => Promise<void>;
loadFolder: (folderId: FolderId, options?: { preserveSearch?: boolean }) => Promise<void>;
handleDocumentsViewModeChange: (mode: string) => void;
tokenRef?: MutableRefObject<string | null>;
tenantIdRef?: MutableRefObject<TenantId | null>;
}
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,
],
);
@@ -33,7 +33,6 @@ interface FolderClickHandlers {
import useNotifyApiError from '../../../hooks/useNotifyApiError';
interface UseFolderTreeActionsOptions {
token?: string | null;
folderNodes: Map<FolderKey, FolderNode>;
setFolderNodes: (updater: (prev: Map<FolderKey, FolderNode>) => Map<FolderKey, FolderNode>) => 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,
@@ -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<void>;
@@ -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,
@@ -9,7 +9,6 @@ interface DropOverlayState {
interface UseFileDropOptions {
shellRef: MutableRefObject<HTMLElement | null>;
token?: string | null;
currentFolderName: string | null;
selectedFolder: FolderId;
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderId) => Promise<void>;
@@ -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,
+4 -4
View File
@@ -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<RevokePasskeyResult>;
}
const usePasskeys = ({ token }: UsePasskeysArgs): UsePasskeysResult => {
const usePasskeys = (_: UsePasskeysArgs = {}): UsePasskeysResult => {
const { token } = useAppState();
const [passkeys, setPasskeys] = useState<PasskeyRecord[]>([]);
const [passkeysSupported, setPasskeysSupported] = useState<boolean | null>(null);
const [passkeysLoading, setPasskeysLoading] = useState(false);