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
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
import { NotfallFormular } from "@/components/anmeldung/NotfallFormular";
import { buttonKlassen, Formularfehler } from "@/components/ui";
import { sicheresZiel } from "@/server/auth/anmeldung";
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
import { angemeldet } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Anmelden" };
/** Codes aus /api/auth/oidc/rueckruf – nie der Meldungstext selbst in der Adresse. */
const FEHLER: Record<string, string> = {
"oidc-aus": "Die Anmeldung über den Identitätsanbieter ist nicht eingerichtet.",
"oidc-start": "Der Identitätsanbieter ist nicht erreichbar. Bitte später erneut versuchen.",
"oidc-rueckruf": "Die Anmeldung beim Identitätsanbieter ist fehlgeschlagen. Bitte erneut versuchen.",
"keine-gruppe":
"Ihr Konto ist keiner SSHKeyVault-Gruppe zugeordnet. Bitte beim Administrator Zugriff beantragen.",
"konto-konflikt":
"Für diese E-Mail-Adresse besteht bereits ein anderes Konto. Bitte den Administrator ansprechen.",
deaktiviert: "Dieses Konto ist deaktiviert.",
"fremdes-konto": "Die Bestätigung kam von einem anderen Konto als dem angemeldeten.",
allgemein: "Die Anmeldung ist fehlgeschlagen.",
};
export default async function AnmeldenSeite({
searchParams,
}: {
searchParams: Promise<{ ziel?: string; fehler?: string; abgemeldet?: string }>;
}) {
const parameter = await searchParams;
const ziel = sicheresZiel(parameter.ziel);
if (await angemeldet()) redirect(ziel);
const oidc = oidcAktiv();
const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined;
return (
<AnmeldeRahmen
titel="Anmelden"
text={parameter.abgemeldet ? "Sie wurden abgemeldet." : "Zentrale Verwaltung von SSH-Schlüsseln."}
>
<div className="flex flex-col gap-5">
<Formularfehler text={fehler} />
{oidc ? (
// Ein echter Link statt next/link: die Route leitet zum Anbieter weiter, ein
// Vorladen durch den Router wuerde einen Anmeldefluss anstossen.
<a
href={`/api/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
className={buttonKlassen("primaer", false, "w-full")}
>
Mit {oidcName()} anmelden
</a>
) : (
<p className="text-muted text-[14px]">
Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv.
</p>
)}
<details className="group border-border rounded-box border" open={!oidc}>
<summary className="text-muted hover:text-text flex min-h-11 cursor-pointer items-center px-4 text-[14px] font-medium">
Notfallzugang
</summary>
<div className="border-border border-t p-4">
<NotfallFormular ziel={ziel} />
</div>
</details>
</div>
</AnmeldeRahmen>
);
}