+
+
+
+
+ )
+}
diff --git a/frontend/src/auth/RegisterPlaceholder.tsx b/frontend/src/auth/RegisterPlaceholder.tsx
deleted file mode 100644
index f11a744..0000000
--- a/frontend/src/auth/RegisterPlaceholder.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-// Placeholder replaced by the real register screen in #52 Story 5.
-export default function RegisterPlaceholder() {
- return
REGISTER
-}
diff --git a/frontend/src/components/ui/FieldError.tsx b/frontend/src/components/ui/FieldError.tsx
new file mode 100644
index 0000000..410369b
--- /dev/null
+++ b/frontend/src/components/ui/FieldError.tsx
@@ -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 (
+
+ {messages.map((message, index) => (
+
+ {message}
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/lib/fieldErrors.test.ts b/frontend/src/lib/fieldErrors.test.ts
new file mode 100644
index 0000000..a9b1a2e
--- /dev/null
+++ b/frontend/src/lib/fieldErrors.test.ts
@@ -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, 'nope'))).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()
+ })
+})
diff --git a/frontend/src/lib/fieldErrors.ts b/frontend/src/lib/fieldErrors.ts
new file mode 100644
index 0000000..2e573fd
--- /dev/null
+++ b/frontend/src/lib/fieldErrors.ts
@@ -0,0 +1,45 @@
+import { ApiError } from './api'
+
+/** Per-field validation messages: a 422 body's `{ errors: { field: [msg] } }`. */
+export type FieldErrors = Record
+
+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)
+ if (!entries.every(([, messages]) => isStringArray(messages))) {
+ return null
+ }
+
+ return parsed.errors as FieldErrors
+}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 13005d5..d33ac3b 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -1,6 +1,6 @@
import { createBrowserRouter, Navigate, type RouteObject } from 'react-router'
import LoginPage from './auth/LoginPage'
-import RegisterPlaceholder from './auth/RegisterPlaceholder'
+import RegisterPage from './auth/RegisterPage'
import { RequireAuth, RequireGuest } from './auth/guards'
import ScenariosPage from './pages/ScenariosPage'
@@ -18,7 +18,7 @@ export const routes: RouteObject[] = [
element: ,
children: [
{ path: '/login', element: },
- { path: '/register', element: },
+ { path: '/register', element: },
],
},
{