Compare commits
6 commits
244f9ec314
...
f761f4a791
| Author | SHA1 | Date | |
|---|---|---|---|
| f761f4a791 | |||
| bc4483821c | |||
| 4d641cae7f | |||
| fa7a7f9a2d | |||
| 2d5f7d074a | |||
| 7143e16bf9 |
24 changed files with 1374 additions and 27 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,23 +0,0 @@
|
|||
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() {
|
||||
return (
|
||||
<AppLayout>
|
||||
<Panel className="mx-auto max-w-md space-y-4">
|
||||
<h2 className="text-primary font-bold uppercase tracking-wider">
|
||||
Emergency Fund
|
||||
</h2>
|
||||
<DigitalProgressBar current={750} capacity={1000} />
|
||||
<p className="font-digital text-primary text-xl">$750 / $1000</p>
|
||||
<Button glow>+ Add Bucket</Button>
|
||||
</Panel>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
135
frontend/src/auth/AuthProvider.test.tsx
Normal file
135
frontend/src/auth/AuthProvider.test.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { delay, http, HttpResponse } from 'msw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { server } from '../test/server'
|
||||
import { AuthProvider } from './AuthProvider'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
/**
|
||||
* Minimal context-consumer used purely to surface AuthProvider's state for
|
||||
* assertions. Not part of the production component tree. Includes a button
|
||||
* wired to `logout()` so logout-driven state transitions can be exercised
|
||||
* without going through a real layout component.
|
||||
*/
|
||||
function AuthProbe() {
|
||||
const auth = useAuth()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="status">{auth.status}</div>
|
||||
<div data-testid="user-email">{auth.user?.email ?? ''}</div>
|
||||
<button onClick={() => void auth.logout()}>Log Out</button>
|
||||
</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()
|
||||
})
|
||||
|
||||
it('logout clears the authenticated state', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||
),
|
||||
http.post(
|
||||
'http://localhost/api/logout',
|
||||
() => new HttpResponse(null, { status: 204 }),
|
||||
),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<AuthProbe />
|
||||
</AuthProvider>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('authenticated')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /log ?out/i }))
|
||||
|
||||
expect(await screen.findByText('unauthenticated')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('user-email')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('clears the authenticated state even if the logout request fails', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||
),
|
||||
http.post(
|
||||
'http://localhost/api/logout',
|
||||
() => new HttpResponse(null, { status: 500 }),
|
||||
),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<AuthProbe />
|
||||
</AuthProvider>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('authenticated')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /log ?out/i }))
|
||||
|
||||
// The user is logged out client-side regardless of the network outcome.
|
||||
expect(await screen.findByText('unauthenticated')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('user-email')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
66
frontend/src/auth/AuthProvider.tsx
Normal file
66
frontend/src/auth/AuthProvider.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { ApiError, api } from '../lib/api'
|
||||
import { AuthContext, type AuthStatus, type AuthUser } from './authContext'
|
||||
|
||||
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
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function login(username: string, password: string): Promise<void> {
|
||||
// Let an ApiError (e.g. 401 on bad credentials) propagate to the caller.
|
||||
// Only flip to authenticated on success.
|
||||
const me = await api.postJson<AuthUser>('/login', { username, password })
|
||||
setUser(me)
|
||||
setStatus('authenticated')
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
// The user's intent is to leave, so clear the context REGARDLESS of the
|
||||
// network outcome — a failed request must not strand them "logged in" in
|
||||
// the UI. Worst case the session cookie lingers, but it's httpOnly and the
|
||||
// next request would 401 anyway. Clearing state makes RequireAuth redirect
|
||||
// to /login. (The firewall logout returns 204 and ignores the body.)
|
||||
try {
|
||||
await api.postJson('/logout', {})
|
||||
} catch {
|
||||
// Swallow — we log out client-side either way.
|
||||
}
|
||||
setUser(null)
|
||||
setStatus('unauthenticated')
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext value={{ status, user, login, logout }}>
|
||||
{children}
|
||||
</AuthContext>
|
||||
)
|
||||
}
|
||||
145
frontend/src/auth/LoginPage.test.tsx
Normal file
145
frontend/src/auth/LoginPage.test.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
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'
|
||||
import LoginPage from './LoginPage'
|
||||
|
||||
/**
|
||||
* Route tree mirroring the real app skeleton (App.tsx): a guest-only /login
|
||||
* rendering the real LoginPage, and an auth-only app root showing a marker so
|
||||
* we can assert the post-login navigation actually lands on authenticated
|
||||
* content.
|
||||
*/
|
||||
function AppWithLogin() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<RequireGuest />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/" element={<div>SCENARIOS</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
it('logs in with valid credentials and navigates to the app', async () => {
|
||||
let loginBody: unknown
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ message: 'unauthorized' }, { status: 401 }),
|
||||
),
|
||||
http.post('http://localhost/api/login', async ({ request }) => {
|
||||
loginBody = await request.json()
|
||||
return HttpResponse.json({ id: 'u1', email: 'a@b.com' })
|
||||
}),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/login']}>
|
||||
<AppWithLogin />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
await user.type(await screen.findByLabelText(/email/i), 'a@b.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'correct-password')
|
||||
await user.click(screen.getByRole('button', { name: /log in/i }))
|
||||
|
||||
expect(await screen.findByText('SCENARIOS')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
|
||||
|
||||
// json_login expects `username` (the email), not `email`.
|
||||
expect(loginBody).toEqual({
|
||||
username: 'a@b.com',
|
||||
password: 'correct-password',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an error and stays on login for invalid credentials', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ message: 'unauthorized' }, { status: 401 }),
|
||||
),
|
||||
http.post('http://localhost/api/login', () =>
|
||||
HttpResponse.json({ message: 'unauthorized' }, { status: 401 }),
|
||||
),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/login']}>
|
||||
<AppWithLogin />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
await user.type(await screen.findByLabelText(/email/i), 'a@b.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'wrong-password')
|
||||
await user.click(screen.getByRole('button', { name: /log in/i }))
|
||||
|
||||
expect(
|
||||
await screen.findByText(/invalid|incorrect|failed/i),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('SCENARIOS')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables the submit button while the request is in flight', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ message: 'unauthorized' }, { status: 401 }),
|
||||
),
|
||||
http.post('http://localhost/api/login', async () => {
|
||||
await delay(50)
|
||||
return HttpResponse.json({ id: 'u1', email: 'a@b.com' })
|
||||
}),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/login']}>
|
||||
<AppWithLogin />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
await user.type(await screen.findByLabelText(/email/i), 'a@b.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'correct-password')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /log in/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
expect(await screen.findByText('SCENARIOS')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links to the register page', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ message: 'unauthorized' }, { status: 401 }),
|
||||
),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/login']}>
|
||||
<AppWithLogin />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
const registerLink = await screen.findByRole('link', {
|
||||
name: /register|sign up|create account/i,
|
||||
})
|
||||
|
||||
expect(registerLink).toHaveAttribute('href', '/register')
|
||||
})
|
||||
})
|
||||
87
frontend/src/auth/LoginPage.tsx
Normal file
87
frontend/src/auth/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import AppLayout from '../components/layout/AppLayout'
|
||||
import Button from '../components/ui/Button'
|
||||
import Input from '../components/ui/Input'
|
||||
import Panel from '../components/ui/Panel'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setPending(true)
|
||||
|
||||
try {
|
||||
// The email is the json_login `username`.
|
||||
await login(email, password)
|
||||
// On success the auth state flips to `authenticated` and the RequireGuest
|
||||
// guard on /login redirects to the app — no explicit navigation needed.
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof ApiError && err.status === 401
|
||||
? 'Invalid email or password.'
|
||||
: 'Login failed. Please try again.'
|
||||
setError(message)
|
||||
} finally {
|
||||
// On success this runs after the guard has already unmounted LoginPage —
|
||||
// a harmless no-op in React 18+ (the unmounted-setState warning is gone),
|
||||
// so no `active`-flag guard is needed here.
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Panel className="mx-auto max-w-sm space-y-4">
|
||||
<h2 className="text-primary font-bold uppercase tracking-wider">
|
||||
Log In
|
||||
</h2>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
{error !== null && (
|
||||
<p role="alert" className="text-destructive font-mono text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" glow disabled={pending} className="w-full">
|
||||
{pending ? 'Logging In…' : 'Log In'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
No account?{' '}
|
||||
<Link to="/register" className="text-primary underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</Panel>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
192
frontend/src/auth/RegisterPage.test.tsx
Normal file
192
frontend/src/auth/RegisterPage.test.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
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 { RequireGuest } from './guards'
|
||||
import RegisterPage from './RegisterPage'
|
||||
|
||||
/**
|
||||
* Route tree mirroring the real app skeleton: a guest-only /register rendering
|
||||
* the real RegisterPage, and a /login route showing a marker so we can assert
|
||||
* the post-register navigation actually lands there. Unlike login, a
|
||||
* successful register does NOT flip auth state — it must explicitly navigate.
|
||||
*/
|
||||
function AppWithRegister() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<RequireGuest />}>
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/login" element={<div>LOGIN</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('RegisterPage', () => {
|
||||
it('registers with valid input and redirects to the login screen', async () => {
|
||||
let registerBody: unknown
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ message: 'unauthorized' }, { status: 401 }),
|
||||
),
|
||||
http.post('http://localhost/api/register', async ({ request }) => {
|
||||
registerBody = await request.json()
|
||||
return HttpResponse.json(
|
||||
{ id: 'u1', email: 'new@example.com' },
|
||||
{ status: 201 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/register']}>
|
||||
<AppWithRegister />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
await user.type(await screen.findByLabelText(/email/i), 'new@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'correct-horse-battery')
|
||||
await user.click(screen.getByRole('button', { name: /register/i }))
|
||||
|
||||
expect(await screen.findByText('LOGIN')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
|
||||
|
||||
// The register endpoint takes email/password — NOT username (login's quirk).
|
||||
expect(registerBody).toEqual({
|
||||
email: 'new@example.com',
|
||||
password: 'correct-horse-battery',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders server-side field errors from a 422', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ message: 'unauthorized' }, { status: 401 }),
|
||||
),
|
||||
http.post('http://localhost/api/register', () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
errors: {
|
||||
email: ['This email is already registered.'],
|
||||
password: ['Too short.'],
|
||||
},
|
||||
},
|
||||
{ status: 422 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/register']}>
|
||||
<AppWithRegister />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
await user.type(await screen.findByLabelText(/email/i), 'taken@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'short')
|
||||
await user.click(screen.getByRole('button', { name: /register/i }))
|
||||
|
||||
expect(
|
||||
await screen.findByText(/this email is already registered/i),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/too short/i)).toBeInTheDocument()
|
||||
|
||||
// Still on /register — no redirect happened.
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('LOGIN')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a duplicate-email error from a 422', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ message: 'unauthorized' }, { status: 401 }),
|
||||
),
|
||||
http.post('http://localhost/api/register', () =>
|
||||
HttpResponse.json(
|
||||
{ errors: { email: ['This email is already registered.'] } },
|
||||
{ status: 422 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/register']}>
|
||||
<AppWithRegister />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
await user.type(await screen.findByLabelText(/email/i), 'taken@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'correct-horse-battery')
|
||||
await user.click(screen.getByRole('button', { name: /register/i }))
|
||||
|
||||
expect(
|
||||
await screen.findByText(/this email is already registered/i),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('LOGIN')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables the submit button while the request is in flight', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ message: 'unauthorized' }, { status: 401 }),
|
||||
),
|
||||
http.post('http://localhost/api/register', async () => {
|
||||
await delay(50)
|
||||
return HttpResponse.json(
|
||||
{ id: 'u1', email: 'new@example.com' },
|
||||
{ status: 201 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/register']}>
|
||||
<AppWithRegister />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
await user.type(await screen.findByLabelText(/email/i), 'new@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'correct-horse-battery')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /register/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
expect(await screen.findByText('LOGIN')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links to the login page', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ message: 'unauthorized' }, { status: 401 }),
|
||||
),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/register']}>
|
||||
<AppWithRegister />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
const loginLink = await screen.findByRole('link', {
|
||||
name: /log ?in|sign in/i,
|
||||
})
|
||||
|
||||
expect(loginLink).toHaveAttribute('href', '/login')
|
||||
})
|
||||
})
|
||||
100
frontend/src/auth/RegisterPage.tsx
Normal file
100
frontend/src/auth/RegisterPage.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import AppLayout from '../components/layout/AppLayout'
|
||||
import Button from '../components/ui/Button'
|
||||
import FieldError from '../components/ui/FieldError'
|
||||
import Input from '../components/ui/Input'
|
||||
import Panel from '../components/ui/Panel'
|
||||
import { api } from '../lib/api'
|
||||
import { parseFieldErrors, type FieldErrors } from '../lib/fieldErrors'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setFieldErrors({})
|
||||
setFormError(null)
|
||||
setPending(true)
|
||||
|
||||
try {
|
||||
await api.postJson('/register', { email, password })
|
||||
// Register does not establish a session — send the user to sign in.
|
||||
navigate('/login')
|
||||
} catch (err: unknown) {
|
||||
const errors = parseFieldErrors(err)
|
||||
if (errors !== null) {
|
||||
setFieldErrors(errors)
|
||||
} else {
|
||||
setFormError('Registration failed. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const emailErrors = fieldErrors.email
|
||||
const passwordErrors = fieldErrors.password
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Panel className="mx-auto max-w-sm space-y-4">
|
||||
<h2 className="text-primary font-bold uppercase tracking-wider">
|
||||
Register
|
||||
</h2>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-invalid={emailErrors !== undefined}
|
||||
aria-describedby={emailErrors ? 'email-errors' : undefined}
|
||||
/>
|
||||
<FieldError id="email-errors" messages={emailErrors} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
aria-invalid={passwordErrors !== undefined}
|
||||
aria-describedby={passwordErrors ? 'password-errors' : undefined}
|
||||
/>
|
||||
<FieldError id="password-errors" messages={passwordErrors} />
|
||||
</div>
|
||||
|
||||
{formError !== null && (
|
||||
<p role="alert" className="text-destructive font-mono text-sm">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" glow disabled={pending} className="w-full">
|
||||
{pending ? 'Registering…' : 'Register'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-primary underline">
|
||||
Log In
|
||||
</Link>
|
||||
</p>
|
||||
</Panel>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
27
frontend/src/auth/authContext.ts
Normal file
27
frontend/src/auth/authContext.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createContext } from 'react'
|
||||
|
||||
/** 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
|
||||
/**
|
||||
* Authenticate with json_login (POST /api/login). On success the context
|
||||
* becomes `authenticated`. On failure the underlying ApiError propagates so
|
||||
* the caller can surface it. `username` is the user's email.
|
||||
*/
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
/**
|
||||
* End the session (POST /api/logout) and clear the context to
|
||||
* `unauthenticated`. The RequireAuth guard then redirects to /login.
|
||||
*/
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
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 './useAuth'
|
||||
|
||||
/**
|
||||
* 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 />
|
||||
}
|
||||
10
frontend/src/auth/useAuth.ts
Normal file
10
frontend/src/auth/useAuth.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useContext } from 'react'
|
||||
import { AuthContext, type AuthContextValue } from './authContext'
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const value = useContext(AuthContext)
|
||||
if (value === null) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return value
|
||||
}
|
||||
85
frontend/src/components/layout/AuthedLayout.test.tsx
Normal file
85
frontend/src/components/layout/AuthedLayout.test.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AuthProvider } from '../../auth/AuthProvider'
|
||||
import { RequireAuth } from '../../auth/guards'
|
||||
import { server } from '../../test/server'
|
||||
import AuthedLayout from './AuthedLayout'
|
||||
|
||||
/**
|
||||
* Route tree mirroring the real app skeleton: an auth-only root rendering a
|
||||
* page built on AuthedLayout (with a SCENARIOS marker for its content), and a
|
||||
* /login route with a marker so we can assert logout's redirect actually
|
||||
* lands there.
|
||||
*/
|
||||
function AppWithAuthedLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>LOGIN</div>} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AuthedLayout>
|
||||
<div>SCENARIOS</div>
|
||||
</AuthedLayout>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AuthedLayout', () => {
|
||||
it('renders a log out control', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||
),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<AppWithAuthedLayout />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('SCENARIOS')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /log ?out|sign out/i }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('logs out and the user ends up unauthenticated', async () => {
|
||||
let logoutWasCalled = false
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||
),
|
||||
http.post('http://localhost/api/logout', () => {
|
||||
logoutWasCalled = true
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<AppWithAuthedLayout />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('SCENARIOS')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /log ?out|sign out/i }))
|
||||
|
||||
expect(await screen.findByText('LOGIN')).toBeInTheDocument()
|
||||
expect(screen.queryByText('SCENARIOS')).not.toBeInTheDocument()
|
||||
expect(logoutWasCalled).toBe(true)
|
||||
})
|
||||
})
|
||||
28
frontend/src/components/layout/AuthedLayout.tsx
Normal file
28
frontend/src/components/layout/AuthedLayout.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { useAuth } from '../../auth/useAuth'
|
||||
import Button from '../ui/Button'
|
||||
|
||||
interface AuthedLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell for authenticated pages: the BUCKETS header plus a log-out control.
|
||||
* Logout clears the auth context, which makes the RequireAuth guard redirect
|
||||
* the user to /login.
|
||||
*/
|
||||
export default function AuthedLayout({ children }: AuthedLayoutProps) {
|
||||
const { logout } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="bg-background text-foreground min-h-screen font-mono">
|
||||
<header className="glow-red border-primary flex items-center justify-between border-b-4 px-6 py-4">
|
||||
<h1 className="text-primary text-2xl font-bold uppercase tracking-widest">
|
||||
BUCKETS
|
||||
</h1>
|
||||
<Button onClick={() => void logout()}>Log Out</Button>
|
||||
</header>
|
||||
<main className="p-6">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
frontend/src/components/ui/FieldError.tsx
Normal file
26
frontend/src/components/ui/FieldError.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
interface FieldErrorProps {
|
||||
/** Used as the element id so an input can reference it via aria-describedby. */
|
||||
id?: string
|
||||
messages?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a form field's validation messages (from a 422 field-error map).
|
||||
* Renders nothing when there are no messages. Each message is a live-region
|
||||
* alert so screen readers announce it when it appears.
|
||||
*/
|
||||
export default function FieldError({ id, messages }: FieldErrorProps) {
|
||||
if (messages === undefined || messages.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ul id={id} className="text-destructive font-mono text-sm">
|
||||
{messages.map((message, index) => (
|
||||
<li key={`${message}-${index}`} role="alert">
|
||||
{message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/ui/Input.tsx
Normal file
34
frontend/src/components/ui/Input.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useId, type InputHTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Labeled text input in the terminal idiom. The label is wired to the input via
|
||||
* a generated id, so consumers (and tests) can query it by its accessible name.
|
||||
*/
|
||||
export default function Input({ label, id, className, ...props }: InputProps) {
|
||||
const generatedId = useId()
|
||||
const inputId = id ?? generatedId
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="text-primary block font-mono text-sm uppercase tracking-wide"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
className={cn(
|
||||
'border-primary text-primary focus:ring-ring w-full border-2 bg-background px-3 py-2 font-mono focus:outline-none focus:ring-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
frontend/src/lib/fieldErrors.test.ts
Normal file
46
frontend/src/lib/fieldErrors.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError } from './api'
|
||||
import { parseFieldErrors } from './fieldErrors'
|
||||
|
||||
function error422(body: unknown): ApiError {
|
||||
return new ApiError(422, JSON.stringify(body))
|
||||
}
|
||||
|
||||
describe('parseFieldErrors', () => {
|
||||
it('extracts a well-formed field-error map from a 422', () => {
|
||||
const result = parseFieldErrors(
|
||||
error422({ errors: { email: ['Already taken.'], password: ['Short.'] } }),
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
email: ['Already taken.'],
|
||||
password: ['Short.'],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for a non-ApiError', () => {
|
||||
expect(parseFieldErrors(new Error('boom'))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a non-422 ApiError', () => {
|
||||
expect(parseFieldErrors(new ApiError(500, 'server error'))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the body is not JSON', () => {
|
||||
expect(parseFieldErrors(new ApiError(422, '<html>nope</html>'))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the body has no errors key', () => {
|
||||
expect(parseFieldErrors(error422({ message: 'bad' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when a field value is a bare string, not an array', () => {
|
||||
expect(
|
||||
parseFieldErrors(error422({ errors: { email: 'taken' } })),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when a field value is an array of non-strings', () => {
|
||||
expect(parseFieldErrors(error422({ errors: { email: [42] } }))).toBeNull()
|
||||
})
|
||||
})
|
||||
45
frontend/src/lib/fieldErrors.ts
Normal file
45
frontend/src/lib/fieldErrors.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { ApiError } from './api'
|
||||
|
||||
/** Per-field validation messages: a 422 body's `{ errors: { field: [msg] } }`. */
|
||||
export type FieldErrors = Record<string, string[]>
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'string')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the `{ errors: { field: [...] } }` map from a 422 ApiError body.
|
||||
* Returns null for anything that isn't a well-formed 422 field-error response
|
||||
* (wrong status, non-ApiError, non-JSON body, or a malformed errors shape) so
|
||||
* the caller can fall back to a generic error message.
|
||||
*/
|
||||
export function parseFieldErrors(error: unknown): FieldErrors | null {
|
||||
if (!(error instanceof ApiError) || error.status !== 422) {
|
||||
return null
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(error.body)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
!('errors' in parsed) ||
|
||||
typeof parsed.errors !== 'object' ||
|
||||
parsed.errors === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Only accept the contract shape: every field maps to an array of strings.
|
||||
const entries = Object.entries(parsed.errors as Record<string, unknown>)
|
||||
if (!entries.every(([, messages]) => isStringArray(messages))) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed.errors as FieldErrors
|
||||
}
|
||||
|
|
@ -1,10 +1,17 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router'
|
||||
import { AuthProvider } from './auth/AuthProvider'
|
||||
import { router } from './router'
|
||||
import './theme.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// Global providers wrap RouterProvider (not the other way around) so that
|
||||
// router-rendered elements — including the route guards' useAuth() — resolve
|
||||
// the context. Any future top-level provider (theme, query client) goes here.
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
21
frontend/src/pages/ScenariosPage.tsx
Normal file
21
frontend/src/pages/ScenariosPage.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import AuthedLayout from '../components/layout/AuthedLayout'
|
||||
import Button from '../components/ui/Button'
|
||||
import DigitalProgressBar from '../components/ui/DigitalProgressBar'
|
||||
import Panel from '../components/ui/Panel'
|
||||
|
||||
// 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.
|
||||
export default function ScenariosPage() {
|
||||
return (
|
||||
<AuthedLayout>
|
||||
<Panel className="mx-auto max-w-md space-y-4">
|
||||
<h2 className="text-primary font-bold uppercase tracking-wider">
|
||||
Emergency Fund
|
||||
</h2>
|
||||
<DigitalProgressBar current={750} capacity={1000} />
|
||||
<p className="font-digital text-primary text-xl">$750 / $1000</p>
|
||||
<Button glow>+ Add Bucket</Button>
|
||||
</Panel>
|
||||
</AuthedLayout>
|
||||
)
|
||||
}
|
||||
73
frontend/src/router.test.tsx
Normal file
73
frontend/src/router.test.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AuthProvider } from './auth/AuthProvider'
|
||||
import { routes } from './router'
|
||||
import { server } from './test/server'
|
||||
|
||||
/**
|
||||
* Smoke tests for the REAL route-object array (`routes` from ./router), driven
|
||||
* through `createMemoryRouter`. Unlike the guard/page component tests, these
|
||||
* exercise the actual shipped config end-to-end — catch-all redirect, both
|
||||
* guards, and the real pages — so a future swapped path/element regression in
|
||||
* router.tsx is caught here instead of only in hand-built test route trees.
|
||||
*/
|
||||
describe('app router config', () => {
|
||||
it('unknown paths redirect to the app root', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||
),
|
||||
)
|
||||
|
||||
const router = createMemoryRouter(routes, {
|
||||
initialEntries: ['/some/unknown/path'],
|
||||
})
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Emergency Fund')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the /login route resolves to the login page for a guest', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ message: 'unauthorized' }, { status: 401 }),
|
||||
),
|
||||
)
|
||||
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/login'] })
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>,
|
||||
)
|
||||
|
||||
expect(await screen.findByLabelText(/email/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('an authenticated user at /login is redirected to the app', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/me', () =>
|
||||
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||
),
|
||||
)
|
||||
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/login'] })
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Emergency Fund')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
32
frontend/src/router.tsx
Normal file
32
frontend/src/router.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { createBrowserRouter, Navigate, type RouteObject } from 'react-router'
|
||||
import LoginPage from './auth/LoginPage'
|
||||
import RegisterPage from './auth/RegisterPage'
|
||||
import { RequireAuth, RequireGuest } from './auth/guards'
|
||||
import ScenariosPage from './pages/ScenariosPage'
|
||||
|
||||
/**
|
||||
* Application route tree. Guards are layout routes: their children render via
|
||||
* <Outlet /> only when the guard allows it (RequireGuest = signed-out users,
|
||||
* RequireAuth = signed-in users). Add new pages as children of the appropriate
|
||||
* guard.
|
||||
*
|
||||
* Exported as a plain array so tests can drive the real route config through
|
||||
* createMemoryRouter (see router.test.tsx).
|
||||
*/
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
element: <RequireGuest />,
|
||||
children: [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/register', element: <RegisterPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [{ path: '/', element: <ScenariosPage /> }],
|
||||
},
|
||||
// Unknown paths fall back to the app root (which re-guards).
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]
|
||||
|
||||
export const router = createBrowserRouter(routes)
|
||||
35
shell.nix
35
shell.nix
|
|
@ -126,6 +126,35 @@ pkgs.mkShell {
|
|||
$COMPOSE exec php vendor/bin/php-cs-fixer fix "$@"
|
||||
}
|
||||
|
||||
# ===================
|
||||
# FRONTEND COMMANDS (run in the frontend/Vite container)
|
||||
# ===================
|
||||
dev-fe-test() {
|
||||
# Run the Vitest suite once. Pass-through args, e.g.: dev-fe-test src/auth
|
||||
$COMPOSE exec frontend npm test "$@"
|
||||
}
|
||||
|
||||
dev-fe-test-watch() {
|
||||
$COMPOSE exec frontend npm run test:watch "$@"
|
||||
}
|
||||
|
||||
dev-fe-lint() {
|
||||
$COMPOSE exec frontend npm run lint "$@"
|
||||
}
|
||||
|
||||
dev-fe-cs() {
|
||||
# Prettier formatting check (no changes).
|
||||
$COMPOSE exec frontend npm run format:check "$@"
|
||||
}
|
||||
|
||||
dev-fe-cs-fix() {
|
||||
$COMPOSE exec frontend npm run format "$@"
|
||||
}
|
||||
|
||||
dev-fe-build() {
|
||||
$COMPOSE exec frontend npm run build "$@"
|
||||
}
|
||||
|
||||
# ===================
|
||||
# DATABASE / MIGRATION COMMANDS
|
||||
# ===================
|
||||
|
|
@ -219,6 +248,11 @@ pkgs.mkShell {
|
|||
echo " dev-stan [args] Run PHPStan static analysis"
|
||||
echo " dev-cs [args] Check code style (dry-run)"
|
||||
echo " dev-cs-fix [args] Apply code style fixes"
|
||||
echo " dev-fe-test [args] Run frontend Vitest suite"
|
||||
echo " dev-fe-lint [args] Lint frontend (oxlint)"
|
||||
echo " dev-fe-cs [args] Check frontend formatting (Prettier)"
|
||||
echo " dev-fe-cs-fix Apply frontend formatting"
|
||||
echo " dev-fe-build Build the frontend (tsc + vite build)"
|
||||
echo " dev-migrate-diff Generate a migration from entity mappings"
|
||||
echo " dev-migrate Apply migrations to dev + test DBs"
|
||||
echo " dev-migrate-prev Roll back one migration on dev + test DBs"
|
||||
|
|
@ -226,6 +260,7 @@ pkgs.mkShell {
|
|||
echo ""
|
||||
echo "Services:"
|
||||
echo " php Symfony/FrankenPHP http://localhost:8100"
|
||||
echo " frontend React/Vite SPA http://localhost:5173"
|
||||
echo " database Postgres 16 localhost:5433"
|
||||
echo ""
|
||||
'';
|
||||
|
|
|
|||
Loading…
Reference in a new issue