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:
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export default function Einstellungen() {
|
||||
export default async function Einstellungen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Einstellungen"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Hostgruppen" };
|
||||
|
||||
export default function Hostgruppen() {
|
||||
export default async function Hostgruppen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Hostgruppen"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Hosts" };
|
||||
|
||||
export default function Hosts() {
|
||||
export default async function Hosts() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Hosts"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { HuellenBenutzer } from "@/components/huelle/typen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
/**
|
||||
* Liefert den Benutzer, den die Seitenleiste anzeigt.
|
||||
*
|
||||
* TODO (#004): durch die echte Sitzung ersetzen (Benutzer + Rolle aus der Datenbank,
|
||||
* Herkunft „authentik“ oder „Notfall-Admin“) und ohne Sitzung zur Anmeldung umleiten.
|
||||
* Bis dahin ein fester Beispielbenutzer, damit der Prototyp ohne Anmeldung läuft.
|
||||
* async schon jetzt, damit sich die Aufrufer beim Umstieg nicht ändern müssen.
|
||||
* Der Benutzer fuer die Seitenleiste – aus der echten Sitzung (#004). Ohne Sitzung leitet
|
||||
* `sitzungErforderlich` zur Anmeldung weiter.
|
||||
*/
|
||||
export async function huellenBenutzerErmitteln(): Promise<HuellenBenutzer> {
|
||||
return { name: "Florian Wilde", rolle: "ADMIN", herkunft: "authentik" };
|
||||
const { benutzer, sitzung } = await sitzungErforderlich();
|
||||
return {
|
||||
name: benutzer.name,
|
||||
rolle: benutzer.rolle,
|
||||
herkunft:
|
||||
sitzung.herkunft === "notfall" ? "Notfallzugang" : (process.env["SSHKV_OIDC_NAME"] ?? "authentik"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Übersicht" };
|
||||
|
||||
@@ -33,7 +34,9 @@ const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
|
||||
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
|
||||
* folgt in #026; Aufbau und Komponenten bleiben.
|
||||
*/
|
||||
export default function Uebersicht() {
|
||||
export default async function Uebersicht() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Personen" };
|
||||
|
||||
export default function Personen() {
|
||||
export default async function Personen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite titel="Personen" untertitel="Wem welche Schlüssel gehören" phase={2} issue="#013" />
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Audit-Log" };
|
||||
|
||||
export default function Protokoll() {
|
||||
export default async function Protokoll() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Audit-Log"
|
||||
|
||||
@@ -20,11 +20,14 @@ import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||
import { datum, datumZeit, tageBis } from "@/lib/format";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
|
||||
|
||||
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
|
||||
export default function SchluesselBeispiel() {
|
||||
export default async function SchluesselBeispiel() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Schlüssel" };
|
||||
|
||||
export default function Schluessel() {
|
||||
export default async function Schluessel() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Platzhalterseite
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Vorlagen" };
|
||||
|
||||
export default function Vorlagen() {
|
||||
export default async function Vorlagen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Vorlagen"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Zuweisungen" };
|
||||
|
||||
export default function Zuweisungen() {
|
||||
export default async function Zuweisungen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Zuweisungen"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import QRCode from "qrcode";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { TotpFormular } from "@/components/anmeldung/TotpFormular";
|
||||
import { MonoKopieren } from "@/components/ui";
|
||||
import { totpAdresse } from "@/lib/totp";
|
||||
import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung";
|
||||
|
||||
export const metadata: Metadata = { title: "TOTP einrichten" };
|
||||
|
||||
/**
|
||||
* Erste Anmeldung des Notfall-Admins: TOTP ist Pflicht (Freigabe Frage 5). Die Seite ist
|
||||
* nur mit dem Einrichtungs-Cookie erreichbar, das ein richtiges Passwort ausgestellt hat.
|
||||
*/
|
||||
export default async function TotpSeite() {
|
||||
const einrichtung = await totpEinrichtungLesen();
|
||||
if (!einrichtung) redirect("/anmelden");
|
||||
|
||||
const adresse = totpAdresse(einrichtung.geheimnis, "Notfall-Admin");
|
||||
// Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser.
|
||||
const svg = await QRCode.toString(adresse, {
|
||||
type: "svg",
|
||||
margin: 1,
|
||||
color: { dark: "#0D1013", light: "#E8ECEF" },
|
||||
});
|
||||
|
||||
return (
|
||||
<AnmeldeRahmen
|
||||
titel="Zweiten Faktor einrichten"
|
||||
text="Code mit einer Authenticator-App scannen und den angezeigten Code eingeben."
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div
|
||||
className="mx-auto w-[200px] overflow-hidden rounded-[10px]"
|
||||
aria-label="QR-Code für die Authenticator-App"
|
||||
role="img"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
<MonoKopieren wert={einrichtung.geheimnis} beschriftung="Geheimnis" umbrechen />
|
||||
<TotpFormular />
|
||||
</div>
|
||||
</AnmeldeRahmen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { BestaetigenFormular } from "@/components/anmeldung/BestaetigenFormular";
|
||||
import { buttonKlassen } from "@/components/ui";
|
||||
import { sicheresZiel } from "@/server/auth/anmeldung";
|
||||
import { oidcName } from "@/server/auth/oidc";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Bestätigen" };
|
||||
|
||||
/** Erneute Bestaetigung vor kritischen Aktionen (Private Key anzeigen, Massenwiderruf …). */
|
||||
export default async function BestaetigenSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ ziel?: string }>;
|
||||
}) {
|
||||
const ziel = sicheresZiel((await searchParams).ziel);
|
||||
const { benutzer } = await sitzungErforderlich("lesen", `/bestaetigen?ziel=${encodeURIComponent(ziel)}`);
|
||||
|
||||
return (
|
||||
<AnmeldeRahmen
|
||||
titel="Anmeldung bestätigen"
|
||||
text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten."
|
||||
>
|
||||
{benutzer.istNotfall ? (
|
||||
<BestaetigenFormular ziel={ziel} />
|
||||
) : (
|
||||
<a
|
||||
href={`/api/auth/oidc/start?erneut=1&ziel=${encodeURIComponent(ziel)}`}
|
||||
className={buttonKlassen("primaer", false, "w-full")}
|
||||
>
|
||||
Bei {oidcName()} erneut anmelden
|
||||
</a>
|
||||
)}
|
||||
</AnmeldeRahmen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { Button } from "@/components/ui";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Kein Zugriff" };
|
||||
|
||||
export default async function KeinZugriffSeite() {
|
||||
await sitzungErforderlich("lesen");
|
||||
return (
|
||||
<AnmeldeRahmen titel="Kein Zugriff" text="Für diesen Bereich fehlt Ihrer Rolle die Berechtigung.">
|
||||
<Button href="/" variante="sekundaer" className="w-full">
|
||||
Zur Übersicht
|
||||
</Button>
|
||||
</AnmeldeRahmen>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user