M4-T08: add two-step login and TOTP settings panel (qrcode.react)

This commit is contained in:
2026-06-21 22:42:41 +02:00
parent 51da8c5716
commit ee1264b66b
10 changed files with 1405 additions and 51 deletions
+162 -45
View File
@@ -1,13 +1,21 @@
/**
* LoginPage — real login form (M2-T07).
* LoginPage — real login form (M2-T07, extended M4-T08).
*
* Behaviours:
* - Renders a Mantine form with username + password fields.
* - Renders a Mantine form with username + password fields (step 1).
* - 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.
* - 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".
* - Already-authenticated users visiting /login → redirect to '/'.
*
* 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.
*/
import { useState } from 'react'
@@ -23,9 +31,10 @@ import {
Alert,
Stack,
Center,
Text,
} from '@mantine/core'
import { useSession } from '../auth/SessionProvider'
import apiClient from '../api/client'
import apiClient, { ApiError } from '../api/client'
import { setCsrfToken } from '../api/csrf'
// ---------------------------------------------------------------------------
@@ -36,6 +45,9 @@ interface LocationState {
from?: { pathname: string }
}
/** The two UI states for the login flow. */
type LoginStep = 'credentials' | 'totp'
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -48,6 +60,8 @@ export function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [step, setStep] = useState<LoginStep>('credentials')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
@@ -57,7 +71,11 @@ export function LoginPage() {
return <Navigate to={from} replace />
}
async function handleSubmit(e: React.FormEvent) {
// ---------------------------------------------------------------------------
// handleCredentialsSubmit — step 1
// ---------------------------------------------------------------------------
async function handleCredentialsSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
@@ -68,31 +86,84 @@ export function LoginPage() {
})
if (res.response.status === 401 || !res.data) {
// Bad credentials — do not leak the password in the message.
// 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.
setError('Incorrect username or password.')
return
}
// Success: store the CSRF token returned by login (same shape as session response).
// Success: store the CSRF token returned by login.
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).
// Refresh session state.
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.')
} 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)
}
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
return (
<Center mih="100vh">
<Container size="xs" w="100%">
@@ -107,39 +178,85 @@ export function LoginPage() {
</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"
/>
{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"
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
autoComplete="current-password"
data-testid="password-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>
<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>
)}
</Paper>
</Container>
</Center>