feat: add password recovery and everyday workflow improvements
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Stand: 9. Oktober 2026 · geprüfter Code: `4702bf5abb0037ab9e0913dbef01ffbe21bbb96d` (`origin/main` zum Prüfzeitpunkt).
|
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
|
## Umfang und Methode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Kita-Planer – Funktionsübersicht und aktueller Stand
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs",
|
"test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs",
|
||||||
"test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs",
|
"test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs",
|
||||||
"test:mail": "node --test tests/mail-transport.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: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:runtime": "node --test tests/runtime-supervisor.test.cjs",
|
||||||
"test:family-absence": "tsx --test tests/absence-policy.test.ts",
|
"test:family-absence": "tsx --test tests/absence-policy.test.ts",
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
|
import {
|
||||||
|
createDuty,
|
||||||
|
assignDuty,
|
||||||
|
removeDutyAssignment,
|
||||||
|
deleteDuty,
|
||||||
|
} from "./duty-actions";
|
||||||
|
|
||||||
type DutyWithAssignments = {
|
type DutyWithAssignments = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -100,17 +105,25 @@ export function DutyManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const unassignedDuties = allDuties.filter(
|
const unassignedDuties = allDuties.filter(
|
||||||
(d) => !userAssignments.some((a) => a.dutyId === d.id)
|
(d) => !userAssignments.some((a) => a.dutyId === d.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 gap-1">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1"
|
||||||
|
aria-label={`Ämter für ${user.firstName} ${user.lastName}`}
|
||||||
|
>
|
||||||
<Shield className="h-3.5 w-3.5" />
|
<Shield className="h-3.5 w-3.5" />
|
||||||
<span className="hidden sm:inline">Ämter</span>
|
<span>Ämter: {user.firstName}</span>
|
||||||
{userAssignments.length > 0 && (
|
{userAssignments.length > 0 && (
|
||||||
<Badge variant="secondary" className="ml-1 h-4 px-1 py-0 tabular-nums">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-1 h-4 px-1 py-0 tabular-nums"
|
||||||
|
>
|
||||||
{userAssignments.length}
|
{userAssignments.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -137,7 +150,11 @@ export function DutyManager({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{userAssignments.map((assignment) => (
|
{userAssignments.map((assignment) => (
|
||||||
<Badge key={assignment.id} variant="default" className="gap-1 pr-1">
|
<Badge
|
||||||
|
key={assignment.id}
|
||||||
|
variant="default"
|
||||||
|
className="gap-1 pr-1"
|
||||||
|
>
|
||||||
{assignment.duty.name}
|
{assignment.duty.name}
|
||||||
<button
|
<button
|
||||||
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-[oklch(0.98_0.005_45_/_0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(0.98_0.005_45)]"
|
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-[oklch(0.98_0.005_45_/_0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(0.98_0.005_45)]"
|
||||||
@@ -199,7 +216,12 @@ export function DutyManager({
|
|||||||
Neues Amt anlegen
|
Neues Amt anlegen
|
||||||
</h4>
|
</h4>
|
||||||
<form action={handleCreateDuty} className="flex gap-2">
|
<form action={handleCreateDuty} className="flex gap-2">
|
||||||
<Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required />
|
<Input
|
||||||
|
name="name"
|
||||||
|
placeholder="z.B. Wäschedienst"
|
||||||
|
className="h-8"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Button size="sm" className="h-8" disabled={isPending}>
|
<Button size="sm" className="h-8" disabled={isPending}>
|
||||||
Erstellen
|
Erstellen
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useTransition } from "react";
|
import { useState, useMemo, useTransition } from "react";
|
||||||
import {
|
import { TerminType, TerminStatus } from "@prisma/client";
|
||||||
TerminType,
|
|
||||||
TerminStatus,
|
|
||||||
} from "@prisma/client";
|
|
||||||
import {
|
import {
|
||||||
format,
|
format,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
@@ -30,7 +27,7 @@ import {
|
|||||||
WashingMachine,
|
WashingMachine,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { RsvpDialog, RsvpForm } from "@/components/rsvp-form";
|
import { RsvpForm } from "@/components/rsvp-form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -42,7 +39,6 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -64,12 +60,32 @@ import {
|
|||||||
const categories = [
|
const categories = [
|
||||||
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
|
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
|
||||||
{ label: "Teamtag", type: TerminType.TEAMTAG, 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: "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: "Geburtstag",
|
||||||
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" },
|
type: TerminType.GEBURTSTAG_INTERN,
|
||||||
{ label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" },
|
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: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
|
||||||
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
|
{ 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 [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
|
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
|
||||||
const [filterScope, setFilterScope] = useState<"own" | "all">(isAdmin ? "all" : "own");
|
const [filterScope, setFilterScope] = useState<"own" | "all">(
|
||||||
|
isAdmin ? "all" : "own",
|
||||||
|
);
|
||||||
|
|
||||||
// Month navigation state
|
// Month navigation state
|
||||||
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
||||||
@@ -152,7 +170,8 @@ export function CalendarOverviewClient({
|
|||||||
item.kind === "notdienst" && item.childName
|
item.kind === "notdienst" && item.childName
|
||||||
? item.childName.toLowerCase().includes(query)
|
? item.childName.toLowerCase().includes(query)
|
||||||
: false;
|
: false;
|
||||||
if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false;
|
if (!titleMatch && !descMatch && !familyMatch && !childMatch)
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Category filter
|
// 2. Category filter
|
||||||
@@ -163,10 +182,10 @@ export function CalendarOverviewClient({
|
|||||||
? "mein-elterndienst"
|
? "mein-elterndienst"
|
||||||
: "elterndienst"
|
: "elterndienst"
|
||||||
: item.kind === "notdienst"
|
: item.kind === "notdienst"
|
||||||
? item.isOwn
|
? item.isOwn
|
||||||
? "mein-notdienst"
|
? "mein-notdienst"
|
||||||
: "notdienst"
|
: "notdienst"
|
||||||
: item.type;
|
: item.type;
|
||||||
if (!activeTypes.has(itemType)) return false;
|
if (!activeTypes.has(itemType)) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,9 +197,21 @@ export function CalendarOverviewClient({
|
|||||||
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
|
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
|
||||||
const eventStart = new Date(item.startDate);
|
const eventStart = new Date(item.startDate);
|
||||||
const eventEnd = new Date(item.endDate);
|
const eventEnd = new Date(item.endDate);
|
||||||
const dayTime = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
|
const dayTime = new Date(
|
||||||
const startTime = new Date(eventStart.getFullYear(), eventStart.getMonth(), eventStart.getDate()).getTime();
|
day.getFullYear(),
|
||||||
const endTime = new Date(eventEnd.getFullYear(), eventEnd.getMonth(), eventEnd.getDate()).getTime();
|
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;
|
return dayTime >= startTime && dayTime <= endTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -330,7 +361,7 @@ export function CalendarOverviewClient({
|
|||||||
<AgendaView
|
<AgendaView
|
||||||
events={filteredEvents}
|
events={filteredEvents}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
userId={userId}
|
onOpenDetails={(item) => setSelectedDay(new Date(item.startDate))}
|
||||||
onDeleteTermin={(id, title) => {
|
onDeleteTermin={(id, title) => {
|
||||||
setDeleteEventId(id);
|
setDeleteEventId(id);
|
||||||
setDeleteEventTitle(title);
|
setDeleteEventTitle(title);
|
||||||
@@ -339,11 +370,15 @@ export function CalendarOverviewClient({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Day details Dialog for Month View */}
|
{/* Day details Dialog for Month View */}
|
||||||
<Dialog open={!!selectedDay} onOpenChange={(open) => !open && setSelectedDay(null)}>
|
<Dialog
|
||||||
|
open={!!selectedDay}
|
||||||
|
onOpenChange={(open) => !open && setSelectedDay(null)}
|
||||||
|
>
|
||||||
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
|
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl">
|
<DialogTitle className="text-xl">
|
||||||
{selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
|
{selectedDay &&
|
||||||
|
format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag.
|
Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag.
|
||||||
@@ -372,7 +407,14 @@ export function CalendarOverviewClient({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
|
||||||
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" style={{ color: item.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)" }} />
|
<WashingMachine
|
||||||
|
className="h-5 w-5 [stroke-width:1.7]"
|
||||||
|
style={{
|
||||||
|
color: item.isOwn
|
||||||
|
? "oklch(0.60 0.16 160)"
|
||||||
|
: "oklch(0.75 0.08 160)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -410,7 +452,11 @@ export function CalendarOverviewClient({
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<a href="/dashboard/admin/dienste">
|
<a href="/dashboard/admin/dienste">
|
||||||
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs px-2.5"
|
||||||
|
>
|
||||||
Im Planer bearbeiten
|
Im Planer bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
@@ -462,8 +508,14 @@ export function CalendarOverviewClient({
|
|||||||
</p>
|
</p>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<a href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}>
|
<a
|
||||||
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
|
href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs px-2.5"
|
||||||
|
>
|
||||||
Im Planer bearbeiten
|
Im Planer bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
@@ -475,7 +527,11 @@ export function CalendarOverviewClient({
|
|||||||
target={item}
|
target={item}
|
||||||
options={notdienstSwapOptions}
|
options={notdienstSwapOptions}
|
||||||
trigger={
|
trigger={
|
||||||
<Button size="sm" variant="outline" className="h-7 px-2.5 text-xs">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2.5 text-xs"
|
||||||
|
>
|
||||||
Tausch anfragen
|
Tausch anfragen
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -500,7 +556,8 @@ export function CalendarOverviewClient({
|
|||||||
style={{
|
style={{
|
||||||
color: getCategoryColor(item.type),
|
color: getCategoryColor(item.type),
|
||||||
borderColor: getCategoryColor(item.type) + "44",
|
borderColor: getCategoryColor(item.type) + "44",
|
||||||
backgroundColor: getCategoryColor(item.type) + "11",
|
backgroundColor:
|
||||||
|
getCategoryColor(item.type) + "11",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getTypeLabel(item.type)}
|
{getTypeLabel(item.type)}
|
||||||
@@ -523,10 +580,17 @@ export function CalendarOverviewClient({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.rsvpEvent ? <section className="mt-4 border-t border-[var(--hairline)] pt-4">
|
{item.rsvpEvent ? (
|
||||||
<h5 className="mb-3 font-medium">Eure Rückmeldung</h5>
|
<section className="mt-4 border-t border-[var(--hairline)] pt-4">
|
||||||
<RsvpForm key={JSON.stringify(item.rsvpEvent)} event={item.rsvpEvent} />
|
<h5 className="mb-3 font-medium">
|
||||||
</section> : null}
|
Eure Rückmeldung
|
||||||
|
</h5>
|
||||||
|
<RsvpForm
|
||||||
|
key={JSON.stringify(item.rsvpEvent)}
|
||||||
|
event={item.rsvpEvent}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{item.participantInfo && item.showParticipantInfo && (
|
{item.participantInfo && item.showParticipantInfo && (
|
||||||
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
|
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
|
||||||
@@ -534,7 +598,9 @@ export function CalendarOverviewClient({
|
|||||||
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
|
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
|
||||||
Hinweis für Teilnehmer
|
Hinweis für Teilnehmer
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap">{item.participantInfo}</p>
|
<p className="whitespace-pre-wrap">
|
||||||
|
{item.participantInfo}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -582,13 +648,17 @@ export function CalendarOverviewClient({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<Dialog open={!!deleteEventId} onOpenChange={(open) => !open && setDeleteEventId(null)}>
|
<Dialog
|
||||||
|
open={!!deleteEventId}
|
||||||
|
onOpenChange={(open) => !open && setDeleteEventId(null)}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
|
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Möchtest du den Termin “{deleteEventTitle}” wirklich löschen? Alle
|
Möchtest du den Termin “{deleteEventTitle}” wirklich
|
||||||
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
|
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
|
||||||
|
gehen verloren.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -642,7 +712,9 @@ function NotdienstSwapRequestRow({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const runAction = (
|
const runAction = (
|
||||||
action: (requestId: string) => Promise<{ success?: boolean; error?: string }>,
|
action: (
|
||||||
|
requestId: string,
|
||||||
|
) => Promise<{ success?: boolean; error?: string }>,
|
||||||
successMessage: string,
|
successMessage: string,
|
||||||
) => {
|
) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -659,13 +731,15 @@ function NotdienstSwapRequestRow({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
|
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold text-[var(--ink)]">
|
<p className="text-sm font-semibold text-[var(--ink)]">
|
||||||
{request.direction === "incoming" ? "Anfrage an dich" : "Deine Anfrage"}
|
{request.direction === "incoming"
|
||||||
|
? "Anfrage an dich"
|
||||||
|
: "Deine Anfrage"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm leading-5 text-[var(--ink-soft)]">
|
<p className="mt-1 text-sm leading-5 text-[var(--ink-soft)]">
|
||||||
{format(request.targetDate, "dd.MM.yyyy", { locale: de })}{" "}
|
{format(request.targetDate, "dd.MM.yyyy", { locale: de })} (
|
||||||
({request.targetFamilyName}, {request.targetChildName}) gegen{" "}
|
{request.targetFamilyName}, {request.targetChildName}) gegen{" "}
|
||||||
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })}{" "}
|
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })} (
|
||||||
({request.offeredFamilyName}, {request.offeredChildName})
|
{request.offeredFamilyName}, {request.offeredChildName})
|
||||||
</p>
|
</p>
|
||||||
{request.message && (
|
{request.message && (
|
||||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
||||||
@@ -679,7 +753,10 @@ function NotdienstSwapRequestRow({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
runAction(acceptNotdienstSwapRequest, "Tausch wurde angenommen.")
|
runAction(
|
||||||
|
acceptNotdienstSwapRequest,
|
||||||
|
"Tausch wurde angenommen.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
@@ -689,7 +766,10 @@ function NotdienstSwapRequestRow({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
runAction(declineNotdienstSwapRequest, "Tausch wurde abgelehnt.")
|
runAction(
|
||||||
|
declineNotdienstSwapRequest,
|
||||||
|
"Tausch wurde abgelehnt.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
@@ -701,7 +781,10 @@ function NotdienstSwapRequestRow({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
runAction(cancelNotdienstSwapRequest, "Tauschanfrage wurde zurückgezogen.")
|
runAction(
|
||||||
|
cancelNotdienstSwapRequest,
|
||||||
|
"Tauschanfrage wurde zurückgezogen.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
@@ -791,7 +874,9 @@ function MonthView({
|
|||||||
onClick={() => onDayClick(day)}
|
onClick={() => onDayClick(day)}
|
||||||
className={[
|
className={[
|
||||||
"min-h-[105px] bg-[var(--surface)] p-2 transition-colors hover:bg-[var(--surface-2)] cursor-pointer flex flex-col justify-between",
|
"min-h-[105px] bg-[var(--surface)] p-2 transition-colors hover:bg-[var(--surface-2)] cursor-pointer flex flex-col justify-between",
|
||||||
!isCurrentMonth ? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60" : "text-[var(--ink)]",
|
!isCurrentMonth
|
||||||
|
? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60"
|
||||||
|
: "text-[var(--ink)]",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{/* Day number cell top */}
|
{/* Day number cell top */}
|
||||||
@@ -817,10 +902,10 @@ function MonthView({
|
|||||||
? "oklch(0.60 0.16 160)"
|
? "oklch(0.60 0.16 160)"
|
||||||
: "oklch(0.75 0.08 160)"
|
: "oklch(0.75 0.08 160)"
|
||||||
: item.kind === "notdienst"
|
: item.kind === "notdienst"
|
||||||
? item.isOwn
|
? item.isOwn
|
||||||
? "oklch(0.60 0.18 45)"
|
? "oklch(0.60 0.18 45)"
|
||||||
: "oklch(0.75 0.10 50)"
|
: "oklch(0.75 0.10 50)"
|
||||||
: getCategoryColor(item.type);
|
: getCategoryColor(item.type);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -856,25 +941,33 @@ function MonthView({
|
|||||||
function AgendaView({
|
function AgendaView({
|
||||||
events,
|
events,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
userId,
|
|
||||||
onDeleteTermin,
|
onDeleteTermin,
|
||||||
|
onOpenDetails,
|
||||||
}: {
|
}: {
|
||||||
events: CalendarListItemDto[];
|
events: CalendarListItemDto[];
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
userId: string;
|
|
||||||
onDeleteTermin: (id: string, title: string) => void;
|
onDeleteTermin: (id: string, title: string) => void;
|
||||||
|
onOpenDetails: (item: CalendarListItemDto) => void;
|
||||||
}) {
|
}) {
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const groups = new Map<string, { date: Date; items: CalendarListItemDto[] }>();
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
{ date: Date; items: CalendarListItemDto[] }
|
||||||
|
>();
|
||||||
|
|
||||||
events.forEach((item) => {
|
events.forEach((item) => {
|
||||||
const key = format(new Date(item.startDate), "yyyy-MM-dd");
|
const key = format(new Date(item.startDate), "yyyy-MM-dd");
|
||||||
const group = groups.get(key) ?? { date: new Date(item.startDate), items: [] };
|
const group = groups.get(key) ?? {
|
||||||
|
date: new Date(item.startDate),
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
group.items.push(item);
|
group.items.push(item);
|
||||||
groups.set(key, group);
|
groups.set(key, group);
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(groups.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
|
return Array.from(groups.values()).sort(
|
||||||
|
(a, b) => a.date.getTime() - b.date.getTime(),
|
||||||
|
);
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
@@ -896,7 +989,10 @@ function AgendaView({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
|
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
<div key={group.date.toString()} className="relative grid grid-cols-[110px_1fr] gap-4 items-start">
|
<div
|
||||||
|
key={group.date.toString()}
|
||||||
|
className="relative grid grid-cols-1 sm:grid-cols-[110px_minmax(0,1fr)] gap-4 items-start"
|
||||||
|
>
|
||||||
{/* Timeline marker */}
|
{/* Timeline marker */}
|
||||||
<div className="absolute -left-[21px] top-1.5 h-3.5 w-3.5 rounded-full border-2 border-[var(--accent-deep)] bg-[var(--surface)]" />
|
<div className="absolute -left-[21px] top-1.5 h-3.5 w-3.5 rounded-full border-2 border-[var(--accent-deep)] bg-[var(--surface)]" />
|
||||||
|
|
||||||
@@ -917,19 +1013,19 @@ function AgendaView({
|
|||||||
item.kind === "termin"
|
item.kind === "termin"
|
||||||
? getCategoryColor(item.type)
|
? getCategoryColor(item.type)
|
||||||
: item.kind === "notdienst"
|
: item.kind === "notdienst"
|
||||||
? item.isOwn
|
? item.isOwn
|
||||||
? "oklch(0.60 0.18 45)"
|
? "oklch(0.60 0.18 45)"
|
||||||
: "oklch(0.75 0.10 50)"
|
: "oklch(0.75 0.10 50)"
|
||||||
: item.kind === "duty"
|
: item.kind === "duty"
|
||||||
? item.isOwn
|
? item.isOwn
|
||||||
? "oklch(0.60 0.16 160)"
|
? "oklch(0.60 0.16 160)"
|
||||||
: "oklch(0.75 0.08 160)"
|
: "oklch(0.75 0.08 160)"
|
||||||
: "oklch(0.60 0.15 160)";
|
: "oklch(0.60 0.15 160)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="group relative flex items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
|
className="group relative flex flex-wrap items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<div
|
<div
|
||||||
@@ -965,13 +1061,19 @@ function AgendaView({
|
|||||||
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
|
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]"
|
||||||
|
>
|
||||||
Elterndienst
|
Elterndienst
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{item.kind === "termin" && item.status === TerminStatus.PENDING && (
|
{item.kind === "termin" &&
|
||||||
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
|
item.status === TerminStatus.PENDING && (
|
||||||
)}
|
<Badge variant="warning" className="text-[10px]">
|
||||||
|
Ausstehend
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5">
|
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5">
|
||||||
@@ -984,17 +1086,19 @@ function AgendaView({
|
|||||||
"HH:mm",
|
"HH:mm",
|
||||||
)}`
|
)}`
|
||||||
: item.kind === "notdienst"
|
: item.kind === "notdienst"
|
||||||
? `${format(new Date(item.startDate), "dd.MM.yyyy")} (ganztägig)`
|
? `${format(new Date(item.startDate), "dd.MM.yyyy")} (ganztägig)`
|
||||||
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
|
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
|
||||||
new Date(item.endDate),
|
new Date(item.endDate),
|
||||||
"dd.MM.yyyy",
|
"dd.MM.yyyy",
|
||||||
)}`}
|
)}`}
|
||||||
{item.kind === "termin" && <MapPin className="ml-1 h-3.5 w-3.5" />}
|
{item.kind === "termin" && (
|
||||||
|
<MapPin className="ml-1 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
{item.kind === "termin"
|
{item.kind === "termin"
|
||||||
? "Kita"
|
? "Kita"
|
||||||
: item.kind === "notdienst"
|
: item.kind === "notdienst"
|
||||||
? `Kind: ${item.childName} · Familie: ${item.familyName}`
|
? `Kind: ${item.childName} · Familie: ${item.familyName}`
|
||||||
: `Familie: ${item.familyName}`}
|
: `Familie: ${item.familyName}`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{item.kind === "termin" && item.description && (
|
{item.kind === "termin" && item.description && (
|
||||||
@@ -1005,7 +1109,14 @@ function AgendaView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.kind === "termin" && item.rsvpEvent ? <RsvpDialog event={item.rsvpEvent} /> : null}
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
aria-label={`Details zu ${item.title}`}
|
||||||
|
onClick={() => onOpenDetails(item)}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Actions for admins */}
|
{/* Actions for admins */}
|
||||||
{isAdmin && item.kind === "termin" && (
|
{isAdmin && item.kind === "termin" && (
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import { UserRole } from "@prisma/client";
|
|||||||
import { requireKitaSession } from "@/lib/auth-utils";
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
import { getTargetMonthData } from "@/lib/date-utils";
|
import { getTargetMonthData } from "@/lib/date-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
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 { Stat } from "@/components/ui/stat";
|
||||||
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
||||||
import { NewsTicker } from "./news-ticker";
|
import { NewsTicker } from "./news-ticker";
|
||||||
@@ -174,6 +178,7 @@ export default async function DashboardPage() {
|
|||||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||||
<OnboardingDialog
|
<OnboardingDialog
|
||||||
user={{
|
user={{
|
||||||
|
id: session.user.id,
|
||||||
phone: onboardingUser.phone,
|
phone: onboardingUser.phone,
|
||||||
street: onboardingUser.street,
|
street: onboardingUser.street,
|
||||||
postalCode: onboardingUser.postalCode,
|
postalCode: onboardingUser.postalCode,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useId, useState, useTransition, type ReactNode } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -32,6 +33,7 @@ export function Field({
|
|||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
|
autoFocus,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -41,6 +43,7 @@ export function Field({
|
|||||||
min?: string | number;
|
min?: string | number;
|
||||||
max?: string | number;
|
max?: string | number;
|
||||||
step?: string | number;
|
step?: string | number;
|
||||||
|
autoFocus?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +60,7 @@ export function Field({
|
|||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -123,7 +127,11 @@ function transform(command: string, form: FormData) {
|
|||||||
return {
|
return {
|
||||||
kind: text("kind"),
|
kind: text("kind"),
|
||||||
startDate: text("startDate"),
|
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",
|
halfDay: form.get("halfDay") === "on",
|
||||||
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
|
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
|
||||||
note: text("note"),
|
note: text("note"),
|
||||||
@@ -142,13 +150,19 @@ function StaffForm({
|
|||||||
children,
|
children,
|
||||||
label = "Speichern",
|
label = "Speichern",
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
anotherLabel,
|
||||||
}: {
|
}: {
|
||||||
command: string;
|
command: string;
|
||||||
profileId?: string;
|
profileId?: string;
|
||||||
extra?: Record<string, unknown>;
|
extra?: Record<string, unknown>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
label?: string;
|
label?: string;
|
||||||
onSuccess?: (result: StaffActionResult) => void;
|
anotherLabel?: string;
|
||||||
|
onSuccess?: (
|
||||||
|
result: StaffActionResult,
|
||||||
|
form: FormData,
|
||||||
|
another: boolean,
|
||||||
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const [pending, start] = useTransition();
|
const [pending, start] = useTransition();
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -159,6 +173,10 @@ function StaffForm({
|
|||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = new FormData(event.currentTarget);
|
const form = new FormData(event.currentTarget);
|
||||||
|
const another =
|
||||||
|
(event.nativeEvent as SubmitEvent).submitter?.getAttribute(
|
||||||
|
"value",
|
||||||
|
) === "another";
|
||||||
setError("");
|
setError("");
|
||||||
start(async () => {
|
start(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -173,7 +191,7 @@ function StaffForm({
|
|||||||
}
|
}
|
||||||
if (result.warning) toast.warning(result.warning);
|
if (result.warning) toast.warning(result.warning);
|
||||||
else toast.success(result.message ?? "Gespeichert.");
|
else toast.success(result.message ?? "Gespeichert.");
|
||||||
onSuccess?.(result);
|
onSuccess?.(result, form, another);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(
|
||||||
@@ -187,10 +205,20 @@ function StaffForm({
|
|||||||
>
|
>
|
||||||
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
|
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
|
||||||
{children}
|
{children}
|
||||||
<div>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="submit">
|
<Button type="submit">
|
||||||
{pending ? "Wird gespeichert …" : label}
|
{pending ? "Wird gespeichert …" : label}
|
||||||
</Button>
|
</Button>
|
||||||
|
{anotherLabel ? (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
name="intent"
|
||||||
|
value="another"
|
||||||
|
>
|
||||||
|
{anotherLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -333,8 +361,12 @@ export function SetupForm({
|
|||||||
export function TimeForm({
|
export function TimeForm({
|
||||||
profileId,
|
profileId,
|
||||||
initial,
|
initial,
|
||||||
|
defaultDate,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
defaultDate?: string;
|
||||||
|
compact?: boolean;
|
||||||
initial?: {
|
initial?: {
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -345,14 +377,34 @@ export function TimeForm({
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [date, setDate] = useState(initial?.date ?? defaultDate ?? todayKey());
|
||||||
|
const [blockVersion, setBlockVersion] = useState(0);
|
||||||
|
const [continuation, setContinuation] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (next) {
|
||||||
|
setDate(initial?.date ?? defaultDate ?? todayKey());
|
||||||
|
setContinuation(false);
|
||||||
|
}
|
||||||
|
setOpen(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={initial ? "outline" : "default"}
|
variant={initial || compact ? "outline" : "default"}
|
||||||
size={initial ? "sm" : "default"}
|
size={initial || compact ? "sm" : "default"}
|
||||||
|
disabled={!initial && !!defaultDate && defaultDate > todayKey()}
|
||||||
|
aria-label={
|
||||||
|
compact ? `Arbeitszeit für ${defaultDate} erfassen` : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{initial ? "Bearbeiten" : "+ Arbeitszeit erfassen"}
|
{initial
|
||||||
|
? "Bearbeiten"
|
||||||
|
: compact
|
||||||
|
? "Zeit erfassen"
|
||||||
|
: "+ Arbeitszeit erfassen"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -366,31 +418,50 @@ export function TimeForm({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<StaffForm
|
<StaffForm
|
||||||
|
key={blockVersion}
|
||||||
command="time"
|
command="time"
|
||||||
profileId={profileId}
|
profileId={profileId}
|
||||||
extra={initial ? { id: initial.id } : {}}
|
extra={initial ? { id: initial.id } : {}}
|
||||||
label="Arbeitszeit speichern"
|
label="Arbeitszeit speichern"
|
||||||
onSuccess={() => 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);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
label="Datum"
|
label="Datum"
|
||||||
name="date"
|
name="date"
|
||||||
type="date"
|
type="date"
|
||||||
value={initial?.date ?? todayKey()}
|
value={date}
|
||||||
max={todayKey()}
|
max={todayKey()}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field
|
<Field
|
||||||
label="Beginn"
|
label="Beginn"
|
||||||
|
autoFocus={continuation}
|
||||||
name="start"
|
name="start"
|
||||||
type="time"
|
type="time"
|
||||||
value={minuteClock(initial?.startMinute ?? 480)}
|
value={
|
||||||
|
continuation
|
||||||
|
? undefined
|
||||||
|
: minuteClock(initial?.startMinute ?? 480)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Ende"
|
label="Ende"
|
||||||
name="end"
|
name="end"
|
||||||
type="time"
|
type="time"
|
||||||
value={minuteClock(initial?.endMinute ?? 960)}
|
value={
|
||||||
|
continuation
|
||||||
|
? undefined
|
||||||
|
: minuteClock(initial?.endMinute ?? 960)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
@@ -399,7 +470,7 @@ export function TimeForm({
|
|||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
value={initial?.breakMinutes ?? 30}
|
value={continuation ? 0 : (initial?.breakMinutes ?? 30)}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Notiz (optional)"
|
label="Notiz (optional)"
|
||||||
@@ -431,9 +502,24 @@ export function LeaveForm({
|
|||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
|
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
|
||||||
|
const [startDate, setStartDate] = useState(initial?.startDate ?? todayKey());
|
||||||
|
const [endDate, setEndDate] = useState(initial?.endDate ?? todayKey());
|
||||||
|
const [halfDay, setHalfDay] = useState(initial?.halfDay ?? false);
|
||||||
|
const singleDay = kind === "TIME_OFF" || (kind === "VACATION" && halfDay);
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (next) {
|
||||||
|
setKind(initial?.kind ?? "VACATION");
|
||||||
|
setStartDate(initial?.startDate ?? todayKey());
|
||||||
|
setEndDate(initial?.endDate ?? todayKey());
|
||||||
|
setHalfDay(initial?.halfDay ?? false);
|
||||||
|
}
|
||||||
|
setOpen(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size={initial ? "sm" : "default"} variant="outline">
|
<Button size={initial ? "sm" : "default"} variant="outline">
|
||||||
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
|
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
|
||||||
@@ -474,33 +560,51 @@ export function LeaveForm({
|
|||||||
{admin && <option value="SICKNESS">Krankheit</option>}
|
{admin && <option value="SICKNESS">Krankheit</option>}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{kind === "VACATION" ? (
|
||||||
<Field
|
|
||||||
label="Von"
|
|
||||||
name="startDate"
|
|
||||||
type="date"
|
|
||||||
value={initial?.startDate ?? todayKey()}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Bis"
|
|
||||||
name="endDate"
|
|
||||||
type="date"
|
|
||||||
value={initial?.endDate ?? todayKey()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{kind === "VACATION" && (
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
name="halfDay"
|
name="halfDay"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
defaultChecked={initial?.halfDay}
|
checked={halfDay}
|
||||||
/>
|
onChange={(event) => setHalfDay(event.target.checked)}
|
||||||
Halber Urlaubstag (Von = Bis)
|
/>{" "}
|
||||||
|
Halber Urlaubstag
|
||||||
</label>
|
</label>
|
||||||
)}
|
) : null}
|
||||||
|
<div className={singleDay ? "grid gap-4" : "grid grid-cols-2 gap-4"}>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor={`${id}-start`}>
|
||||||
|
{singleDay ? "Datum" : "Von"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-start`}
|
||||||
|
name="startDate"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(event) => setStartDate(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{singleDay ? (
|
||||||
|
<input type="hidden" name="endDate" value={startDate} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor={`${id}-end`}>Bis</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-end`}
|
||||||
|
name="endDate"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(event) => setEndDate(event.target.value)}
|
||||||
|
required
|
||||||
|
min={startDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{kind === "TIME_OFF" && (
|
{kind === "TIME_OFF" && (
|
||||||
<Field
|
<Field
|
||||||
label="Freizeitausgleich in Stunden (Von = Bis)"
|
label="Freizeitausgleich in Stunden"
|
||||||
name="hours"
|
name="hours"
|
||||||
type="number"
|
type="number"
|
||||||
min={1 / 60}
|
min={1 / 60}
|
||||||
|
|||||||
@@ -284,7 +284,17 @@ export default async function PersonalPage({
|
|||||||
</div>
|
</div>
|
||||||
{profile && (
|
{profile && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<TimeForm profileId={profile.id} />
|
<TimeForm
|
||||||
|
key={`${profile.id}-${start}`}
|
||||||
|
profileId={profile.id}
|
||||||
|
defaultDate={
|
||||||
|
tab === "zeit"
|
||||||
|
? today >= start && today <= end
|
||||||
|
? today
|
||||||
|
: start
|
||||||
|
: today
|
||||||
|
}
|
||||||
|
/>
|
||||||
<LeaveForm profileId={profile.id} admin={admin} />
|
<LeaveForm profileId={profile.id} admin={admin} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -645,6 +655,7 @@ export default async function PersonalPage({
|
|||||||
"Ist",
|
"Ist",
|
||||||
"Urlaub / Krankheit",
|
"Urlaub / Krankheit",
|
||||||
"Differenz",
|
"Differenz",
|
||||||
|
"Erfassen",
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<th key={s} className="pb-3 pr-4 font-medium">
|
<th key={s} className="pb-3 pr-4 font-medium">
|
||||||
{s}
|
{s}
|
||||||
@@ -682,6 +693,15 @@ export default async function PersonalPage({
|
|||||||
>
|
>
|
||||||
{formatMinutes(d.balance)}
|
{formatMinutes(d.balance)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3 pl-3">
|
||||||
|
{editable && d.date <= today ? (
|
||||||
|
<TimeForm
|
||||||
|
profileId={profile.id}
|
||||||
|
defaultDate={d.date}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -696,6 +716,7 @@ export default async function PersonalPage({
|
|||||||
<td className="pt-4">
|
<td className="pt-4">
|
||||||
{formatMinutes(totals.balance)}
|
{formatMinutes(totals.balance)}
|
||||||
</td>
|
</td>
|
||||||
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { DeleteAccountDialog } from "./_components/delete-account-dialog";
|
|||||||
import { MyChildrenManager } from "./_components/my-children-manager";
|
import { MyChildrenManager } from "./_components/my-children-manager";
|
||||||
import { CreateFamilyDialog } from "./_components/create-family-dialog";
|
import { CreateFamilyDialog } from "./_components/create-family-dialog";
|
||||||
import { ContactForm } from "./_components/contact-form";
|
import { ContactForm } from "./_components/contact-form";
|
||||||
|
import { PasswordForm } from "../../passwort-vergessen/password-form";
|
||||||
import { StaffEmailSettings } from "../personal/forms";
|
import { StaffEmailSettings } from "../personal/forms";
|
||||||
|
|
||||||
export const metadata = { title: "Mein Profil · Kita-Planer" };
|
export const metadata = { title: "Mein Profil · Kita-Planer" };
|
||||||
@@ -211,6 +212,18 @@ export default async function ProfilPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Passwort ändern</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Verwalte dein persönliches Anmeldepasswort.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PasswordForm mode="change" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{user.staffProfile && (
|
{user.staffProfile && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useActionState, useState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,7 +24,10 @@ export function LoginForm({
|
|||||||
devParentEmail?: string;
|
devParentEmail?: string;
|
||||||
devParentPassword?: string;
|
devParentPassword?: string;
|
||||||
}) {
|
}) {
|
||||||
const [state, formAction, pending] = useActionState(loginAction, initialState);
|
const [state, formAction, pending] = useActionState(
|
||||||
|
loginAction,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
@@ -67,6 +71,13 @@ export function LoginForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/passwort-vergessen"
|
||||||
|
className="inline-block text-sm text-[var(--accent-deep)] underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Passwort vergessen?
|
||||||
|
</Link>
|
||||||
|
|
||||||
{state.errors?._form?.[0] && (
|
{state.errors?._form?.[0] && (
|
||||||
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
{state.errors._form[0]}
|
{state.errors._form[0]}
|
||||||
@@ -79,7 +90,9 @@ export function LoginForm({
|
|||||||
|
|
||||||
{showDevHelpers && (
|
{showDevHelpers && (
|
||||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/40 p-3 space-y-2 text-xs">
|
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/40 p-3 space-y-2 text-xs">
|
||||||
<p className="text-center font-medium text-muted-foreground">Dev-Schnellzugriff (nur lokal):</p>
|
<p className="text-center font-medium text-muted-foreground">
|
||||||
|
Dev-Schnellzugriff (nur lokal):
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{devAdminEmail && (
|
{devAdminEmail && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
+24
-4
@@ -17,7 +17,11 @@ import { LoginForm } from "./login-form";
|
|||||||
export const metadata = { title: "Anmelden · Kita-Planer" };
|
export const metadata = { title: "Anmelden · Kita-Planer" };
|
||||||
|
|
||||||
type LoginPageProps = {
|
type LoginPageProps = {
|
||||||
searchParams?: Promise<{ callbackUrl?: string | string[]; reauth?: string }>;
|
searchParams?: Promise<{
|
||||||
|
callbackUrl?: string | string[];
|
||||||
|
reauth?: string;
|
||||||
|
passwordChanged?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
@@ -31,9 +35,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
|
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
|
||||||
const devAdminPassword = isDev ? process.env.DEV_LOGIN_ADMIN_PASSWORD : undefined;
|
const devAdminPassword = isDev
|
||||||
|
? process.env.DEV_LOGIN_ADMIN_PASSWORD
|
||||||
|
: undefined;
|
||||||
const devParentEmail = isDev ? process.env.DEV_LOGIN_PARENT_EMAIL : undefined;
|
const devParentEmail = isDev ? process.env.DEV_LOGIN_PARENT_EMAIL : undefined;
|
||||||
const devParentPassword = isDev ? process.env.DEV_LOGIN_PARENT_PASSWORD : undefined;
|
const devParentPassword = isDev
|
||||||
|
? process.env.DEV_LOGIN_PARENT_PASSWORD
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
|
||||||
@@ -43,6 +51,15 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
<CardDescription>Willkommen zurück.</CardDescription>
|
<CardDescription>Willkommen zurück.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{resolvedSearchParams?.passwordChanged === "1" ? (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="rounded-lg bg-[var(--good-soft)] p-3 text-sm text-[var(--ink)]"
|
||||||
|
>
|
||||||
|
Dein Passwort wurde geändert. Melde dich mit dem neuen Passwort
|
||||||
|
an.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<LoginForm
|
<LoginForm
|
||||||
callbackUrl={callbackUrl}
|
callbackUrl={callbackUrl}
|
||||||
devAdminEmail={devAdminEmail}
|
devAdminEmail={devAdminEmail}
|
||||||
@@ -52,7 +69,10 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
/>
|
/>
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Noch keinen Account?{" "}
|
Noch keinen Account?{" "}
|
||||||
<Link href="/register" className="font-medium text-foreground underline">
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="font-medium text-foreground underline"
|
||||||
|
>
|
||||||
Kita registrieren
|
Kita registrieren
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { isPasswordResetValid } from "@/lib/password-management";
|
||||||
|
import { PasswordForm } from "../../passwort-vergessen/password-form";
|
||||||
|
export const metadata = {
|
||||||
|
title: "Neues Passwort · Kita-Planer",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
export default async function ResetPasswordPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ token: string }>;
|
||||||
|
}) {
|
||||||
|
const { token } = await params;
|
||||||
|
const valid = await isPasswordResetValid(token);
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{valid
|
||||||
|
? "Neues Passwort festlegen"
|
||||||
|
: "Dieser Link ist nicht mehr gültig"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{valid
|
||||||
|
? "Wähle ein neues Passwort für dein Kita-Planer-Konto."
|
||||||
|
: "Der Link ist abgelaufen, wurde bereits verwendet oder ist ungültig. Du kannst einen neuen Link anfordern."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{valid ? (
|
||||||
|
<PasswordForm mode="reset" token={token} />
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/passwort-vergessen"
|
||||||
|
className="text-sm font-semibold text-[var(--accent-deep)] underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Neuen Link anfordern
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { after } from "next/server";
|
||||||
|
import { limitPublicAction } from "@/lib/rate-limit";
|
||||||
|
import {
|
||||||
|
requestPasswordReset,
|
||||||
|
passwordResetMessage,
|
||||||
|
resetPassword,
|
||||||
|
changeOwnPassword,
|
||||||
|
} from "@/lib/password-management";
|
||||||
|
import { hashToken } from "@/lib/tokens";
|
||||||
|
import { requireSession } from "@/lib/auth-utils";
|
||||||
|
import { publicActionError } from "@/lib/action-error";
|
||||||
|
|
||||||
|
export type PasswordActionState = {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
success?: boolean;
|
||||||
|
};
|
||||||
|
export async function requestResetAction(
|
||||||
|
_previous: PasswordActionState,
|
||||||
|
form: FormData,
|
||||||
|
): Promise<PasswordActionState> {
|
||||||
|
const email = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(254)
|
||||||
|
.email()
|
||||||
|
.toLowerCase()
|
||||||
|
.safeParse(form.get("email"));
|
||||||
|
if (!email.success)
|
||||||
|
return { error: "Bitte gib eine gültige E-Mail-Adresse ein." };
|
||||||
|
try {
|
||||||
|
if (await limitPublicAction("password-reset-request", email.data, 3, 3600))
|
||||||
|
after(async () => {
|
||||||
|
try {
|
||||||
|
await requestPasswordReset(email.data);
|
||||||
|
} catch {
|
||||||
|
console.error(
|
||||||
|
"Passwort-Wiederherstellung konnte nicht vorbereitet werden.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
console.error(
|
||||||
|
"Passwort-Wiederherstellung konnte nicht vorbereitet werden.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { success: true, message: passwordResetMessage };
|
||||||
|
}
|
||||||
|
export async function finishResetAction(
|
||||||
|
_previous: PasswordActionState,
|
||||||
|
form: FormData,
|
||||||
|
): Promise<PasswordActionState> {
|
||||||
|
const token = form.get("token");
|
||||||
|
if (typeof token !== "string" || !/^[a-f0-9]{64}$/i.test(token))
|
||||||
|
return {
|
||||||
|
error: "Dieser Link ist ungültig. Bitte fordere einen neuen Link an.",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!(await limitPublicAction(
|
||||||
|
"password-reset-complete",
|
||||||
|
hashToken(token),
|
||||||
|
5,
|
||||||
|
900,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return { error: "Zu viele Versuche. Bitte versuche es später erneut." };
|
||||||
|
await resetPassword({
|
||||||
|
token,
|
||||||
|
password: form.get("password"),
|
||||||
|
confirmation: form.get("confirmation"),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"Dein Passwort wurde geändert. Melde dich mit deinem neuen Passwort an.",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Das Passwort konnte nicht geändert werden. Bitte versuche es erneut.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function changePasswordAction(
|
||||||
|
_previous: PasswordActionState,
|
||||||
|
form: FormData,
|
||||||
|
): Promise<PasswordActionState> {
|
||||||
|
const { user } = await requireSession();
|
||||||
|
try {
|
||||||
|
if (!(await limitPublicAction("password-change", user.id, 5, 900)))
|
||||||
|
return { error: "Zu viele Versuche. Bitte versuche es später erneut." };
|
||||||
|
await changeOwnPassword(
|
||||||
|
{ id: user.id, sessionVersion: user.sessionVersion },
|
||||||
|
{
|
||||||
|
currentPassword: form.get("currentPassword"),
|
||||||
|
password: form.get("password"),
|
||||||
|
confirmation: form.get("confirmation"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Dein Passwort wurde geändert. Bitte melde dich erneut an.",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Das Passwort konnte nicht geändert werden. Bitte versuche es erneut.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { PasswordForm } from "./password-form";
|
||||||
|
export const metadata = {
|
||||||
|
title: "Passwort vergessen · Kita-Planer",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Passwort vergessen?</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Fordere einen persönlichen Link an, um ein neues Passwort
|
||||||
|
festzulegen. Der Link ist 30 Minuten gültig.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<PasswordForm mode="request" />
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-block text-sm text-[var(--accent-deep)] underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Zurück zur Anmeldung
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useActionState, useEffect, useId } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
requestResetAction,
|
||||||
|
finishResetAction,
|
||||||
|
changePasswordAction,
|
||||||
|
type PasswordActionState,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
export function PasswordForm({
|
||||||
|
mode,
|
||||||
|
token,
|
||||||
|
}: {
|
||||||
|
mode: "request" | "reset" | "change";
|
||||||
|
token?: string;
|
||||||
|
}) {
|
||||||
|
const action =
|
||||||
|
mode === "request"
|
||||||
|
? requestResetAction
|
||||||
|
: mode === "reset"
|
||||||
|
? finishResetAction
|
||||||
|
: changePasswordAction;
|
||||||
|
const [state, formAction, pending] = useActionState(
|
||||||
|
action,
|
||||||
|
{} as PasswordActionState,
|
||||||
|
);
|
||||||
|
const id = useId();
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "change" && state.success)
|
||||||
|
window.location.replace(
|
||||||
|
new URL("/login?passwordChanged=1", window.location.origin).href,
|
||||||
|
);
|
||||||
|
}, [mode, state.success]);
|
||||||
|
if (state.success)
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="rounded-xl border border-[var(--hairline)] bg-[var(--good-soft)] p-4 text-sm leading-6 text-[var(--ink)]"
|
||||||
|
>
|
||||||
|
{state.message}
|
||||||
|
</p>
|
||||||
|
{mode !== "request" ? (
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-sm font-semibold text-[var(--accent-deep)] underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Zur Anmeldung
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
{token ? <input type="hidden" name="token" value={token} /> : null}
|
||||||
|
{mode === "request" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${id}-email`}>E-Mail-Adresse</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-email`}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
maxLength={254}
|
||||||
|
required
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{mode === "change" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${id}-current`}>Aktuelles Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-current`}
|
||||||
|
name="currentPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${id}-password`}>Neues Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-password`}
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
Mindestens 8 Zeichen. Verwende ein eigenes Passwort für den
|
||||||
|
Kita-Planer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${id}-confirmation`}>
|
||||||
|
Neues Passwort wiederholen
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-confirmation`}
|
||||||
|
name="confirmation"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
Nach dem Speichern werden alle bisherigen Anmeldungen beendet.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state.error ? (
|
||||||
|
<p role="alert" className="text-sm leading-6 text-[var(--danger)]">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" className="w-full sm:w-auto" disabled={pending}>
|
||||||
|
{pending
|
||||||
|
? "Wird verarbeitet …"
|
||||||
|
: mode === "request"
|
||||||
|
? "Link anfordern"
|
||||||
|
: "Passwort speichern"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ type UserRoleDto =
|
|||||||
|
|
||||||
type OnboardingDialogProps = {
|
type OnboardingDialogProps = {
|
||||||
user: {
|
user: {
|
||||||
|
id: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
street: string | null;
|
street: string | null;
|
||||||
postalCode: string | null;
|
postalCode: string | null;
|
||||||
@@ -43,7 +44,7 @@ type OnboardingDialogProps = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HIDE_ONBOARDING_DIALOG_KEY = "hideOnboardingDialog";
|
const onboardingKey = (userId: string) => `hideOnboardingDialog:${userId}`;
|
||||||
|
|
||||||
export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
|
export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -79,14 +80,18 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
|
|||||||
}, [family?.childrenCount, user]);
|
}, [family?.childrenCount, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status.isComplete) return;
|
let hidden: string | null = null;
|
||||||
|
try {
|
||||||
const hidden = localStorage.getItem(HIDE_ONBOARDING_DIALOG_KEY);
|
hidden = localStorage.getItem(onboardingKey(user.id));
|
||||||
if (hidden !== "true") {
|
} catch {
|
||||||
const timer = window.setTimeout(() => setOpen(true), 0);
|
/* Unavailable browser storage does not prevent using the app. */
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
}, [status.isComplete]);
|
const timer = window.setTimeout(
|
||||||
|
() => setOpen(!status.isComplete && hidden !== "true"),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [status.isComplete, user.id]);
|
||||||
|
|
||||||
if (status.isComplete) {
|
if (status.isComplete) {
|
||||||
return null;
|
return null;
|
||||||
@@ -97,7 +102,11 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
|
|||||||
: "Haushalt & Kinder einrichten";
|
: "Haushalt & Kinder einrichten";
|
||||||
|
|
||||||
function hidePermanently() {
|
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);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +122,8 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
|
|||||||
Dein Kita-Profil ist fast bereit
|
Dein Kita-Profil ist fast bereit
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-emerald-50/80">
|
<DialogDescription className="text-emerald-50/80">
|
||||||
Vervollständige die wichtigsten Daten, damit Notdienst,
|
Vervollständige die wichtigsten Daten, damit Notdienst, Adressbuch
|
||||||
Adressbuch und Familienverwaltung reibungslos funktionieren.
|
und Familienverwaltung reibungslos funktionieren.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Html lang="de">
|
||||||
|
<Head />
|
||||||
|
<Preview>Dein Link für ein neues Kita-Planer-Passwort</Preview>
|
||||||
|
<Body style={baseBodyStyle}>
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
maxWidth: "520px",
|
||||||
|
padding: "32px",
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Heading>Neues Passwort festlegen</Heading>
|
||||||
|
<Text>
|
||||||
|
Hallo {name}, du hast einen Link angefordert, um dein
|
||||||
|
Kita-Planer-Passwort zurückzusetzen.
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
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.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
href={resetLink}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.brandGreenAccent,
|
||||||
|
color: "#ffffff",
|
||||||
|
padding: "12px 18px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Neues Passwort festlegen
|
||||||
|
</Button>
|
||||||
|
<Text>Falls der Button nicht funktioniert: {resetLink}</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user