52 - Add register screen with shared field-error parsing
This commit is contained in:
parent
fa7a7f9a2d
commit
4d641cae7f
7 changed files with 384 additions and 6 deletions
172
frontend/src/auth/RegisterPage.test.tsx
Normal file
172
frontend/src/auth/RegisterPage.test.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
93
frontend/src/auth/RegisterPage.tsx
Normal file
93
frontend/src/auth/RegisterPage.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { 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>
|
||||||
|
</Panel>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
// Placeholder replaced by the real register screen in #52 Story 5.
|
|
||||||
export default function RegisterPlaceholder() {
|
|
||||||
return <div>REGISTER</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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,6 +1,6 @@
|
||||||
import { createBrowserRouter, Navigate, type RouteObject } from 'react-router'
|
import { createBrowserRouter, Navigate, type RouteObject } from 'react-router'
|
||||||
import LoginPage from './auth/LoginPage'
|
import LoginPage from './auth/LoginPage'
|
||||||
import RegisterPlaceholder from './auth/RegisterPlaceholder'
|
import RegisterPage from './auth/RegisterPage'
|
||||||
import { RequireAuth, RequireGuest } from './auth/guards'
|
import { RequireAuth, RequireGuest } from './auth/guards'
|
||||||
import ScenariosPage from './pages/ScenariosPage'
|
import ScenariosPage from './pages/ScenariosPage'
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ export const routes: RouteObject[] = [
|
||||||
element: <RequireGuest />,
|
element: <RequireGuest />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: '/login', element: <LoginPage /> },
|
||||||
{ path: '/register', element: <RegisterPlaceholder /> },
|
{ path: '/register', element: <RegisterPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue