This commit is contained in:
2025-11-23 13:22:00 +01:00
parent 85afff2c19
commit 71f9330c67
6 changed files with 92 additions and 12 deletions
+16 -1
View File
@@ -1,5 +1,5 @@
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
import { clearAuthToken, setAuthToken } from '../lib/apiClient';
import { clearAuthToken, setAuthToken, setAuthRefreshHandlers } from '../lib/apiClient';
import { ApiProvider } from './ApiContext';
import { listTenants } from '../lib/apiClient';
@@ -209,6 +209,21 @@ const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children }
}
}, [state.tenant]);
useEffect(() => {
setAuthRefreshHandlers({
onRefreshSuccess: (token, payload) => {
dispatch({ type: 'TOKEN_REFRESH_SUCCESS', token, tenant: payload?.tenant ?? null });
},
onRefreshFailure: (error) => {
dispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null });
},
});
return () => {
setAuthRefreshHandlers({});
};
}, [dispatch]);
useEffect(() => {
let abort = false;
+1 -1
View File
@@ -104,7 +104,7 @@ const useDocumentPreview = ({
const existing = documentLinks.get(documentId);
const now = Date.now();
const expiresAt = Number.isFinite(existing?.expiresAt) ? Number(existing?.expiresAt) : null;
const expiresAt = existing?.expiresAt ?? null;
if (!force && existing && (!expiresAt || expiresAt > now)) {
return existing;
}
+2 -4
View File
@@ -184,9 +184,7 @@ export const resolveDocumentAssetUrl = (
const view = createAssetView(asset);
const object = view.getPrimaryObject();
const url = object?.url || view.getPrimaryUrl();
const expiresAt = Number.isFinite(object?.expires_at)
? Number(object?.expires_at)
: resolveAssetExpiresAt(asset);
const expiresAt = object?.expires_at ?? resolveAssetExpiresAt(asset);
const now = Date.now();
if (url && (!expiresAt || expiresAt > now)) {
return url;
@@ -242,7 +240,7 @@ class AssetManager {
const isPrimarySatisfied = () => {
const object = view.getObject(1);
if (object?.url) {
const objectExpiresAt = Number.isFinite(object.expires_at) ? Number(object.expires_at) : null;
const objectExpiresAt = object.expires_at ?? null;
if (!objectExpiresAt || objectExpiresAt > now) {
return true;
}
@@ -495,7 +495,7 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
const extraSummaryRows = useMemo(() => {
const rows: DocumentSummaryRow[] = [];
const currentVersionNumber = document?.current_version?.version_number;
if (Number.isFinite(currentVersionNumber)) {
if (currentVersionNumber != null) {
rows.push({
key: 'current-version',
label: 'Current version',
@@ -349,9 +349,7 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
const documentCount = documentIdList.length;
const folderCount = folderIdList.length;
const totalCount = Number.isFinite(selectionCount)
? Number(selectionCount)
: documentCount + folderCount;
const totalCount = selectionCount ?? documentCount + folderCount;
const selectedDocuments = useMemo<DocumentLike[]>(() => {
if (!documentIdList.length || !(documentLookupMap instanceof Map)) {
+71 -2
View File
@@ -12,7 +12,7 @@ import type {
TenantSnippet,
TagResponse,
} from './apiTypes';
import type { AxiosInstance } from 'axios';
import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosRequestConfig } from 'axios';
export const httpClient: Pick<AxiosInstance, 'get' | 'post' | 'patch' | 'delete' | 'defaults'> = {
get: api.get.bind(api),
@@ -22,6 +22,22 @@ export const httpClient: Pick<AxiosInstance, 'get' | 'post' | 'patch' | 'delete'
defaults: api.defaults,
};
type AuthAwareRequestConfig = InternalAxiosRequestConfig & {
_retry?: boolean;
skipAuthRefresh?: boolean;
};
type AuthRequestConfig = AxiosRequestConfig & {
skipAuthRefresh?: boolean;
};
type AuthRefreshHandlers = {
onRefreshSuccess?: (token: string, payload?: { tenant?: unknown }) => void;
onRefreshFailure?: (error: unknown) => void;
};
let refreshPromise: Promise<string> | null = null;
let authRefreshHandlers: AuthRefreshHandlers = {};
const normalizeNumber = (value: unknown): number | undefined => {
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
@@ -168,7 +184,7 @@ export const performLogin = async (payload: Record<string, unknown>): Promise<un
};
export const refreshSession = async (): Promise<{ access_token?: string; tenant?: unknown }> => {
const { data } = await api.post('/auth/refresh');
const { data } = await api.post('/auth/refresh', undefined, { skipAuthRefresh: true } as AuthRequestConfig);
return data as { access_token?: string; tenant?: unknown };
};
@@ -277,6 +293,59 @@ export const clearAuthToken = () => {
delete api.defaults.headers.common.Authorization;
};
export const setAuthRefreshHandlers = (handlers: AuthRefreshHandlers) => {
authRefreshHandlers = handlers;
};
const performTokenRefresh = async (): Promise<string> => {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = refreshSession()
.then((data) => {
const token = data?.access_token;
if (!token) {
throw new Error('Missing access token in refresh response');
}
setAuthToken(token);
authRefreshHandlers.onRefreshSuccess?.(token, { tenant: data?.tenant });
return token;
})
.catch((error) => {
authRefreshHandlers.onRefreshFailure?.(error);
throw error;
})
.finally(() => {
refreshPromise = null;
});
return refreshPromise;
};
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const response = error.response;
const config = (error.config || {}) as AuthAwareRequestConfig;
if (!response || response.status !== 401 || config._retry || config.skipAuthRefresh) {
return Promise.reject(error);
}
config._retry = true;
try {
const token = await performTokenRefresh();
const headers = (config.headers ?? {}) as Record<string, unknown>;
headers.Authorization = `Bearer ${token}`;
config.headers = headers as AuthAwareRequestConfig['headers'];
return api(config);
} catch (refreshError) {
return Promise.reject(refreshError);
}
},
);
export type {
DownloadLink,
DocumentResponse,