diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
deleted file mode 100644
index 4cc3fd5..0000000
--- a/frontend/src/App.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Navigate, Route, Routes } from 'react-router'
-import { RequireAuth, RequireGuest } from './auth/guards'
-import AppLayout from './components/layout/AppLayout'
-import Button from './components/ui/Button'
-import DigitalProgressBar from './components/ui/DigitalProgressBar'
-import Panel from './components/ui/Panel'
-
-// Placeholder for the authenticated app home. The real scenario UI lands in the
-// frontend feature tickets (#53–#56); for now it shows the primitives + theme.
-function ScenariosPlaceholder() {
- return (
-
-
-
- Emergency Fund
-
-
-
$750 / $1000
-
-
-
- )
-}
-
-// Placeholders replaced by the real auth screens in #52 Stories 4 (login) and 5
-// (register).
-function LoginPlaceholder() {
- return
LOGIN
-}
-
-function RegisterPlaceholder() {
- return
REGISTER
-}
-
-function App() {
- return (
-
- {/* Guest-only routes (redirect to the app if already signed in). */}
- }>
- } />
- } />
-
-
- {/* Authenticated-only routes (redirect to /login if signed out). */}
- }>
- } />
-
-
- {/* Unknown paths fall back to the app root (which re-guards). */}
- } />
-
- )
-}
-
-export default App
diff --git a/frontend/src/auth/AuthProvider.test.tsx b/frontend/src/auth/AuthProvider.test.tsx
index 27ae4b2..8bec3f4 100644
--- a/frontend/src/auth/AuthProvider.test.tsx
+++ b/frontend/src/auth/AuthProvider.test.tsx
@@ -2,7 +2,8 @@ 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'
+import { AuthProvider } from './AuthProvider'
+import { useAuth } from './useAuth'
/**
* Minimal context-consumer used purely to surface AuthProvider's state for
diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx
index abd5b0b..c8cbb0d 100644
--- a/frontend/src/auth/AuthProvider.tsx
+++ b/frontend/src/auth/AuthProvider.tsx
@@ -1,26 +1,6 @@
-import {
- createContext,
- useContext,
- useEffect,
- useState,
- type ReactNode,
-} from 'react'
+import { 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(null)
+import { AuthContext, type AuthStatus, type AuthUser } from './authContext'
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState('loading')
@@ -55,13 +35,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [])
- return {children}
-}
-
-export function useAuth(): AuthContextValue {
- const value = useContext(AuthContext)
- if (value === null) {
- throw new Error('useAuth must be used within an AuthProvider')
+ async function login(username: string, password: string): Promise {
+ // Let an ApiError (e.g. 401 on bad credentials) propagate to the caller.
+ // Only flip to authenticated on success.
+ const me = await api.postJson('/login', { username, password })
+ setUser(me)
+ setStatus('authenticated')
}
- return value
+
+ return {children}
}
diff --git a/frontend/src/auth/LoginPage.test.tsx b/frontend/src/auth/LoginPage.test.tsx
new file mode 100644
index 0000000..5a98fe4
--- /dev/null
+++ b/frontend/src/auth/LoginPage.test.tsx
@@ -0,0 +1,125 @@
+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 (
+
+
+ }>
+ } />
+
+ }>
+ SCENARIOS} />
+
+
+
+ )
+}
+
+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(
+
+
+ ,
+ )
+
+ 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(
+
+
+ ,
+ )
+
+ 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(
+
+
+ ,
+ )
+
+ 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()
+ })
+})
diff --git a/frontend/src/auth/LoginPage.tsx b/frontend/src/auth/LoginPage.tsx
new file mode 100644
index 0000000..ade5a94
--- /dev/null
+++ b/frontend/src/auth/LoginPage.tsx
@@ -0,0 +1,79 @@
+import { useState, type FormEvent } from 'react'
+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(null)
+ const [pending, setPending] = useState(false)
+
+ async function handleSubmit(event: FormEvent) {
+ 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 (
+
+
+
+ Log In
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/auth/RegisterPlaceholder.tsx b/frontend/src/auth/RegisterPlaceholder.tsx
new file mode 100644
index 0000000..f11a744
--- /dev/null
+++ b/frontend/src/auth/RegisterPlaceholder.tsx
@@ -0,0 +1,4 @@
+// Placeholder replaced by the real register screen in #52 Story 5.
+export default function RegisterPlaceholder() {
+ return
REGISTER
+}
diff --git a/frontend/src/auth/authContext.ts b/frontend/src/auth/authContext.ts
new file mode 100644
index 0000000..0e4850f
--- /dev/null
+++ b/frontend/src/auth/authContext.ts
@@ -0,0 +1,22 @@
+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
+}
+
+export const AuthContext = createContext(null)
diff --git a/frontend/src/auth/guards.tsx b/frontend/src/auth/guards.tsx
index bacb4a6..2129f57 100644
--- a/frontend/src/auth/guards.tsx
+++ b/frontend/src/auth/guards.tsx
@@ -1,5 +1,5 @@
import { Navigate, Outlet } from 'react-router'
-import { useAuth } from './AuthProvider'
+import { useAuth } from './useAuth'
/**
* Layout-route guard for authenticated users only. Use as a parent route's
diff --git a/frontend/src/auth/useAuth.ts b/frontend/src/auth/useAuth.ts
new file mode 100644
index 0000000..fd55cd4
--- /dev/null
+++ b/frontend/src/auth/useAuth.ts
@@ -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
+}
diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx
new file mode 100644
index 0000000..4f4bc9b
--- /dev/null
+++ b/frontend/src/components/ui/Input.tsx
@@ -0,0 +1,34 @@
+import { useId, type InputHTMLAttributes } from 'react'
+import { cn } from '../../lib/cn'
+
+interface InputProps extends InputHTMLAttributes {
+ 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 (
+
+
+
+
+ )
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 28685a8..7c73877 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,16 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
-import { BrowserRouter } from 'react-router'
-import App from './App.tsx'
+import { RouterProvider } from 'react-router'
import { AuthProvider } from './auth/AuthProvider'
+import { router } from './router'
import './theme.css'
+// 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(
-
-
-
-
-
+
+
+ ,
)
diff --git a/frontend/src/pages/ScenariosPage.tsx b/frontend/src/pages/ScenariosPage.tsx
new file mode 100644
index 0000000..7cf6785
--- /dev/null
+++ b/frontend/src/pages/ScenariosPage.tsx
@@ -0,0 +1,21 @@
+import AppLayout from '../components/layout/AppLayout'
+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 (
+
+
+
+ Emergency Fund
+
+
+
$750 / $1000
+
+
+
+ )
+}
diff --git a/frontend/src/router.test.tsx b/frontend/src/router.test.tsx
new file mode 100644
index 0000000..a4554b3
--- /dev/null
+++ b/frontend/src/router.test.tsx
@@ -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(
+
+
+ ,
+ )
+
+ 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(
+
+
+ ,
+ )
+
+ 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(
+
+
+ ,
+ )
+
+ expect(await screen.findByText('Emergency Fund')).toBeInTheDocument()
+ expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
new file mode 100644
index 0000000..13005d5
--- /dev/null
+++ b/frontend/src/router.tsx
@@ -0,0 +1,32 @@
+import { createBrowserRouter, Navigate, type RouteObject } from 'react-router'
+import LoginPage from './auth/LoginPage'
+import RegisterPlaceholder from './auth/RegisterPlaceholder'
+import { RequireAuth, RequireGuest } from './auth/guards'
+import ScenariosPage from './pages/ScenariosPage'
+
+/**
+ * Application route tree. Guards are layout routes: their children render via
+ * 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: ,
+ children: [
+ { path: '/login', element: },
+ { path: '/register', element: },
+ ],
+ },
+ {
+ element: ,
+ children: [{ path: '/', element: }],
+ },
+ // Unknown paths fall back to the app root (which re-guards).
+ { path: '*', element: },
+]
+
+export const router = createBrowserRouter(routes)