52 - Add auth context, route guards, and router wiring
This commit is contained in:
parent
244f9ec314
commit
7143e16bf9
8 changed files with 363 additions and 7 deletions
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
75
frontend/src/auth/AuthProvider.test.tsx
Normal file
75
frontend/src/auth/AuthProvider.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
67
frontend/src/auth/AuthProvider.tsx
Normal file
67
frontend/src/auth/AuthProvider.tsx
Normal 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
|
||||
}
|
||||
103
frontend/src/auth/guards.test.tsx
Normal file
103
frontend/src/auth/guards.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
44
frontend/src/auth/guards.tsx
Normal file
44
frontend/src/auth/guards.tsx
Normal 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 />
|
||||
}
|
||||
|
|
@ -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>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue