diff --git a/web/src/hooks/auth/useAuthStatus.spec.tsx b/web/src/hooks/auth/useAuthStatus.spec.tsx
new file mode 100644
index 00000000..9fb5839b
--- /dev/null
+++ b/web/src/hooks/auth/useAuthStatus.spec.tsx
@@ -0,0 +1,97 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useAuthStatus } from './useServices';
+
+const checkAuthStatusMock = vi.hoisted(() => vi.fn());
+
+vi.mock('@/utils/auth.guards', () => ({
+ checkAuthStatus: checkAuthStatusMock,
+}));
+
+const wrapper = (client: QueryClient) => {
+ return ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+};
+
+const newClient = () =>
+ new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+describe('useAuthStatus', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ checkAuthStatusMock.mockResolvedValue({
+ isAuthenticated: true,
+ role: null,
+ organizationId: null,
+ });
+ });
+
+ it('shares one request between callers that do not set the flag', async () => {
+ const client = newClient();
+ const { result } = renderHook(
+ () => ({ a: useAuthStatus(), b: useAuthStatus() }),
+ { wrapper: wrapper(client) },
+ );
+
+ await waitFor(() => expect(result.current.a.isSuccess).toBe(true));
+ expect(checkAuthStatusMock).toHaveBeenCalledTimes(1);
+ expect(checkAuthStatusMock).toHaveBeenCalledWith({
+ anonymousAllowed: undefined,
+ });
+ });
+
+ // `anonymousAllowed: false` must behave exactly like an unflagged caller, so a
+ // non-404 route shares the sidebar's cache entry instead of fetching twice.
+ it('shares the plain cache entry when the flag is off', async () => {
+ const client = newClient();
+ const { result } = renderHook(
+ () => ({
+ root: useAuthStatus({ anonymousAllowed: false }),
+ other: useAuthStatus(),
+ }),
+ { wrapper: wrapper(client) },
+ );
+
+ await waitFor(() => expect(result.current.root.isSuccess).toBe(true));
+ expect(checkAuthStatusMock).toHaveBeenCalledTimes(1);
+ expect(
+ client
+ .getQueryCache()
+ .findAll()
+ .map((q) => q.queryKey),
+ ).toEqual([['auth', 'status']]);
+ });
+
+ // The variants must never share a key: whichever observer fetched first would
+ // otherwise decide the flag for the other (mount-order dependent).
+ it('keeps the anonymous variant on its own cache key', async () => {
+ const client = newClient();
+ const { result } = renderHook(
+ () => ({
+ notFound: useAuthStatus({ anonymousAllowed: true }),
+ other: useAuthStatus(),
+ }),
+ { wrapper: wrapper(client) },
+ );
+
+ await waitFor(() => expect(result.current.notFound.isSuccess).toBe(true));
+ expect(
+ client
+ .getQueryCache()
+ .findAll()
+ .map((q) => q.queryKey),
+ ).toEqual(
+ expect.arrayContaining([
+ ['auth', 'status', 'anonymous'],
+ ['auth', 'status'],
+ ]),
+ );
+ expect(checkAuthStatusMock).toHaveBeenCalledWith({
+ anonymousAllowed: true,
+ });
+ });
+});
diff --git a/web/src/hooks/auth/useServices.ts b/web/src/hooks/auth/useServices.ts
index d167b681..6e3186b3 100644
--- a/web/src/hooks/auth/useServices.ts
+++ b/web/src/hooks/auth/useServices.ts
@@ -8,11 +8,23 @@ import toast from 'react-hot-toast';
const authService = new AuthService();
-/** Role/org for UI gating (B4). Guards fetch their own copy; this one is for rendering. */
-export const useAuthStatus = () => {
+/**
+ * Role/org for UI gating (B4). Guards fetch their own copy; this one is for rendering.
+ *
+ * Set `anonymousAllowed` when the caller renders for logged-out visitors, so a 401 resolves
+ * to ANONYMOUS instead of bouncing the browser to /login. See `checkAuthStatus`.
+ */
+export const useAuthStatus = ({
+ anonymousAllowed,
+}: { anonymousAllowed?: boolean } = {}) => {
return useQuery({
- queryKey: ['auth', 'status'],
- queryFn: checkAuthStatus,
+ // Distinct key per variant. Sharing one key would let whichever observer fetches
+ // first decide the flag for every other caller (mount-order dependent), leaking
+ // the anonymous no-refresh behaviour onto guarded pages.
+ queryKey: anonymousAllowed
+ ? ['auth', 'status', 'anonymous']
+ : ['auth', 'status'],
+ queryFn: () => checkAuthStatus({ anonymousAllowed }),
staleTime: 60 * 1000,
});
};
diff --git a/web/src/main.tsx b/web/src/main.tsx
index f81cf456..3fc36879 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -9,13 +9,17 @@ import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { routeTree } from './routeTree.gen';
-import notFoundRoute from './routes/-not-found';
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
- defaultNotFoundComponent: notFoundRoute,
+ // __root.tsx owns the 404: it detects the not-found match itself and renders
+ // with the auth-aware shell *instead of* . This
+ // component only renders where the router substitutes a not-found inside an
+ // — a path the root's branch never reaches — so returning null
+ // keeps the page from rendering a second time without its shell.
+ defaultNotFoundComponent: () => null,
});
const rootElement = document.getElementById('root')!;
diff --git a/web/src/routes/-__root.spec.tsx b/web/src/routes/-__root.spec.tsx
new file mode 100644
index 00000000..63548a33
--- /dev/null
+++ b/web/src/routes/-__root.spec.tsx
@@ -0,0 +1,188 @@
+import { render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { Route } from './__root';
+
+const routerStateMock = vi.fn();
+const useAuthStatusMock = vi.fn();
+
+vi.mock('@tanstack/react-router', () => ({
+ Outlet: () => ,
+ createRootRoute: (opts: any) => ({ options: opts }),
+ useRouterState: ({ select }: any) => select(routerStateMock()),
+ Link: ({ children, to, ...rest }: any) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock('@tanstack/react-router-devtools', () => ({
+ TanStackRouterDevtools: () => null,
+}));
+
+vi.mock('@/hooks/auth/useServices', () => ({
+ // Forward args: the not-found branch depends on which variant the root asks for.
+ useAuthStatus: (options?: unknown) => useAuthStatusMock(options),
+}));
+
+vi.mock('@/components/organisms/sidebar', () => ({
+ default: () => ,
+}));
+
+vi.mock('@/components/organisms/landing-nav', () => ({
+ default: () => ,
+}));
+
+vi.mock('@/contexts/AuthContext', () => ({
+ useAuth: () => ({ isAuthenticated: false }),
+}));
+
+vi.mock('@/contexts/NavigationContext', () => ({
+ NavigationProvider: ({ children }: any) => <>{children}>,
+}));
+
+vi.mock('./-not-found', () => ({
+ default: ({ isAuthenticated }: { isAuthenticated: boolean }) => (
+
{isAuthenticated ? 'authed' : 'anon'}
+ ),
+}));
+
+const RootComponent = (Route as any).options.component;
+
+// The shape the router actually produces for an unmatched URL: the root match
+// stays `success` and carries globalNotFound. A nested `notFound()` throw is the
+// `status: 'notFound'` shape instead — both must route to the 404 shell.
+const notFoundState = {
+ matches: [{ status: 'success', globalNotFound: true }],
+ location: { pathname: '/zzz' },
+};
+
+const nestedNotFoundState = {
+ matches: [{ status: 'success' }, { status: 'notFound' }],
+ location: { pathname: '/zzz' },
+};
+
+describe('RootComponent not-found shell', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useAuthStatusMock.mockReturnValue({ data: undefined, isLoading: true });
+ });
+
+ it('hides the sidebar for an anonymous visitor', () => {
+ routerStateMock.mockReturnValue(notFoundState);
+ useAuthStatusMock.mockReturnValue({
+ data: { isAuthenticated: false },
+ isLoading: false,
+ });
+ render();
+ expect(screen.getByTestId('not-found')).toHaveTextContent('anon');
+ expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
+ });
+
+ it('renders the 404 shell for a nested notFound() throw', () => {
+ routerStateMock.mockReturnValue(nestedNotFoundState);
+ useAuthStatusMock.mockReturnValue({
+ data: { isAuthenticated: false },
+ isLoading: false,
+ });
+ render();
+ expect(screen.getByTestId('not-found')).toBeInTheDocument();
+ expect(screen.queryByTestId('outlet')).not.toBeInTheDocument();
+ });
+
+ // Committing to a variant while auth is unresolved would show an authed user the
+ // anonymous page, then jump them into the sidebar layout once it settles.
+ it('renders neither variant while the auth request is in flight', () => {
+ routerStateMock.mockReturnValue(notFoundState);
+ useAuthStatusMock.mockReturnValue({ data: undefined, isLoading: true });
+ render();
+ expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('not-found')).not.toBeInTheDocument();
+ expect(screen.getByTestId('not-found-pending')).toHaveAttribute(
+ 'aria-busy',
+ 'true',
+ );
+ });
+
+ // Offline, the query pauses: `isPending` stays true forever and the fetch never runs,
+ // so waiting on it would leave a permanently blank page. `isLoading` is false while
+ // paused, so we fall through and show the anonymous page instead of hanging.
+ it('shows the anonymous page rather than hanging when the query is paused', () => {
+ routerStateMock.mockReturnValue(notFoundState);
+ useAuthStatusMock.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isPending: true,
+ isPaused: true,
+ });
+ render();
+ expect(screen.queryByTestId('not-found-pending')).not.toBeInTheDocument();
+ expect(screen.getByTestId('not-found')).toHaveTextContent('anon');
+ });
+
+ // The root must ask for the anonymous variant ONLY on a 404: elsewhere it shares
+ // the plain cache key with the sidebar instead of fetching /auth/status twice.
+ it('requests the anonymous auth variant only on a not-found match', () => {
+ routerStateMock.mockReturnValue(notFoundState);
+ useAuthStatusMock.mockReturnValue({
+ data: { isAuthenticated: false },
+ isLoading: false,
+ });
+ render();
+ expect(useAuthStatusMock).toHaveBeenCalledWith({ anonymousAllowed: true });
+
+ vi.clearAllMocks();
+ routerStateMock.mockReturnValue({
+ matches: [{ status: 'success' }],
+ location: { pathname: '/applications' },
+ });
+ useAuthStatusMock.mockReturnValue({
+ data: { isAuthenticated: true },
+ isLoading: false,
+ });
+ render();
+ expect(useAuthStatusMock).toHaveBeenCalledWith({ anonymousAllowed: false });
+ });
+
+ it('shows the sidebar for an authed user', () => {
+ routerStateMock.mockReturnValue(notFoundState);
+ useAuthStatusMock.mockReturnValue({
+ data: { isAuthenticated: true },
+ isLoading: false,
+ });
+ render();
+ expect(screen.getByTestId('sidebar')).toBeInTheDocument();
+ expect(screen.getByTestId('not-found')).toHaveTextContent('authed');
+ });
+
+ it('leaves a matched app route on the normal sidebar shell', () => {
+ routerStateMock.mockReturnValue({
+ matches: [{ status: 'success' }],
+ location: { pathname: '/applications' },
+ });
+ useAuthStatusMock.mockReturnValue({
+ data: { isAuthenticated: true },
+ isLoading: false,
+ });
+ render();
+ expect(screen.getByTestId('sidebar')).toBeInTheDocument();
+ expect(screen.getByTestId('outlet')).toBeInTheDocument();
+ expect(screen.queryByTestId('not-found')).not.toBeInTheDocument();
+ });
+
+ it('leaves a matched public route on the public shell', () => {
+ routerStateMock.mockReturnValue({
+ matches: [{ status: 'success' }],
+ location: { pathname: '/login' },
+ });
+ useAuthStatusMock.mockReturnValue({
+ data: { isAuthenticated: false },
+ isLoading: false,
+ });
+ render();
+ expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
+ expect(screen.getByTestId('outlet')).toBeInTheDocument();
+ expect(screen.queryByTestId('not-found')).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/routes/-not-found.spec.tsx b/web/src/routes/-not-found.spec.tsx
new file mode 100644
index 00000000..d0fdbbf2
--- /dev/null
+++ b/web/src/routes/-not-found.spec.tsx
@@ -0,0 +1,76 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import NotFoundPage from './-not-found';
+
+const mockBack = vi.fn();
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({ children, to, ...rest }: any) => (
+
+ {children}
+
+ ),
+ useRouter: () => ({ history: { back: mockBack } }),
+}));
+
+describe('NotFoundPage', () => {
+ it('shows the heading and copy', () => {
+ render();
+ expect(
+ screen.getByRole('heading', { name: /page not found/i, level: 1 }),
+ ).toBeInTheDocument();
+ });
+
+ // The sidebar's h1 is gone when it is collapsed, so the page owns its own.
+ it.each([false, true])(
+ 'keeps exactly one h1 when isAuthenticated=%s',
+ (isAuthenticated) => {
+ render();
+ expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
+ },
+ );
+
+ it('sends an anonymous visitor home and to login', () => {
+ render();
+ expect(screen.getByRole('link', { name: /go home/i })).toHaveAttribute(
+ 'href',
+ '/',
+ );
+ expect(screen.getByRole('link', { name: /log in/i })).toHaveAttribute(
+ 'href',
+ '/login',
+ );
+ expect(
+ screen.queryByRole('button', { name: /go back/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('sends an authed user to applications and offers going back', () => {
+ render();
+ expect(screen.getByRole('link', { name: /go home/i })).toHaveAttribute(
+ 'href',
+ '/applications',
+ );
+ expect(
+ screen.getByRole('button', { name: /go back/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('link', { name: /log in/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('goes back through router history when asked', async () => {
+ const { default: userEvent } = await import('@testing-library/user-event');
+ render();
+ await userEvent.click(screen.getByRole('button', { name: /go back/i }));
+ expect(mockBack).toHaveBeenCalledOnce();
+ });
+
+ it('shows the logo only for anonymous visitors', () => {
+ const { rerender } = render();
+ expect(screen.getByTestId('not-found-logo')).toBeInTheDocument();
+ rerender();
+ expect(screen.queryByTestId('not-found-logo')).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/routes/-not-found.tsx b/web/src/routes/-not-found.tsx
index d09a7791..b12c3a24 100644
--- a/web/src/routes/-not-found.tsx
+++ b/web/src/routes/-not-found.tsx
@@ -1,12 +1,74 @@
-import { Link } from '@tanstack/react-router';
+import { Button } from '@/components/atoms/button';
+import { Icon } from '@/components/atoms/icon';
+import Logo from '@/components/molecules/logo';
+import { Link, useRouter } from '@tanstack/react-router';
+
+interface NotFoundPageProps {
+ isAuthenticated?: boolean;
+}
+
+/**
+ * 404 page. Purely presentational — `__root.tsx` decides the surrounding shell
+ * and passes the auth state down.
+ */
+const NotFoundPage = ({ isAuthenticated = false }: NotFoundPageProps) => {
+ const router = useRouter();
-const notFoundRoute = () => {
return (
-
-
Not found!
- Go home
+
+ {!isAuthenticated && (
+
+
+
+ )}
+
+
+
+
+
+
404
+ {/* The page's only h1. The logo above uses `no-text` so it renders no
+ competing heading, and the sidebar's h1 disappears when collapsed. */}
+
+ Page not found
+
+
+ This page doesn't exist, or it moved somewhere else. Check the
+ address, or head back to familiar ground.
+
+
+
+
+
+ {isAuthenticated ? (
+
+ ) : (
+
+ )}
+
);
};
-export default notFoundRoute;
+export default NotFoundPage;
diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx
index 3399e736..18387084 100644
--- a/web/src/routes/__root.tsx
+++ b/web/src/routes/__root.tsx
@@ -2,6 +2,7 @@ import LandingNav from '@/components/organisms/landing-nav';
import Sidebar from '@/components/organisms/sidebar';
import { useAuth } from '@/contexts/AuthContext';
import { NavigationProvider } from '@/contexts/NavigationContext';
+import { useAuthStatus } from '@/hooks/auth/useServices';
import {
Outlet,
createRootRoute,
@@ -10,6 +11,8 @@ import {
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import { useState } from 'react';
+import NotFoundPage from './-not-found';
+
const PUBLIC_SHELL_PATHS = new Set([
'/',
'/login',
@@ -27,10 +30,80 @@ const RootComponent = () => {
const pathname = useRouterState({
select: (s) => s.resolvedLocation?.pathname ?? s.location.pathname,
});
+ // A 404 matches no path, so PUBLIC_SHELL_PATHS can't classify it — ask the
+ // router directly. Verified against @tanstack/router-core Matches.d.ts:49,72.
+ const isNotFoundMatch = useRouterState({
+ select: (s) =>
+ s.matches.some((m) => m.status === 'notFound' || m.globalNotFound),
+ });
+ // 404s run no route guard, so AuthContext never populates — read the query.
+ // `anonymousAllowed` stops a logged-out 401 from escalating to refresh-then-redirect:
+ // the interceptor's no-redirect list is keyed on pathname and a 404 URL is never on it,
+ // so without this an anonymous visitor lands on /login instead of this page.
+ //
+ // The flag is scoped to 404s so every other route keeps sharing the plain cache key
+ // with the rest of the app (the sidebar's OrgViewFlip) instead of fetching twice.
+ // The authed 404 does still pay one extra fetch — it holds the anonymous key while the
+ // sidebar it renders holds the plain one. Left as-is: the flag keys the cache entry, so
+ // deriving it from this query's own result would flip the key once auth resolved and
+ // re-fetch under the plain key anyway, rather than saving the request.
+ //
+ // `isLoading`, not `isPending`: pending stays true while a fetch is *paused* (offline),
+ // where the query function never runs, so the wait below would never end.
+ const { data: authStatus, isLoading: authLoading } = useAuthStatus({
+ anonymousAllowed: isNotFoundMatch,
+ });
const { isAuthenticated } = useAuth();
const usePublicShell = PUBLIC_SHELL_PATHS.has(pathname);
const usePublicNav = PUBLIC_NAV_PATHS.has(pathname) && !isAuthenticated;
+ if (isNotFoundMatch) {
+ const isAuthed = authStatus?.isAuthenticated ?? false;
+
+ // Navigating into a 404 swaps this query's cache key, so `data` is briefly
+ // undefined. Every part of this page differs by auth state (logo, CTAs, sidebar),
+ // so there is nothing neutral to show: committing to a variant here would send an
+ // authed user to the anonymous page and then jump them into the sidebar layout.
+ // Only a real in-flight fetch waits — an offline/paused query falls through and
+ // renders the anonymous variant rather than hanging on a blank page.
+ if (authLoading) {
+ return (
+
+ );
+ }
+
+ if (!isAuthed) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
if (usePublicShell) {
return (
diff --git a/web/src/services/axiosInstance.spec.ts b/web/src/services/axiosInstance.spec.ts
new file mode 100644
index 00000000..6f305c2b
--- /dev/null
+++ b/web/src/services/axiosInstance.spec.ts
@@ -0,0 +1,81 @@
+import type { AxiosRequestConfig } from 'axios';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import axiosInstance from './axiosInstance';
+
+/**
+ * Drives the real interceptor with only the transport (adapter) swapped, so the
+ * refresh-then-redirect chain is exercised rather than mocked away.
+ */
+const setLocation = (pathname: string) => {
+ const assigned: string[] = [];
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: {
+ pathname,
+ get href() {
+ return `http://localhost${pathname}`;
+ },
+ set href(value: string) {
+ assigned.push(value);
+ },
+ },
+ });
+ return assigned;
+};
+
+const unauthorized = (config: AxiosRequestConfig) =>
+ Promise.reject(
+ Object.assign(new Error('401'), {
+ isAxiosError: true,
+ config,
+ response: { status: 401, data: {}, headers: {}, config },
+ }),
+ );
+
+describe('axiosInstance 401 handling', () => {
+ const originalLocation = window.location;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: originalLocation,
+ });
+ });
+
+ it('escalates a 401 to refresh and redirects to /login on an unknown path', async () => {
+ const assigned = setLocation('/some-404-url');
+ const requested: string[] = [];
+ axiosInstance.defaults.adapter = (config) => {
+ requested.push(config.url ?? '');
+ return unauthorized(config);
+ };
+
+ await expect(axiosInstance.get('/v1/api/auth/status')).rejects.toThrow();
+
+ expect(requested).toContain('/v1/api/auth/status');
+ expect(requested.some((u) => u.includes('/refresh'))).toBe(true);
+ expect(assigned).toEqual(['/login']);
+ });
+
+ it('treats a 401 as anonymous when anonymousAllowed is set: no refresh, no redirect', async () => {
+ const assigned = setLocation('/some-404-url');
+ const requested: string[] = [];
+ axiosInstance.defaults.adapter = (config) => {
+ requested.push(config.url ?? '');
+ return unauthorized(config);
+ };
+
+ await expect(
+ axiosInstance.get('/v1/api/auth/status', { anonymousAllowed: true }),
+ ).rejects.toThrow();
+
+ expect(requested).toEqual(['/v1/api/auth/status']);
+ expect(requested.some((u) => u.includes('/refresh'))).toBe(false);
+ expect(assigned).toEqual([]);
+ });
+});
diff --git a/web/src/services/axiosInstance.ts b/web/src/services/axiosInstance.ts
index 99d80dd5..73afbc25 100644
--- a/web/src/services/axiosInstance.ts
+++ b/web/src/services/axiosInstance.ts
@@ -3,6 +3,13 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { API_BASE_URL } from '../config/env';
import { API_ROUTES } from './api';
+declare module 'axios' {
+ export interface AxiosRequestConfig {
+ /** Treat a 401 as a valid "anonymous" answer: no refresh, no /login redirect. */
+ anonymousAllowed?: boolean;
+ }
+}
+
/**
* API client: httpOnly auth cookies require withCredentials.
*
@@ -11,6 +18,11 @@ import { API_ROUTES } from './api';
* /auth/refresh is allowlisted so a failed refresh does not recurse. /auth/status is not
* allowlisted: a 401 there still attempts refresh once (session may be recoverable).
* If refresh fails, we redirect to /login except on public auth routes (avoids reload loops).
+ *
+ * Per-request `anonymousAllowed: true` opts a single call out of that escalation, for
+ * callers that render for anonymous visitors and read 401 as "not logged in" rather than
+ * "session expired". Needed because the redirect allowlist above is keyed on pathname, and
+ * a 404 URL is unknowable ahead of time — see the not-found branch in `__root.tsx`.
*/
const axiosInstance = axios.create({
baseURL: API_BASE_URL, // Dynamically determined API base URL
@@ -70,7 +82,10 @@ axiosInstance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as
- | (InternalAxiosRequestConfig & { _retry?: boolean })
+ | (InternalAxiosRequestConfig & {
+ _retry?: boolean;
+ anonymousAllowed?: boolean;
+ })
| undefined;
const requestUrl = originalRequest?.url ?? '';
@@ -81,6 +96,8 @@ axiosInstance.interceptors.response.use(
if (
error.response?.status !== 401 ||
isPublicAuthEndpoint ||
+ // Caller asked to treat 401 as a valid "you are anonymous" answer.
+ originalRequest?.anonymousAllowed ||
!originalRequest ||
originalRequest._retry
) {
diff --git a/web/src/utils/auth.guards.spec.ts b/web/src/utils/auth.guards.spec.ts
index a74078da..38af34a5 100644
--- a/web/src/utils/auth.guards.spec.ts
+++ b/web/src/utils/auth.guards.spec.ts
@@ -41,6 +41,32 @@ describe('auth.guards', () => {
emitAuthMock.mockImplementation(() => undefined);
});
+ describe('checkAuthStatus anonymousAllowed', () => {
+ it('sends an unchanged request when the flag is not set', async () => {
+ await checkAuthStatus();
+ expect(axiosGet).toHaveBeenCalledWith('/v1/api/auth/status');
+ });
+
+ it('opts the request out of refresh escalation when set', async () => {
+ await checkAuthStatus({ anonymousAllowed: true });
+ expect(axiosGet).toHaveBeenCalledWith('/v1/api/auth/status', {
+ anonymousAllowed: true,
+ });
+ });
+
+ it('never sends the flag for guards, which must keep bouncing to login', async () => {
+ getRouteConfigMock.mockReturnValue({ requiresAuth: true });
+ // Authed guards redirect by design; the assertion is about the request shape.
+ await createAuthGuard('/dashboard')();
+ await createAuthRedirectGuard('/applications')().catch(() => undefined);
+ await createPublicRouteGuard('/login')().catch(() => undefined);
+ expect(axiosGet).toHaveBeenCalledTimes(3);
+ for (const call of axiosGet.mock.calls) {
+ expect(call[1]?.anonymousAllowed).toBeUndefined();
+ }
+ });
+ });
+
describe('createAuthGuard', () => {
it('returns when route does not require auth', async () => {
getRouteConfigMock.mockReturnValue({ requiresAuth: false });
diff --git a/web/src/utils/auth.guards.ts b/web/src/utils/auth.guards.ts
index 94bc6d10..a977973d 100644
--- a/web/src/utils/auth.guards.ts
+++ b/web/src/utils/auth.guards.ts
@@ -23,10 +23,23 @@ const ANONYMOUS: AuthStatus = {
/**
* Validates authentication with the backend (HTTP-only cookie) and returns
* the caller's org role/id (B4). Anonymous → all-null AuthStatus.
+ *
+ * `anonymousAllowed` keeps a 401 from escalating to a refresh-then-redirect in the
+ * axios interceptor. Guards leave it off: they run on known paths, where a bounce to
+ * /login is the intended outcome. Callers that merely *render* for anonymous visitors
+ * (the 404 page) set it, so a logged-out visitor gets ANONYMOUS instead of /login.
*/
-export const checkAuthStatus = async (): Promise => {
+export const checkAuthStatus = async ({
+ anonymousAllowed,
+}: { anonymousAllowed?: boolean } = {}): Promise => {
try {
- const response = await axiosInstance.get('/v1/api/auth/status');
+ // Omit the config argument entirely when unset so existing callers send an
+ // unchanged request (axios turns an explicit `undefined` into `{}`).
+ const response = anonymousAllowed
+ ? await axiosInstance.get('/v1/api/auth/status', {
+ anonymousAllowed: true,
+ })
+ : await axiosInstance.get('/v1/api/auth/status');
// Backend returns { authenticated: true } with 200 status when authenticated
const isAuth = response.data?.authenticated === true;