52 - Add auth context, route guards, and router wiring

This commit is contained in:
myrmidex 2026-06-28 14:31:38 +02:00
parent 244f9ec314
commit 7143e16bf9
8 changed files with 363 additions and 7 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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 (
<AppLayout>
<Panel className="mx-auto max-w-md space-y-4">
@ -20,4 +22,34 @@ function App() {
)
}
// Placeholders replaced by the real auth screens in #52 Stories 4 (login) and 5
// (register).
function LoginPlaceholder() {
return <div>LOGIN</div>
}
function RegisterPlaceholder() {
return <div>REGISTER</div>
}
function App() {
return (
<Routes>
{/* Guest-only routes (redirect to the app if already signed in). */}
<Route element={<RequireGuest />}>
<Route path="/login" element={<LoginPlaceholder />} />
<Route path="/register" element={<RegisterPlaceholder />} />
</Route>
{/* Authenticated-only routes (redirect to /login if signed out). */}
<Route element={<RequireAuth />}>
<Route path="/" element={<ScenariosPlaceholder />} />
</Route>
{/* Unknown paths fall back to the app root (which re-guards). */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default App

View file

@ -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 (
<div>
<div data-testid="status">{auth.status}</div>
<div data-testid="user-email">{auth.user?.email ?? ''}</div>
</div>
)
}
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(
<AuthProvider>
<AuthProbe />
</AuthProvider>,
)
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(
<AuthProvider>
<AuthProbe />
</AuthProvider>,
)
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(
<AuthProvider>
<AuthProbe />
</AuthProvider>,
)
expect(screen.getByTestId('status')).toHaveTextContent('loading')
expect(await screen.findByText('authenticated')).toBeInTheDocument()
})
})

View file

@ -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<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AuthStatus>('loading')
const [user, setUser] = useState<AuthUser | null>(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<AuthUser>('/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 <AuthContext value={{ status, user }}>{children}</AuthContext>
}
export function useAuth(): AuthContextValue {
const value = useContext(AuthContext)
if (value === null) {
throw new Error('useAuth must be used within an AuthProvider')
}
return value
}

View file

@ -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 (
<AuthProvider>
<Routes>
<Route element={<RequireGuest />}>
<Route path="/login" element={<div>LOGIN</div>} />
</Route>
<Route element={<RequireAuth />}>
<Route path="/" element={<div>SCENARIOS</div>} />
</Route>
</Routes>
</AuthProvider>
)
}
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(
<MemoryRouter initialEntries={['/']}>
<GuardedApp />
</MemoryRouter>,
)
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(
<MemoryRouter initialEntries={['/']}>
<GuardedApp />
</MemoryRouter>,
)
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(
<MemoryRouter initialEntries={['/login']}>
<GuardedApp />
</MemoryRouter>,
)
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(
<MemoryRouter initialEntries={['/']}>
<GuardedApp />
</MemoryRouter>,
)
expect(screen.queryByText('SCENARIOS')).not.toBeInTheDocument()
expect(screen.queryByText('LOGIN')).not.toBeInTheDocument()
expect(await screen.findByText('SCENARIOS')).toBeInTheDocument()
})
})

View file

@ -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 <Outlet />).
*
* 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 <Navigate to="/login" replace />
}
return <Outlet />
}
/**
* 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 <Navigate to="/" replace />
}
return <Outlet />
}

View file

@ -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(
<StrictMode>
<App />
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
)