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;