From 6b2baa66c5773676acff603eff5e9a0bea168fb0 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 14 Aug 2026 13:46:21 +0200 Subject: [PATCH] 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. --- src/app/api/auth/route.ts | 65 +++++++++- src/app/konto/page.tsx | 133 +++++++++++++++++++ src/app/layout.tsx | 36 +++++- src/components/account.tsx | 158 +++++++++++++++++++++++ src/components/auth-forms.tsx | 10 +- src/components/icons.tsx | 22 ++++ src/components/password-input.tsx | 69 ++++++++++ src/components/qr-forms/simple-forms.tsx | 5 +- src/components/qr-generator.tsx | 2 + src/components/ui.tsx | 3 +- src/components/user-admin.tsx | 59 ++++++--- 11 files changed, 533 insertions(+), 29 deletions(-) create mode 100644 src/app/konto/page.tsx create mode 100644 src/components/account.tsx create mode 100644 src/components/password-input.tsx diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index 99987b6..88853d6 100644 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -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 { // 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 { } } +/** + * Eigenes Passwort ändern. Das bisherige muss stimmen — sonst könnte jemand + * an einem unbeaufsichtigten Rechner das Konto übernehmen. + */ +async function handleChangePassword( + input: z.infer, +): Promise { + 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, diff --git a/src/app/konto/page.tsx b/src/app/konto/page.tsx new file mode 100644 index 0000000..f43b8c1 --- /dev/null +++ b/src/app/konto/page.tsx @@ -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 ( +
+

+ Konto +

+
+ + Die Anmeldung ist ausgeschaltet.{" "} + Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die + Anwendung erreicht, darf alles. Zum Einschalten{" "} + + AUTH_ENABLED=true + {" "} + in der .env setzen und{" "} + + docker compose up -d + {" "} + ausführen. Beim nächsten Aufruf legst du den ersten Administrator + an. + +
+ + Zur Typ-Auswahl + +
+ ); + } + + const { user } = actor; + + return ( +
+
+

+ Konto +

+

+ Deine Anmeldedaten und die Gruppen, in denen du mitarbeitest. +

+
+ +
+ + Angemeldet als +
+
+
Name:
+
{user.name?.trim() || "—"}
+
+
+
E-Mail:
+
{user.email}
+
+
+
Rolle:
+
+ {user.isInstanceAdmin ? ( + Administrator der Installation + ) : ( + Benutzer + )} +
+
+
+
+ + + + Meine Gruppen + + {user.memberships.length === 0 ? ( +

+ Du bist in keiner Gruppe. Bitte einen Administrator, dich einer + zuzuordnen — sonst kannst du keine Codes anlegen. +

+ ) : ( +
    + {user.memberships.map((membership) => ( +
  • + + {membership.groupName} + + {ROLE_LABELS[membership.role]} + + {ROLE_DESCRIPTIONS[membership.role]} + +
  • + ))} +
+ )} +
+ + {isAuthEnabled() ? ( + <> + + + + ) : null} +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a9533a7..1fb47f2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -65,11 +65,21 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { {isAuthEnabled() && actor.kind === "user" ? ( <> + + {actorLabel(actor).slice(0, 2)} + + + {actorLabel(actor)} + + ) : null} @@ -79,6 +89,24 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { Anmelden ) : null} + + {/* Ohne Anmeldung gibt es kein Konto. Das gehört sichtbar + gesagt — sonst sucht man den Abmelden-Knopf vergeblich. */} + {!isAuthEnabled() ? ( + <> +