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:
@@ -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