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:
hsiegeln
2026-04-09 18:28:45 +02:00
parent 3f9fd44ea5
commit b6b93dc3cc
2 changed files with 52 additions and 3 deletions

View File

@@ -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<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 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<T>(path: string, options?: RequestInit): Promis
...options?.headers,
},
});
}
async function _handleResponse<T>(res: Response): Promise<T> {
if (res.status === 401 || res.status === 403) {
useAuthStore.getState().logout();
throw new Error('Unauthorized');
}
if (!res.ok) {