/** * Anmeldemaske mit zweistufigem Ablauf. * * Schritt 1 fragt Name und Passwort ab. Verlangt das Konto einen zweiten Faktor, * folgt Schritt 2 mit der Codeeingabe (SYNCOVA_API.md §2). */ import { useState } from 'react'; import { ApiError } from '../../api/client'; import { login, verifyMFA } from './authApi'; import type { CurrentUser } from '../../types/auth'; /** Eigenschaften der Anmeldemaske. */ export interface LoginPageProps { /** Wird nach erfolgreicher Anmeldung mit dem Benutzer aufgerufen. */ onAuthenticated: (authenticatedUser: CurrentUser) => void; } /** Schritt des Anmeldevorgangs. */ type LoginStep = 'credentials' | 'mfa'; /** Zeigt die Anmeldemaske. */ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Element { const [loginStep, setLoginStep] = useState('credentials'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [mfaCode, setMfaCode] = useState(''); const [challengeId, setChallengeId] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); /** Wandelt einen Fehler in eine verständliche Meldung (PROMPT.md §124). */ function describeError(caughtError: unknown): string { if (caughtError instanceof ApiError) { return caughtError.message; } return 'Die Anmeldung ist unerwartet fehlgeschlagen. Bitte erneut versuchen.'; } /** Verarbeitet Schritt 1: Name und Passwort. */ async function handleCredentialsSubmit(formEvent: React.FormEvent): Promise { formEvent.preventDefault(); setErrorMessage(null); setIsSubmitting(true); try { const loginResult = await login(username, password); if (loginResult.mfa_required && loginResult.challenge_id) { setChallengeId(loginResult.challenge_id); setLoginStep('mfa'); return; } if (loginResult.user) { onAuthenticated(loginResult.user); return; } // Weder Tokens noch Herausforderung: die Antwort passt nicht zum Vertrag. setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.'); } catch (caughtError) { setErrorMessage(describeError(caughtError)); // Das Passwort wird nach einem Fehlversuch geleert. setPassword(''); } finally { setIsSubmitting(false); } } /** Verarbeitet Schritt 2: den zweiten Faktor. */ async function handleMFASubmit(formEvent: React.FormEvent): Promise { formEvent.preventDefault(); if (challengeId === null) { return; } setErrorMessage(null); setIsSubmitting(true); try { const loginResult = await verifyMFA(challengeId, mfaCode); if (loginResult.user) { onAuthenticated(loginResult.user); return; } setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.'); } catch (caughtError) { setErrorMessage(describeError(caughtError)); setMfaCode(''); // Eine abgelaufene Herausforderung erfordert eine neue Anmeldung. if (caughtError instanceof ApiError && caughtError.code === 'MFA_CHALLENGE_EXPIRED') { setLoginStep('credentials'); setChallengeId(null); setPassword(''); } } finally { setIsSubmitting(false); } } return (

Syncova

{loginStep === 'credentials' ? (

Bitte anmelden

setUsername(changeEvent.target.value)} autoComplete="username" autoFocus required /> setPassword(changeEvent.target.value)} autoComplete="current-password" required /> {errorMessage && (

{errorMessage}

)}
) : (

Zweiter Faktor

Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein Wiederherstellungscode möglich.

setMfaCode(changeEvent.target.value)} // one-time-code lässt Mobilgeräte den Code aus der SMS/App vorschlagen. autoComplete="one-time-code" inputMode="text" autoFocus required /> {errorMessage && (

{errorMessage}

)}
)}
); }