2026-06-13 09:52:56 +02:00
|
|
|
/**
|
2026-06-21 22:42:41 +02:00
|
|
|
* LoginPage — real login form (M2-T07, extended M4-T08).
|
2026-06-13 09:52:56 +02:00
|
|
|
*
|
2026-06-13 10:04:14 +02:00
|
|
|
* Behaviours:
|
2026-06-21 22:42:41 +02:00
|
|
|
* - Renders a Mantine form with username + password fields (step 1).
|
2026-06-13 10:04:14 +02:00
|
|
|
* - 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 '/'.
|
2026-06-21 22:42:41 +02:00
|
|
|
* - On 401 with totp_required=true → switch to step 2 (TOTP code entry).
|
|
|
|
|
* - On step 2 submit → POST /api/auth/login with {username, password, totp_code}.
|
|
|
|
|
* - On 401 plain (bad credentials / wrong TOTP code) → show inline error.
|
|
|
|
|
* - On 429 (rate-limited) → show "too many attempts, please try again later".
|
2026-06-13 10:04:14 +02:00
|
|
|
* - Already-authenticated users visiting /login → redirect to '/'.
|
2026-06-21 22:42:41 +02:00
|
|
|
*
|
|
|
|
|
* Security:
|
|
|
|
|
* - Password is retained in state for the step-2 re-submission but is NEVER
|
|
|
|
|
* written to localStorage, sessionStorage, or console.
|
|
|
|
|
* - totp_code / recovery codes are NEVER logged or persisted.
|
2026-06-13 09:52:56 +02:00
|
|
|
*/
|
|
|
|
|
|
2026-06-13 10:04:14 +02:00
|
|
|
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,
|
2026-06-21 22:42:41 +02:00
|
|
|
Text,
|
2026-06-13 10:04:14 +02:00
|
|
|
} from '@mantine/core'
|
|
|
|
|
import { useSession } from '../auth/SessionProvider'
|
2026-06-21 22:42:41 +02:00
|
|
|
import apiClient, { ApiError } from '../api/client'
|
2026-06-13 10:04:14 +02:00
|
|
|
import { setCsrfToken } from '../api/csrf'
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Types
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface LocationState {
|
|
|
|
|
from?: { pathname: string }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 22:42:41 +02:00
|
|
|
/** The two UI states for the login flow. */
|
|
|
|
|
type LoginStep = 'credentials' | 'totp'
|
|
|
|
|
|
2026-06-13 10:04:14 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Component
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-06-13 09:52:56 +02:00
|
|
|
|
|
|
|
|
export function LoginPage() {
|
2026-06-13 10:04:14 +02:00
|
|
|
const { status } = useSession()
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
const location = useLocation()
|
|
|
|
|
const queryClient = useQueryClient()
|
|
|
|
|
|
|
|
|
|
const [username, setUsername] = useState('')
|
|
|
|
|
const [password, setPassword] = useState('')
|
2026-06-21 22:42:41 +02:00
|
|
|
const [totpCode, setTotpCode] = useState('')
|
|
|
|
|
const [step, setStep] = useState<LoginStep>('credentials')
|
2026-06-13 10:04:14 +02:00
|
|
|
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 />
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 22:42:41 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// handleCredentialsSubmit — step 1
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async function handleCredentialsSubmit(e: React.FormEvent) {
|
2026-06-13 10:04:14 +02:00
|
|
|
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) {
|
2026-06-21 22:42:41 +02:00
|
|
|
// Check if the backend wants a TOTP code.
|
|
|
|
|
// openapi-fetch puts 401 body in res.error (not res.data).
|
|
|
|
|
// The shape is { detail: { totp_required: true } } for TOTP-required,
|
|
|
|
|
// or { detail: "invalid username or password" } for bad credentials.
|
|
|
|
|
const detail = (res.error as { detail?: unknown } | undefined)?.detail
|
|
|
|
|
if (detail && typeof detail === 'object' && (detail as { totp_required?: boolean }).totp_required === true) {
|
|
|
|
|
// Transition to step 2 — TOTP entry. Password stays in state for re-send.
|
|
|
|
|
setStep('totp')
|
|
|
|
|
setTotpCode('')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// Ordinary bad credentials.
|
2026-06-13 10:04:14 +02:00
|
|
|
setError('Incorrect username or password.')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 22:42:41 +02:00
|
|
|
// Success: store the CSRF token returned by login.
|
2026-06-13 10:04:14 +02:00
|
|
|
if (res.data.csrf_token) {
|
|
|
|
|
setCsrfToken(res.data.csrf_token)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 22:42:41 +02:00
|
|
|
// Refresh session state.
|
2026-06-13 10:04:14 +02:00
|
|
|
await queryClient.invalidateQueries({ queryKey: ['session'] })
|
|
|
|
|
const from = (location.state as LocationState)?.from?.pathname ?? '/'
|
|
|
|
|
navigate(from, { replace: true })
|
2026-06-21 22:42:41 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
if (err instanceof ApiError && err.status === 429) {
|
|
|
|
|
setError('Too many login attempts. Please try again later.')
|
|
|
|
|
} else {
|
|
|
|
|
setError('Login failed. Please try again.')
|
|
|
|
|
}
|
2026-06-13 10:04:14 +02:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 22:42:41 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// handleTotpSubmit — step 2
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async function handleTotpSubmit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
setError(null)
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await apiClient.POST('/api/auth/login', {
|
|
|
|
|
body: { username, password, totp_code: totpCode },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (res.response.status === 401 || !res.data) {
|
|
|
|
|
// Wrong code (or expired) — stay on step 2 and show error.
|
|
|
|
|
setError('Incorrect verification code. Please try again.')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Success.
|
|
|
|
|
if (res.data.csrf_token) {
|
|
|
|
|
setCsrfToken(res.data.csrf_token)
|
|
|
|
|
}
|
|
|
|
|
await queryClient.invalidateQueries({ queryKey: ['session'] })
|
|
|
|
|
const from = (location.state as LocationState)?.from?.pathname ?? '/'
|
|
|
|
|
navigate(from, { replace: true })
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err instanceof ApiError && err.status === 429) {
|
|
|
|
|
setError('Too many login attempts. Please try again later.')
|
|
|
|
|
} else {
|
|
|
|
|
setError('Login failed. Please try again.')
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Render
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-06-13 09:52:56 +02:00
|
|
|
return (
|
2026-06-13 10:04:14 +02:00
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-21 22:42:41 +02:00
|
|
|
{step === 'credentials' && (
|
|
|
|
|
<form onSubmit={handleCredentialsSubmit} 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"
|
|
|
|
|
/>
|
2026-06-13 10:04:14 +02:00
|
|
|
|
2026-06-21 22:42:41 +02:00
|
|
|
<PasswordInput
|
|
|
|
|
label="Password"
|
|
|
|
|
placeholder="Enter your password"
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
|
|
|
|
required
|
|
|
|
|
autoComplete="current-password"
|
|
|
|
|
data-testid="password-input"
|
|
|
|
|
/>
|
2026-06-13 10:04:14 +02:00
|
|
|
|
2026-06-21 22:42:41 +02:00
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
fullWidth
|
|
|
|
|
loading={loading}
|
|
|
|
|
mt="sm"
|
|
|
|
|
data-testid="login-submit"
|
|
|
|
|
>
|
|
|
|
|
Sign In
|
|
|
|
|
</Button>
|
|
|
|
|
</Stack>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{step === 'totp' && (
|
|
|
|
|
<form onSubmit={handleTotpSubmit} data-testid="login-totp-form">
|
|
|
|
|
<Stack gap="md">
|
|
|
|
|
<Text size="sm" c="dimmed">
|
|
|
|
|
Enter the 6-digit code from your authenticator app, or a recovery code.
|
|
|
|
|
</Text>
|
|
|
|
|
|
|
|
|
|
<TextInput
|
|
|
|
|
label="Verification Code"
|
|
|
|
|
placeholder="6-digit code or recovery code"
|
|
|
|
|
value={totpCode}
|
|
|
|
|
onChange={(e) => setTotpCode(e.currentTarget.value)}
|
|
|
|
|
required
|
|
|
|
|
autoComplete="one-time-code"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
data-testid="totp-code-input"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
fullWidth
|
|
|
|
|
loading={loading}
|
|
|
|
|
mt="sm"
|
|
|
|
|
data-testid="totp-submit"
|
|
|
|
|
>
|
|
|
|
|
Verify
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="subtle"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setStep('credentials')
|
|
|
|
|
setError(null)
|
|
|
|
|
setTotpCode('')
|
|
|
|
|
}}
|
|
|
|
|
data-testid="totp-back"
|
|
|
|
|
>
|
|
|
|
|
Back to login
|
|
|
|
|
</Button>
|
|
|
|
|
</Stack>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
2026-06-13 10:04:14 +02:00
|
|
|
</Paper>
|
|
|
|
|
</Container>
|
|
|
|
|
</Center>
|
2026-06-13 09:52:56 +02:00
|
|
|
)
|
|
|
|
|
}
|