From 661776e6092fccf6cf61cad1759ceecf14fd884a Mon Sep 17 00:00:00 2001 From: "t.indorf" Date: Mon, 12 Oct 2026 17:51:50 +0200 Subject: [PATCH] feat: add password recovery and everyday workflow improvements --- docs/ABLAUFPRUEFUNG.md | 2 +- docs/FUNKTIONSUEBERSICHT.md | 16 + package.json | 1 + src/app/dashboard/families/duty-manager.tsx | 38 +- .../_components/calendar-overview-client.tsx | 269 +++++--- src/app/dashboard/page.tsx | 7 +- src/app/dashboard/personal/forms.tsx | 172 +++++- src/app/dashboard/personal/page.tsx | 23 +- src/app/dashboard/profil/page.tsx | 13 + src/app/login/login-form.tsx | 17 +- src/app/login/page.tsx | 28 +- src/app/neues-passwort/[token]/page.tsx | 52 ++ src/app/passwort-vergessen/actions.ts | 117 ++++ src/app/passwort-vergessen/page.tsx | 37 ++ src/app/passwort-vergessen/password-form.tsx | 137 ++++ src/components/OnboardingDialog.tsx | 31 +- src/emails/PasswordResetEmail.tsx | 58 ++ src/lib/password-management.ts | 260 ++++++++ .../password-management.integration.test.cjs | 584 ++++++++++++++++++ 19 files changed, 1721 insertions(+), 141 deletions(-) create mode 100644 src/app/neues-passwort/[token]/page.tsx create mode 100644 src/app/passwort-vergessen/actions.ts create mode 100644 src/app/passwort-vergessen/page.tsx create mode 100644 src/app/passwort-vergessen/password-form.tsx create mode 100644 src/emails/PasswordResetEmail.tsx create mode 100644 src/lib/password-management.ts create mode 100644 tests/password-management.integration.test.cjs diff --git a/docs/ABLAUFPRUEFUNG.md b/docs/ABLAUFPRUEFUNG.md index 42a31df..562a7a7 100644 --- a/docs/ABLAUFPRUEFUNG.md +++ b/docs/ABLAUFPRUEFUNG.md @@ -2,7 +2,7 @@ Stand: 9. Oktober 2026 · geprüfter Code: `4702bf5abb0037ab9e0913dbef01ffbe21bbb96d` (`origin/main` zum Prüfzeitpunkt). -Fortschreibung: F01 (Familieneinladungen), F02 (Abwesenheitsverwaltung) und die Entfernung des künstlichen Gruppenfilters aus F12 wurden anschließend auf `codex/family-absence-flows` bearbeitet. Den neuen Ablauf und die Deployment-Schritte beschreibt [Familien und Abwesenheiten](FAMILIEN_UND_ABWESENHEITEN.md). F03, F04 und F05 (Verfügbarkeit bei manueller Zuweisung sowie Erstveröffentlichung) sind inzwischen ebenfalls umgesetzt; den aktuellen Stand und die Prüfungen dokumentiert die Ergänzung vom 12.10.2026 in [Funktionsübersicht](FUNKTIONSUEBERSICHT.md). Die folgenden Befunde dokumentieren weiterhin den ursprünglichen Prüfstand. +Fortschreibung: F01 (Familieneinladungen), F02 (Abwesenheitsverwaltung) und die Entfernung des künstlichen Gruppenfilters aus F12 wurden anschließend auf `codex/family-absence-flows` bearbeitet. Den neuen Ablauf und die Deployment-Schritte beschreibt [Familien und Abwesenheiten](FAMILIEN_UND_ABWESENHEITEN.md). F03, F04 und F05 (Verfügbarkeit bei manueller Zuweisung sowie Erstveröffentlichung) sind inzwischen ebenfalls umgesetzt; den aktuellen Stand und die Prüfungen dokumentiert die Ergänzung vom 12.10.2026 in [Funktionsübersicht](FUNKTIONSUEBERSICHT.md). F06 (Agenda-Details und Mitbringen), F09 (Arbeitszeitkontext und Ein-Tages-Anträge), F10 (personenbezogene Ämter-Schaltflächen) und F11 (Passwort-Wiederherstellung/-Änderung) sind ebenfalls umgesetzt; F08 enthält nun kontoabhängiges Ausblenden des Einrichtungshinweises. Die weiteren Teile von F08 und F11 bleiben durch diese Ergänzung unberührt. Die folgenden Befunde dokumentieren weiterhin den ursprünglichen Prüfstand. ## Umfang und Methode diff --git a/docs/FUNKTIONSUEBERSICHT.md b/docs/FUNKTIONSUEBERSICHT.md index 06c0fc2..6961bd8 100644 --- a/docs/FUNKTIONSUEBERSICHT.md +++ b/docs/FUNKTIONSUEBERSICHT.md @@ -1,5 +1,21 @@ # Kita-Planer – Funktionsübersicht und aktueller Stand +## Ergänzung vom 12.10.2026: Kontozugang und weniger wiederholte Eingaben + +- Die Ämter-Schaltflächen nennen die betroffene Person („Ämter: Vorname“) und tragen einen vollständigen zugänglichen Namen. Der Einrichtungshinweis wird je Konto in diesem Browser ausgeblendet. Das alte gemeinsame Ausblenden gilt nicht mehr für alle Konten; fehlender Browser-Speicher verhindert die Nutzung nicht. +- Unter `/passwort-vergessen` können aktivierte Konten einen Wiederherstellungslink anfordern. Die öffentliche Antwort bleibt für bestehende, unbekannte, deaktivierte und begrenzte Anfragen gleich. Kontosuche und Mailversand laufen nach der Antwort über Next.js `after`; ein Fehler verrät öffentlich keine Kontodaten. +- Reset-Links sind 30 Minuten gültig, einmal nutzbar und an die aktuelle Sitzungsfassung gebunden. Die vorhandene Verifikationstabelle speichert ausschließlich den Tokenhash und einen eigenen Reset-Zweck. Einladungslinks können kein Passwort zurücksetzen. Neue Reset-Anfragen ersetzen ältere Links; zwischenzeitliche E-Mail-/Passwortänderungen, administrative Kontosperren oder Löschungen verhindern die Verwendung alter Links. Fehlgeschlagener Versand entfernt ausschließlich den eigenen neu erzeugten Link. +- Im Profil lässt sich das Passwort mit dem aktuellen Passwort und übereinstimmender Bestätigung ändern. Sowohl Wiederherstellung als auch eigene Änderung erhöhen die Sitzungsfassung und beenden alte Anmeldungen; eine neue Anmeldung ist erforderlich. Persönliche Alarmlinks, die an diese frühere Sitzungsfassung gebunden sind, werden ebenfalls ungültig. Kontoaktivierung, Kita, Haushalt, Rollen und Einwilligungen bleiben erhalten. Es gelten weiterhin mindestens acht Zeichen und die bcrypt-Grenze von 72 UTF-8-Bytes. +- Anfragebegrenzungen: drei Reset-Anfragen je E-Mail/Stunde, fünf Reset-Abschlussversuche je Link/15 Minuten und fünf eigene Passwortänderungsversuche je Konto/15 Minuten; zusätzlich die bestehenden gemeinsamen und ggf. Proxy-IP-Limits. Bestehende Mailkonfiguration und App-Adresse werden verwendet. Keine neue Umgebungsvariable oder Datenbankmigration erforderlich. +- Die Kalender-Agenda öffnet den bereits vorhandenen Tagesdetaildialog mit Beschreibung, Rückmeldung, Mitbringliste und berechtigten Verwaltungsaktionen. Ansicht und Suche bleiben beim Schließen erhalten. Die Agenda passt sich auf schmalen Bildschirmen an. +- Neue Arbeitszeitformulare übernehmen die betrachtete Woche bzw. den ausgewählten Tag. Tage in der Zukunft sind nicht erfassbar. „Speichern und weiteren Block erfassen“ hält den tatsächlich eingegebenen Tag fest, leert die Uhrzeiten, setzt die Pause auf null und fokussiert den Beginn. Bestehende Zeiteinträge werden weiterhin einzeln bearbeitet; serverseitige Zeit-, Überschneidungs- und Freigaberegeln bleiben maßgeblich. +- Freizeitausgleich und halber Urlaubstag benötigen ein Datum. Das Ende wird aus diesem Datum übernommen; mehrtägiger Urlaub und Krankheit behalten Von/Bis. Die Berechnungs- und Genehmigungsregeln ändern sich nicht. + +Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_account_test npm run test:account` nach Anwendung der vorhandenen Migrationen auf dieser separaten lokalen Testdatenbank. Die 20 neuen PostgreSQL-Tests prüfen den tatsächlichen Passwortdienst, Server Actions, bcrypt und JWT-Entwertung mit ersetztem Mailtransport. Dazu 139 bestehende Sicherheits-, Rollen-, Kalender-, Familien- und Personaltests erfolgreich (159 insgesamt, keine übersprungenen Tests). + +Browser-Abnahme mit synthetischen Konten auf Desktop und bei 390 Pixeln: neutrale Reset-Bestätigung und tatsächliche nachgelagerte Vorbereitung, gültige/ungültige Linkseiten, Passwortformular im Profil, getrennte Einrichtungshinweise bei Konto-Wechsel, eindeutige Ämter, Agenda-Details samt gespeichertem Mitbringbeitrag und erhaltenem Suchfilter. Arbeitszeit in vergangener Woche und am ausgewählten Tag geprüft; zwei Blöcke mit 60 und 45 Minuten am selben eingegebenen Tag gespeichert. Halber Urlaubstag mit identischem Start/Ende gespeichert; Freizeitausgleich zeigt ebenfalls nur ein Datum. Passwortänderungen selbst wurden in den Integrationstests ausgeführt. Es wurden keine echten Mails verschickt oder produktiven Daten verändert. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit fünf bestehenden Warnungen. + + ## Ergänzung vom 12.10.2026: Manuelle Planung und Zugriffsrechte - Die manuelle Notdienst-Zuweisung gruppiert verfügbare Haushalte zuerst. Familien ohne Verfügbarkeit für den gewählten Tag bleiben als abgestimmter Sonderfall auswählbar und werden nach einer solchen Einteilung ausdrücklich gekennzeichnet. Geschwister zählen als ein Haushalt; Einteilungszahlen gelten ebenfalls je Haushalt. Die Anzeige verwendet dieselben gültigen Monatstage wie die Planung. Bestehende Alarm- und Schließtagssperren bleiben bestehen. diff --git a/package.json b/package.json index 6652b3f..6d74c56 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs", "test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs", "test:mail": "node --test tests/mail-transport.test.cjs", + "test:account": "node --test tests/password-management.integration.test.cjs", "test:roles": "node --test tests/kita-roles.integration.test.cjs tests/notdienst-assignment-view.test.cjs", "test:runtime": "node --test tests/runtime-supervisor.test.cjs", "test:family-absence": "tsx --test tests/absence-policy.test.ts", diff --git a/src/app/dashboard/families/duty-manager.tsx b/src/app/dashboard/families/duty-manager.tsx index 27049a8..c709c6c 100644 --- a/src/app/dashboard/families/duty-manager.tsx +++ b/src/app/dashboard/families/duty-manager.tsx @@ -16,7 +16,12 @@ import { import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; -import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions"; +import { + createDuty, + assignDuty, + removeDutyAssignment, + deleteDuty, +} from "./duty-actions"; type DutyWithAssignments = { id: string; @@ -100,17 +105,25 @@ export function DutyManager({ }; const unassignedDuties = allDuties.filter( - (d) => !userAssignments.some((a) => a.dutyId === d.id) + (d) => !userAssignments.some((a) => a.dutyId === d.id), ); return ( - diff --git a/src/app/dashboard/kalender/_components/calendar-overview-client.tsx b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx index de5c647..f1e2c02 100644 --- a/src/app/dashboard/kalender/_components/calendar-overview-client.tsx +++ b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx @@ -1,10 +1,7 @@ "use client"; import { useState, useMemo, useTransition } from "react"; -import { - TerminType, - TerminStatus, -} from "@prisma/client"; +import { TerminType, TerminStatus } from "@prisma/client"; import { format, startOfMonth, @@ -30,7 +27,7 @@ import { WashingMachine, Trash2, } from "lucide-react"; -import { RsvpDialog, RsvpForm } from "@/components/rsvp-form"; +import { RsvpForm } from "@/components/rsvp-form"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -42,7 +39,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { toast } from "sonner"; @@ -64,12 +60,32 @@ import { const categories = [ { label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" }, { label: "Teamtag", type: TerminType.TEAMTAG, color: "var(--danger)" }, - { label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" }, + { + label: "Elternabend", + type: TerminType.ELTERNABEND, + color: "oklch(0.50 0.10 280)", + }, { label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" }, - { label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" }, - { label: "Mein Elterndienst", type: "mein-elterndienst", color: "oklch(0.60 0.16 160)" }, - { label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" }, - { label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" }, + { + label: "Geburtstag", + type: TerminType.GEBURTSTAG_INTERN, + color: "oklch(0.50 0.10 320)", + }, + { + label: "Mein Elterndienst", + type: "mein-elterndienst", + color: "oklch(0.60 0.16 160)", + }, + { + label: "Elterndienst", + type: "elterndienst", + color: "oklch(0.75 0.08 160)", + }, + { + label: "Mein Notdienst", + type: "mein-notdienst", + color: "oklch(0.60 0.18 45)", + }, { label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" }, { label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" }, ]; @@ -102,7 +118,9 @@ export function CalendarOverviewClient({ const [view, setView] = useState<"liste" | "monat" | "agenda">("monat"); const [searchQuery, setSearchQuery] = useState(""); const [activeTypes, setActiveTypes] = useState>(new Set()); - const [filterScope, setFilterScope] = useState<"own" | "all">(isAdmin ? "all" : "own"); + const [filterScope, setFilterScope] = useState<"own" | "all">( + isAdmin ? "all" : "own", + ); // Month navigation state const [currentMonthDate, setCurrentMonthDate] = useState(new Date()); @@ -152,7 +170,8 @@ export function CalendarOverviewClient({ item.kind === "notdienst" && item.childName ? item.childName.toLowerCase().includes(query) : false; - if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false; + if (!titleMatch && !descMatch && !familyMatch && !childMatch) + return false; } // 2. Category filter @@ -163,10 +182,10 @@ export function CalendarOverviewClient({ ? "mein-elterndienst" : "elterndienst" : item.kind === "notdienst" - ? item.isOwn - ? "mein-notdienst" - : "notdienst" - : item.type; + ? item.isOwn + ? "mein-notdienst" + : "notdienst" + : item.type; if (!activeTypes.has(itemType)) return false; } @@ -178,9 +197,21 @@ export function CalendarOverviewClient({ const isEventOnDay = (item: CalendarListItemDto, day: Date) => { const eventStart = new Date(item.startDate); const eventEnd = new Date(item.endDate); - const dayTime = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime(); - const startTime = new Date(eventStart.getFullYear(), eventStart.getMonth(), eventStart.getDate()).getTime(); - const endTime = new Date(eventEnd.getFullYear(), eventEnd.getMonth(), eventEnd.getDate()).getTime(); + const dayTime = new Date( + day.getFullYear(), + day.getMonth(), + day.getDate(), + ).getTime(); + const startTime = new Date( + eventStart.getFullYear(), + eventStart.getMonth(), + eventStart.getDate(), + ).getTime(); + const endTime = new Date( + eventEnd.getFullYear(), + eventEnd.getMonth(), + eventEnd.getDate(), + ).getTime(); return dayTime >= startTime && dayTime <= endTime; }; @@ -330,7 +361,7 @@ export function CalendarOverviewClient({ setSelectedDay(new Date(item.startDate))} onDeleteTermin={(id, title) => { setDeleteEventId(id); setDeleteEventTitle(title); @@ -339,11 +370,15 @@ export function CalendarOverviewClient({ )} {/* Day details Dialog for Month View */} - !open && setSelectedDay(null)}> + !open && setSelectedDay(null)} + > - {selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })} + {selectedDay && + format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })} Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag. @@ -372,7 +407,14 @@ export function CalendarOverviewClient({ }} />
- +
@@ -410,7 +452,11 @@ export function CalendarOverviewClient({ {isAdmin && (
- @@ -462,8 +508,14 @@ export function CalendarOverviewClient({

{isAdmin && (
- - @@ -475,7 +527,11 @@ export function CalendarOverviewClient({ target={item} options={notdienstSwapOptions} trigger={ - } @@ -500,7 +556,8 @@ export function CalendarOverviewClient({ style={{ color: getCategoryColor(item.type), borderColor: getCategoryColor(item.type) + "44", - backgroundColor: getCategoryColor(item.type) + "11", + backgroundColor: + getCategoryColor(item.type) + "11", }} > {getTypeLabel(item.type)} @@ -523,10 +580,17 @@ export function CalendarOverviewClient({

)} - {item.rsvpEvent ?
-
Eure Rückmeldung
- -
: null} + {item.rsvpEvent ? ( +
+
+ Eure Rückmeldung +
+ +
+ ) : null} {item.participantInfo && item.showParticipantInfo && (
@@ -534,7 +598,9 @@ export function CalendarOverviewClient({ Hinweis für Teilnehmer
-

{item.participantInfo}

+

+ {item.participantInfo} +

)} @@ -582,13 +648,17 @@ export function CalendarOverviewClient({
{/* Delete Confirmation Dialog */} - !open && setDeleteEventId(null)}> + !open && setDeleteEventId(null)} + > Termin unwiderruflich löschen? - Möchtest du den Termin “{deleteEventTitle}” wirklich löschen? Alle - zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren. + Möchtest du den Termin “{deleteEventTitle}” wirklich + löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge + gehen verloren. @@ -642,7 +712,9 @@ function NotdienstSwapRequestRow({ const [isPending, startTransition] = useTransition(); const runAction = ( - action: (requestId: string) => Promise<{ success?: boolean; error?: string }>, + action: ( + requestId: string, + ) => Promise<{ success?: boolean; error?: string }>, successMessage: string, ) => { startTransition(async () => { @@ -659,13 +731,15 @@ function NotdienstSwapRequestRow({

- {request.direction === "incoming" ? "Anfrage an dich" : "Deine Anfrage"} + {request.direction === "incoming" + ? "Anfrage an dich" + : "Deine Anfrage"}

- {format(request.targetDate, "dd.MM.yyyy", { locale: de })}{" "} - ({request.targetFamilyName}, {request.targetChildName}) gegen{" "} - {format(request.offeredDate, "dd.MM.yyyy", { locale: de })}{" "} - ({request.offeredFamilyName}, {request.offeredChildName}) + {format(request.targetDate, "dd.MM.yyyy", { locale: de })} ( + {request.targetFamilyName}, {request.targetChildName}) gegen{" "} + {format(request.offeredDate, "dd.MM.yyyy", { locale: de })} ( + {request.offeredFamilyName}, {request.offeredChildName})

{request.message && (

@@ -679,7 +753,10 @@ function NotdienstSwapRequestRow({ {/* Actions for admins */} {isAdmin && item.kind === "termin" && ( diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 2bb51f0..d57a68b 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -16,7 +16,11 @@ import { UserRole } from "@prisma/client"; import { requireKitaSession } from "@/lib/auth-utils"; import { getTargetMonthData } from "@/lib/date-utils"; import { prisma } from "@/lib/prisma"; -import { absenceDate, absenceToday, canManageKitaAbsences } from "@/lib/absence-policy"; +import { + absenceDate, + absenceToday, + canManageKitaAbsences, +} from "@/lib/absence-policy"; import { Stat } from "@/components/ui/stat"; import { OnboardingDialog } from "@/components/OnboardingDialog"; import { NewsTicker } from "./news-ticker"; @@ -174,6 +178,7 @@ export default async function DashboardPage() {

); @@ -123,7 +127,11 @@ function transform(command: string, form: FormData) { return { kind: text("kind"), startDate: text("startDate"), - endDate: text("endDate"), + endDate: + text("kind") === "TIME_OFF" || + (text("kind") === "VACATION" && form.get("halfDay") === "on") + ? text("startDate") + : text("endDate"), halfDay: form.get("halfDay") === "on", ...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}), note: text("note"), @@ -142,13 +150,19 @@ function StaffForm({ children, label = "Speichern", onSuccess, + anotherLabel, }: { command: string; profileId?: string; extra?: Record; children: ReactNode; label?: string; - onSuccess?: (result: StaffActionResult) => void; + anotherLabel?: string; + onSuccess?: ( + result: StaffActionResult, + form: FormData, + another: boolean, + ) => void; }) { const [pending, start] = useTransition(); const [error, setError] = useState(""); @@ -159,6 +173,10 @@ function StaffForm({ onSubmit={(event) => { event.preventDefault(); const form = new FormData(event.currentTarget); + const another = + (event.nativeEvent as SubmitEvent).submitter?.getAttribute( + "value", + ) === "another"; setError(""); start(async () => { try { @@ -173,7 +191,7 @@ function StaffForm({ } if (result.warning) toast.warning(result.warning); else toast.success(result.message ?? "Gespeichert."); - onSuccess?.(result); + onSuccess?.(result, form, another); router.refresh(); } catch (error) { setError( @@ -187,10 +205,20 @@ function StaffForm({ >
{children} -
+
+ {anotherLabel ? ( + + ) : null}
{error && ( @@ -333,8 +361,12 @@ export function SetupForm({ export function TimeForm({ profileId, initial, + defaultDate, + compact = false, }: { profileId: string; + defaultDate?: string; + compact?: boolean; initial?: { id: string; date: string; @@ -345,14 +377,34 @@ export function TimeForm({ }; }) { const [open, setOpen] = useState(false); + const [date, setDate] = useState(initial?.date ?? defaultDate ?? todayKey()); + const [blockVersion, setBlockVersion] = useState(0); + const [continuation, setContinuation] = useState(false); return ( - + { + if (next) { + setDate(initial?.date ?? defaultDate ?? todayKey()); + setContinuation(false); + } + setOpen(next); + }} + > @@ -366,31 +418,50 @@ export function TimeForm({ setOpen(false)} + anotherLabel={ + !initial ? "Speichern und weiteren Block erfassen" : undefined + } + onSuccess={(_result, form, another) => { + if (another && !initial) { + setDate(String(form.get("date"))); + setContinuation(true); + setBlockVersion((version) => version + 1); + } else setOpen(false); + }} >
+ { + if (next) { + setKind(initial?.kind ?? "VACATION"); + setStartDate(initial?.startDate ?? todayKey()); + setEndDate(initial?.endDate ?? todayKey()); + setHalfDay(initial?.halfDay ?? false); + } + setOpen(next); + }} + >
-
- - -
- {kind === "VACATION" && ( + {kind === "VACATION" ? ( - )} + ) : null} +
+
+ + setStartDate(event.target.value)} + required + /> +
+ {singleDay ? ( + + ) : ( +
+ + setEndDate(event.target.value)} + required + min={startDate} + /> +
+ )} +
{kind === "TIME_OFF" && ( {profile && (
- + = start && today <= end + ? today + : start + : today + } + />
)} @@ -645,6 +655,7 @@ export default async function PersonalPage({ "Ist", "Urlaub / Krankheit", "Differenz", + "Erfassen", ].map((s) => ( {s} @@ -682,6 +693,15 @@ export default async function PersonalPage({ > {formatMinutes(d.balance)} + + {editable && d.date <= today ? ( + + ) : null} + ))} @@ -696,6 +716,7 @@ export default async function PersonalPage({ {formatMinutes(totals.balance)} + diff --git a/src/app/dashboard/profil/page.tsx b/src/app/dashboard/profil/page.tsx index 29ac742..c3dc85f 100644 --- a/src/app/dashboard/profil/page.tsx +++ b/src/app/dashboard/profil/page.tsx @@ -29,6 +29,7 @@ import { DeleteAccountDialog } from "./_components/delete-account-dialog"; import { MyChildrenManager } from "./_components/my-children-manager"; import { CreateFamilyDialog } from "./_components/create-family-dialog"; import { ContactForm } from "./_components/contact-form"; +import { PasswordForm } from "../../passwort-vergessen/password-form"; import { StaffEmailSettings } from "../personal/forms"; export const metadata = { title: "Mein Profil · Kita-Planer" }; @@ -211,6 +212,18 @@ export default async function ProfilPage() { + + + Passwort ändern + + Verwalte dein persönliches Anmeldepasswort. + + + + + + + {user.staffProfile && ( diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 5b885f8..0004e26 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useActionState, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -23,7 +24,10 @@ export function LoginForm({ devParentEmail?: string; devParentPassword?: string; }) { - const [state, formAction, pending] = useActionState(loginAction, initialState); + const [state, formAction, pending] = useActionState( + loginAction, + initialState, + ); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -67,6 +71,13 @@ export function LoginForm({ )}
+ + Passwort vergessen? + + {state.errors?._form?.[0] && (

{state.errors._form[0]} @@ -79,7 +90,9 @@ export function LoginForm({ {showDevHelpers && (

-

Dev-Schnellzugriff (nur lokal):

+

+ Dev-Schnellzugriff (nur lokal): +

{devAdminEmail && ( + + ); +} diff --git a/src/components/OnboardingDialog.tsx b/src/components/OnboardingDialog.tsx index 14b6b1e..1d3ab69 100644 --- a/src/components/OnboardingDialog.tsx +++ b/src/components/OnboardingDialog.tsx @@ -31,6 +31,7 @@ type UserRoleDto = type OnboardingDialogProps = { user: { + id: string; phone: string | null; street: string | null; postalCode: string | null; @@ -43,7 +44,7 @@ type OnboardingDialogProps = { } | null; }; -const HIDE_ONBOARDING_DIALOG_KEY = "hideOnboardingDialog"; +const onboardingKey = (userId: string) => `hideOnboardingDialog:${userId}`; export function OnboardingDialog({ user, family }: OnboardingDialogProps) { const [open, setOpen] = useState(false); @@ -79,14 +80,18 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) { }, [family?.childrenCount, user]); useEffect(() => { - if (status.isComplete) return; - - const hidden = localStorage.getItem(HIDE_ONBOARDING_DIALOG_KEY); - if (hidden !== "true") { - const timer = window.setTimeout(() => setOpen(true), 0); - return () => window.clearTimeout(timer); + let hidden: string | null = null; + try { + hidden = localStorage.getItem(onboardingKey(user.id)); + } catch { + /* Unavailable browser storage does not prevent using the app. */ } - }, [status.isComplete]); + const timer = window.setTimeout( + () => setOpen(!status.isComplete && hidden !== "true"), + 0, + ); + return () => window.clearTimeout(timer); + }, [status.isComplete, user.id]); if (status.isComplete) { return null; @@ -97,7 +102,11 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) { : "Haushalt & Kinder einrichten"; function hidePermanently() { - localStorage.setItem(HIDE_ONBOARDING_DIALOG_KEY, "true"); + try { + localStorage.setItem(onboardingKey(user.id), "true"); + } catch { + /* Close for this visit if storage is unavailable. */ + } setOpen(false); } @@ -113,8 +122,8 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) { Dein Kita-Profil ist fast bereit - Vervollständige die wichtigsten Daten, damit Notdienst, - Adressbuch und Familienverwaltung reibungslos funktionieren. + Vervollständige die wichtigsten Daten, damit Notdienst, Adressbuch + und Familienverwaltung reibungslos funktionieren.
diff --git a/src/emails/PasswordResetEmail.tsx b/src/emails/PasswordResetEmail.tsx new file mode 100644 index 0000000..3dbaffb --- /dev/null +++ b/src/emails/PasswordResetEmail.tsx @@ -0,0 +1,58 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Preview, + Text, +} from "@react-email/components"; +import { baseBodyStyle, colors } from "./_styles"; + +export function PasswordResetEmail({ + name, + resetLink, +}: { + name: string; + resetLink: string; +}) { + return ( + + + Dein Link für ein neues Kita-Planer-Passwort + + + Neues Passwort festlegen + + Hallo {name}, du hast einen Link angefordert, um dein + Kita-Planer-Passwort zurückzusetzen. + + + Der Link ist 30 Minuten gültig und kann einmal verwendet werden. + Wenn du diese Anfrage nicht gestellt hast, kannst du die Nachricht + ignorieren. Dein Passwort bleibt dann unverändert. + + + Falls der Button nicht funktioniert: {resetLink} + + + + ); +} diff --git a/src/lib/password-management.ts b/src/lib/password-management.ts new file mode 100644 index 0000000..5cb0bfb --- /dev/null +++ b/src/lib/password-management.ts @@ -0,0 +1,260 @@ +import { createElement } from "react"; +import { compare, hash } from "bcryptjs"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; +import { serializableTransaction } from "@/lib/transaction"; +import { hashToken, newToken } from "@/lib/tokens"; +import { passwordSchema } from "@/lib/password"; +import { sendAppEmail } from "@/lib/mail"; +import { PasswordResetEmail } from "@/emails/PasswordResetEmail"; +import { UserFacingError } from "@/lib/action-error"; + +const RESET_PREFIX = "password-reset:"; +const resetScope = (userId: string) => + `${RESET_PREFIX}${Buffer.from(userId).toString("base64url")}:`; +function resetIdentity(identifier: string) { + const match = /^password-reset:([a-zA-Z0-9_-]+):(\d+)$/.exec(identifier); + if (!match) return null; + const sessionVersion = Number(match[2]); + if (!Number.isSafeInteger(sessionVersion)) return null; + return { + id: Buffer.from(match[1], "base64url").toString("utf8"), + sessionVersion, + }; +} +const tokenSchema = z.string().regex(/^[a-f0-9]{64}$/i); +export const passwordResetMessage = + "Wenn für diese E-Mail-Adresse ein aktiviertes Konto besteht, erhältst du einen Link für ein neues Passwort. Prüfe auch deinen Spamordner."; +const newPasswordSchema = z + .object({ password: passwordSchema, confirmation: z.string() }) + .refine((input) => input.password === input.confirmation, { + message: "Die neuen Passwörter stimmen nicht überein.", + path: ["confirmation"], + }); +const resetSchema = newPasswordSchema.and(z.object({ token: tokenSchema })); +const invalidLink = () => + new UserFacingError( + "Dieser Link ist ungültig, abgelaufen oder wurde bereits verwendet. Bitte fordere einen neuen Link an.", + ); + +function siteOrigin() { + const base = + process.env.NEXT_PUBLIC_SITE_URL || + process.env.AUTH_URL || + process.env.NEXTAUTH_URL; + if (!base && process.env.NODE_ENV !== "development") + throw new Error("App-Adresse fehlt."); + const url = new URL(base || "http://localhost:3000"); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password + ) + throw new Error("Ungültige App-Adresse."); + return url.origin; +} + +export async function requestPasswordReset(email: string, send = sendAppEmail) { + const parsed = z + .string() + .trim() + .max(254) + .email() + .toLowerCase() + .safeParse(email); + if (!parsed.success) return; + const resetToken = newToken(); + const tokenHash = hashToken(resetToken); + const job = await serializableTransaction(async (tx) => { + const user = await tx.user.findUnique({ where: { email: parsed.data } }); + if (!user || user.disabledAt || !user.emailVerifiedAt || !user.passwordHash) + return null; + await tx.$queryRaw`SELECT id FROM users WHERE id = ${user.id} FOR UPDATE`; + const fresh = await tx.user.findUniqueOrThrow({ where: { id: user.id } }); + if ( + fresh.email !== parsed.data || + fresh.disabledAt || + !fresh.emailVerifiedAt || + !fresh.passwordHash + ) + return null; + const identifier = resetScope(user.id) + fresh.sessionVersion; + await tx.verificationToken.deleteMany({ + where: { identifier: { startsWith: resetScope(user.id) } }, + }); + await tx.verificationToken.create({ + data: { + identifier, + token: tokenHash, + expires: new Date(Date.now() + 30 * 60_000), + }, + }); + return { + id: user.id, + email: fresh.email, + name: fresh.firstName, + identifier, + }; + }); + if (!job) return; + try { + const result = await send({ + to: job.email, + subject: "Kita-Planer: Neues Passwort festlegen", + react: createElement(PasswordResetEmail, { + name: job.name, + resetLink: `${siteOrigin()}/neues-passwort/${resetToken}`, + }), + idempotencyKey: `password-reset/${tokenHash}`, + }); + if (result.success) return; + } catch { + /* Return the same public response, including transport failure. */ + } + await prisma.verificationToken.deleteMany({ + where: { identifier: job.identifier, token: tokenHash }, + }); +} + +export async function isPasswordResetValid(token: string) { + if (!tokenSchema.safeParse(token).success) return false; + const entry = await prisma.verificationToken.findUnique({ + where: { token: hashToken(token) }, + }); + if (!entry || !resetIdentity(entry.identifier) || entry.expires <= new Date()) + return false; + return !!(await prisma.user.findFirst({ + where: { + ...resetIdentity(entry.identifier)!, + disabledAt: null, + emailVerifiedAt: { not: null }, + passwordHash: { not: "" }, + }, + select: { id: true }, + })); +} + +export async function resetPassword(raw: unknown) { + const input = resetSchema.safeParse(raw); + if (!input.success) + throw new UserFacingError( + input.error.issues[0]?.message ?? "Bitte die Eingaben prüfen.", + ); + if (!(await isPasswordResetValid(input.data.token))) throw invalidLink(); + const passwordHash = await hash(input.data.password, 12); + await serializableTransaction(async (tx) => { + const tokenHash = hashToken(input.data.token); + const entry = await tx.verificationToken.findUnique({ + where: { token: tokenHash }, + }); + if ( + !entry || + !resetIdentity(entry.identifier) || + entry.expires <= new Date() + ) + throw invalidLink(); + const identity = resetIdentity(entry.identifier)!; + const userId = identity.id; + await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`; + const user = await tx.user.findFirst({ + where: { + ...identity, + disabledAt: null, + emailVerifiedAt: { not: null }, + passwordHash: { not: "" }, + }, + }); + if (!user) throw invalidLink(); + const consumed = await tx.verificationToken.deleteMany({ + where: { + identifier: entry.identifier, + token: tokenHash, + expires: { gt: new Date() }, + }, + }); + if (consumed.count !== 1) throw invalidLink(); + await tx.user.update({ + where: { id: userId }, + data: { + passwordHash, + sessionVersion: { increment: 1 }, + failedLoginAttempts: 0, + lockedUntil: null, + }, + }); + await tx.verificationToken.deleteMany({ + where: { + OR: [ + { identifier: userId }, + { identifier: { startsWith: resetScope(userId) } }, + ], + }, + }); + }); +} + +export async function changeOwnPassword( + actor: { id: string; sessionVersion: number }, + raw: unknown, +) { + const schema = newPasswordSchema.and( + z.object({ + currentPassword: z + .string() + .min(1) + .refine( + (value) => Buffer.byteLength(value, "utf8") <= 72, + "Aktuelles Passwort ist ungültig.", + ), + }), + ); + const input = schema.safeParse(raw); + if (!input.success) + throw new UserFacingError( + input.error.issues[0]?.message ?? "Bitte die Eingaben prüfen.", + ); + const user = await prisma.user.findFirst({ + where: { + id: actor.id, + sessionVersion: actor.sessionVersion, + disabledAt: null, + emailVerifiedAt: { not: null }, + }, + }); + if (!user || !(await compare(input.data.currentPassword, user.passwordHash))) + throw new UserFacingError( + "Das aktuelle Passwort ist nicht korrekt. Bitte melde dich bei Bedarf erneut an.", + ); + if (await compare(input.data.password, user.passwordHash)) + throw new UserFacingError("Bitte wähle ein anderes neues Passwort."); + const passwordHash = await hash(input.data.password, 12); + await serializableTransaction(async (tx) => { + const changed = await tx.user.updateMany({ + where: { + id: user.id, + passwordHash: user.passwordHash, + sessionVersion: actor.sessionVersion, + disabledAt: null, + emailVerifiedAt: { not: null }, + }, + data: { + passwordHash, + sessionVersion: { increment: 1 }, + failedLoginAttempts: 0, + lockedUntil: null, + }, + }); + if (changed.count !== 1) + throw new UserFacingError( + "Das Konto wurde inzwischen geändert. Bitte melde dich erneut an.", + ); + await tx.verificationToken.deleteMany({ + where: { + OR: [ + { identifier: user.id }, + { identifier: { startsWith: resetScope(user.id) } }, + ], + }, + }); + }); +} diff --git a/tests/password-management.integration.test.cjs b/tests/password-management.integration.test.cjs new file mode 100644 index 0000000..bf92dab --- /dev/null +++ b/tests/password-management.integration.test.cjs @@ -0,0 +1,584 @@ +/* eslint-disable @typescript-eslint/no-require-imports -- Test the actual services/actions with request and transport boundaries replaced. */ +const { test, before, beforeEach, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const ts = require("typescript"); +const { randomUUID } = require("node:crypto"); +const { PrismaClient } = require("@prisma/client"); +const { hash, compare } = require("bcryptjs"); +const { render } = require("@react-email/render"); +let prisma, current, f, service, actions, tokenHash, initialHash, jwt; +const tenants = [], + emails = [], + background = []; +let transportFailure = false; +const oldPassword = "Test-password-old-2026"; +const newPassword = "Test-password-new-2026"; +function loader(overrides = {}) { + const cache = new Map(); + function load(file) { + file = path.resolve(file); + if (cache.has(file)) return cache.get(file).exports; + const mod = { exports: {} }; + cache.set(file, mod); + const code = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + }, + fileName: file, + }).outputText; + const req = (spec) => { + if (Object.hasOwn(overrides, spec)) return overrides[spec]; + if (spec === "server-only") return {}; + if (spec === "@/lib/prisma") return { prisma }; + if (spec === "next/headers") + return { headers: async () => new Headers() }; + if (spec === "next/cache") return { revalidatePath() {} }; + if (spec === "next/navigation") + return { + redirect(url) { + throw new Error(`REDIRECT:${url}`); + }, + notFound() { + throw new Error("NOT_FOUND"); + }, + }; + if (spec.startsWith("@/") || spec.startsWith(".")) { + const target = spec.startsWith("@/") + ? path.resolve("src", spec.slice(2)) + : path.resolve(path.dirname(file), spec); + for (const suffix of [".ts", ".tsx", "/index.ts"]) + if (fs.existsSync(target + suffix)) return load(target + suffix); + } + return require(spec); + }; + vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { + filename: file, + })(req, mod, mod.exports); + return mod.exports; + } + return load; +} + +const form = (data) => { + const f = new FormData(); + for (const [key, value] of Object.entries(data)) f.set(key, value); + return f; +}; +const token = () => + new URL(emails.at(-1).react.props.resetLink).pathname.split("/").pop(); +const resetInput = (link = token(), password = newPassword) => ({ + token: link, + password, + confirmation: password, +}); +const drain = async () => { + while (background.length) await background.shift()(); +}; +before(async () => { + const raw = process.env.TEST_DATABASE_URL; + assert.ok( + raw, + "TEST_DATABASE_URL muss ausdrücklich auf die lokale kita_account_test zeigen.", + ); + const url = new URL(raw); + assert.ok(["127.0.0.1", "localhost"].includes(url.hostname)); + assert.equal(url.pathname, "/kita_account_test"); + prisma = new PrismaClient({ datasourceUrl: raw }); + initialHash = await hash(oldPassword, 12); + process.env.AUTH_URL = "http://127.0.0.1:3014"; + const load = loader({ + "@/lib/auth-utils": { requireSession: async () => ({ user: current }) }, + "@/lib/mail": { + sendAppEmail: async (input) => { + emails.push(input); + return transportFailure + ? { success: false, error: "test-only" } + : { success: true }; + }, + }, + "next/server": { after: (fn) => background.push(fn) }, + "next-auth": (config) => { + jwt = config.callbacks.jwt; + return {}; + }, + "next-auth/providers/credentials": (options) => options, + }); + service = load("src/lib/password-management.ts"); + actions = load("src/app/passwort-vergessen/actions.ts"); + tokenHash = load("src/lib/tokens.ts").hashToken; + load("src/auth.ts"); +}); +beforeEach(async () => { + const kita = await prisma.kita.create({ + data: { name: "Kontotest", slug: "account-" + randomUUID() }, + }); + tenants.push(kita.id); + const user = await prisma.user.create({ + data: { + kitaId: kita.id, + email: randomUUID() + "@example.invalid", + firstName: "Test", + lastName: "Konto", + passwordHash: initialHash, + emailVerifiedAt: new Date(), + privacyPolicyAcceptedAt: new Date(), + }, + }); + f = { kita, user }; + current = user; + emails.length = 0; + background.length = 0; + transportFailure = false; +}); +after(async () => { + if (!prisma) return; + const users = await prisma.user.findMany({ + where: { kitaId: { in: tenants } }, + select: { id: true }, + }); + for (const u of users) + await prisma.verificationToken.deleteMany({ + where: { + OR: [ + { identifier: u.id }, + { + identifier: { + startsWith: + "password-reset:" + + Buffer.from(u.id).toString("base64url") + + ":", + }, + }, + ], + }, + }); + await prisma.kita.deleteMany({ where: { id: { in: tenants } } }); + await prisma.$disconnect(); +}); + +test("Reset speichert nur Hash, gültige App-Adresse und 30-Minuten-Frist; GET verbraucht den Link nicht", async () => { + const before = Date.now(); + await service.requestPasswordReset(f.user.email); + const link = token(), + rows = await prisma.verificationToken.findMany({ + where: { token: tokenHash(link) }, + }); + assert.equal(rows.length, 1); + assert.notEqual(rows[0].token, link); + assert.ok(rows[0].identifier.startsWith("password-reset:")); + assert.ok(rows[0].expires.getTime() >= before + 29 * 60_000); + assert.ok(rows[0].expires.getTime() <= Date.now() + 30 * 60_000); + assert.equal(await service.isPasswordResetValid(link), true); + assert.equal(await service.isPasswordResetValid(link), true); + assert.match( + emails[0].react.props.resetLink, + /^http:\/\/127\.0\.0\.1:3014\/neues-passwort\//, + ); + const html = await render(emails[0].react); + assert.match(html, /30 Minuten/); + assert.match(html, /ignoriere|ignorieren/); +}); +test("Anfrage antwortet gleich für bekannte, unbekannte und deaktivierte Adresse; Verarbeitung folgt nach der Antwort", async () => { + const known = await actions.requestResetAction( + {}, + form({ email: f.user.email }), + ); + assert.equal(emails.length, 0); + assert.equal(background.length, 1); + await drain(); + assert.equal(emails.length, 1); + const missing = await actions.requestResetAction( + {}, + form({ email: randomUUID() + "@example.invalid" }), + ); + await drain(); + await prisma.user.update({ + where: { id: f.user.id }, + data: { disabledAt: new Date() }, + }); + const disabled = await actions.requestResetAction( + {}, + form({ email: f.user.email }), + ); + await drain(); + assert.deepEqual(known, missing); + assert.deepEqual(known, disabled); + assert.equal(emails.length, 1); +}); +test("Anfrage normalisiert E-Mail und lässt offene Einladungen unverändert", async () => { + await service.requestPasswordReset(" " + f.user.email.toUpperCase() + " "); + assert.equal(emails.length, 1); + await prisma.user.update({ + where: { id: f.user.id }, + data: { emailVerifiedAt: null, passwordHash: "" }, + }); + emails.length = 0; + await prisma.verificationToken.create({ + data: { + identifier: f.user.id, + token: "invitation-" + randomUUID(), + expires: new Date(Date.now() + 60_000), + }, + }); + await service.requestPasswordReset(f.user.email); + assert.equal(emails.length, 0); + assert.equal( + await prisma.verificationToken.count({ where: { identifier: f.user.id } }), + 1, + ); +}); +test("Neue Anfrage ersetzt nur frühere Reset-Links und nicht Aktivierungstokens", async () => { + await prisma.verificationToken.create({ + data: { + identifier: f.user.id, + token: "invite-" + randomUUID(), + expires: new Date(Date.now() + 60_000), + }, + }); + await service.requestPasswordReset(f.user.email); + const first = token(); + await service.requestPasswordReset(f.user.email); + assert.equal(await service.isPasswordResetValid(first), false); + assert.equal(await service.isPasswordResetValid(token()), true); + assert.equal( + await prisma.verificationToken.count({ where: { identifier: f.user.id } }), + 1, + ); +}); +test("Passwort wird gehasht, Lockout aufgehoben und alle alten Anmeldungen werden ungültig", async () => { + await service.requestPasswordReset(f.user.email); + await prisma.user.update({ + where: { id: f.user.id }, + data: { + lockedUntil: new Date(Date.now() + 60_000), + failedLoginAttempts: 10, + }, + }); + await service.resetPassword(resetInput()); + const fresh = await prisma.user.findUniqueOrThrow({ + where: { id: f.user.id }, + }); + assert.equal(await compare(newPassword, fresh.passwordHash), true); + assert.equal(await compare(oldPassword, fresh.passwordHash), false); + assert.equal(fresh.sessionVersion, 1); + assert.equal(fresh.failedLoginAttempts, 0); + assert.equal(fresh.lockedUntil, null); + assert.equal( + await jwt({ + token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() }, + }), + null, + ); +}); +test("Reset-Link ist nur einmal nutzbar", async () => { + await service.requestPasswordReset(f.user.email); + const payload = resetInput(); + await service.resetPassword(payload); + assert.equal(await service.isPasswordResetValid(payload.token), false); + await assert.rejects(service.resetPassword(payload), /Link ist ungültig/); +}); +test("Parallele Verwendung desselben Links ändert das Passwort genau einmal", async () => { + await service.requestPasswordReset(f.user.email); + const payload = resetInput(); + const results = await Promise.allSettled([ + service.resetPassword(payload), + service.resetPassword({ + ...payload, + password: "Another-test-password", + confirmation: "Another-test-password", + }), + ]); + assert.equal(results.filter((r) => r.status === "fulfilled").length, 1); + assert.equal( + (await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } })) + .sessionVersion, + 1, + ); +}); +test("Abgelaufene, erfundene und zweckfremde Aktivierungslinks funktionieren nicht", async () => { + await service.requestPasswordReset(f.user.email); + const link = token(); + await prisma.verificationToken.update({ + where: { token: tokenHash(link) }, + data: { expires: new Date(0) }, + }); + await assert.rejects( + service.resetPassword(resetInput(link)), + /Link ist ungültig/, + ); + const fake = "a".repeat(64); + await assert.rejects( + service.resetPassword(resetInput(fake)), + /Link ist ungültig/, + ); + await prisma.verificationToken.create({ + data: { + identifier: f.user.id, + token: tokenHash(fake), + expires: new Date(Date.now() + 60_000), + }, + }); + await assert.rejects( + service.resetPassword(resetInput(fake)), + /Link ist ungültig/, + ); +}); +test("Deaktivierung und Kontolöschung verhindern die Verwendung eines vorhandenen Links", async () => { + await service.requestPasswordReset(f.user.email); + const link = token(); + await prisma.user.update({ + where: { id: f.user.id }, + data: { disabledAt: new Date(), sessionVersion: { increment: 1 } }, + }); + await assert.rejects( + service.resetPassword(resetInput(link)), + /Link ist ungültig/, + ); + await prisma.user.delete({ where: { id: f.user.id } }); + assert.equal(await service.isPasswordResetValid(link), false); +}); +test("Ein Reset-Link für die alte E-Mail-Adresse bleibt auch nach neuer Aktivierung ungültig", async () => { + await service.requestPasswordReset(f.user.email); + const link = token(); + await prisma.user.update({ + where: { id: f.user.id }, + data: { + email: randomUUID() + "@example.invalid", + sessionVersion: { increment: 1 }, + emailVerifiedAt: null, + passwordHash: "", + }, + }); + await prisma.user.update({ + where: { id: f.user.id }, + data: { emailVerifiedAt: new Date(), passwordHash: initialHash }, + }); + assert.equal(await service.isPasswordResetValid(link), false); + await assert.rejects( + service.resetPassword(resetInput(link)), + /Link ist ungültig/, + ); +}); +test("Mailfehler hinterlässt keinen nutzbaren Link und verrät keine Kontodaten in der öffentlichen Antwort", async () => { + transportFailure = true; + const response = await actions.requestResetAction( + {}, + form({ email: f.user.email }), + ); + await drain(); + assert.equal(response.message, service.passwordResetMessage); + assert.equal(await service.isPasswordResetValid(token()), false); + assert.equal( + (await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } })) + .passwordHash, + initialHash, + ); +}); +test("Anfragelimit greift ohne abweichende öffentliche Antwort", async () => { + const responses = []; + for (let i = 0; i < 4; i++) { + responses.push( + await actions.requestResetAction({}, form({ email: f.user.email })), + ); + await drain(); + } + assert.equal(emails.length, 3); + for (const response of responses) assert.deepEqual(response, responses[0]); +}); +test("Passwortbestätigung und UTF-8-Grenze werden geprüft, bevor ein gültiger Link verbraucht wird", async () => { + await service.requestPasswordReset(f.user.email); + const link = token(); + await assert.rejects( + service.resetPassword({ ...resetInput(link), confirmation: "different" }), + /stimmen nicht überein/, + ); + await assert.rejects( + service.resetPassword(resetInput(link, "😀".repeat(19))), + /72 UTF-8/, + ); + assert.equal(await service.isPasswordResetValid(link), true); +}); +test("Eigene Passwortänderung verlangt aktuelles Passwort und kann kein fremdes Konto verändern", async () => { + const other = await prisma.user.create({ + data: { + kitaId: f.kita.id, + email: randomUUID() + "@example.invalid", + firstName: "Andere", + lastName: "Person", + passwordHash: initialHash, + emailVerifiedAt: new Date(), + }, + }); + await assert.rejects( + service.changeOwnPassword(current, { + currentPassword: "wrong", + password: newPassword, + confirmation: newPassword, + }), + /aktuelle Passwort/, + ); + assert.equal( + (await prisma.user.findUnique({ where: { id: f.user.id } })).sessionVersion, + 0, + ); + await service.changeOwnPassword(current, { + userId: other.id, + currentPassword: oldPassword, + password: newPassword, + confirmation: newPassword, + }); + assert.equal( + (await prisma.user.findUnique({ where: { id: other.id } })).passwordHash, + initialHash, + ); + assert.equal( + await compare( + newPassword, + (await prisma.user.findUnique({ where: { id: f.user.id } })).passwordHash, + ), + true, + ); +}); +test("Aktuelles Passwort wiederverwenden und veraltete Sitzung werden zurückgewiesen", async () => { + await assert.rejects( + service.changeOwnPassword(current, { + currentPassword: oldPassword, + password: oldPassword, + confirmation: oldPassword, + }), + /anderes neues Passwort/, + ); + await assert.rejects( + service.changeOwnPassword( + { ...current, sessionVersion: 4 }, + { + currentPassword: oldPassword, + password: newPassword, + confirmation: newPassword, + }, + ), + /aktuelle Passwort/, + ); +}); +test("Profil-Aktion ändert Passwort und widerruft ausstehende Reset-Links", async () => { + await service.requestPasswordReset(f.user.email); + const link = token(); + const result = await actions.changePasswordAction( + {}, + form({ + currentPassword: oldPassword, + password: newPassword, + confirmation: newPassword, + }), + ); + assert.equal(result.success, true); + assert.equal(await service.isPasswordResetValid(link), false); + assert.equal( + await jwt({ + token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() }, + }), + null, + ); +}); +test("Anonyme Reset-Aktion benötigt keinen Login, validiert das Token und gibt keine internen Daten zurück", async () => { + await service.requestPasswordReset(f.user.email); + assert.equal( + (await actions.finishResetAction({}, form(resetInput()))).success, + true, + ); + assert.deepEqual( + Object.keys( + await actions.finishResetAction( + {}, + form({ ...resetInput(), token: "invalid" }), + ), + ), + ["error"], + ); +}); + +test("Ein fehlgeschlagener älterer Versand entfernt keinen inzwischen neu angeforderten Link", async () => { + let oldToken; + await service.requestPasswordReset(f.user.email, async (input) => { + oldToken = new URL(input.react.props.resetLink).pathname.split("/").pop(); + await service.requestPasswordReset(f.user.email); + return { success: false, error: "test-only" }; + }); + assert.equal(await service.isPasswordResetValid(oldToken), false); + assert.equal(await service.isPasswordResetValid(token()), true); +}); + +test("Wiederholte falsche aktuelle Passwörter werden im Profil begrenzt", async () => { + for (let i = 0; i < 5; i++) { + assert.match( + ( + await actions.changePasswordAction( + {}, + form({ + currentPassword: "wrong", + password: newPassword, + confirmation: newPassword, + }), + ) + ).error, + /aktuelle Passwort/, + ); + } + assert.match( + ( + await actions.changePasswordAction( + {}, + form({ + currentPassword: oldPassword, + password: newPassword, + confirmation: newPassword, + }), + ) + ).error, + /Zu viele Versuche/, + ); + assert.equal( + (await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } })) + .sessionVersion, + 0, + ); +}); + +test("Fehlende oder unzulässige App-Adresse verschickt keinen nutzbaren Reset-Link", async () => { + const keys = ["AUTH_URL", "NEXTAUTH_URL", "NEXT_PUBLIC_SITE_URL", "NODE_ENV"]; + const old = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + try { + process.env.NODE_ENV = "production"; + delete process.env.AUTH_URL; + delete process.env.NEXTAUTH_URL; + delete process.env.NEXT_PUBLIC_SITE_URL; + await service.requestPasswordReset(f.user.email); + process.env.NEXT_PUBLIC_SITE_URL = "javascript:invalid"; + await service.requestPasswordReset(f.user.email); + assert.equal(emails.length, 0); + assert.equal( + await prisma.verificationToken.count({ + where: { + identifier: { + startsWith: + "password-reset:" + + Buffer.from(f.user.id).toString("base64url") + + ":", + }, + }, + }), + 0, + ); + } finally { + for (const key of keys) { + if (old[key] === undefined) delete process.env[key]; + else process.env[key] = old[key]; + } + } +});