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:
Florian Wilde
2026-08-14 13:46:21 +02:00
parent 2a80f15e20
commit 6b2baa66c5
11 changed files with 533 additions and 29 deletions
+158
View File
@@ -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>
);
}
+4 -6
View File
@@ -3,6 +3,7 @@
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";
@@ -64,9 +65,8 @@ export function LoginForm() {
</Field>
<Field label="Passwort" htmlFor="login-password">
<TextInput
<PasswordInput
id="login-password"
type="password"
autoComplete="current-password"
required
value={password}
@@ -131,9 +131,8 @@ export function SetupForm({ minLength }: { minLength: number }) {
htmlFor="setup-password"
hint={`Mindestens ${minLength} Zeichen.`}
>
<TextInput
<PasswordInput
id="setup-password"
type="password"
autoComplete="new-password"
required
minLength={minLength}
@@ -148,9 +147,8 @@ export function SetupForm({ minLength }: { minLength: number }) {
htmlFor="setup-repeat"
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
>
<TextInput
<PasswordInput
id="setup-repeat"
type="password"
autoComplete="new-password"
required
invalid={mismatch}
+22
View File
@@ -115,6 +115,28 @@ const PATHS = {
</>
),
check: <path d="m4.5 12.5 5 5 10-11" />,
eye: (
<>
<path d="M2.5 12s3.6-6.5 9.5-6.5S21.5 12 21.5 12s-3.6 6.5-9.5 6.5S2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</>
),
eyeOff: (
<>
<path d="M10.8 6.1A8.6 8.6 0 0 1 12 5.5c5.9 0 9.5 6.5 9.5 6.5a16 16 0 0 1-3 3.8" />
<path d="M6.5 6.9A15.7 15.7 0 0 0 2.5 12s3.6 6.5 9.5 6.5a8.8 8.8 0 0 0 4-1" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
<path d="m3 3 18 18" />
</>
),
dice: (
<>
<rect x="3.5" y="3.5" width="17" height="17" rx="3" />
<circle cx="8.5" cy="8.5" r="1.1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="15.5" r="1.1" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.1" fill="currentColor" stroke="none" />
</>
),
copy: (
<>
<rect x="9" y="9" width="12" height="12" rx="2" />
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useState, type InputHTMLAttributes } from "react";
import { Icon } from "@/components/icons";
import { CONTROL_BASE, cx } from "@/components/ui";
/**
* Passwortfeld: verdeckt als Voreinstellung, mit Auge zum Aufdecken.
*
* Der Umschalter ist kein Beiwerk. Wer ein Passwort für jemand anderen setzt,
* muss es ablesen können, um es weiterzugeben — sichtbar sein soll es aber
* nur auf ausdrücklichen Wunsch und nicht schon beim Tippen vor fremden Augen
* oder in einer Bildschirmübertragung.
*/
export function PasswordInput({
invalid,
className,
revealLabel = "Passwort anzeigen",
hideLabel = "Passwort verbergen",
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
invalid?: boolean;
revealLabel?: string;
hideLabel?: string;
}) {
const [visible, setVisible] = useState(false);
return (
<div className="relative">
<input
type={visible ? "text" : "password"}
className={cx(
CONTROL_BASE,
"pr-11",
visible ? "font-mono" : undefined,
invalid ? "border-red-400" : "border-slate-300",
className,
)}
{...props}
/>
<button
type="button"
onClick={() => setVisible((current) => !current)}
aria-label={visible ? hideLabel : revealLabel}
aria-pressed={visible}
title={visible ? hideLabel : revealLabel}
className="absolute top-1/2 right-1 -translate-y-1/2 rounded-md p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<Icon name={visible ? "eyeOff" : "eye"} className="size-4" />
</button>
</div>
);
}
/**
* Erzeugt ein zufälliges, gut vorlesbares Passwort.
*
* Ohne leicht verwechselbare Zeichen (0/O, 1/l/I), dafür in Vierergruppen —
* ein Administrator muss es am Telefon durchgeben können.
*/
export function generatePassword(groups = 4): string {
const alphabet = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
const bytes = new Uint32Array(groups * 4);
crypto.getRandomValues(bytes);
const chars = Array.from(bytes, (value) => alphabet[value % alphabet.length]);
return (chars.join("").match(/.{1,4}/g) ?? []).join("-");
}
+4 -1
View File
@@ -9,6 +9,7 @@ import {
TextArea,
TextInput,
} from "@/components/ui";
import { PasswordInput } from "@/components/password-input";
import type { QrFormProps } from "@/components/qr-forms/types";
import { parseCoordinates } from "@/lib/qr/encoding";
import {
@@ -193,10 +194,12 @@ export function WifiForm({ value, onChange, error, disabled }: QrFormProps) {
{security !== "nopass" ? (
<Field label="Passwort" htmlFor="wifi-password">
<TextInput
<PasswordInput
id="wifi-password"
autoComplete="off"
spellCheck={false}
revealLabel="WLAN-Passwort anzeigen"
hideLabel="WLAN-Passwort verbergen"
value={text(value, "passwort")}
disabled={disabled}
onChange={(event) =>
+2
View File
@@ -283,6 +283,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
type="button"
data-mode="static"
aria-pressed={!isDynamic}
disabled={!qrType.supportsStatic}
onClick={() => setIsDynamic(false)}
@@ -306,6 +307,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<button
type="button"
data-mode="dynamic"
aria-pressed={isDynamic}
disabled={!qrType.supportsDynamic}
onClick={() => setIsDynamic(true)}
+2 -1
View File
@@ -135,7 +135,8 @@ export function TextInput({
);
}
const CONTROL_BASE =
/** Gemeinsame Grundgestaltung aller Eingabefelder. */
export const CONTROL_BASE =
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500";
export function TextArea({
+43 -16
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import { generatePassword, PasswordInput } from "@/components/password-input";
import {
Badge,
Button,
@@ -306,16 +307,29 @@ export function UserAdmin({
<Field
label="Passwort"
htmlFor="new-user-password"
hint={`Mindestens ${minPasswordLength} Zeichen. Gib es der Person weiter — sie kann es später nicht selbst ändern, aber ein Administrator kann es neu setzen.`}
hint={`Mindestens ${minPasswordLength} Zeichen. Gib es der Person weiter — sie kann es danach unter „Konto“ selbst ändern.`}
>
<TextInput
id="new-user-password"
type="text"
value={form.password}
onChange={(event) =>
setForm({ ...form, password: event.target.value })
}
/>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<PasswordInput
id="new-user-password"
autoComplete="new-password"
value={form.password}
onChange={(event) =>
setForm({ ...form, password: event.target.value })
}
/>
</div>
<Button
type="button"
variant="secondary"
title="Zufallspasswort erzeugen"
onClick={() => setForm({ ...form, password: generatePassword() })}
>
<Icon name="dice" className="size-4" />
Erzeugen
</Button>
</div>
</Field>
<Checkbox
id="new-user-admin"
@@ -364,14 +378,27 @@ export function UserAdmin({
<Field
label="Neues Passwort"
htmlFor="reset-password"
hint={`Mindestens ${minPasswordLength} Zeichen.`}
hint={`Mindestens ${minPasswordLength} Zeichen. Zum Weitergeben lässt es sich über das Auge kurz sichtbar machen.`}
>
<TextInput
id="reset-password"
type="text"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<PasswordInput
id="reset-password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
</div>
<Button
type="button"
variant="secondary"
title="Zufallspasswort erzeugen"
onClick={() => setNewPassword(generatePassword())}
>
<Icon name="dice" className="size-4" />
Erzeugen
</Button>
</div>
</Field>
</div>
) : null}