Files
qrcode-generator/src/components/auth-forms.tsx
T
Florian Wilde 6b2baa66c5 Passwoerter verdeckt, Kontobereich mit eigenem Passwortwechsel
Zwei berechtigte Rueckmeldungen von Florian:

1. In der Benutzerverwaltung standen die Passwortfelder auf type='text',
   das Passwort war also im Klartext lesbar - vor fremden Augen und in
   jeder Bildschirmuebertragung. Alle Passwortfelder nutzen jetzt ein
   gemeinsames PasswordInput: verdeckt als Voreinstellung, mit Auge zum
   Aufdecken. Weil ein Administrator das Passwort weitergeben muss, gibt es
   daneben einen Generator fuer ein zufaelliges, vorlesbares Passwort in
   Vierergruppen.

2. Es gab keinen Kontobereich. Neu ist /konto mit Profil, den eigenen
   Gruppen samt Rollenerklaerung, Passwortwechsel und Abmelden. Im
   Kopfbereich fuehrt ein Namenskuerzel dorthin. Ist die Anmeldung
   ausgeschaltet, steht dort 'Ohne Anmeldung' und die Seite erklaert, wie
   man sie einschaltet - vorher suchte man den Abmelden-Knopf vergeblich.

Dabei eine echte Luecke geschlossen: ein Benutzer konnte sein Passwort gar
nicht selbst aendern, nur ein Administrator konnte es setzen. Der Wechsel
verlangt das bisherige Passwort, lehnt ein identisches ab und beendet alle
anderen Sitzungen - die eigene bleibt bestehen.

Nebenbei: die Betriebsart-Knoepfe im Generator haben jetzt data-mode. Ueber
aria-pressed waren sie nicht mehr eindeutig, seit das Passwortfeld einen
eigenen Umschalter hat.

Geprueft: 63 Pruefungen fuer Anmeldung inkl. Passwortwechsel und Konto,
dazu 280 bestehende bei ausgeschalteter Anmeldung.
2026-08-14 13:46:21 +02:00

195 lines
5.4 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { PasswordInput } from "@/components/password-input";
import { Button, Card, Field, Notice, TextInput } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
function useSubmit() {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (json: unknown) => {
setPending(true);
setError(null);
try {
await apiJson("/api/auth", { method: "POST", json });
// refresh() ist hier entscheidend: Kopfbereich und Startseite werden
// serverseitig je nach Anmeldung unterschiedlich gerendert, und ohne
// Verwerfen des Zwischenspeichers käme die Seite von vor der Anmeldung.
router.replace("/");
router.refresh();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Anmeldung ist fehlgeschlagen.",
);
setPending(false);
}
};
return { submit, pending, error, router };
}
export function LoginForm() {
const { submit, pending, error } = useSubmit();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
return (
<Card>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void submit({ action: "login", email, password });
}}
>
{error ? <Notice tone="error">{error}</Notice> : null}
<Field label="E-Mail-Adresse" htmlFor="login-email">
<TextInput
id="login-email"
type="email"
autoComplete="username"
autoFocus
required
value={email}
disabled={pending}
onChange={(event) => setEmail(event.target.value)}
/>
</Field>
<Field label="Passwort" htmlFor="login-password">
<PasswordInput
id="login-password"
autoComplete="current-password"
required
value={password}
disabled={pending}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Button type="submit" disabled={pending}>
{pending ? "Wird angemeldet …" : "Anmelden"}
</Button>
</form>
</Card>
);
}
export function SetupForm({ minLength }: { minLength: number }) {
const { submit, pending, error } = useSubmit();
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const mismatch = repeat.length > 0 && password !== repeat;
return (
<Card>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (mismatch) return;
void submit({ action: "setup", email, name, password });
}}
>
{error ? <Notice tone="error">{error}</Notice> : null}
<Field label="Name (optional)" htmlFor="setup-name">
<TextInput
id="setup-name"
autoComplete="name"
value={name}
disabled={pending}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<Field label="E-Mail-Adresse" htmlFor="setup-email">
<TextInput
id="setup-email"
type="email"
autoComplete="username"
required
value={email}
disabled={pending}
onChange={(event) => setEmail(event.target.value)}
/>
</Field>
<Field
label="Passwort"
htmlFor="setup-password"
hint={`Mindestens ${minLength} Zeichen.`}
>
<PasswordInput
id="setup-password"
autoComplete="new-password"
required
minLength={minLength}
value={password}
disabled={pending}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Field
label="Passwort wiederholen"
htmlFor="setup-repeat"
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
>
<PasswordInput
id="setup-repeat"
autoComplete="new-password"
required
invalid={mismatch}
value={repeat}
disabled={pending}
onChange={(event) => setRepeat(event.target.value)}
/>
</Field>
<Button type="submit" disabled={pending || mismatch}>
{pending ? "Wird angelegt …" : "Administrator anlegen"}
</Button>
</form>
</Card>
);
}
export function LogoutButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
return (
<button
type="button"
disabled={pending}
onClick={async () => {
setPending(true);
try {
await apiJson("/api/auth", {
method: "POST",
json: { action: "logout" },
});
} finally {
router.replace("/anmelden");
router.refresh();
}
}}
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:opacity-50"
>
{pending ? "…" : "Abmelden"}
</button>
);
}