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.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { Button, Card, CardTitle, Field, Notice } from "@/components/ui";
|
||||
import { ApiError, apiJson } from "@/lib/api-client";
|
||||
|
||||
export function ChangePasswordForm({ minLength }: { minLength: number }) {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const mismatch = repeat.length > 0 && next !== repeat;
|
||||
const tooShort = next.length > 0 && next.length < minLength;
|
||||
|
||||
const submit = async () => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
setDone(false);
|
||||
try {
|
||||
await apiJson("/api/auth", {
|
||||
method: "POST",
|
||||
json: {
|
||||
action: "change-password",
|
||||
currentPassword: current,
|
||||
newPassword: next,
|
||||
},
|
||||
});
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setRepeat("");
|
||||
setDone(true);
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof ApiError
|
||||
? caught.message
|
||||
: "Das Passwort konnte nicht geändert werden.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardTitle hint="Nach der Änderung wirst du auf allen anderen Geräten abgemeldet.">
|
||||
Passwort ändern
|
||||
</CardTitle>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (mismatch || tooShort) return;
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
{done ? (
|
||||
<Notice tone="success">
|
||||
Das Passwort wurde geändert. Andere Geräte wurden abgemeldet.
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<Field label="Bisheriges Passwort" htmlFor="current-password">
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={current}
|
||||
disabled={pending}
|
||||
onChange={(event) => setCurrent(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Neues Passwort"
|
||||
htmlFor="next-password"
|
||||
error={tooShort ? `Mindestens ${minLength} Zeichen.` : null}
|
||||
hint={`Mindestens ${minLength} Zeichen.`}
|
||||
>
|
||||
<PasswordInput
|
||||
id="next-password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
invalid={tooShort}
|
||||
value={next}
|
||||
disabled={pending}
|
||||
onChange={(event) => setNext(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Neues Passwort wiederholen"
|
||||
htmlFor="repeat-password"
|
||||
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
|
||||
>
|
||||
<PasswordInput
|
||||
id="repeat-password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
invalid={mismatch}
|
||||
value={repeat}
|
||||
disabled={pending}
|
||||
onChange={(event) => setRepeat(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<Button type="submit" disabled={pending || mismatch || tooShort}>
|
||||
{pending ? "Wird geändert …" : "Passwort ändern"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SignOutCard() {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardTitle hint="Beendet die Sitzung auf diesem Gerät.">Abmelden</CardTitle>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={pending}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await apiJson("/api/auth", {
|
||||
method: "POST",
|
||||
json: { action: "logout" },
|
||||
});
|
||||
} finally {
|
||||
// refresh() verwirft den serverseitig gerenderten Kopfbereich —
|
||||
// sonst stünde dort noch der abgemeldete Name.
|
||||
router.replace("/anmelden");
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="lock" className="size-4" />
|
||||
{pending ? "Wird abgemeldet …" : "Jetzt abmelden"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user