diff --git a/frontend/src/app/appState.tsx b/frontend/src/app/appState.tsx index 004680b..0f3357d 100644 --- a/frontend/src/app/appState.tsx +++ b/frontend/src/app/appState.tsx @@ -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; diff --git a/frontend/src/app/useDocumentPreview.ts b/frontend/src/app/useDocumentPreview.ts index b1655a4..1048bb4 100644 --- a/frontend/src/app/useDocumentPreview.ts +++ b/frontend/src/app/useDocumentPreview.ts @@ -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; } diff --git a/frontend/src/asset_manager.ts b/frontend/src/asset_manager.ts index b2c516a..f2fbd00 100644 --- a/frontend/src/asset_manager.ts +++ b/frontend/src/asset_manager.ts @@ -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; } diff --git a/frontend/src/documents/DocumentSummarySection.tsx b/frontend/src/documents/DocumentSummarySection.tsx index 258be49..62b30a7 100644 --- a/frontend/src/documents/DocumentSummarySection.tsx +++ b/frontend/src/documents/DocumentSummarySection.tsx @@ -495,7 +495,7 @@ const DocumentSummarySection: React.FC = ({ 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', diff --git a/frontend/src/documents/SelectionFloatingActions.tsx b/frontend/src/documents/SelectionFloatingActions.tsx index b36a4b3..b4374e8 100644 --- a/frontend/src/documents/SelectionFloatingActions.tsx +++ b/frontend/src/documents/SelectionFloatingActions.tsx @@ -349,9 +349,7 @@ const SelectionFloatingActions: React.FC = ({ 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(() => { if (!documentIdList.length || !(documentLookupMap instanceof Map)) { diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts index a189f1f..93f0df1 100644 --- a/frontend/src/lib/apiClient.ts +++ b/frontend/src/lib/apiClient.ts @@ -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 = { get: api.get.bind(api), @@ -22,6 +22,22 @@ export const httpClient: Pick void; + onRefreshFailure?: (error: unknown) => void; +}; + +let refreshPromise: Promise | 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): Promise => { - 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 => { + 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; + headers.Authorization = `Bearer ${token}`; + config.headers = headers as AuthAwareRequestConfig['headers']; + return api(config); + } catch (refreshError) { + return Promise.reject(refreshError); + } + }, +); + export type { DownloadLink, DocumentResponse,