fix: prevent admin page redirect during token refresh
adminFetch called logout() directly on 401/403 responses, which cleared roles and caused RequireAdmin to redirect to /exchanges while users were editing forms. Now adminFetch attempts a token refresh before failing, and RequireAdmin tolerates a transient empty-roles state during refresh. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,40 @@
|
|||||||
import { config } from '../../../config';
|
import { config } from '../../../config';
|
||||||
import { useAuthStore } from '../../../auth/auth-store';
|
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<T>(path: string, options?: RequestInit): Promise<T> {
|
export async function adminFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
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<T>(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<T>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _doFetch(path: string, options?: RequestInit): Promise<Response> {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
const res = await fetch(`${config.apiBaseUrl}/admin${path}`, {
|
return fetch(`${config.apiBaseUrl}/admin${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -12,8 +43,10 @@ export async function adminFetch<T>(path: string, options?: RequestInit): Promis
|
|||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _handleResponse<T>(res: Response): Promise<T> {
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
useAuthStore.getState().logout();
|
|
||||||
throw new Error('Unauthorized');
|
throw new Error('Unauthorized');
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
import { Navigate, Outlet } from 'react-router';
|
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() {
|
export function RequireAdmin() {
|
||||||
const isAdmin = useIsAdmin();
|
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 <Navigate to="/" replace />;
|
if (!isAdmin) return <Navigate to="/" replace />;
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user