From 0fdb928afc09ee49ffb90f600e9b007269ce0aa3 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 08:33:11 +0200 Subject: [PATCH 01/11] feat: design the not-found page with auth-aware escape routes --- web/src/main.tsx | 6 ++- web/src/routes/-not-found.spec.tsx | 67 +++++++++++++++++++++++++++ web/src/routes/-not-found.tsx | 72 +++++++++++++++++++++++++++--- 3 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 web/src/routes/-not-found.spec.tsx diff --git a/web/src/main.tsx b/web/src/main.tsx index f81cf456..d1d1b355 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -9,13 +9,15 @@ import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; import { routeTree } from './routeTree.gen'; -import notFoundRoute from './routes/-not-found'; +import NotFoundPage from './routes/-not-found'; const queryClient = new QueryClient(); const router = createRouter({ routeTree, - defaultNotFoundComponent: notFoundRoute, + // The router passes its own not-found props, which NotFoundPage doesn't take. + // Auth-aware shell selection moves to __root.tsx. + defaultNotFoundComponent: () => , }); const rootElement = document.getElementById('root')!; diff --git a/web/src/routes/-not-found.spec.tsx b/web/src/routes/-not-found.spec.tsx new file mode 100644 index 00000000..37c79275 --- /dev/null +++ b/web/src/routes/-not-found.spec.tsx @@ -0,0 +1,67 @@ +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 }), + ).toBeInTheDocument(); + }); + + 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..b3d3ac9b 100644 --- a/web/src/routes/-not-found.tsx +++ b/web/src/routes/-not-found.tsx @@ -1,12 +1,72 @@ -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

+

+ 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; From 4bf18851f46b5218c6f2ec34c6a8353ce3605229 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 08:40:11 +0200 Subject: [PATCH 02/11] feat: hide sidebar on not-found for anonymous visitors --- web/src/main.tsx | 10 +-- web/src/routes/-__root.spec.tsx | 121 ++++++++++++++++++++++++++++++++ web/src/routes/__root.tsx | 40 +++++++++++ 3 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 web/src/routes/-__root.spec.tsx diff --git a/web/src/main.tsx b/web/src/main.tsx index d1d1b355..3fc36879 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -9,15 +9,17 @@ import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; import { routeTree } from './routeTree.gen'; -import NotFoundPage from './routes/-not-found'; const queryClient = new QueryClient(); const router = createRouter({ routeTree, - // The router passes its own not-found props, which NotFoundPage doesn't take. - // Auth-aware shell selection moves to __root.tsx. - defaultNotFoundComponent: () => , + // __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..f1748fce --- /dev/null +++ b/web/src/routes/-__root.spec.tsx @@ -0,0 +1,121 @@ +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', () => ({ + useAuthStatus: () => useAuthStatusMock(), +})); + +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; + +const notFoundState = { + matches: [{ 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('hides the sidebar while auth status is still loading', () => { + routerStateMock.mockReturnValue(notFoundState); + useAuthStatusMock.mockReturnValue({ data: undefined, isLoading: true }); + render(); + expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument(); + }); + + 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/__root.tsx b/web/src/routes/__root.tsx index 3399e736..b7371952 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,47 @@ 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. + const { data: authStatus } = useAuthStatus(); 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; + + if (!isAuthed) { + return ( + +
+ + +
+
+ ); + } + + return ( + +
+ +
+ +
+ +
+
+ ); + } + if (usePublicShell) { return ( From 83ee7aff2d67f83393a51ccc54e70489755881c9 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 09:05:42 +0200 Subject: [PATCH 03/11] fix: centre the not-found column and drop its duplicate h1 --- web/src/routes/-not-found.spec.tsx | 8 +++++++- web/src/routes/-not-found.tsx | 7 ++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/web/src/routes/-not-found.spec.tsx b/web/src/routes/-not-found.spec.tsx index 37c79275..121543b4 100644 --- a/web/src/routes/-not-found.spec.tsx +++ b/web/src/routes/-not-found.spec.tsx @@ -18,10 +18,16 @@ describe('NotFoundPage', () => { it('shows the heading and copy', () => { render(); expect( - screen.getByRole('heading', { name: /page not found/i }), + screen.getByRole('heading', { name: /page not found/i, level: 2 }), ).toBeInTheDocument(); }); + // The anonymous variant renders , which owns the page's only h1. + it('leaves the single h1 to the logo when anonymous', () => { + 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( diff --git a/web/src/routes/-not-found.tsx b/web/src/routes/-not-found.tsx index b3d3ac9b..3108522c 100644 --- a/web/src/routes/-not-found.tsx +++ b/web/src/routes/-not-found.tsx @@ -15,7 +15,7 @@ const NotFoundPage = ({ isAuthenticated = false }: NotFoundPageProps) => { const router = useRouter(); return ( -
+
{!isAuthenticated && ( {

404

-

+ {/* h2, not h1: the anonymous variant's already renders the page's h1. */} +

Page not found -

+

This page doesn't exist, or it moved somewhere else. Check the address, or head back to familiar ground. From bf47c2c5653862d53f9b9e8757b99a7d5b156851 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 10:43:22 +0200 Subject: [PATCH 04/11] bug: fix 404 contrast and give the page its own h1 --- web/src/routes/-not-found.spec.tsx | 15 +++++++++------ web/src/routes/-not-found.tsx | 11 ++++++----- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/web/src/routes/-not-found.spec.tsx b/web/src/routes/-not-found.spec.tsx index 121543b4..d0fdbbf2 100644 --- a/web/src/routes/-not-found.spec.tsx +++ b/web/src/routes/-not-found.spec.tsx @@ -18,15 +18,18 @@ describe('NotFoundPage', () => { it('shows the heading and copy', () => { render(); expect( - screen.getByRole('heading', { name: /page not found/i, level: 2 }), + screen.getByRole('heading', { name: /page not found/i, level: 1 }), ).toBeInTheDocument(); }); - // The anonymous variant renders , which owns the page's only h1. - it('leaves the single h1 to the logo when anonymous', () => { - render(); - expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); - }); + // 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(); diff --git a/web/src/routes/-not-found.tsx b/web/src/routes/-not-found.tsx index 3108522c..b12c3a24 100644 --- a/web/src/routes/-not-found.tsx +++ b/web/src/routes/-not-found.tsx @@ -23,7 +23,7 @@ const NotFoundPage = ({ isAuthenticated = false }: NotFoundPageProps) => { className="mb-10" data-testid="not-found-logo" > - + )} @@ -36,11 +36,12 @@ const NotFoundPage = ({ isAuthenticated = false }: NotFoundPageProps) => { />

-

404

- {/* h2, not h1: the anonymous variant's already renders the page's h1. */} -

+

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. From 7554b5baa8aae94da93d1ead0bd66aaf7151bf5b Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 10:43:22 +0200 Subject: [PATCH 05/11] tests: pin the globalNotFound branch the router really uses --- web/src/routes/-__root.spec.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/web/src/routes/-__root.spec.tsx b/web/src/routes/-__root.spec.tsx index f1748fce..d79345e4 100644 --- a/web/src/routes/-__root.spec.tsx +++ b/web/src/routes/-__root.spec.tsx @@ -49,8 +49,16 @@ vi.mock('./-not-found', () => ({ 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: 'notFound' }], + matches: [{ status: 'success', globalNotFound: true }], + location: { pathname: '/zzz' }, +}; + +const nestedNotFoundState = { + matches: [{ status: 'success' }, { status: 'notFound' }], location: { pathname: '/zzz' }, }; @@ -71,6 +79,17 @@ describe('RootComponent not-found shell', () => { 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(); + }); + it('hides the sidebar while auth status is still loading', () => { routerStateMock.mockReturnValue(notFoundState); useAuthStatusMock.mockReturnValue({ data: undefined, isLoading: true }); From d6262159a6e61e8fbaafcde5839e873251021e4b Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 13:42:59 +0200 Subject: [PATCH 06/11] bug: keep anonymous visitors on the not-found page instead of /login --- web/src/hooks/auth/useServices.ts | 20 +++++-- web/src/routes/__root.tsx | 5 +- web/src/services/axiosInstance.spec.ts | 81 ++++++++++++++++++++++++++ web/src/services/axiosInstance.ts | 19 +++++- web/src/utils/auth.guards.spec.ts | 26 +++++++++ web/src/utils/auth.guards.ts | 17 +++++- 6 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 web/src/services/axiosInstance.spec.ts 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/routes/__root.tsx b/web/src/routes/__root.tsx index b7371952..4a4255a4 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -37,7 +37,10 @@ const RootComponent = () => { s.matches.some((m) => m.status === 'notFound' || m.globalNotFound), }); // 404s run no route guard, so AuthContext never populates — read the query. - const { data: authStatus } = useAuthStatus(); + // `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. + const { data: authStatus } = useAuthStatus({ anonymousAllowed: true }); const { isAuthenticated } = useAuth(); const usePublicShell = PUBLIC_SHELL_PATHS.has(pathname); const usePublicNav = PUBLIC_NAV_PATHS.has(pathname) && !isAuthenticated; 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; From aa5a8a6ca2adfe41f213481b821139e607b581e5 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 14:14:24 +0200 Subject: [PATCH 07/11] bug: stop the root shell fetching auth status twice per page --- web/src/hooks/auth/useAuthStatus.spec.tsx | 97 +++++++++++++++++++++++ web/src/routes/__root.tsx | 6 +- 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 web/src/hooks/auth/useAuthStatus.spec.tsx 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/routes/__root.tsx b/web/src/routes/__root.tsx index 4a4255a4..70d69d58 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -40,7 +40,11 @@ const RootComponent = () => { // `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. - const { data: authStatus } = useAuthStatus({ anonymousAllowed: true }); + // Only on a 404: elsewhere this shares the plain cache key with the rest of the app + // (the sidebar's OrgViewFlip), rather than fetching /auth/status a second time. + const { data: authStatus } = useAuthStatus({ + anonymousAllowed: isNotFoundMatch, + }); const { isAuthenticated } = useAuth(); const usePublicShell = PUBLIC_SHELL_PATHS.has(pathname); const usePublicNav = PUBLIC_NAV_PATHS.has(pathname) && !isAuthenticated; From 0b4155cb03cef228f9cb58a9897ea6c63f35b490 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 14:32:16 +0200 Subject: [PATCH 08/11] bug: hold the not-found shell until auth status settles --- web/src/routes/-__root.spec.tsx | 46 ++++++++++++++++++++++++++------- web/src/routes/__root.tsx | 9 ++++++- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/web/src/routes/-__root.spec.tsx b/web/src/routes/-__root.spec.tsx index d79345e4..7ea035d1 100644 --- a/web/src/routes/-__root.spec.tsx +++ b/web/src/routes/-__root.spec.tsx @@ -22,7 +22,8 @@ vi.mock('@tanstack/react-router-devtools', () => ({ })); vi.mock('@/hooks/auth/useServices', () => ({ - useAuthStatus: () => useAuthStatusMock(), + // Forward args: the not-found branch depends on which variant the root asks for. + useAuthStatus: (options?: unknown) => useAuthStatusMock(options), })); vi.mock('@/components/organisms/sidebar', () => ({ @@ -65,14 +66,14 @@ const nestedNotFoundState = { describe('RootComponent not-found shell', () => { beforeEach(() => { vi.clearAllMocks(); - useAuthStatusMock.mockReturnValue({ data: undefined, isLoading: true }); + useAuthStatusMock.mockReturnValue({ data: undefined, isPending: true }); }); it('hides the sidebar for an anonymous visitor', () => { routerStateMock.mockReturnValue(notFoundState); useAuthStatusMock.mockReturnValue({ data: { isAuthenticated: false }, - isLoading: false, + isPending: false, }); render(); expect(screen.getByTestId('not-found')).toHaveTextContent('anon'); @@ -83,25 +84,52 @@ describe('RootComponent not-found shell', () => { routerStateMock.mockReturnValue(nestedNotFoundState); useAuthStatusMock.mockReturnValue({ data: { isAuthenticated: false }, - isLoading: false, + isPending: false, }); render(); expect(screen.getByTestId('not-found')).toBeInTheDocument(); expect(screen.queryByTestId('outlet')).not.toBeInTheDocument(); }); - it('hides the sidebar while auth status is still loading', () => { + // 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 auth status is still pending', () => { routerStateMock.mockReturnValue(notFoundState); - useAuthStatusMock.mockReturnValue({ data: undefined, isLoading: true }); + useAuthStatusMock.mockReturnValue({ data: undefined, isPending: true }); render(); expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument(); + expect(screen.queryByTestId('not-found')).not.toBeInTheDocument(); + }); + + // 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 }, + isPending: false, + }); + render(); + expect(useAuthStatusMock).toHaveBeenCalledWith({ anonymousAllowed: true }); + + vi.clearAllMocks(); + routerStateMock.mockReturnValue({ + matches: [{ status: 'success' }], + location: { pathname: '/applications' }, + }); + useAuthStatusMock.mockReturnValue({ + data: { isAuthenticated: true }, + isPending: 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, + isPending: false, }); render(); expect(screen.getByTestId('sidebar')).toBeInTheDocument(); @@ -115,7 +143,7 @@ describe('RootComponent not-found shell', () => { }); useAuthStatusMock.mockReturnValue({ data: { isAuthenticated: true }, - isLoading: false, + isPending: false, }); render(); expect(screen.getByTestId('sidebar')).toBeInTheDocument(); @@ -130,7 +158,7 @@ describe('RootComponent not-found shell', () => { }); useAuthStatusMock.mockReturnValue({ data: { isAuthenticated: false }, - isLoading: false, + isPending: false, }); render(); expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument(); diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 70d69d58..954471f3 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -42,7 +42,7 @@ const RootComponent = () => { // so without this an anonymous visitor lands on /login instead of this page. // Only on a 404: elsewhere this shares the plain cache key with the rest of the app // (the sidebar's OrgViewFlip), rather than fetching /auth/status a second time. - const { data: authStatus } = useAuthStatus({ + const { data: authStatus, isPending: authPending } = useAuthStatus({ anonymousAllowed: isNotFoundMatch, }); const { isAuthenticated } = useAuth(); @@ -52,6 +52,13 @@ const RootComponent = () => { if (isNotFoundMatch) { const isAuthed = authStatus?.isAuthenticated ?? false; + // Navigating into a 404 swaps this query's cache key, so `data` is briefly + // undefined. Hold the shell until it settles: picking a variant here would show + // an authed user the anonymous page, then jump them into the sidebar layout. + if (authPending) { + return

; + } + if (!isAuthed) { return ( From a863925c6d5c9a4e2fe64150dbdcc4828ccda871 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 14:53:12 +0200 Subject: [PATCH 09/11] bug: never hang the not-found page on a paused auth query --- web/src/routes/-__root.spec.tsx | 40 ++++++++++++++++++++++++--------- web/src/routes/__root.tsx | 23 ++++++++++++++----- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/web/src/routes/-__root.spec.tsx b/web/src/routes/-__root.spec.tsx index 7ea035d1..63548a33 100644 --- a/web/src/routes/-__root.spec.tsx +++ b/web/src/routes/-__root.spec.tsx @@ -66,14 +66,14 @@ const nestedNotFoundState = { describe('RootComponent not-found shell', () => { beforeEach(() => { vi.clearAllMocks(); - useAuthStatusMock.mockReturnValue({ data: undefined, isPending: true }); + useAuthStatusMock.mockReturnValue({ data: undefined, isLoading: true }); }); it('hides the sidebar for an anonymous visitor', () => { routerStateMock.mockReturnValue(notFoundState); useAuthStatusMock.mockReturnValue({ data: { isAuthenticated: false }, - isPending: false, + isLoading: false, }); render(); expect(screen.getByTestId('not-found')).toHaveTextContent('anon'); @@ -84,7 +84,7 @@ describe('RootComponent not-found shell', () => { routerStateMock.mockReturnValue(nestedNotFoundState); useAuthStatusMock.mockReturnValue({ data: { isAuthenticated: false }, - isPending: false, + isLoading: false, }); render(); expect(screen.getByTestId('not-found')).toBeInTheDocument(); @@ -93,12 +93,32 @@ describe('RootComponent not-found shell', () => { // 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 auth status is still pending', () => { + it('renders neither variant while the auth request is in flight', () => { routerStateMock.mockReturnValue(notFoundState); - useAuthStatusMock.mockReturnValue({ data: undefined, isPending: true }); + 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 @@ -107,7 +127,7 @@ describe('RootComponent not-found shell', () => { routerStateMock.mockReturnValue(notFoundState); useAuthStatusMock.mockReturnValue({ data: { isAuthenticated: false }, - isPending: false, + isLoading: false, }); render(); expect(useAuthStatusMock).toHaveBeenCalledWith({ anonymousAllowed: true }); @@ -119,7 +139,7 @@ describe('RootComponent not-found shell', () => { }); useAuthStatusMock.mockReturnValue({ data: { isAuthenticated: true }, - isPending: false, + isLoading: false, }); render(); expect(useAuthStatusMock).toHaveBeenCalledWith({ anonymousAllowed: false }); @@ -129,7 +149,7 @@ describe('RootComponent not-found shell', () => { routerStateMock.mockReturnValue(notFoundState); useAuthStatusMock.mockReturnValue({ data: { isAuthenticated: true }, - isPending: false, + isLoading: false, }); render(); expect(screen.getByTestId('sidebar')).toBeInTheDocument(); @@ -143,7 +163,7 @@ describe('RootComponent not-found shell', () => { }); useAuthStatusMock.mockReturnValue({ data: { isAuthenticated: true }, - isPending: false, + isLoading: false, }); render(); expect(screen.getByTestId('sidebar')).toBeInTheDocument(); @@ -158,7 +178,7 @@ describe('RootComponent not-found shell', () => { }); useAuthStatusMock.mockReturnValue({ data: { isAuthenticated: false }, - isPending: false, + isLoading: false, }); render(); expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument(); diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 954471f3..b22dd8f5 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -42,7 +42,9 @@ const RootComponent = () => { // so without this an anonymous visitor lands on /login instead of this page. // Only on a 404: elsewhere this shares the plain cache key with the rest of the app // (the sidebar's OrgViewFlip), rather than fetching /auth/status a second time. - const { data: authStatus, isPending: authPending } = useAuthStatus({ + // `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(); @@ -53,10 +55,21 @@ const RootComponent = () => { const isAuthed = authStatus?.isAuthenticated ?? false; // Navigating into a 404 swaps this query's cache key, so `data` is briefly - // undefined. Hold the shell until it settles: picking a variant here would show - // an authed user the anonymous page, then jump them into the sidebar layout. - if (authPending) { - return
; + // 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) { From 44b8c92eb5d2eebc43fdeb015998a8c4461ffaa1 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 18:06:57 +0200 Subject: [PATCH 10/11] docs: correct what the not-found auth query actually shares --- web/src/routes/__root.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index b22dd8f5..3dcd42c5 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -40,8 +40,13 @@ const RootComponent = () => { // `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. - // Only on a 404: elsewhere this shares the plain cache key with the rest of the app - // (the sidebar's OrgViewFlip), rather than fetching /auth/status a second time. + // + // 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: dropping the flag once authed + // would re-run this query through the redirect-on-401 path we are avoiding here. + // // `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({ From 56128e5ab955a8a093a6fd1baaf58aaaa21ebe23 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 18:10:38 +0200 Subject: [PATCH 11/11] docs: give the real reason the auth flag stays unconditional --- web/src/routes/__root.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 3dcd42c5..18387084 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -44,8 +44,9 @@ const RootComponent = () => { // 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: dropping the flag once authed - // would re-run this query through the redirect-on-401 path we are avoiding here. + // 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.