fix: dedupe parallel admin 401 redirects to /admin/login

Visiting /admin/dashboard while logged out caused a navigation storm:
the dashboard fires ~7 /api/admin/* queries on mount, each returns 401,
each axios interceptor call did `window.location.href = '/admin/login'`.

The path-based guard `currentPath.includes('/admin/login')` reads
`location.pathname` *synchronously* — but `location.href = …` is async,
so all 7 parallel handlers saw the still-old pathname and each fired a
fresh navigation. The browser logged 6+ ERR_ABORTED entries and the user
saw a flicker storm. Same shape would bite any admin page that fans out
queries on mount.

Add a module-level `adminLoginRedirectPending` flag set the moment we
kick off the first redirect; subsequent 401s in the same tick see it
and skip. Single navigation, clean transition to login.

Smoke spec 10-admin-redirect-loop locks the regression in by sampling
the URL across 5 ticks — if any tick lands somewhere other than
/admin/login, the spec fails.
This commit is contained in:
Paul Nothaft
2026-04-27 22:38:00 +02:00
parent 2eead52319
commit 038e84cae7
+16 -3
View File
@@ -11,6 +11,12 @@ import { getApiBaseUrl } from '../utils/url';
// Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
// Set true the moment we kick off a hard redirect to /admin/login so
// subsequent 401s in the same tick don't queue more navigations on top
// (each `window.location.href = …` aborts the previous, producing a
// flicker storm — see the response interceptor below).
let adminLoginRedirectPending = false;
export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void) => {
maintenanceModeCallback = callback;
};
@@ -131,10 +137,17 @@ api.interceptors.response.use(
// Check if it's an admin route (but not public endpoints)
const isAdminRoute = error.config?.url?.includes('/admin') && !error.config?.url?.includes('/public/');
const currentPath = window.location.pathname;
if (isAdminRoute) {
// Only redirect if we're not already on the admin login page
if (!currentPath.includes('/admin/login')) {
// Only redirect if we're not already on the admin login page.
// `window.location.href = …` is async — `pathname` doesn't change
// synchronously — so a fan-out of 401s (the dashboard fires 7 admin
// queries in parallel) would each see the old pathname and each
// call `location.href`, producing a navigation storm where every
// request is aborted by the next. Guard with a module-level flag
// so only the first 401 triggers the redirect.
if (!currentPath.includes('/admin/login') && !adminLoginRedirectPending) {
adminLoginRedirectPending = true;
window.location.href = '/admin/login';
}
} else {