diff --git a/ui/src/api/queries/admin/admin-api.ts b/ui/src/api/queries/admin/admin-api.ts index a92516b7..cea51560 100644 --- a/ui/src/api/queries/admin/admin-api.ts +++ b/ui/src/api/queries/admin/admin-api.ts @@ -1,9 +1,40 @@ import { config } from '../../../config'; import { useAuthStore } from '../../../auth/auth-store'; +/** + * Shared fetch helper for admin API endpoints. + * + * On a 401 response the helper attempts a single token refresh and retries + * the original request. Only if the retry also fails (or there is no refresh + * token) does it fall through to the normal error path. + * + * Previously this function called `logout()` directly on 401/403 which could + * race with background polling: when an access-token expired while the user + * was editing a form, the immediate logout cleared `roles`, causing + * `RequireAdmin` to redirect to `/` -> `/exchanges` and losing unsaved state. + */ export async function adminFetch(path: string, options?: RequestInit): Promise { + const res = await _doFetch(path, options); + + // 401 — attempt a token refresh and retry once + if (res.status === 401) { + const refreshed = await useAuthStore.getState().refresh(); + if (refreshed) { + const retry = await _doFetch(path, options); + return _handleResponse(retry); + } + // Refresh failed — throw without calling logout(). + // The central onUnauthorized handler (use-auth.ts) will take care of + // redirecting to /login if appropriate. + throw new Error('Unauthorized'); + } + + return _handleResponse(res); +} + +async function _doFetch(path: string, options?: RequestInit): Promise { const token = useAuthStore.getState().accessToken; - const res = await fetch(`${config.apiBaseUrl}/admin${path}`, { + return fetch(`${config.apiBaseUrl}/admin${path}`, { ...options, headers: { 'Content-Type': 'application/json', @@ -12,8 +43,10 @@ export async function adminFetch(path: string, options?: RequestInit): Promis ...options?.headers, }, }); +} + +async function _handleResponse(res: Response): Promise { if (res.status === 401 || res.status === 403) { - useAuthStore.getState().logout(); throw new Error('Unauthorized'); } if (!res.ok) { diff --git a/ui/src/auth/RequireAdmin.tsx b/ui/src/auth/RequireAdmin.tsx index ca31947e..124be772 100644 --- a/ui/src/auth/RequireAdmin.tsx +++ b/ui/src/auth/RequireAdmin.tsx @@ -1,8 +1,24 @@ import { Navigate, Outlet } from 'react-router'; -import { useIsAdmin } from './auth-store'; +import { useAuthStore, useIsAdmin } from './auth-store'; +/** + * Route guard for admin pages. + * + * Redirects non-admin users to '/'. The guard is intentionally lenient when + * the user is authenticated but their roles array is transiently empty (e.g. + * during a token refresh). In that case we render nothing rather than + * navigating away — the refresh will restore the roles within milliseconds and + * avoid losing unsaved form state on admin pages. + */ export function RequireAdmin() { const isAdmin = useIsAdmin(); + const roles = useAuthStore((s) => s.roles); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + // Authenticated but roles not yet populated (token refresh in progress) — + // render nothing and wait rather than redirecting. + if (isAuthenticated && roles.length === 0) return null; + if (!isAdmin) return ; return ; }