Violett als Handlungsfarbe, Radius 0, schattenlos, durchgehend Geist Mono. Kantig und ruhig — die praegenden Merkmale des Presets. Drei begruendete Abweichungen, im Code festgehalten: - **Die Statusfarben bleiben.** Das Preset kennt nur `destructive` und fuenf Diagrammfarben; PROMPT §106 verlangt fuenf Bedeutungen. Ohne sie liesse sich ein Teilfehler nicht von einem Erfolg unterscheiden — die eine Aussage, auf die es in dieser Konsole ankommt. Sie sind an die Neutraltoene des Presets angepasst; Rot stammt aus dessen `destructive`. - **Das dunkle Thema haengt weiter an `[data-theme='dark']`.** Der Umschalter setzt dieses Attribut und ist getestet; `.dark` wird zusaetzlich unterstuetzt. - **Die Schrift laedt nicht nach.** Geist Mono steht zuerst im Stapel und wird verwendet, wenn sie auf dem Geraet liegt; sonst greift die System-Monospace. Eine Schrift von einem fremden Host zu holen verbietet die CSP der Auslieferung — und ein Backup-Server, der fuer seine Oberflaeche ins Internet greift, waere auch ohne CSP falsch. Eine Zuordnung war die Stolperstelle: In shadcn ist `accent` die dezente Hover-Flaeche und `primary` die Farbe der Handlung. Sie zu verwechseln machte jede Schaltflaeche grau. Das Preset-`primary` wird deshalb zu `--accent`, das Preset-`accent` zu `--surface-hover`. Die Benennung bleibt semantisch (`--surface-card`, `--text-primary`) statt shadcn-typisch: Die Zuordnung steht an genau einer Stelle, und ein Wechsel des Presets fasst keine einzige Komponente an. Karten, Schaltflaechen, Dialoge, Toasts und die Anmeldemaske sind auf schattenlos umgestellt; Dialog und Toast behalten einen kraeftigen Rand, weil sie sonst ohne Trennung ueber der Seite schwebten — im dunklen Thema ist der normale Rand schwarz. Die Seitenleiste bekommt die eigene Flaeche des Presets. Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das ausgelieferte CSS traegt Akzent, Radius 0 und Geist Mono, /retention zeigt 0 eigene Regeln und 6 anklickbare Vorlagen. 84 Tests gruen, tsc sauber, eslint ohne Warnung. CSS 28,7 KB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
7.5 KiB
TypeScript
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 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<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 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<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 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 (
|
|
<div className="flex min-h-screen items-center justify-center bg-page p-4">
|
|
<main className="w-full max-w-sm border border-line-strong bg-card p-6">
|
|
<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 läuft …' : '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 möglich.
|
|
</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 lässt Mobilgeräte 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 ? 'Prüfung läuft …' : 'Bestätigen'}
|
|
</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>
|
|
);
|
|
}
|