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:
@@ -8,7 +8,11 @@ import {
|
||||
MIN_PASSWORD_LENGTH,
|
||||
verifyPassword,
|
||||
} from "@/lib/auth/password";
|
||||
import { createSession, destroySession } from "@/lib/auth/session";
|
||||
import {
|
||||
createSession,
|
||||
destroyAllSessions,
|
||||
destroySession,
|
||||
} from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
@@ -46,10 +50,21 @@ const setupSchema = z.object({
|
||||
|
||||
const logoutSchema = z.object({ action: z.literal("logout") });
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
action: z.literal("change-password"),
|
||||
currentPassword: z.string().min(1, {
|
||||
error: "Bitte gib dein bisheriges Passwort ein.",
|
||||
}),
|
||||
newPassword: z.string().min(MIN_PASSWORD_LENGTH, {
|
||||
error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||
}),
|
||||
});
|
||||
|
||||
const bodySchema = z.discriminatedUnion("action", [
|
||||
loginSchema,
|
||||
setupSchema,
|
||||
logoutSchema,
|
||||
changePasswordSchema,
|
||||
]);
|
||||
|
||||
/** Wer ist angemeldet? Von der Oberfläche für den Kopfbereich genutzt. */
|
||||
@@ -96,6 +111,9 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
// Das `await` ist hier nicht optional: ohne es verlässt das Promise den
|
||||
// try-Block, bevor es sich auflöst — eine Ablehnung käme dann nie im
|
||||
// catch an und würde als nackter 500er beim Aufrufer landen.
|
||||
if (parsed.data.action === "change-password") {
|
||||
return await handleChangePassword(parsed.data);
|
||||
}
|
||||
if (parsed.data.action === "setup") {
|
||||
return await handleSetup(parsed.data);
|
||||
}
|
||||
@@ -105,6 +123,51 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eigenes Passwort ändern. Das bisherige muss stimmen — sonst könnte jemand
|
||||
* an einem unbeaufsichtigten Rechner das Konto übernehmen.
|
||||
*/
|
||||
async function handleChangePassword(
|
||||
input: z.infer<typeof changePasswordSchema>,
|
||||
): Promise<Response> {
|
||||
const actor = await getActor();
|
||||
if (actor.kind !== "user") {
|
||||
throw new QrRequestError("Bitte melde dich an.", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: actor.user.id } });
|
||||
if (!user) {
|
||||
throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404);
|
||||
}
|
||||
|
||||
if (!(await verifyPassword(input.currentPassword, user.passwordHash))) {
|
||||
throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403);
|
||||
}
|
||||
|
||||
if (input.currentPassword === input.newPassword) {
|
||||
throw new QrRequestError(
|
||||
"Das neue Passwort ist mit dem bisherigen identisch.",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash: await hashPassword(input.newPassword),
|
||||
failedLogins: 0,
|
||||
lockedUntil: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Alle Sitzungen beenden — auch die eigene — und sofort eine neue
|
||||
// aufmachen. So fliegen andere Geräte raus, ohne dass man sich hier neu
|
||||
// anmelden muss.
|
||||
await destroyAllSessions(user.id);
|
||||
await createSession(user.id);
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
/** Legt den ersten Benutzer an — nur solange es noch keinen gibt. */
|
||||
async function handleSetup(
|
||||
input: z.infer<typeof setupSchema>,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ChangePasswordForm, SignOutCard } from "@/components/account";
|
||||
import { Badge, Card, CardTitle, Notice } from "@/components/ui";
|
||||
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
||||
import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy";
|
||||
|
||||
export const metadata: Metadata = { title: "Konto" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AccountPage() {
|
||||
const actor = await requirePageActor();
|
||||
|
||||
// Bei ausgeschalteter Anmeldung gibt es kein Konto — dann erklärt die Seite,
|
||||
// wie sich das umstellen lässt, statt einfach leer zu sein.
|
||||
if (actor.kind !== "user") {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
Konto
|
||||
</h1>
|
||||
<div className="mt-5">
|
||||
<Notice tone="info">
|
||||
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
||||
Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die
|
||||
Anwendung erreicht, darf alles. Zum Einschalten{" "}
|
||||
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
AUTH_ENABLED=true
|
||||
</code>{" "}
|
||||
in der <code className="font-mono text-xs">.env</code> setzen und{" "}
|
||||
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
docker compose up -d
|
||||
</code>{" "}
|
||||
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
||||
an.
|
||||
</Notice>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
Zur Typ-Auswahl
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = actor;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
Konto
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Deine Anmeldedaten und die Gruppen, in denen du mitarbeitest.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardTitle>Angemeldet als</CardTitle>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<dt className="text-slate-500">Name:</dt>
|
||||
<dd className="text-slate-900">{user.name?.trim() || "—"}</dd>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<dt className="text-slate-500">E-Mail:</dt>
|
||||
<dd className="text-slate-900">{user.email}</dd>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2">
|
||||
<dt className="text-slate-500">Rolle:</dt>
|
||||
<dd>
|
||||
{user.isInstanceAdmin ? (
|
||||
<Badge tone="popular">Administrator der Installation</Badge>
|
||||
) : (
|
||||
<span className="text-slate-900">Benutzer</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle
|
||||
hint={
|
||||
user.isInstanceAdmin
|
||||
? "Als Administrator der Installation siehst du ohnehin alle Gruppen."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Meine Gruppen
|
||||
</CardTitle>
|
||||
{user.memberships.length === 0 ? (
|
||||
<p className="text-sm text-slate-600">
|
||||
Du bist in keiner Gruppe. Bitte einen Administrator, dich einer
|
||||
zuzuordnen — sonst kannst du keine Codes anlegen.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{user.memberships.map((membership) => (
|
||||
<li
|
||||
key={membership.groupId}
|
||||
className="flex flex-wrap items-center gap-2 rounded-lg border border-slate-200 p-3"
|
||||
>
|
||||
<span className="font-medium text-slate-900">
|
||||
{membership.groupName}
|
||||
</span>
|
||||
<Badge tone="neutral">{ROLE_LABELS[membership.role]}</Badge>
|
||||
<span className="w-full text-sm text-slate-500">
|
||||
{ROLE_DESCRIPTIONS[membership.role]}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{isAuthEnabled() ? (
|
||||
<>
|
||||
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
|
||||
<SignOutCard />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
-2
@@ -65,11 +65,21 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
{isAuthEnabled() && actor.kind === "user" ? (
|
||||
<>
|
||||
<span
|
||||
className="ml-1 hidden max-w-[14rem] truncate text-slate-500 sm:inline"
|
||||
title={actor.user.email}
|
||||
aria-hidden="true"
|
||||
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
|
||||
/>
|
||||
<Link
|
||||
href="/konto"
|
||||
title={`Konto von ${actor.user.email}`}
|
||||
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-[0.7rem] font-semibold text-indigo-700 uppercase">
|
||||
{actorLabel(actor).slice(0, 2)}
|
||||
</span>
|
||||
<span className="hidden truncate sm:inline">
|
||||
{actorLabel(actor)}
|
||||
</span>
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
</>
|
||||
) : null}
|
||||
@@ -79,6 +89,24 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
Anmelden
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{/* Ohne Anmeldung gibt es kein Konto. Das gehört sichtbar
|
||||
gesagt — sonst sucht man den Abmelden-Knopf vergeblich. */}
|
||||
{!isAuthEnabled() ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
|
||||
/>
|
||||
<Link
|
||||
href="/konto"
|
||||
title="Die Anmeldung ist ausgeschaltet — hier steht, wie du sie einschaltest"
|
||||
className="rounded-lg px-3 py-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
Ohne Anmeldung
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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("-");
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
<div className="flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<PasswordInput
|
||||
id="new-user-password"
|
||||
type="text"
|
||||
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
|
||||
<div className="flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<PasswordInput
|
||||
id="reset-password"
|
||||
type="text"
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user