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;
+13 -10
View File
@@ -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<string, unknown> | 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 (
<AppStateContext.Provider value={stateValue}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
<ApiProvider initialToken={state.token}>
<AppStateContext.Provider value={stateValue}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
</ApiProvider>
);
};
@@ -266,4 +269,4 @@ const useAppDispatch = (): React.Dispatch<AppAction> => {
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;
type Nullable<T> = T | null;
@@ -205,20 +203,20 @@ export const resolveDocumentAssetUrl = (
};
class AssetManager {
api: AxiosInstance | null;
fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null;
assetPresignTtlMs: number;
assetCache: Map<Identifier, AssetLike>;
assetInflight: Map<string, Promise<AssetLike | null>>;
constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) {
this.api = api;
constructor({ fetchAsset, assetPresignTtlMs }: { fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | 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<AssetLike | null>) | null) {
this.fetchAsset = fetchAsset;
}
rememberAsset(entry?: Nullable<AssetLike>) {
@@ -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<AssetLike | null> = this.api
.get(`/assets/${asset.id}`)
.then(({ data }) => {
const request: Promise<AssetLike | null> = 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),
+7 -104
View File
@@ -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<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 = ({
apiClient,
token,
appStatus,
appDispatch,
notifyApiError,
setStatusMessage,
setLoading,
}: UseAuthManagerArgs): UseAuthManagerResult => {
const tokenRef = useRef<string | null>(token);
const refreshPromiseRef = useRef<Promise<string> | null>(null);
const initialRefreshAttemptedRef = useRef(Boolean(token));
const refreshAccessToken = useCallback(async (): Promise<string> => {
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 };
};
@@ -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,
+30
View File
@@ -12,6 +12,15 @@ import type {
TenantSnippet,
TagResponse,
} 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 n = Number(value);
@@ -158,6 +167,15 @@ export const performLogin = async (payload: Record<string, unknown>): Promise<un
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 (
payload: { tenant_id: Identifier },
selectionToken: string,
@@ -247,6 +265,18 @@ export const listTenants = async (): Promise<TenantSnippet[]> => {
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,
+1
View File
@@ -25,6 +25,7 @@ export interface AssetResponse {
mime_type: string;
metadata: Record<string, unknown>;
download?: DownloadLink | null;
[key: string]: unknown;
}
export interface DocumentVersionResponse {