ai-shit
This commit is contained in:
@@ -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;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface AssetResponse {
|
||||
mime_type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
download?: DownloadLink | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DocumentVersionResponse {
|
||||
|
||||
Reference in New Issue
Block a user