syncova-backup/apps/web/src/features/auth/LoginPage.tsx
Jerrit Fritzsche 6e23696fcb
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s
Weboberflaeche: letzte Seiten auf das Design-System gezogen
Damit gibt es nur noch ein Stylesheet. Uebersicht, Meldungen, Kennzahlen,
Berichte, Security Center, Wiederherstellungspunkte, Anmeldung, Backup-Assistent
und Gesundheitsanzeige nutzten noch das alte — sie funktionierten, sahen aber
anders aus als der Rest.

Der Backup-Assistent wurde als **Klassenabbildung** umgestellt, nicht neu
geschrieben: Seine Logik ist geprueft und richtig; eine Neufassung haette 500
Zeilen Verhalten ohne Not angefasst. Die elf Tests des Assistenten pruefen
Verhalten und blieben unveraendert gueltig.

Entfernt, weil ersetzt und nirgends mehr verwendet: JobsPanel, StatusIndicator,
PageState, App.css, tokens.css und vier weitere Stylesheets. Das ausgelieferte
CSS faellt von 54 auf 30 KB.

Drei Funde beim Umbau, alle von Tests aufgedeckt:

- **`describeApiError` warf die genauere Servermeldung weg.** Sie ersetzte sie
  durch den allgemeinen Satz aus der Codetabelle. Ein `SERVICE_UNAVAILABLE` mit
  der Meldung "Fuer diesen Bericht ist keine Sicherheitspruefung eingerichtet."
  wurde zu "Der Dienst ist derzeit nicht vollstaendig verfuegbar" — der
  Betreiber haette den Fehler bei seiner Anlage gesucht statt bei der
  Einrichtung dieses einen Berichts. Jetzt hat die Servermeldung Vorrang; die
  Tabelle springt nur ein, wenn keine mitkommt.
- **Einem Fehler nach einer Handlung fehlte `role="alert"`.** Ein Screenreader
  haette ihn nicht angesagt. `Callout` nimmt jetzt eine Rolle entgegen; Standard
  bleibt `note`, weil die meisten Hinweise schon beim Oeffnen dastehen.
- **Zwei Statusbeschriftungen wichen von den etablierten ab** (`Gesund` statt
  `Fehlerfrei`, `Nicht verbunden` statt `Nicht erreichbar`). Die etablierten
  gewinnen — sie stehen in Tests fest und sind treffender.

Dazu zwei kleinere Korrekturen: Die Gesundheitsanzeige hing kurzzeitig in der
Uebersicht und verband damit zwei Ladewege, die nichts miteinander zu tun haben;
sie steht jetzt wieder daneben. Und ein frueherer Regex hatte
`(row) => void | undefined` erzeugt — gemeint war eine optionale Eigenschaft,
geschrieben stand "gibt void oder undefined zurueck".

Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das
Design-System steckt im ausgelieferten CSS samt Dark-Mode-Regeln, und vom alten
Stylesheet ist kein Klassenname mehr darin.

76 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:50:15 +02:00

206 lines
7.5 KiB
TypeScript

/**
* 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<LoginStep>('credentials');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [challengeId, setChallengeId] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
/** Wandelt einen Fehler in eine verstaendliche 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<void> {
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 unvollstaendig. 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<void> {
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 unvollstaendig. 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 (
<div className="flex min-h-screen items-center justify-center bg-page p-4">
<main className="w-full max-w-sm rounded-lg border border-line bg-card p-6 shadow-sm">
<h1 className="text-lg font-semibold text-fg">Syncova</h1>
{loginStep === 'credentials' ? (
<form className="mt-5 space-y-4" onSubmit={handleCredentialsSubmit}>
<p className="mt-1 text-sm text-fg-muted">Bitte anmelden</p>
<label className="block text-sm font-medium text-fg" htmlFor="username">
Benutzername
</label>
<input
id="username"
className="mt-1.5 w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={username}
onChange={(changeEvent) => setUsername(changeEvent.target.value)}
autoComplete="username"
autoFocus
required
/>
<label className="block text-sm font-medium text-fg" htmlFor="password">
Passwort
</label>
<input
id="password"
className="mt-1.5 w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="password"
value={password}
onChange={(changeEvent) => setPassword(changeEvent.target.value)}
autoComplete="current-password"
required
/>
{errorMessage && (
<p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{errorMessage}
</p>
)}
<button className="w-full rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-50" type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Anmeldung laeuft …' : 'Anmelden'}
</button>
</form>
) : (
<form className="mt-5 space-y-4" onSubmit={handleMFASubmit}>
<p className="mt-1 text-sm text-fg-muted">Zweiter Faktor</p>
<p className="mt-1.5 text-xs text-fg-muted">
Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein
Wiederherstellungscode moeglich.
</p>
<label className="block text-sm font-medium text-fg" htmlFor="mfa-code">
Code
</label>
<input
id="mfa-code"
className="mt-1.5 w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle text-center font-mono text-lg tracking-[0.4em]"
type="text"
value={mfaCode}
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
// one-time-code laesst Mobilgeraete den Code aus der SMS/App vorschlagen.
autoComplete="one-time-code"
inputMode="text"
autoFocus
required
/>
{errorMessage && (
<p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{errorMessage}
</p>
)}
<button className="w-full rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-50" type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Pruefung laeuft …' : 'Bestaetigen'}
</button>
<button
className="w-full rounded-md px-4 py-2 text-sm text-fg-muted transition-colors hover:bg-hover hover:text-fg"
type="button"
onClick={() => {
setLoginStep('credentials');
setChallengeId(null);
setMfaCode('');
setPassword('');
setErrorMessage(null);
}}
>
Abbrechen
</button>
</form>
)}
</main>
</div>
);
}