This commit is contained in:
2025-11-22 18:19:55 +01:00
parent d3c76bc303
commit 014f489857
7 changed files with 124 additions and 141 deletions
+45
View File
@@ -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<ApiContextValue | null>(null);
export const ApiProvider: React.FC<PropsWithChildren<{ initialToken?: string | null }>> = ({
initialToken = null,
children,
}) => {
useEffect(() => {
if (initialToken) {
setAuthToken(initialToken);
}
}, [initialToken]);
const value = useMemo<ApiContextValue>(
() => ({
client: httpClient,
setAuthToken,
clearAuthToken,
}),
[],
);
return <ApiContext.Provider value={value}>{children}</ApiContext.Provider>;
};
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;
+8 -5
View File
@@ -1,5 +1,6 @@
import React, { useContext, useEffect, useMemo, useReducer } from 'react'; 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'; import { listTenants } from '../lib/apiClient';
type Tenant = Record<string, unknown> | null; type Tenant = Record<string, unknown> | null;
@@ -61,7 +62,7 @@ if (storage) {
} }
if (STORED_TOKEN) { if (STORED_TOKEN) {
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; setAuthToken(STORED_TOKEN);
} }
const initialAppState: AppState = { const initialAppState: AppState = {
@@ -188,10 +189,10 @@ const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children }
useEffect(() => { useEffect(() => {
const token = state.token ?? ''; const token = state.token ?? '';
if (token) { if (token) {
api.defaults.headers.common.Authorization = `Bearer ${token}`; setAuthToken(token);
storage?.setItem('papercrate_token', token); storage?.setItem('papercrate_token', token);
} else { } else {
delete api.defaults.headers.common.Authorization; clearAuthToken();
storage?.removeItem('papercrate_token'); storage?.removeItem('papercrate_token');
} }
}, [state.token]); }, [state.token]);
@@ -242,11 +243,13 @@ const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children }
const stateValue = useMemo(() => state, [state]); const stateValue = useMemo(() => state, [state]);
return ( return (
<ApiProvider initialToken={state.token}>
<AppStateContext.Provider value={stateValue}> <AppStateContext.Provider value={stateValue}>
<AppDispatchContext.Provider value={dispatch}> <AppDispatchContext.Provider value={dispatch}>
{children} {children}
</AppDispatchContext.Provider> </AppDispatchContext.Provider>
</AppStateContext.Provider> </AppStateContext.Provider>
</ApiProvider>
); );
}; };
@@ -266,4 +269,4 @@ const useAppDispatch = (): React.Dispatch<AppAction> => {
return context; return context;
}; };
export { api, AppStateProvider, useAppState, useAppDispatch }; export { AppStateProvider, useAppState, useAppDispatch };
+11 -15
View File
@@ -1,5 +1,3 @@
import type { AxiosInstance } from 'axios';
export type Identifier = string | number; export type Identifier = string | number;
type Nullable<T> = T | null; type Nullable<T> = T | null;
@@ -205,20 +203,20 @@ export const resolveDocumentAssetUrl = (
}; };
class AssetManager { class AssetManager {
api: AxiosInstance | null; fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null;
assetPresignTtlMs: number; assetPresignTtlMs: number;
assetCache: Map<Identifier, AssetLike>; assetCache: Map<Identifier, AssetLike>;
assetInflight: Map<string, Promise<AssetLike | null>>; assetInflight: Map<string, Promise<AssetLike | null>>;
constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) { constructor({ fetchAsset, assetPresignTtlMs }: { fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null; assetPresignTtlMs: number }) {
this.api = api; this.fetchAsset = fetchAsset;
this.assetPresignTtlMs = assetPresignTtlMs; this.assetPresignTtlMs = assetPresignTtlMs;
this.assetCache = new Map(); this.assetCache = new Map();
this.assetInflight = new Map(); this.assetInflight = new Map();
} }
setApi(api: AxiosInstance | null) { setFetchAsset(fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null) {
this.api = api; this.fetchAsset = fetchAsset;
} }
rememberAsset(entry?: Nullable<AssetLike>) { rememberAsset(entry?: Nullable<AssetLike>) {
@@ -271,18 +269,16 @@ class AssetManager {
return this.assetInflight.get(inflightKey); return this.assetInflight.get(inflightKey);
} }
if (!this.api) { if (!this.fetchAsset) {
return Promise.reject(new Error('AssetManager API client is not configured.')); return Promise.reject(new Error('AssetManager fetcher is not configured.'));
} }
const request: Promise<AssetLike | null> = this.api const request: Promise<AssetLike | null> = this.fetchAsset(asset.id)
.get(`/assets/${asset.id}`) .then((data) => {
.then(({ data }) => { if (!data) return null;
const cachedEntry = this.assetCache.get(asset.id) || baseAsset; const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
const combined = { ...cachedEntry, ...asset, ...data }; const combined = { ...cachedEntry, ...asset, ...data };
const expires_at = const expires_at = resolveAssetExpiresAt(combined);
resolveAssetExpiresAt(data)
?? resolveAssetExpiresAt(combined);
const entry = { const entry = {
...combined, ...combined,
url: resolveAssetUrl(combined), url: resolveAssetUrl(combined),
+7 -104
View File
@@ -1,28 +1,19 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; import { clearAuthToken, logoutSession, refreshSession, setAuthToken } from '../../lib/apiClient';
import { AxiosHeaders } from 'axios';
type AppStatus = string; type AppStatus = string;
type AppDispatch = (action: { type: string; [key: string]: unknown }) => void; 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 SetStatusMessage = (message: string, variant?: string) => void;
type SetLoading = (state: boolean) => void; type SetLoading = (state: boolean) => void;
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
interface UseAuthManagerArgs { interface UseAuthManagerArgs {
apiClient: AxiosInstance;
token?: string | null; token?: string | null;
appStatus: AppStatus; appStatus: AppStatus;
appDispatch: AppDispatch; appDispatch: AppDispatch;
notifyApiError: NotifyApiError;
setStatusMessage: SetStatusMessage; setStatusMessage: SetStatusMessage;
setLoading: SetLoading; setLoading: SetLoading;
} }
@@ -33,40 +24,23 @@ interface UseAuthManagerResult {
handleLogout: () => Promise<void>; handleLogout: () => Promise<void>;
} }
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 = ({ const useAuthManager = ({
apiClient,
token, token,
appStatus, appStatus,
appDispatch, appDispatch,
notifyApiError,
setStatusMessage, setStatusMessage,
setLoading, setLoading,
}: UseAuthManagerArgs): UseAuthManagerResult => { }: UseAuthManagerArgs): UseAuthManagerResult => {
const tokenRef = useRef<string | null>(token); const tokenRef = useRef<string | null>(token);
const refreshPromiseRef = useRef<Promise<string> | null>(null);
const initialRefreshAttemptedRef = useRef(Boolean(token)); const initialRefreshAttemptedRef = useRef(Boolean(token));
const refreshAccessToken = useCallback(async (): Promise<string> => { const refreshAccessToken = useCallback(async (): Promise<string> => {
console.log('[Auth] Attempting to refresh access token…'); console.log('[Auth] Attempting to refresh access token…');
appDispatch({ type: 'TOKEN_REFRESH_START' }); appDispatch({ type: 'TOKEN_REFRESH_START' });
try { try {
const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh'); const data = await refreshSession();
if (data?.access_token) { if (data?.access_token) {
setAuthToken(data.access_token);
appDispatch({ appDispatch({
type: 'TOKEN_REFRESH_SUCCESS', type: 'TOKEN_REFRESH_SUCCESS',
token: data.access_token, token: data.access_token,
@@ -81,7 +55,7 @@ const useAuthManager = ({
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null }); appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null });
throw error; throw error;
} }
}, [apiClient, appDispatch]); }, [appDispatch]);
useEffect(() => { useEffect(() => {
tokenRef.current = token; tokenRef.current = token;
@@ -95,90 +69,19 @@ const useAuthManager = ({
} }
}, [token, appStatus, refreshAccessToken]); }, [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 () => { const handleLogout = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
await apiClient.post('/auth/logout'); await logoutSession();
} catch (error) { } catch (error) {
console.warn('[Auth] Failed to revoke refresh token during logout', error); console.warn('[Auth] Failed to revoke refresh token during logout', error);
} finally { } finally {
clearAuthToken();
setLoading(false); setLoading(false);
appDispatch({ type: 'LOGOUT' }); appDispatch({ type: 'LOGOUT' });
setStatusMessage('Logged out.', 'info'); setStatusMessage('Logged out.', 'info');
} }
}, [apiClient, appDispatch, setLoading, setStatusMessage]); }, [appDispatch, setLoading, setStatusMessage]);
return { tokenRef, refreshAccessToken, handleLogout }; return { tokenRef, refreshAccessToken, handleLogout };
}; };
@@ -18,7 +18,9 @@ import useApiError from '../useApiError';
import TagManager from '../../tag_manager'; import TagManager from '../../tag_manager';
import usePasskeys from '../../settings/usePasskeys'; import usePasskeys from '../../settings/usePasskeys';
import { useManagementModals } from '../../app/useManagementModals'; 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 useWorkspaceSelection from '../../app/useWorkspaceSelection';
import { useEntryPointer as useEntryPointerCore } from '../../documents/useEntryPointer'; import { useEntryPointer as useEntryPointerCore } from '../../documents/useEntryPointer';
import { isTagTransferEvent } from '../../documents/tagTransfer'; import { isTagTransferEvent } from '../../documents/tagTransfer';
@@ -153,6 +155,7 @@ const useDocumentsWorkspace = ({
tenant, tenant,
tenants: tenantOptionsRaw = [], tenants: tenantOptionsRaw = [],
} = appState; } = appState;
const { client: apiClient } = useApi();
const tenantRecord = (tenant ?? null) as TenantOption | null; const tenantRecord = (tenant ?? null) as TenantOption | null;
const tenantNameCandidate = tenantRecord?.name ?? tenantRecord?.slug ?? null; const tenantNameCandidate = tenantRecord?.name ?? tenantRecord?.slug ?? null;
@@ -179,11 +182,9 @@ const useDocumentsWorkspace = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [creatingFolder, setCreatingFolder] = useState(false); const [creatingFolder, setCreatingFolder] = useState(false);
const { tokenRef, handleLogout } = useAuthManager({ const { tokenRef, handleLogout } = useAuthManager({
apiClient: api,
token, token,
appStatus, appStatus,
appDispatch, appDispatch,
notifyApiError,
setStatusMessage, setStatusMessage,
setLoading, setLoading,
}); });
@@ -220,7 +221,11 @@ const useDocumentsWorkspace = ({
const shellRef = useRef(null); const shellRef = useRef(null);
const assetManagerRef = useRef(null); const assetManagerRef = useRef(null);
if (!assetManagerRef.current) { 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; const assetManager = assetManagerRef.current;
@@ -346,7 +351,7 @@ const useDocumentsWorkspace = ({
isInvalidFolderDrop, isInvalidFolderDrop,
} = useFolderTree({ } = useFolderTree({
initialSelectedFolder: routeFolderId || 'root', initialSelectedFolder: routeFolderId || 'root',
apiClient: api, apiClient,
tenantIdRef, tenantIdRef,
documentsSortFieldRef: activeSortFieldRef, documentsSortFieldRef: activeSortFieldRef,
documentsSortDirectionRef: activeSortDirectionRef, documentsSortDirectionRef: activeSortDirectionRef,
@@ -369,7 +374,7 @@ const useDocumentsWorkspace = ({
isFilterActive, isFilterActive,
documentsFilterValue, documentsFilterValue,
} = useDocumentsSearch({ } = useDocumentsSearch({
api, api: apiClient,
token, token,
selectedFolder, selectedFolder,
navigate, navigate,
@@ -505,7 +510,7 @@ const useDocumentsWorkspace = ({
handleTagDelete, handleTagDelete,
setTags, setTags,
} = useTags({ } = useTags({
apiClient: api, apiClient,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
tagManager, tagManager,
@@ -549,7 +554,7 @@ const useDocumentsWorkspace = ({
handleCorrespondentDelete, handleCorrespondentDelete,
setCorrespondents, setCorrespondents,
} = useCorrespondents({ } = useCorrespondents({
apiClient: api, apiClient,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
tenantIdRef, tenantIdRef,
@@ -604,7 +609,7 @@ const useDocumentsWorkspace = ({
handleBulkTagRemoveFromDetail, handleBulkTagRemoveFromDetail,
handleBulkSelectionReanalyze, handleBulkSelectionReanalyze,
} = useDocumentTagging({ } = useDocumentTagging({
apiClient: api, apiClient,
tags, tags,
tagManager, tagManager,
refreshTags, refreshTags,
@@ -623,7 +628,7 @@ const useDocumentsWorkspace = ({
clearUploadQueue, clearUploadQueue,
resetUploadsState, resetUploadsState,
} = useDocumentUploads({ } = useDocumentUploads({
apiClient: api, apiClient,
token, token,
selectedFolder, selectedFolder,
currentFolderName, currentFolderName,
@@ -660,7 +665,7 @@ const useDocumentsWorkspace = ({
handleCorrespondentRemove, handleCorrespondentRemove,
handleCorrespondentAdd, handleCorrespondentAdd,
} = useDocumentCorrespondentActions({ } = useDocumentCorrespondentActions({
apiClient: api, apiClient,
correspondents, correspondents,
handleCorrespondentCreate, handleCorrespondentCreate,
notifyApiError, notifyApiError,
@@ -1369,7 +1374,7 @@ const useDocumentsWorkspace = ({
}, [missingBreadcrumbAncestors, ensureFolderData]); }, [missingBreadcrumbAncestors, ensureFolderData]);
const { handleTenantSelect } = useTenantManager({ const { handleTenantSelect } = useTenantManager({
apiClient: api, apiClient,
appDispatch, appDispatch,
currentTenantId, currentTenantId,
resetWorkspaceState, resetWorkspaceState,
+30
View File
@@ -12,6 +12,15 @@ import type {
TenantSnippet, TenantSnippet,
TagResponse, TagResponse,
} from './apiTypes'; } from './apiTypes';
import type { AxiosInstance } from 'axios';
export const httpClient: Pick<AxiosInstance, 'get' | 'post' | 'patch' | 'delete' | 'defaults'> = {
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 normalizeNumber = (value: unknown): number | undefined => {
const n = Number(value); const n = Number(value);
@@ -158,6 +167,15 @@ export const performLogin = async (payload: Record<string, unknown>): Promise<un
return data; return data;
}; };
export const refreshSession = async (): Promise<{ access_token?: string; tenant?: unknown }> => {
const { data } = await api.post('/auth/refresh');
return data as { access_token?: string; tenant?: unknown };
};
export const logoutSession = async (): Promise<void> => {
await api.post('/auth/logout');
};
export const selectTenant = async ( export const selectTenant = async (
payload: { tenant_id: Identifier }, payload: { tenant_id: Identifier },
selectionToken: string, selectionToken: string,
@@ -247,6 +265,18 @@ export const listTenants = async (): Promise<TenantSnippet[]> => {
return Array.isArray(data?.tenants) ? data.tenants : []; 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 { export type {
DownloadLink, DownloadLink,
DocumentResponse, DocumentResponse,
+1
View File
@@ -25,6 +25,7 @@ export interface AssetResponse {
mime_type: string; mime_type: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
download?: DownloadLink | null; download?: DownloadLink | null;
[key: string]: unknown;
} }
export interface DocumentVersionResponse { export interface DocumentVersionResponse {