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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user