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:
+58
-14
@@ -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).
|
||||
* T07–T10 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 — T07–T10 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={
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user