feat(auth): Anmeldeseiten, TOTP-Einrichtung und Bestaetigung (#004)

Anmeldung, Einrichtung des zweiten Faktors fuer den Notfall-Admin,
erneute Bestaetigung vor kritischen Aktionen und die Seite fuer
fehlende Rechte. Die Huelle zeigt jetzt den echten Benutzer, und jede
Seite prueft die Sitzung selbst; ein struktureller Test haelt das fest.

Die Ablaeufe sind im gebauten Stand gegen den Mock-OIDC geprueft
(pnpm test:e2e): Admin, Konto ohne Gruppe, Notfall-Admin mit TOTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:28:51 +02:00
co-authored by Claude Opus 5.5
parent 4d0044b380
commit d1ba822a4a
37 changed files with 763 additions and 38 deletions
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import { Marke } from "@/components/marke";
/** Zentrierte Karte fuer Anmeldung, TOTP und Bestaetigung (#004). */
export function AnmeldeRahmen({
titel,
text,
children,
}: {
titel: string;
text?: ReactNode;
children: ReactNode;
}) {
return (
<main className="bg-bg flex min-h-dvh items-center justify-center px-4 py-10">
<div className="w-full max-w-[420px]">
<div className="mb-8 flex justify-center">
<Marke bildGroesse={40} textGroesse={26} />
</div>
<section className="bg-surface border-border rounded-karte border p-6 sm:p-7">
<h1 className="font-display text-text text-[22px] font-semibold">{titel}</h1>
{text && <div className="text-muted mt-1.5 text-[14px]">{text}</div>}
<div className="mt-6">{children}</div>
</section>
<p className="text-muted mt-6 text-center text-[12.5px]">
Nur im internen Netz. Alle Anmeldungen werden protokolliert.
</p>
</div>
</main>
);
}
@@ -0,0 +1,34 @@
"use client";
import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui";
import { notfallBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung";
/** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */
export function BestaetigenFormular({ ziel }: { ziel: string }) {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallBestaetigenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="ziel" value={ziel} />
<Formularfehler text={zustand.fehler} />
<Feld
beschriftung="Passwort"
name="passwort"
type="password"
autoComplete="current-password"
required
/>
<Feld
beschriftung="Code aus der Authenticator-App"
name="code"
inputMode="numeric"
autoComplete="one-time-code"
mono
required
/>
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Prüfe …" : "Bestätigen"}
</Button>
</form>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui";
import { notfallAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung";
/** Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code (#004). */
export function NotfallFormular({ ziel }: { ziel: string }) {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallAnmeldenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="ziel" value={ziel} />
<Formularfehler text={zustand.fehler} />
<Feld beschriftung="Anmeldename" name="anmeldename" autoComplete="username" required />
<Feld
beschriftung="Passwort"
name="passwort"
type="password"
autoComplete="current-password"
required
/>
<Feld
beschriftung="Code aus der Authenticator-App"
name="code"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9 ]*"
mono
hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet."
/>
<Button type="submit" variante="sekundaer" disabled={laeuft}>
{laeuft ? "Prüfe …" : "Mit Notfallzugang anmelden"}
</Button>
</form>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui";
import { totpEinrichtenAktion, type FormularZustand } from "@/server/actions/anmeldung";
export function TotpFormular() {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(totpEinrichtenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<Formularfehler text={zustand.fehler} />
<Feld
beschriftung="Code zur Bestätigung"
name="code"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9 ]*"
mono
required
autoFocus
/>
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Prüfe …" : "TOTP einrichten und anmelden"}
</Button>
</form>
);
}