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 +

+ +
+ setEmail(e.target.value)} + /> + + setPassword(e.target.value)} + /> + + {error !== null && ( +

+ {error} +

+ )} + + +
+
+
+ ) +} 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)