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",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router": "^8.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|
@ -1862,6 +1863,12 @@
|
||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
|
|
@ -2814,6 +2821,27 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router": "^8.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@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 AppLayout from './components/layout/AppLayout'
|
||||||
import Button from './components/ui/Button'
|
import Button from './components/ui/Button'
|
||||||
import DigitalProgressBar from './components/ui/DigitalProgressBar'
|
import DigitalProgressBar from './components/ui/DigitalProgressBar'
|
||||||
import Panel from './components/ui/Panel'
|
import Panel from './components/ui/Panel'
|
||||||
|
|
||||||
// Scaffold-only demo composition proving the primitives + theme render.
|
// Placeholder for the authenticated app home. The real scenario UI lands in the
|
||||||
// Replaced by real routed pages in the frontend feature tickets (#52–#56).
|
// frontend feature tickets (#53–#56); for now it shows the primitives + theme.
|
||||||
function App() {
|
function ScenariosPlaceholder() {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<Panel className="mx-auto max-w-md space-y-4">
|
<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
|
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 { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './theme.css'
|
import { BrowserRouter } from 'react-router'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { AuthProvider } from './auth/AuthProvider'
|
||||||
|
import './theme.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue