From 014f489857bc12c4f54c4b345ac40ce09476d77d Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 22 Nov 2025 18:19:55 +0100 Subject: [PATCH] ai-shit --- frontend/src/app/ApiContext.tsx | 45 +++++++ frontend/src/app/appState.tsx | 23 ++-- frontend/src/asset_manager.ts | 26 ++-- .../src/hooks/documents/useAuthManager.ts | 111 ++---------------- .../hooks/documents/useDocumentsWorkspace.ts | 29 +++-- frontend/src/lib/apiClient.ts | 30 +++++ frontend/src/lib/apiTypes.ts | 1 + 7 files changed, 124 insertions(+), 141 deletions(-) create mode 100644 frontend/src/app/ApiContext.tsx diff --git a/frontend/src/app/ApiContext.tsx b/frontend/src/app/ApiContext.tsx new file mode 100644 index 0000000..901ccc5 --- /dev/null +++ b/frontend/src/app/ApiContext.tsx @@ -0,0 +1,45 @@ +import React, { createContext, useContext, useEffect, useMemo } from 'react'; +import type { PropsWithChildren } from 'react'; +import { httpClient, setAuthToken, clearAuthToken } from '../lib/apiClient'; + +type HttpClient = typeof httpClient; + +interface ApiContextValue { + client: HttpClient; + setAuthToken: (token?: string | null) => void; + clearAuthToken: () => void; +} + +const ApiContext = createContext(null); + +export const ApiProvider: React.FC> = ({ + initialToken = null, + children, +}) => { + useEffect(() => { + if (initialToken) { + setAuthToken(initialToken); + } + }, [initialToken]); + + const value = useMemo( + () => ({ + client: httpClient, + setAuthToken, + clearAuthToken, + }), + [], + ); + + return {children}; +}; + +export const useApi = (): ApiContextValue => { + const ctx = useContext(ApiContext); + if (!ctx) { + throw new Error('useApi must be used within an ApiProvider'); + } + return ctx; +}; + +export const getHttpClient = (): HttpClient => httpClient; diff --git a/frontend/src/app/appState.tsx b/frontend/src/app/appState.tsx index cc05244..004680b 100644 --- a/frontend/src/app/appState.tsx +++ b/frontend/src/app/appState.tsx @@ -1,5 +1,6 @@ import React, { useContext, useEffect, useMemo, useReducer } from 'react'; -import api from '../lib/api'; +import { clearAuthToken, setAuthToken } from '../lib/apiClient'; +import { ApiProvider } from './ApiContext'; import { listTenants } from '../lib/apiClient'; type Tenant = Record | null; @@ -61,7 +62,7 @@ if (storage) { } if (STORED_TOKEN) { - api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; + setAuthToken(STORED_TOKEN); } const initialAppState: AppState = { @@ -188,10 +189,10 @@ const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children } useEffect(() => { const token = state.token ?? ''; if (token) { - api.defaults.headers.common.Authorization = `Bearer ${token}`; + setAuthToken(token); storage?.setItem('papercrate_token', token); } else { - delete api.defaults.headers.common.Authorization; + clearAuthToken(); storage?.removeItem('papercrate_token'); } }, [state.token]); @@ -242,11 +243,13 @@ const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children } const stateValue = useMemo(() => state, [state]); return ( - - - {children} - - + + + + {children} + + + ); }; @@ -266,4 +269,4 @@ const useAppDispatch = (): React.Dispatch => { return context; }; -export { api, AppStateProvider, useAppState, useAppDispatch }; +export { AppStateProvider, useAppState, useAppDispatch }; diff --git a/frontend/src/asset_manager.ts b/frontend/src/asset_manager.ts index ec4fded..b2c516a 100644 --- a/frontend/src/asset_manager.ts +++ b/frontend/src/asset_manager.ts @@ -1,5 +1,3 @@ -import type { AxiosInstance } from 'axios'; - export type Identifier = string | number; type Nullable = T | null; @@ -205,20 +203,20 @@ export const resolveDocumentAssetUrl = ( }; class AssetManager { - api: AxiosInstance | null; + fetchAsset: ((id: Identifier) => Promise) | null; assetPresignTtlMs: number; assetCache: Map; assetInflight: Map>; - constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) { - this.api = api; + constructor({ fetchAsset, assetPresignTtlMs }: { fetchAsset: ((id: Identifier) => Promise) | null; assetPresignTtlMs: number }) { + this.fetchAsset = fetchAsset; this.assetPresignTtlMs = assetPresignTtlMs; this.assetCache = new Map(); this.assetInflight = new Map(); } - setApi(api: AxiosInstance | null) { - this.api = api; + setFetchAsset(fetchAsset: ((id: Identifier) => Promise) | null) { + this.fetchAsset = fetchAsset; } rememberAsset(entry?: Nullable) { @@ -271,18 +269,16 @@ class AssetManager { return this.assetInflight.get(inflightKey); } - if (!this.api) { - return Promise.reject(new Error('AssetManager API client is not configured.')); + if (!this.fetchAsset) { + return Promise.reject(new Error('AssetManager fetcher is not configured.')); } - const request: Promise = this.api - .get(`/assets/${asset.id}`) - .then(({ data }) => { + const request: Promise = this.fetchAsset(asset.id) + .then((data) => { + if (!data) return null; const cachedEntry = this.assetCache.get(asset.id) || baseAsset; const combined = { ...cachedEntry, ...asset, ...data }; - const expires_at = - resolveAssetExpiresAt(data) - ?? resolveAssetExpiresAt(combined); + const expires_at = resolveAssetExpiresAt(combined); const entry = { ...combined, url: resolveAssetUrl(combined), diff --git a/frontend/src/hooks/documents/useAuthManager.ts b/frontend/src/hooks/documents/useAuthManager.ts index 444908b..d97c704 100644 --- a/frontend/src/hooks/documents/useAuthManager.ts +++ b/frontend/src/hooks/documents/useAuthManager.ts @@ -1,28 +1,19 @@ import { useCallback, useEffect, useRef } from 'react'; import type { MutableRefObject } from 'react'; -import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; -import { AxiosHeaders } from 'axios'; +import { clearAuthToken, logoutSession, refreshSession, setAuthToken } from '../../lib/apiClient'; type AppStatus = string; type AppDispatch = (action: { type: string; [key: string]: unknown }) => void; -type NotifyApiError = (error: unknown, fallbackMessage: string, variant?: string) => void; - type SetStatusMessage = (message: string, variant?: string) => void; type SetLoading = (state: boolean) => void; -interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig { - _retry?: boolean; -} - interface UseAuthManagerArgs { - apiClient: AxiosInstance; token?: string | null; appStatus: AppStatus; appDispatch: AppDispatch; - notifyApiError: NotifyApiError; setStatusMessage: SetStatusMessage; setLoading: SetLoading; } @@ -33,40 +24,23 @@ interface UseAuthManagerResult { handleLogout: () => Promise; } -const ensureAxiosHeaders = ( - headers?: InternalAxiosRequestConfig['headers'], -): AxiosHeaders => { - if (headers instanceof AxiosHeaders) { - return headers; - } - return AxiosHeaders.from(headers || {}); -}; - -const setHeaderAuthorization = (config: InternalAxiosRequestConfig, token: string): void => { - const headers = ensureAxiosHeaders(config.headers); - headers.set('Authorization', `Bearer ${token}`); - config.headers = headers; -}; - const useAuthManager = ({ - apiClient, token, appStatus, appDispatch, - notifyApiError, setStatusMessage, setLoading, }: UseAuthManagerArgs): UseAuthManagerResult => { const tokenRef = useRef(token); - const refreshPromiseRef = useRef | null>(null); const initialRefreshAttemptedRef = useRef(Boolean(token)); const refreshAccessToken = useCallback(async (): Promise => { console.log('[Auth] Attempting to refresh access token…'); appDispatch({ type: 'TOKEN_REFRESH_START' }); try { - const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh'); + const data = await refreshSession(); if (data?.access_token) { + setAuthToken(data.access_token); appDispatch({ type: 'TOKEN_REFRESH_SUCCESS', token: data.access_token, @@ -81,7 +55,7 @@ const useAuthManager = ({ appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null }); throw error; } - }, [apiClient, appDispatch]); + }, [appDispatch]); useEffect(() => { tokenRef.current = token; @@ -95,90 +69,19 @@ const useAuthManager = ({ } }, [token, appStatus, refreshAccessToken]); - useEffect(() => { - const requestInterceptor = apiClient.interceptors.request.use((config) => { - const currentToken = tokenRef.current; - if (currentToken) { - const headers = ensureAxiosHeaders(config.headers); - if (!headers.has('Authorization')) { - headers.set('Authorization', `Bearer ${currentToken}`); - } - config.headers = headers; - } - return config; - }); - - const responseInterceptor = apiClient.interceptors.response.use( - (response) => response, - async (error) => { - const axiosError = error as AxiosError & { config?: RetryableAxiosRequestConfig }; - const { response, config } = axiosError; - if (!response || !config) { - return Promise.reject(error); - } - - const status = response.status; - const url = String(config?.url ?? ''); - const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh'); - - if (status === 401 && !config._retry && !isAuthRoute) { - console.warn('[Auth] 401 received for', url, '- attempting token refresh'); - - if (!refreshPromiseRef.current) { - refreshPromiseRef.current = (async () => { - try { - return await refreshAccessToken(); - } finally { - refreshPromiseRef.current = null; - } - })(); - } - - try { - const newToken = await refreshPromiseRef.current; - if (!newToken) { - throw new Error('No token returned from refresh'); - } - config._retry = true; - setHeaderAuthorization(config, newToken); - console.log('[Auth] Retrying original request', url); - try { - return await apiClient(config); - } catch (retryError) { - if ((retryError as AxiosError)?.response?.status === 401) { - notifyApiError(retryError, 'Session expired. Please log in again.'); - } - throw retryError; - } - } catch (refreshError) { - console.warn('[Auth] Refresh failed, clearing session'); - notifyApiError(refreshError, 'Session expired. Please log in again.'); - return Promise.reject(refreshError); - } - } - - return Promise.reject(error); - }, - ); - - return () => { - apiClient.interceptors.request.eject(requestInterceptor); - apiClient.interceptors.response.eject(responseInterceptor); - }; - }, [apiClient, notifyApiError, refreshAccessToken]); - const handleLogout = useCallback(async () => { try { setLoading(true); - await apiClient.post('/auth/logout'); + await logoutSession(); } catch (error) { console.warn('[Auth] Failed to revoke refresh token during logout', error); } finally { + clearAuthToken(); setLoading(false); appDispatch({ type: 'LOGOUT' }); setStatusMessage('Logged out.', 'info'); } - }, [apiClient, appDispatch, setLoading, setStatusMessage]); + }, [appDispatch, setLoading, setStatusMessage]); return { tokenRef, refreshAccessToken, handleLogout }; }; diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index 9db8f63..da83393 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -18,7 +18,9 @@ import useApiError from '../useApiError'; import TagManager from '../../tag_manager'; import usePasskeys from '../../settings/usePasskeys'; import { useManagementModals } from '../../app/useManagementModals'; -import { api, useAppDispatch, useAppState } from '../../app/appState'; +import { useAppDispatch, useAppState } from '../../app/appState'; +import { fetchAsset } from '../../lib/apiClient'; +import { useApi } from '../../app/ApiContext'; import useWorkspaceSelection from '../../app/useWorkspaceSelection'; import { useEntryPointer as useEntryPointerCore } from '../../documents/useEntryPointer'; import { isTagTransferEvent } from '../../documents/tagTransfer'; @@ -153,6 +155,7 @@ const useDocumentsWorkspace = ({ tenant, tenants: tenantOptionsRaw = [], } = appState; + const { client: apiClient } = useApi(); const tenantRecord = (tenant ?? null) as TenantOption | null; const tenantNameCandidate = tenantRecord?.name ?? tenantRecord?.slug ?? null; @@ -179,11 +182,9 @@ const useDocumentsWorkspace = ({ const [loading, setLoading] = useState(false); const [creatingFolder, setCreatingFolder] = useState(false); const { tokenRef, handleLogout } = useAuthManager({ - apiClient: api, token, appStatus, appDispatch, - notifyApiError, setStatusMessage, setLoading, }); @@ -220,7 +221,11 @@ const useDocumentsWorkspace = ({ const shellRef = useRef(null); const assetManagerRef = useRef(null); if (!assetManagerRef.current) { - assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS }); + const fetcher = async (id: Identifier) => { + const asset = await fetchAsset(id); + return (asset as unknown) as any; + }; + assetManagerRef.current = new AssetManager({ fetchAsset: fetcher, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS }); } const assetManager = assetManagerRef.current; @@ -346,7 +351,7 @@ const useDocumentsWorkspace = ({ isInvalidFolderDrop, } = useFolderTree({ initialSelectedFolder: routeFolderId || 'root', - apiClient: api, + apiClient, tenantIdRef, documentsSortFieldRef: activeSortFieldRef, documentsSortDirectionRef: activeSortDirectionRef, @@ -369,7 +374,7 @@ const useDocumentsWorkspace = ({ isFilterActive, documentsFilterValue, } = useDocumentsSearch({ - api, + api: apiClient, token, selectedFolder, navigate, @@ -505,7 +510,7 @@ const useDocumentsWorkspace = ({ handleTagDelete, setTags, } = useTags({ - apiClient: api, + apiClient, notifyApiError, setStatusMessage, tagManager, @@ -549,7 +554,7 @@ const useDocumentsWorkspace = ({ handleCorrespondentDelete, setCorrespondents, } = useCorrespondents({ - apiClient: api, + apiClient, notifyApiError, setStatusMessage, tenantIdRef, @@ -604,7 +609,7 @@ const useDocumentsWorkspace = ({ handleBulkTagRemoveFromDetail, handleBulkSelectionReanalyze, } = useDocumentTagging({ - apiClient: api, + apiClient, tags, tagManager, refreshTags, @@ -623,7 +628,7 @@ const useDocumentsWorkspace = ({ clearUploadQueue, resetUploadsState, } = useDocumentUploads({ - apiClient: api, + apiClient, token, selectedFolder, currentFolderName, @@ -660,7 +665,7 @@ const useDocumentsWorkspace = ({ handleCorrespondentRemove, handleCorrespondentAdd, } = useDocumentCorrespondentActions({ - apiClient: api, + apiClient, correspondents, handleCorrespondentCreate, notifyApiError, @@ -1369,7 +1374,7 @@ const useDocumentsWorkspace = ({ }, [missingBreadcrumbAncestors, ensureFolderData]); const { handleTenantSelect } = useTenantManager({ - apiClient: api, + apiClient, appDispatch, currentTenantId, resetWorkspaceState, diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts index 66115a0..a189f1f 100644 --- a/frontend/src/lib/apiClient.ts +++ b/frontend/src/lib/apiClient.ts @@ -12,6 +12,15 @@ import type { TenantSnippet, TagResponse, } from './apiTypes'; +import type { AxiosInstance } from 'axios'; + +export const httpClient: Pick = { + get: api.get.bind(api), + post: api.post.bind(api), + patch: api.patch.bind(api), + delete: api.delete.bind(api), + defaults: api.defaults, +}; const normalizeNumber = (value: unknown): number | undefined => { const n = Number(value); @@ -158,6 +167,15 @@ export const performLogin = async (payload: Record): Promise => { + const { data } = await api.post('/auth/refresh'); + return data as { access_token?: string; tenant?: unknown }; +}; + +export const logoutSession = async (): Promise => { + await api.post('/auth/logout'); +}; + export const selectTenant = async ( payload: { tenant_id: Identifier }, selectionToken: string, @@ -247,6 +265,18 @@ export const listTenants = async (): Promise => { return Array.isArray(data?.tenants) ? data.tenants : []; }; +export const setAuthToken = (token?: string | null) => { + if (token) { + api.defaults.headers.common.Authorization = `Bearer ${token}`; + } else { + delete api.defaults.headers.common.Authorization; + } +}; + +export const clearAuthToken = () => { + delete api.defaults.headers.common.Authorization; +}; + export type { DownloadLink, DocumentResponse, diff --git a/frontend/src/lib/apiTypes.ts b/frontend/src/lib/apiTypes.ts index 430919a..3c2638c 100644 --- a/frontend/src/lib/apiTypes.ts +++ b/frontend/src/lib/apiTypes.ts @@ -25,6 +25,7 @@ export interface AssetResponse { mime_type: string; metadata: Record; download?: DownloadLink | null; + [key: string]: unknown; } export interface DocumentVersionResponse {