/** * LoginPage — real login form (M2-T07, extended M4-T08). * * Behaviours: * - 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 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' import { useNavigate, useLocation, Navigate } from 'react-router-dom' import { useQueryClient } from '@tanstack/react-query' import { Container, Paper, Title, TextInput, PasswordInput, Button, Alert, Stack, Center, Text, } from '@mantine/core' import { useSession } from '../auth/SessionProvider' import apiClient, { ApiError } from '../api/client' import { setCsrfToken } from '../api/csrf' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface LocationState { from?: { pathname: string } } /** The two UI states for the login flow. */ type LoginStep = 'credentials' | 'totp' // --------------------------------------------------------------------------- // 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 [totpCode, setTotpCode] = useState('') const [step, setStep] = useState('credentials') const [error, setError] = useState(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 } // --------------------------------------------------------------------------- // handleCredentialsSubmit — step 1 // --------------------------------------------------------------------------- async function handleCredentialsSubmit(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) { // 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. if (res.data.csrf_token) { setCsrfToken(res.data.csrf_token) } // Refresh session state. 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) } } // --------------------------------------------------------------------------- // 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 (
Sign In {error && ( {error} )} {step === 'credentials' && (
setUsername(e.currentTarget.value)} required autoComplete="username" data-testid="username-input" /> setPassword(e.currentTarget.value)} required autoComplete="current-password" data-testid="password-input" />
)} {step === 'totp' && (
Enter the 6-digit code from your authenticator app, or a recovery code. setTotpCode(e.currentTarget.value)} required autoComplete="one-time-code" inputMode="numeric" data-testid="totp-code-input" />
)}
) }