From 7143e16bf91573cfcc8692f6ca9bbd9153b94e70 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 28 Jun 2026 14:31:38 +0200 Subject: [PATCH] 52 - Add auth context, route guards, and router wiring --- frontend/package-lock.json | 30 ++++++- frontend/package.json | 3 +- frontend/src/App.tsx | 38 ++++++++- frontend/src/auth/AuthProvider.test.tsx | 75 +++++++++++++++++ frontend/src/auth/AuthProvider.tsx | 67 +++++++++++++++ frontend/src/auth/guards.test.tsx | 103 ++++++++++++++++++++++++ frontend/src/auth/guards.tsx | 44 ++++++++++ frontend/src/main.tsx | 10 ++- 8 files changed, 363 insertions(+), 7 deletions(-) create mode 100644 frontend/src/auth/AuthProvider.test.tsx create mode 100644 frontend/src/auth/AuthProvider.tsx create mode 100644 frontend/src/auth/guards.test.tsx create mode 100644 frontend/src/auth/guards.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a097490..d62a8ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router": "^8.0.1" }, "devDependencies": { "@tailwindcss/vite": "^4.3.1", @@ -1862,6 +1863,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2814,6 +2821,27 @@ "license": "MIT", "peer": true }, + "node_modules/react-router": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.0.1.tgz", + "integrity": "sha512-5EL/fANovVUhRK50NLS8RYfX0BxrimoKsHWUPPy8v5UEl8i6vzF7e4POo3u+AhPItDwccUAJjMfIOmydxBJmQw==", + "license": "MIT", + "dependencies": { + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6590831..b84a714 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,8 @@ }, "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router": "^8.0.1" }, "devDependencies": { "@tailwindcss/vite": "^4.3.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a8a123..4cc3fd5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,13 @@ +import { Navigate, Route, Routes } from 'react-router' +import { RequireAuth, RequireGuest } from './auth/guards' import AppLayout from './components/layout/AppLayout' import Button from './components/ui/Button' import DigitalProgressBar from './components/ui/DigitalProgressBar' import Panel from './components/ui/Panel' -// Scaffold-only demo composition proving the primitives + theme render. -// Replaced by real routed pages in the frontend feature tickets (#52–#56). -function App() { +// Placeholder for the authenticated app home. The real scenario UI lands in the +// frontend feature tickets (#53–#56); for now it shows the primitives + theme. +function ScenariosPlaceholder() { return ( @@ -20,4 +22,34 @@ function App() { ) } +// Placeholders replaced by the real auth screens in #52 Stories 4 (login) and 5 +// (register). +function LoginPlaceholder() { + return
LOGIN
+} + +function RegisterPlaceholder() { + return
REGISTER
+} + +function App() { + return ( + + {/* Guest-only routes (redirect to the app if already signed in). */} + }> + } /> + } /> + + + {/* Authenticated-only routes (redirect to /login if signed out). */} + }> + } /> + + + {/* Unknown paths fall back to the app root (which re-guards). */} + } /> + + ) +} + export default App diff --git a/frontend/src/auth/AuthProvider.test.tsx b/frontend/src/auth/AuthProvider.test.tsx new file mode 100644 index 0000000..27ae4b2 --- /dev/null +++ b/frontend/src/auth/AuthProvider.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react' +import { delay, http, HttpResponse } from 'msw' +import { describe, expect, it } from 'vitest' +import { server } from '../test/server' +import { AuthProvider, useAuth } from './AuthProvider' + +/** + * Minimal context-consumer used purely to surface AuthProvider's state for + * assertions. Not part of the production component tree. + */ +function AuthProbe() { + const auth = useAuth() + + return ( +
+
{auth.status}
+
{auth.user?.email ?? ''}
+
+ ) +} + +describe('AuthProvider', () => { + it('probes /api/me on mount and exposes authenticated state', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ id: 'u1', email: 'a@b.com' }), + ), + ) + + render( + + + , + ) + + expect(await screen.findByText('authenticated')).toBeInTheDocument() + expect(screen.getByTestId('user-email')).toHaveTextContent('a@b.com') + }) + + it('treats a 401 from /api/me as unauthenticated', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ message: 'unauthorized' }, { status: 401 }), + ), + ) + + render( + + + , + ) + + expect(await screen.findByText('unauthenticated')).toBeInTheDocument() + expect(screen.getByTestId('user-email')).toHaveTextContent('') + }) + + it('does not flash a resolved status while the auth probe is loading', async () => { + server.use( + http.get('http://localhost/api/me', async () => { + await delay(50) + return HttpResponse.json({ id: 'u1', email: 'a@b.com' }) + }), + ) + + render( + + + , + ) + + expect(screen.getByTestId('status')).toHaveTextContent('loading') + + expect(await screen.findByText('authenticated')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..abd5b0b --- /dev/null +++ b/frontend/src/auth/AuthProvider.tsx @@ -0,0 +1,67 @@ +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from 'react' +import { ApiError, api } from '../lib/api' + +/** The authenticated user as returned by GET /api/me (plain JSON, not Hydra). */ +export interface AuthUser { + id: string + email: string +} + +export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' + +export interface AuthContextValue { + status: AuthStatus + user: AuthUser | null +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState('loading') + const [user, setUser] = useState(null) + + useEffect(() => { + let active = true + + // Probe the session once on mount. /api/me throws ApiError (401) when there + // is no active session — treat any failure as unauthenticated. + api + .get('/me') + .then((me) => { + if (!active) return + setUser(me) + setStatus('authenticated') + }) + .catch((error: unknown) => { + if (!active) return + // A 401 is the normal "no session" case. Anything else (network error, + // 5xx) is treated as unauthenticated too, but logged so a backend + // outage isn't silently indistinguishable from being logged out. + if (!(error instanceof ApiError) || error.status !== 401) { + console.error('Auth probe (/api/me) failed unexpectedly:', error) + } + setUser(null) + setStatus('unauthenticated') + }) + + return () => { + active = false + } + }, []) + + return {children} +} + +export function useAuth(): AuthContextValue { + const value = useContext(AuthContext) + if (value === null) { + throw new Error('useAuth must be used within an AuthProvider') + } + return value +} diff --git a/frontend/src/auth/guards.test.tsx b/frontend/src/auth/guards.test.tsx new file mode 100644 index 0000000..64dd8bd --- /dev/null +++ b/frontend/src/auth/guards.test.tsx @@ -0,0 +1,103 @@ +import { render, screen } from '@testing-library/react' +import { delay, http, HttpResponse } from 'msw' +import { MemoryRouter, Route, Routes } from 'react-router' +import { describe, expect, it } from 'vitest' +import { server } from '../test/server' +import { AuthProvider } from './AuthProvider' +import { RequireAuth, RequireGuest } from './guards' + +/** + * Minimal route tree exercising both guards, mirroring the real router + * skeleton: a guest-only /login and an auth-only app placeholder at /. + */ +function GuardedApp() { + return ( + + + }> + LOGIN} /> + + }> + SCENARIOS} /> + + + + ) +} + +describe('RequireAuth', () => { + it('redirects an unauthenticated user to /login', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ message: 'unauthorized' }, { status: 401 }), + ), + ) + + render( + + + , + ) + + expect(await screen.findByText('LOGIN')).toBeInTheDocument() + expect(screen.queryByText('SCENARIOS')).not.toBeInTheDocument() + }) + + it('renders children for an authenticated user', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ id: 'u1', email: 'a@b.com' }), + ), + ) + + render( + + + , + ) + + expect(await screen.findByText('SCENARIOS')).toBeInTheDocument() + expect(screen.queryByText('LOGIN')).not.toBeInTheDocument() + }) +}) + +describe('RequireGuest', () => { + it('redirects an authenticated user away from /login', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ id: 'u1', email: 'a@b.com' }), + ), + ) + + render( + + + , + ) + + expect(await screen.findByText('SCENARIOS')).toBeInTheDocument() + expect(screen.queryByText('LOGIN')).not.toBeInTheDocument() + }) +}) + +describe('auth probe loading state (no flash)', () => { + it('does not flash protected or login content while the auth probe is pending', async () => { + server.use( + http.get('http://localhost/api/me', async () => { + await delay(50) + return HttpResponse.json({ id: 'u1', email: 'a@b.com' }) + }), + ) + + render( + + + , + ) + + expect(screen.queryByText('SCENARIOS')).not.toBeInTheDocument() + expect(screen.queryByText('LOGIN')).not.toBeInTheDocument() + + expect(await screen.findByText('SCENARIOS')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/auth/guards.tsx b/frontend/src/auth/guards.tsx new file mode 100644 index 0000000..bacb4a6 --- /dev/null +++ b/frontend/src/auth/guards.tsx @@ -0,0 +1,44 @@ +import { Navigate, Outlet } from 'react-router' +import { useAuth } from './AuthProvider' + +/** + * Layout-route guard for authenticated users only. Use as a parent route's + * element with the protected routes nested inside (they render via ). + * + * While the auth probe is still resolving (`loading`) we render nothing — this + * prevents a flash of the login screen on a hard reload before /api/me answers. + * Once resolved: authenticated users see the nested route; everyone else is + * redirected to /login (`replace` so the guarded URL doesn't linger in history). + */ +export function RequireAuth() { + const { status } = useAuth() + + if (status === 'loading') { + return null + } + + if (status === 'unauthenticated') { + return + } + + return +} + +/** + * Layout-route guard for unauthenticated (guest) users only — the mirror of + * RequireAuth. Renders nothing while `loading` (no flash); redirects an + * `authenticated` user to the app ("/"); otherwise renders the nested route. + */ +export function RequireGuest() { + const { status } = useAuth() + + if (status === 'loading') { + return null + } + + if (status === 'authenticated') { + return + } + + return +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index dc68443..28685a8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import './theme.css' +import { BrowserRouter } from 'react-router' import App from './App.tsx' +import { AuthProvider } from './auth/AuthProvider' +import './theme.css' createRoot(document.getElementById('root')!).render( - + + + + + , )