M2-T07: build auth UI (login, session bootstrap, forced password change, logout)

- real Mantine login form -> POST /api/auth/login; 401 inline error; redirect when already authed
- ProtectedRoute: loading state, preserves intended destination, gates force_password_change
- ChangePasswordPage forced-change gate -> POST /api/auth/password
- logout control in AppLayout nav -> POST /api/auth/logout
- typed client only; vitest tests for the login flow
This commit is contained in:
2026-06-13 10:04:14 +02:00
parent 8975acc48b
commit b2e26f0b17
8 changed files with 892 additions and 31 deletions
+58 -14
View File
@@ -5,17 +5,18 @@
* MantineProvider → QueryClientProvider → BrowserRouter → SessionProvider → routes
*
* Route tree:
* /login → LoginPage (public, T07 will build the real form)
* /login → LoginPage (public)
* /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate)
* / → ProtectedRoute → AppLayout → HomePage (T09)
* /config → ProtectedRoute → AppLayout → ConfigPage (T08)
*
* AppLayout renders a minimal shell with a gear-icon nav entry for /config (§5#10).
* T07T10 slot their real pages in without touching the provider/router setup.
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
*/
import { MantineProvider } from '@mantine/core'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Link, Outlet } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
import { Button, Group } from '@mantine/core'
// Mantine requires its CSS to be imported once.
import '@mantine/core/styles.css'
@@ -25,6 +26,9 @@ import { ProtectedRoute } from './auth/ProtectedRoute'
import { LoginPage } from './pages/LoginPage'
import { HomePage } from './pages/HomePage'
import { ConfigPage } from './pages/ConfigPage'
import { ChangePasswordPage } from './pages/ChangePasswordPage'
import apiClient from './api/client'
import { useQueryClient } from '@tanstack/react-query'
// ---------------------------------------------------------------------------
// TanStack Query client (singleton, created outside render to avoid re-creation)
@@ -45,6 +49,32 @@ const queryClient = new QueryClient({
},
})
// ---------------------------------------------------------------------------
// Logout button component (needs navigate + queryClient hooks, so it's a component)
// ---------------------------------------------------------------------------
function LogoutButton() {
const navigate = useNavigate()
const qc = useQueryClient()
async function handleLogout() {
try {
await apiClient.POST('/api/auth/logout')
} catch {
// Ignore errors on logout — we clear the session regardless.
}
// Invalidate session so SessionProvider becomes unauthenticated.
await qc.invalidateQueries({ queryKey: ['session'] })
navigate('/login', { replace: true })
}
return (
<Button variant="subtle" size="xs" onClick={handleLogout} data-testid="logout-button">
Log out
</Button>
)
}
// ---------------------------------------------------------------------------
// App shell layout (used by all protected pages)
// ---------------------------------------------------------------------------
@@ -52,7 +82,7 @@ const queryClient = new QueryClient({
function AppLayout() {
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Minimal top nav — T07T10 can enhance this with Mantine AppShell */}
{/* Top nav */}
<nav
style={{
display: 'flex',
@@ -65,15 +95,19 @@ function AppLayout() {
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
Home Automation
</Link>
{/* Gear icon nav slot — links to config page (§5#10) */}
<Link
to="/config"
aria-label="Configuration"
style={{ fontSize: '1.25rem', textDecoration: 'none' }}
title="Configuration"
>
</Link>
<Group gap="xs">
{/* Gear icon nav slot — links to config page (§5#10) */}
<Link
to="/config"
aria-label="Configuration"
style={{ fontSize: '1.25rem', textDecoration: 'none' }}
title="Configuration"
>
</Link>
<LogoutButton />
</Group>
</nav>
{/* Page content */}
@@ -98,6 +132,16 @@ export default function App() {
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
{/* Forced password change — protected (must be logged in) but outside AppLayout */}
<Route
path="/change-password"
element={
<ProtectedRoute>
<ChangePasswordPage />
</ProtectedRoute>
}
/>
{/* Protected routes — all nested under AppLayout */}
<Route
element={
+24 -7
View File
@@ -1,12 +1,18 @@
/**
* ProtectedRoute — renders children when authenticated; redirects to /login otherwise.
*
* Shows nothing while the session is still loading to avoid flash-of-login.
* T07 can replace the loading placeholder with a proper spinner/skeleton.
* Additional gate (M2-T07):
* - If the authenticated user has force_password_change === true, redirect to
* /change-password instead of rendering children. This prevents access to any
* protected page until the password is changed.
* - Shows a loading spinner while the session is still resolving to avoid flash-of-login.
* - On unauthenticated access, preserves the intended destination in location.state.from
* so LoginPage can redirect back after login.
*/
import type { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { Navigate, useLocation } from 'react-router-dom'
import { Center, Loader } from '@mantine/core'
import { useSession } from './SessionProvider'
interface ProtectedRouteProps {
@@ -14,15 +20,26 @@ interface ProtectedRouteProps {
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { status } = useSession()
const { status, user } = useSession()
const location = useLocation()
if (status === 'loading') {
// Render nothing while we check the session — avoids a flash to /login.
return null
// Render a centred spinner while we check the session — avoids a flash to /login.
return (
<Center mih="100vh">
<Loader />
</Center>
)
}
if (status === 'unauthenticated') {
return <Navigate to="/login" replace />
// Preserve the intended destination so LoginPage can redirect back after login.
return <Navigate to="/login" state={{ from: location }} replace />
}
// Authenticated but forced to change password — gate all protected pages.
if (user?.force_password_change && location.pathname !== '/change-password') {
return <Navigate to="/change-password" replace />
}
return <>{children}</>
@@ -0,0 +1,193 @@
/**
* Tests for ChangePasswordPage (M2-T07 rework-1).
*
* Strategy: vi.mock the apiClient and useSession modules so we can control
* POST /api/auth/password responses and session state without a real server.
*
* Coverage:
* 1. Renders the change-password form when user has force_password_change=true.
* 2. Successful password change → navigates to '/' (proceeds into the app).
* 3. Client-side mismatch → shows error, does NOT call the API.
* 4. API 400 error → shows generic error, stays on form.
* 5. Guard: non-forced user visiting /change-password → redirected to '/'.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { ChangePasswordPage } from './ChangePasswordPage'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
POST: (...args: unknown[]) => mockPost(...args),
GET: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Mock useSession — default: forced-change user
// ---------------------------------------------------------------------------
const mockUseSession = vi.fn(() => ({
status: 'authenticated' as 'loading' | 'authenticated' | 'unauthenticated',
user: { username: 'admin', force_password_change: true } as
| null
| { username: string; force_password_change: boolean },
}))
vi.mock('../auth/SessionProvider', () => ({
useSession: () => mockUseSession(),
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderChangePw(initialPath = '/change-password') {
return renderWithProviders(<ChangePasswordPage />, {
initialPath,
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
})
}
function fillAndSubmit(currentPw: string, newPw: string, confirmPw: string) {
fireEvent.change(screen.getByTestId('current-password-input'), {
target: { value: currentPw },
})
fireEvent.change(screen.getByTestId('new-password-input'), {
target: { value: newPw },
})
fireEvent.change(screen.getByTestId('confirm-password-input'), {
target: { value: confirmPw },
})
fireEvent.submit(screen.getByTestId('change-password-form'))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ChangePasswordPage', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: authenticated user with force_password_change=true
mockUseSession.mockReturnValue({
status: 'authenticated',
user: { username: 'admin', force_password_change: true },
})
})
it('renders the change-password form for a forced-change user', () => {
renderChangePw()
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
expect(screen.getByTestId('current-password-input')).toBeInTheDocument()
expect(screen.getByTestId('new-password-input')).toBeInTheDocument()
expect(screen.getByTestId('confirm-password-input')).toBeInTheDocument()
expect(screen.getByTestId('change-password-submit')).toBeInTheDocument()
})
it('navigates to "/" after a successful password change', async () => {
// Simulate successful POST /api/auth/password
mockPost.mockResolvedValueOnce({
data: {},
response: { status: 200, ok: true },
})
renderChangePw()
fillAndSubmit('old-password', 'new-password', 'new-password')
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
})
it('calls POST /api/auth/password with the correct body', async () => {
mockPost.mockResolvedValueOnce({
data: {},
response: { status: 200, ok: true },
})
renderChangePw()
fillAndSubmit('current123', 'newpass456', 'newpass456')
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/api/auth/password', {
body: {
current_password: 'current123',
new_password: 'newpass456',
confirm_password: 'newpass456',
},
})
})
})
it('shows error and does NOT call the API when new passwords do not match', async () => {
renderChangePw()
fillAndSubmit('current-pw', 'new-pw-1', 'new-pw-2')
await waitFor(() => {
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
})
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
/do not match/i,
)
expect(mockPost).not.toHaveBeenCalled()
// Should remain on the form
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
})
it('shows generic error on API 400 and stays on form', async () => {
// Simulate 400 via ApiError throw (as the client middleware does)
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValueOnce(new ApiError(400, { detail: 'wrong password' }))
renderChangePw()
fillAndSubmit('wrong-current', 'newpass', 'newpass')
await waitFor(() => {
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
})
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
/password change failed/i,
)
// Should NOT have navigated away
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
})
it('redirects a non-forced user away from /change-password to "/"', async () => {
// A user who has already changed their password
mockUseSession.mockReturnValue({
status: 'authenticated',
user: { username: 'admin', force_password_change: false },
})
renderChangePw()
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
// The change-password form must NOT be shown
expect(screen.queryByTestId('change-password-form')).not.toBeInTheDocument()
})
})
+168
View File
@@ -0,0 +1,168 @@
/**
* ChangePasswordPage — forced password change gate (M2-T07).
*
* Shown when the authenticated user has force_password_change === true.
* Blocks access to all other pages until the password is changed.
*
* Behaviours:
* - If the current user does NOT have force_password_change, redirect to '/'
* (mirrors LoginPage's already-authenticated guard).
* - POST /api/auth/password with { current_password, new_password, confirm_password }.
* - On ApiError 400 → show a generic failure message (do not leak details).
* - On success → invalidate ['session'] so SessionProvider re-fetches with
* force_password_change=false, then navigate to '/' to enter the app.
*/
import { useState } from 'react'
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import {
Container,
Paper,
Title,
Text,
PasswordInput,
Button,
Alert,
Stack,
Center,
} from '@mantine/core'
import { useSession } from '../auth/SessionProvider'
import apiClient from '../api/client'
import { ApiError } from '../api/client'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface LocationState {
from?: { pathname: string }
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ChangePasswordPage() {
const { user } = useSession()
const navigate = useNavigate()
const location = useLocation()
const queryClient = useQueryClient()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
// Guard: if the user is authenticated but NOT in forced-change state, redirect
// to the app. This prevents a non-forced user from sitting on /change-password.
// (Mirrors LoginPage's already-authenticated redirect.)
if (user && !user.force_password_change) {
const from = (location.state as LocationState)?.from?.pathname ?? '/'
return <Navigate to={from} replace />
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
// Client-side validation: confirm passwords match before hitting the server.
if (newPassword !== confirmPassword) {
setError('New passwords do not match.')
return
}
setLoading(true)
try {
await apiClient.POST('/api/auth/password', {
body: {
current_password: currentPassword,
new_password: newPassword,
confirm_password: confirmPassword,
},
})
// Success: refresh session so force_password_change becomes false,
// then navigate into the app — the guard above (and ProtectedRoute) will
// no longer block access once the session is updated.
await queryClient.invalidateQueries({ queryKey: ['session'] })
navigate('/', { replace: true })
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
// Generic failure message — do not leak backend detail.
setError('Password change failed. Please check your current password and try again.')
} else {
setError('An unexpected error occurred. Please try again.')
}
} finally {
setLoading(false)
}
}
return (
<Center mih="100vh">
<Container size="xs" w="100%">
<Paper shadow="sm" p="xl" radius="md" withBorder>
<Title order={2} mb="xs" ta="center">
Change Password
</Title>
<Text c="dimmed" size="sm" mb="lg" ta="center">
You must change your password before continuing.
</Text>
{error && (
<Alert color="red" mb="md" role="alert" data-testid="change-password-error">
{error}
</Alert>
)}
<form onSubmit={handleSubmit} data-testid="change-password-form">
<Stack gap="md">
<PasswordInput
label="Current Password"
placeholder="Enter your current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
autoComplete="current-password"
data-testid="current-password-input"
/>
<PasswordInput
label="New Password"
placeholder="Enter your new password"
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
required
autoComplete="new-password"
data-testid="new-password-input"
/>
<PasswordInput
label="Confirm New Password"
placeholder="Confirm your new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
autoComplete="new-password"
data-testid="confirm-password-input"
/>
<Button
type="submit"
fullWidth
loading={loading}
mt="sm"
data-testid="change-password-submit"
>
Change Password
</Button>
</Stack>
</form>
</Paper>
</Container>
</Center>
)
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Tests for LoginPage (M2-T07).
*
* Strategy: vi.mock the apiClient module so we can control POST /api/auth/login
* responses without a real server. We also mock useSession so tests can control
* the authentication state.
*
* Coverage:
* 1. Renders the login form.
* 2. Successful login → invalidates session query + navigates.
* 3. 401 bad credentials → shows inline error, does not navigate.
* 4. Already-authenticated users visiting /login → redirected to '/'.
* 5. Unexpected error → shows generic error message.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { LoginPage } from './LoginPage'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
// We mock the entire api/client module. Each test can override POST as needed.
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
POST: (...args: unknown[]) => mockPost(...args),
GET: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Mock useSession — default: unauthenticated
// ---------------------------------------------------------------------------
// Typed as returning the wider union so mockReturnValue accepts all status variants.
const mockUseSession = vi.fn(() => ({
status: 'unauthenticated' as 'loading' | 'authenticated' | 'unauthenticated',
user: null as null | { username: string; force_password_change: boolean },
}))
vi.mock('../auth/SessionProvider', () => ({
useSession: () => mockUseSession(),
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderLogin(initialPath = '/login') {
return renderWithProviders(<LoginPage />, {
initialPath,
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
})
}
function fillAndSubmit(username: string, password: string) {
fireEvent.change(screen.getByTestId('username-input'), { target: { value: username } })
fireEvent.change(screen.getByTestId('password-input'), { target: { value: password } })
fireEvent.submit(screen.getByTestId('login-form'))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('LoginPage', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to unauthenticated by default
mockUseSession.mockReturnValue({ status: 'unauthenticated', user: null })
})
it('renders the login form with username and password fields', () => {
renderLogin()
expect(screen.getByTestId('login-form')).toBeInTheDocument()
expect(screen.getByTestId('username-input')).toBeInTheDocument()
expect(screen.getByTestId('password-input')).toBeInTheDocument()
expect(screen.getByTestId('login-submit')).toBeInTheDocument()
})
it('shows Sign In heading', () => {
renderLogin()
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
})
it('navigates to "/" on successful login', async () => {
// Simulate a successful POST /api/auth/login response
mockPost.mockResolvedValueOnce({
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
response: { status: 200, ok: true },
})
renderLogin()
fillAndSubmit('admin', 'correct-password')
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
})
it('calls POST /api/auth/login with the correct body', async () => {
mockPost.mockResolvedValueOnce({
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
response: { status: 200, ok: true },
})
renderLogin()
fillAndSubmit('myuser', 'mypassword')
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/api/auth/login', {
body: { username: 'myuser', password: 'mypassword' },
})
})
})
it('shows inline error on 401 and does NOT navigate', async () => {
// Simulate 401: openapi-fetch returns { data: undefined, response: { status: 401 } }
mockPost.mockResolvedValueOnce({
data: undefined,
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'wrong-password')
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
expect(screen.getByTestId('login-error')).toHaveTextContent(
/incorrect username or password/i,
)
// Should still be on the login form, not navigated away
expect(screen.getByTestId('login-form')).toBeInTheDocument()
})
it('does not include the password in the error message', async () => {
mockPost.mockResolvedValueOnce({
data: undefined,
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'super-secret-password')
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
expect(screen.getByTestId('login-error')).not.toHaveTextContent('super-secret-password')
})
it('shows generic error on unexpected network failure', async () => {
mockPost.mockRejectedValueOnce(new Error('Network error'))
renderLogin()
fillAndSubmit('admin', 'password')
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
expect(screen.getByTestId('login-error')).toHaveTextContent(/login failed/i)
})
it('redirects already-authenticated users to "/"', async () => {
mockUseSession.mockReturnValue({
status: 'authenticated',
user: { username: 'admin', force_password_change: false },
})
renderLogin()
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
})
})
+138 -10
View File
@@ -1,19 +1,147 @@
/**
* LoginPage — placeholder for M2-T07.
* LoginPage — real login form (M2-T07).
*
* T07 replaces this with the real login form (username/password → POST /api/auth/login,
* force-password-change flow, redirect to home on success).
* Behaviours:
* - Renders a Mantine form with username + password fields.
* - On submit → POST /api/auth/login via apiClient (no CSRF needed; unauthenticated endpoint).
* - On success → invalidate ['session'] so SessionProvider re-fetches, then navigate to the
* originally-requested route (from location.state.from) or fall back to '/'.
* - On 401 (bad credentials) → show an inline error without leaking the password.
* - Already-authenticated users visiting /login → redirect to '/'.
*/
import { Container, Title, Text } from '@mantine/core'
import { useState } from 'react'
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import {
Container,
Paper,
Title,
TextInput,
PasswordInput,
Button,
Alert,
Stack,
Center,
} from '@mantine/core'
import { useSession } from '../auth/SessionProvider'
import apiClient from '../api/client'
import { setCsrfToken } from '../api/csrf'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface LocationState {
from?: { pathname: string }
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function LoginPage() {
const { status } = useSession()
const navigate = useNavigate()
const location = useLocation()
const queryClient = useQueryClient()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
// Already authenticated → redirect to intended destination or home.
if (status === 'authenticated') {
const from = (location.state as LocationState)?.from?.pathname ?? '/'
return <Navigate to={from} replace />
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await apiClient.POST('/api/auth/login', {
body: { username, password },
})
if (res.response.status === 401 || !res.data) {
// Bad credentials — do not leak the password in the message.
setError('Incorrect username or password.')
return
}
// Success: store the CSRF token returned by login (same shape as session response).
if (res.data.csrf_token) {
setCsrfToken(res.data.csrf_token)
}
// Refresh session state: invalidate the ['session'] query so SessionProvider
// picks up the new authenticated state (which may include force_password_change).
await queryClient.invalidateQueries({ queryKey: ['session'] })
// Navigate to the originally-requested route or home.
const from = (location.state as LocationState)?.from?.pathname ?? '/'
navigate(from, { replace: true })
} catch {
// Any unexpected error (network, 5xx, etc.)
setError('Login failed. Please try again.')
} finally {
setLoading(false)
}
}
return (
<Container size="xs" pt="xl">
<Title order={2}>Login</Title>
<Text c="dimmed" mt="sm">
Login form implemented in M2-T07.
</Text>
</Container>
<Center mih="100vh">
<Container size="xs" w="100%">
<Paper shadow="sm" p="xl" radius="md" withBorder>
<Title order={2} mb="lg" ta="center">
Sign In
</Title>
{error && (
<Alert color="red" mb="md" role="alert" data-testid="login-error">
{error}
</Alert>
)}
<form onSubmit={handleSubmit} data-testid="login-form">
<Stack gap="md">
<TextInput
label="Username"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
required
autoComplete="username"
data-testid="username-input"
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
autoComplete="current-password"
data-testid="password-input"
/>
<Button
type="submit"
fullWidth
loading={loading}
mt="sm"
data-testid="login-submit"
>
Sign In
</Button>
</Stack>
</form>
</Paper>
</Container>
</Center>
)
}
+33
View File
@@ -1,5 +1,38 @@
/**
* Vitest global setup file.
* Imports @testing-library/jest-dom to extend vitest matchers with DOM assertions.
*
* Also polyfills browser APIs that jsdom does not implement but Mantine needs:
* - window.matchMedia (Mantine uses it for color-scheme detection)
* - ResizeObserver (Mantine uses it for responsive components)
*/
import '@testing-library/jest-dom'
// ---------------------------------------------------------------------------
// window.matchMedia polyfill (jsdom does not implement this)
// ---------------------------------------------------------------------------
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
// ---------------------------------------------------------------------------
// ResizeObserver polyfill (jsdom does not implement this)
// ---------------------------------------------------------------------------
if (typeof ResizeObserver === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(globalThis as any).ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Shared test utilities — wraps components in the providers they need.
*
* Usage:
* import { renderWithProviders } from '../test-utils'
* renderWithProviders(<LoginPage />, { initialPath: '/login' })
*/
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { MantineProvider } from '@mantine/core'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
// ---------------------------------------------------------------------------
// Provider wrapper
// ---------------------------------------------------------------------------
interface RenderOptions {
/** Initial URL path (default: '/'). */
initialPath?: string
/**
* Extra routes to register alongside the component under test.
* Useful for asserting navigation (e.g. render a /home sentinel and check
* that the component navigates there after login).
*/
routes?: Array<{ path: string; element: ReactNode }>
/**
* React-router initial entries (overrides initialPath when provided).
* Use when you need to seed location.state (e.g. from-path for redirect-after-login).
*/
initialEntries?: Array<string | { pathname: string; state?: unknown }>
}
/**
* Render `ui` inside MantineProvider + a fresh QueryClientProvider + MemoryRouter.
* SessionProvider is NOT included — tests that need session state should mock
* `GET /api/session` via vi.fn() on the apiClient or use MSW.
*/
export function renderWithProviders(ui: ReactNode, options: RenderOptions = {}) {
const { initialPath = '/', routes = [], initialEntries } = options
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const entries = initialEntries ?? [initialPath]
function Wrapper() {
return (
<MantineProvider>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={entries}>
<Routes>
<Route path={initialPath} element={ui} />
{routes.map(({ path, element }) => (
<Route key={path} path={path} element={element} />
))}
</Routes>
</MemoryRouter>
</QueryClientProvider>
</MantineProvider>
)
}
return render(<Wrapper />)
}
/**
* Create a minimal SessionProvider-less wrapper that just supplies the
* query and router context. Returns the queryClient so tests can prime it.
*/
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
}