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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LogOut } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Bildmarke, Kurzname, Marke, Wortmarke } from "@/components/marke";
|
||||
@@ -86,6 +87,17 @@ function BenutzerKasten({ benutzer }: { benutzer: HuellenBenutzer }) {
|
||||
{ROLLEN_TEXT[benutzer.rolle]} · über {benutzer.herkunft}
|
||||
</p>
|
||||
</div>
|
||||
{/* Abmelden nur per POST, damit ein fremder Link niemanden abmelden kann (#004). */}
|
||||
<form action="/api/auth/abmelden" method="post" className="ml-auto">
|
||||
<button
|
||||
type="submit"
|
||||
title="Abmelden"
|
||||
aria-label="Abmelden"
|
||||
className="text-muted hover:text-text hover:bg-raised rounded-feld flex size-11 items-center justify-center"
|
||||
>
|
||||
<LogOut size={17} aria-hidden />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/** Eingabefeld mit Beschriftung (#004). Mindesthoehe 44 px wie alle Klickflaechen. */
|
||||
export function Feld({
|
||||
beschriftung,
|
||||
hinweis,
|
||||
mono = false,
|
||||
className,
|
||||
id,
|
||||
name,
|
||||
...rest
|
||||
}: ComponentProps<"input"> & { beschriftung: string; hinweis?: string; mono?: boolean }) {
|
||||
const feldId = id ?? `feld-${name}`;
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-1.5", className)}>
|
||||
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
||||
{beschriftung}
|
||||
</label>
|
||||
<input
|
||||
id={feldId}
|
||||
name={name}
|
||||
{...rest}
|
||||
className={clsx(
|
||||
"bg-bg border-border-strong text-text rounded-feld min-h-11 border px-3 text-[15px]",
|
||||
"placeholder:text-muted/70 focus-visible:border-accent",
|
||||
mono && "font-mono tracking-wide",
|
||||
)}
|
||||
/>
|
||||
{hinweis && <p className="text-muted text-[12.5px]">{hinweis}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fehlermeldung eines Formulars – mit role=alert, damit Screenreader sie vorlesen. */
|
||||
export function Formularfehler({ text }: { text?: string }) {
|
||||
if (!text) return null;
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[14px]"
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export { MonoKopieren } from "./MonoKopieren";
|
||||
export { Seitenkopf } from "./Seitenkopf";
|
||||
export { Statuspunkt, type HostStatus } from "./Statuspunkt";
|
||||
export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle";
|
||||
export { Feld, Formularfehler } from "./Feld";
|
||||
|
||||
Reference in New Issue
Block a user