52 - Add login screen with data-router refactor and auth-module split
This commit is contained in:
parent
2d5f7d074a
commit
fa7a7f9a2d
14 changed files with 421 additions and 94 deletions
|
|
@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// Placeholders replaced by the real auth screens in #52 Stories 4 (login) and 5
|
||||
// (register).
|
||||
function LoginPlaceholder() {
|
||||
return <div>LOGIN</div>
|
||||
}
|
||||
|
||||
function RegisterPlaceholder() {
|
||||
return <div>REGISTER</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Guest-only routes (redirect to the app if already signed in). */}
|
||||
<Route element={<RequireGuest />}>
|
||||
<Route path="/login" element={<LoginPlaceholder />} />
|
||||
<Route path="/register" element={<RegisterPlaceholder />} />
|
||||
</Route>
|
||||
|
||||
{/* Authenticated-only routes (redirect to /login if signed out). */}
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/" element={<ScenariosPlaceholder />} />
|
||||
</Route>
|
||||
|
||||
{/* Unknown paths fall back to the app root (which re-guards). */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<AuthContextValue | null>(null)
|
||||
import { AuthContext, type AuthStatus, type AuthUser } from './authContext'
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>('loading')
|
||||
|
|
@ -55,13 +35,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, [])
|
||||
|
||||
return <AuthContext value={{ status, user }}>{children}</AuthContext>
|
||||
}
|
||||
|
||||
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<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')
|
||||
}
|
||||
return value
|
||||
|
||||
return <AuthContext value={{ status, user, login }}>{children}</AuthContext>
|
||||
}
|
||||
|
|
|
|||
125
frontend/src/auth/LoginPage.test.tsx
Normal file
125
frontend/src/auth/LoginPage.test.tsx
Normal file
|
|
@ -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 (
|
||||
<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()
|
||||
})
|
||||
})
|
||||
79
frontend/src/auth/LoginPage.tsx
Normal file
79
frontend/src/auth/LoginPage.tsx
Normal file
|
|
@ -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<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>
|
||||
</Panel>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
4
frontend/src/auth/RegisterPlaceholder.tsx
Normal file
4
frontend/src/auth/RegisterPlaceholder.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Placeholder replaced by the real register screen in #52 Story 5.
|
||||
export default function RegisterPlaceholder() {
|
||||
return <div>REGISTER</div>
|
||||
}
|
||||
22
frontend/src/auth/authContext.ts
Normal file
22
frontend/src/auth/authContext.ts
Normal file
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
<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 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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
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 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
|
||||
* <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: <RegisterPlaceholder /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
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)
|
||||
Loading…
Reference in a new issue