feat: add password recovery and everyday workflow improvements

This commit is contained in:
t.indorf
2026-10-12 17:51:50 +02:00
parent 061024d901
commit 661776e609
19 changed files with 1721 additions and 141 deletions
+30 -8
View File
@@ -16,7 +16,12 @@ import {
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
import {
createDuty,
assignDuty,
removeDutyAssignment,
deleteDuty,
} from "./duty-actions";
type DutyWithAssignments = {
id: string;
@@ -100,17 +105,25 @@ export function DutyManager({
};
const unassignedDuties = allDuties.filter(
(d) => !userAssignments.some((a) => a.dutyId === d.id)
(d) => !userAssignments.some((a) => a.dutyId === d.id),
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<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" />
<span className="hidden sm:inline">Ämter</span>
<span>Ämter: {user.firstName}</span>
{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}
</Badge>
)}
@@ -137,7 +150,11 @@ export function DutyManager({
) : (
<div className="flex flex-wrap gap-2">
{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}
<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)]"
@@ -172,7 +189,7 @@ export function DutyManager({
>
<Plus className="h-3 w-3" />
{duty.name}
{/* Only show delete if no one is assigned to it across the entire DB */}
{duty.assignments.length === 0 && (
<button
@@ -199,7 +216,12 @@ export function DutyManager({
Neues Amt anlegen
</h4>
<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}>
Erstellen
</Button>
@@ -1,10 +1,7 @@
"use client";
import { useState, useMemo, useTransition } from "react";
import {
TerminType,
TerminStatus,
} from "@prisma/client";
import { TerminType, TerminStatus } from "@prisma/client";
import {
format,
startOfMonth,
@@ -30,7 +27,7 @@ import {
WashingMachine,
Trash2,
} from "lucide-react";
import { RsvpDialog, RsvpForm } from "@/components/rsvp-form";
import { RsvpForm } from "@/components/rsvp-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -42,7 +39,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
@@ -64,12 +60,32 @@ import {
const categories = [
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
{ label: "Teamtag", type: TerminType.TEAMTAG, color: "var(--danger)" },
{ label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" },
{
label: "Elternabend",
type: TerminType.ELTERNABEND,
color: "oklch(0.50 0.10 280)",
},
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
{ label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" },
{ label: "Mein Elterndienst", type: "mein-elterndienst", color: "oklch(0.60 0.16 160)" },
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" },
{ label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" },
{
label: "Geburtstag",
type: TerminType.GEBURTSTAG_INTERN,
color: "oklch(0.50 0.10 320)",
},
{
label: "Mein Elterndienst",
type: "mein-elterndienst",
color: "oklch(0.60 0.16 160)",
},
{
label: "Elterndienst",
type: "elterndienst",
color: "oklch(0.75 0.08 160)",
},
{
label: "Mein Notdienst",
type: "mein-notdienst",
color: "oklch(0.60 0.18 45)",
},
{ label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
];
@@ -102,7 +118,9 @@ export function CalendarOverviewClient({
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
const [searchQuery, setSearchQuery] = useState("");
const [activeTypes, setActiveTypes] = useState<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
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
@@ -152,7 +170,8 @@ export function CalendarOverviewClient({
item.kind === "notdienst" && item.childName
? item.childName.toLowerCase().includes(query)
: false;
if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false;
if (!titleMatch && !descMatch && !familyMatch && !childMatch)
return false;
}
// 2. Category filter
@@ -163,10 +182,10 @@ export function CalendarOverviewClient({
? "mein-elterndienst"
: "elterndienst"
: item.kind === "notdienst"
? item.isOwn
? "mein-notdienst"
: "notdienst"
: item.type;
? item.isOwn
? "mein-notdienst"
: "notdienst"
: item.type;
if (!activeTypes.has(itemType)) return false;
}
@@ -178,9 +197,21 @@ export function CalendarOverviewClient({
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
const eventStart = new Date(item.startDate);
const eventEnd = new Date(item.endDate);
const dayTime = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
const startTime = new Date(eventStart.getFullYear(), eventStart.getMonth(), eventStart.getDate()).getTime();
const endTime = new Date(eventEnd.getFullYear(), eventEnd.getMonth(), eventEnd.getDate()).getTime();
const dayTime = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
).getTime();
const startTime = new Date(
eventStart.getFullYear(),
eventStart.getMonth(),
eventStart.getDate(),
).getTime();
const endTime = new Date(
eventEnd.getFullYear(),
eventEnd.getMonth(),
eventEnd.getDate(),
).getTime();
return dayTime >= startTime && dayTime <= endTime;
};
@@ -330,7 +361,7 @@ export function CalendarOverviewClient({
<AgendaView
events={filteredEvents}
isAdmin={isAdmin}
userId={userId}
onOpenDetails={(item) => setSelectedDay(new Date(item.startDate))}
onDeleteTermin={(id, title) => {
setDeleteEventId(id);
setDeleteEventTitle(title);
@@ -339,11 +370,15 @@ export function CalendarOverviewClient({
)}
{/* Day details Dialog for Month View */}
<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">
<DialogHeader>
<DialogTitle className="text-xl">
{selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
{selectedDay &&
format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
</DialogTitle>
<DialogDescription>
Ü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">
<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 className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
@@ -410,7 +452,11 @@ export function CalendarOverviewClient({
{isAdmin && (
<div className="mt-3 flex gap-2">
<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
</Button>
</a>
@@ -462,8 +508,14 @@ export function CalendarOverviewClient({
</p>
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}>
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
<a
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
</Button>
</a>
@@ -475,7 +527,11 @@ export function CalendarOverviewClient({
target={item}
options={notdienstSwapOptions}
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
</Button>
}
@@ -500,7 +556,8 @@ export function CalendarOverviewClient({
style={{
color: getCategoryColor(item.type),
borderColor: getCategoryColor(item.type) + "44",
backgroundColor: getCategoryColor(item.type) + "11",
backgroundColor:
getCategoryColor(item.type) + "11",
}}
>
{getTypeLabel(item.type)}
@@ -523,10 +580,17 @@ export function CalendarOverviewClient({
</p>
)}
{item.rsvpEvent ? <section className="mt-4 border-t border-[var(--hairline)] pt-4">
<h5 className="mb-3 font-medium">Eure Rückmeldung</h5>
<RsvpForm key={JSON.stringify(item.rsvpEvent)} event={item.rsvpEvent} />
</section> : null}
{item.rsvpEvent ? (
<section className="mt-4 border-t border-[var(--hairline)] pt-4">
<h5 className="mb-3 font-medium">
Eure Rückmeldung
</h5>
<RsvpForm
key={JSON.stringify(item.rsvpEvent)}
event={item.rsvpEvent}
/>
</section>
) : null}
{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)]">
@@ -534,7 +598,9 @@ export function CalendarOverviewClient({
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{item.participantInfo}</p>
<p className="whitespace-pre-wrap">
{item.participantInfo}
</p>
</div>
)}
@@ -582,13 +648,17 @@ export function CalendarOverviewClient({
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteEventId} onOpenChange={(open) => !open && setDeleteEventId(null)}>
<Dialog
open={!!deleteEventId}
onOpenChange={(open) => !open && setDeleteEventId(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{deleteEventTitle}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
Möchtest du den Termin &ldquo;{deleteEventTitle}&rdquo; wirklich
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -642,7 +712,9 @@ function NotdienstSwapRequestRow({
const [isPending, startTransition] = useTransition();
const runAction = (
action: (requestId: string) => Promise<{ success?: boolean; error?: string }>,
action: (
requestId: string,
) => Promise<{ success?: boolean; error?: string }>,
successMessage: string,
) => {
startTransition(async () => {
@@ -659,13 +731,15 @@ function NotdienstSwapRequestRow({
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<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 className="mt-1 text-sm leading-5 text-[var(--ink-soft)]">
{format(request.targetDate, "dd.MM.yyyy", { locale: de })}{" "}
({request.targetFamilyName}, {request.targetChildName}) gegen{" "}
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })}{" "}
({request.offeredFamilyName}, {request.offeredChildName})
{format(request.targetDate, "dd.MM.yyyy", { locale: de })} (
{request.targetFamilyName}, {request.targetChildName}) gegen{" "}
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })} (
{request.offeredFamilyName}, {request.offeredChildName})
</p>
{request.message && (
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
@@ -679,7 +753,10 @@ function NotdienstSwapRequestRow({
<Button
size="sm"
onClick={() =>
runAction(acceptNotdienstSwapRequest, "Tausch wurde angenommen.")
runAction(
acceptNotdienstSwapRequest,
"Tausch wurde angenommen.",
)
}
disabled={isPending}
>
@@ -689,7 +766,10 @@ function NotdienstSwapRequestRow({
size="sm"
variant="ghost"
onClick={() =>
runAction(declineNotdienstSwapRequest, "Tausch wurde abgelehnt.")
runAction(
declineNotdienstSwapRequest,
"Tausch wurde abgelehnt.",
)
}
disabled={isPending}
>
@@ -701,7 +781,10 @@ function NotdienstSwapRequestRow({
size="sm"
variant="ghost"
onClick={() =>
runAction(cancelNotdienstSwapRequest, "Tauschanfrage wurde zurückgezogen.")
runAction(
cancelNotdienstSwapRequest,
"Tauschanfrage wurde zurückgezogen.",
)
}
disabled={isPending}
>
@@ -791,7 +874,9 @@ function MonthView({
onClick={() => onDayClick(day)}
className={[
"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(" ")}
>
{/* Day number cell top */}
@@ -817,10 +902,10 @@ function MonthView({
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)"
: item.kind === "notdienst"
? item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)"
: getCategoryColor(item.type);
? item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)"
: getCategoryColor(item.type);
return (
<div
key={item.id}
@@ -856,25 +941,33 @@ function MonthView({
function AgendaView({
events,
isAdmin,
userId,
onDeleteTermin,
onOpenDetails,
}: {
events: CalendarListItemDto[];
isAdmin: boolean;
userId: string;
onDeleteTermin: (id: string, title: string) => void;
onOpenDetails: (item: CalendarListItemDto) => void;
}) {
const grouped = useMemo(() => {
const groups = new Map<string, { date: Date; items: CalendarListItemDto[] }>();
const groups = new Map<
string,
{ date: Date; items: CalendarListItemDto[] }
>();
events.forEach((item) => {
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);
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]);
if (events.length === 0) {
@@ -896,7 +989,10 @@ function AgendaView({
return (
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
{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 */}
<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"
? getCategoryColor(item.type)
: item.kind === "notdienst"
? item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)"
: item.kind === "duty"
? item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)"
: "oklch(0.60 0.15 160)";
? item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)"
: item.kind === "duty"
? item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)"
: "oklch(0.60 0.15 160)";
return (
<div
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
@@ -965,13 +1061,19 @@ function AgendaView({
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
</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
</Badge>
)}
{item.kind === "termin" && item.status === TerminStatus.PENDING && (
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
)}
{item.kind === "termin" &&
item.status === TerminStatus.PENDING && (
<Badge variant="warning" className="text-[10px]">
Ausstehend
</Badge>
)}
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5">
@@ -984,17 +1086,19 @@ function AgendaView({
"HH:mm",
)}`
: item.kind === "notdienst"
? `${format(new Date(item.startDate), "dd.MM.yyyy")} (ganztägig)`
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
new Date(item.endDate),
"dd.MM.yyyy",
)}`}
{item.kind === "termin" && <MapPin className="ml-1 h-3.5 w-3.5" />}
? `${format(new Date(item.startDate), "dd.MM.yyyy")} (ganztägig)`
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
new Date(item.endDate),
"dd.MM.yyyy",
)}`}
{item.kind === "termin" && (
<MapPin className="ml-1 h-3.5 w-3.5" />
)}
{item.kind === "termin"
? "Kita"
: item.kind === "notdienst"
? `Kind: ${item.childName} · Familie: ${item.familyName}`
: `Familie: ${item.familyName}`}
? `Kind: ${item.childName} · Familie: ${item.familyName}`
: `Familie: ${item.familyName}`}
</p>
{item.kind === "termin" && item.description && (
@@ -1005,7 +1109,14 @@ function AgendaView({
</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 */}
{isAdmin && item.kind === "termin" && (
+6 -1
View File
@@ -16,7 +16,11 @@ import { UserRole } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { getTargetMonthData } from "@/lib/date-utils";
import { prisma } from "@/lib/prisma";
import { absenceDate, absenceToday, canManageKitaAbsences } from "@/lib/absence-policy";
import {
absenceDate,
absenceToday,
canManageKitaAbsences,
} from "@/lib/absence-policy";
import { Stat } from "@/components/ui/stat";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { NewsTicker } from "./news-ticker";
@@ -174,6 +178,7 @@ export default async function DashboardPage() {
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<OnboardingDialog
user={{
id: session.user.id,
phone: onboardingUser.phone,
street: onboardingUser.street,
postalCode: onboardingUser.postalCode,
+138 -34
View File
@@ -4,6 +4,7 @@ import { useId, useState, useTransition, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Dialog,
@@ -32,6 +33,7 @@ export function Field({
min,
max,
step,
autoFocus,
}: {
label: string;
name: string;
@@ -41,6 +43,7 @@ export function Field({
min?: string | number;
max?: string | number;
step?: string | number;
autoFocus?: boolean;
}) {
const id = useId();
return (
@@ -57,6 +60,7 @@ export function Field({
min={min}
max={max}
step={step}
autoFocus={autoFocus}
/>
</div>
);
@@ -123,7 +127,11 @@ function transform(command: string, form: FormData) {
return {
kind: text("kind"),
startDate: text("startDate"),
endDate: text("endDate"),
endDate:
text("kind") === "TIME_OFF" ||
(text("kind") === "VACATION" && form.get("halfDay") === "on")
? text("startDate")
: text("endDate"),
halfDay: form.get("halfDay") === "on",
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
note: text("note"),
@@ -142,13 +150,19 @@ function StaffForm({
children,
label = "Speichern",
onSuccess,
anotherLabel,
}: {
command: string;
profileId?: string;
extra?: Record<string, unknown>;
children: ReactNode;
label?: string;
onSuccess?: (result: StaffActionResult) => void;
anotherLabel?: string;
onSuccess?: (
result: StaffActionResult,
form: FormData,
another: boolean,
) => void;
}) {
const [pending, start] = useTransition();
const [error, setError] = useState("");
@@ -159,6 +173,10 @@ function StaffForm({
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const another =
(event.nativeEvent as SubmitEvent).submitter?.getAttribute(
"value",
) === "another";
setError("");
start(async () => {
try {
@@ -173,7 +191,7 @@ function StaffForm({
}
if (result.warning) toast.warning(result.warning);
else toast.success(result.message ?? "Gespeichert.");
onSuccess?.(result);
onSuccess?.(result, form, another);
router.refresh();
} catch (error) {
setError(
@@ -187,10 +205,20 @@ function StaffForm({
>
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
{children}
<div>
<div className="flex flex-wrap gap-2">
<Button type="submit">
{pending ? "Wird gespeichert …" : label}
</Button>
{anotherLabel ? (
<Button
type="submit"
variant="outline"
name="intent"
value="another"
>
{anotherLabel}
</Button>
) : null}
</div>
</fieldset>
{error && (
@@ -333,8 +361,12 @@ export function SetupForm({
export function TimeForm({
profileId,
initial,
defaultDate,
compact = false,
}: {
profileId: string;
defaultDate?: string;
compact?: boolean;
initial?: {
id: string;
date: string;
@@ -345,14 +377,34 @@ export function TimeForm({
};
}) {
const [open, setOpen] = useState(false);
const [date, setDate] = useState(initial?.date ?? defaultDate ?? todayKey());
const [blockVersion, setBlockVersion] = useState(0);
const [continuation, setContinuation] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(next) => {
if (next) {
setDate(initial?.date ?? defaultDate ?? todayKey());
setContinuation(false);
}
setOpen(next);
}}
>
<DialogTrigger asChild>
<Button
variant={initial ? "outline" : "default"}
size={initial ? "sm" : "default"}
variant={initial || compact ? "outline" : "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>
</DialogTrigger>
<DialogContent>
@@ -366,31 +418,50 @@ export function TimeForm({
</DialogDescription>
</DialogHeader>
<StaffForm
key={blockVersion}
command="time"
profileId={profileId}
extra={initial ? { id: initial.id } : {}}
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
label="Datum"
name="date"
type="date"
value={initial?.date ?? todayKey()}
value={date}
max={todayKey()}
/>
<div className="grid grid-cols-2 gap-4">
<Field
label="Beginn"
autoFocus={continuation}
name="start"
type="time"
value={minuteClock(initial?.startMinute ?? 480)}
value={
continuation
? undefined
: minuteClock(initial?.startMinute ?? 480)
}
/>
<Field
label="Ende"
name="end"
type="time"
value={minuteClock(initial?.endMinute ?? 960)}
value={
continuation
? undefined
: minuteClock(initial?.endMinute ?? 960)
}
/>
</div>
<Field
@@ -399,7 +470,7 @@ export function TimeForm({
type="number"
min={0}
step={1}
value={initial?.breakMinutes ?? 30}
value={continuation ? 0 : (initial?.breakMinutes ?? 30)}
/>
<Field
label="Notiz (optional)"
@@ -431,9 +502,24 @@ export function LeaveForm({
}) {
const [open, setOpen] = useState(false);
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();
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>
<Button size={initial ? "sm" : "default"} variant="outline">
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
@@ -474,33 +560,51 @@ export function LeaveForm({
{admin && <option value="SICKNESS">Krankheit</option>}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<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" && (
{kind === "VACATION" ? (
<label className="flex items-center gap-2 text-sm">
<input
name="halfDay"
type="checkbox"
defaultChecked={initial?.halfDay}
/>
Halber Urlaubstag (Von = Bis)
checked={halfDay}
onChange={(event) => setHalfDay(event.target.checked)}
/>{" "}
Halber Urlaubstag
</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" && (
<Field
label="Freizeitausgleich in Stunden (Von = Bis)"
label="Freizeitausgleich in Stunden"
name="hours"
type="number"
min={1 / 60}
+22 -1
View File
@@ -284,7 +284,17 @@ export default async function PersonalPage({
</div>
{profile && (
<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} />
</div>
)}
@@ -645,6 +655,7 @@ export default async function PersonalPage({
"Ist",
"Urlaub / Krankheit",
"Differenz",
"Erfassen",
].map((s) => (
<th key={s} className="pb-3 pr-4 font-medium">
{s}
@@ -682,6 +693,15 @@ export default async function PersonalPage({
>
{formatMinutes(d.balance)}
</td>
<td className="py-3 pl-3">
{editable && d.date <= today ? (
<TimeForm
profileId={profile.id}
defaultDate={d.date}
compact
/>
) : null}
</td>
</tr>
))}
</tbody>
@@ -696,6 +716,7 @@ export default async function PersonalPage({
<td className="pt-4">
{formatMinutes(totals.balance)}
</td>
<td />
</tr>
</tfoot>
</table>
+13
View File
@@ -29,6 +29,7 @@ import { DeleteAccountDialog } from "./_components/delete-account-dialog";
import { MyChildrenManager } from "./_components/my-children-manager";
import { CreateFamilyDialog } from "./_components/create-family-dialog";
import { ContactForm } from "./_components/contact-form";
import { PasswordForm } from "../../passwort-vergessen/password-form";
import { StaffEmailSettings } from "../personal/forms";
export const metadata = { title: "Mein Profil · Kita-Planer" };
@@ -211,6 +212,18 @@ export default async function ProfilPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Passwort ändern</CardTitle>
<CardDescription>
Verwalte dein persönliches Anmeldepasswort.
</CardDescription>
</CardHeader>
<CardContent>
<PasswordForm mode="change" />
</CardContent>
</Card>
{user.staffProfile && (
<Card>
<CardHeader>
+15 -2
View File
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -23,7 +24,10 @@ export function LoginForm({
devParentEmail?: string;
devParentPassword?: string;
}) {
const [state, formAction, pending] = useActionState(loginAction, initialState);
const [state, formAction, pending] = useActionState(
loginAction,
initialState,
);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -67,6 +71,13 @@ export function LoginForm({
)}
</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] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
@@ -79,7 +90,9 @@ export function LoginForm({
{showDevHelpers && (
<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">
{devAdminEmail && (
<Button
+24 -4
View File
@@ -17,7 +17,11 @@ import { LoginForm } from "./login-form";
export const metadata = { title: "Anmelden · Kita-Planer" };
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) {
@@ -31,9 +35,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const isDev = process.env.NODE_ENV === "development";
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 devParentPassword = isDev ? process.env.DEV_LOGIN_PARENT_PASSWORD : undefined;
const devParentPassword = isDev
? process.env.DEV_LOGIN_PARENT_PASSWORD
: undefined;
return (
<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>
</CardHeader>
<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
callbackUrl={callbackUrl}
devAdminEmail={devAdminEmail}
@@ -52,7 +69,10 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
/>
<p className="text-center text-sm text-muted-foreground">
Noch keinen Account?{" "}
<Link href="/register" className="font-medium text-foreground underline">
<Link
href="/register"
className="font-medium text-foreground underline"
>
Kita registrieren
</Link>
</p>
+52
View File
@@ -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>
);
}
+117
View File
@@ -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.",
),
};
}
}
+37
View File
@@ -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>
);
}
+20 -11
View File
@@ -31,6 +31,7 @@ type UserRoleDto =
type OnboardingDialogProps = {
user: {
id: string;
phone: string | null;
street: string | null;
postalCode: string | null;
@@ -43,7 +44,7 @@ type OnboardingDialogProps = {
} | null;
};
const HIDE_ONBOARDING_DIALOG_KEY = "hideOnboardingDialog";
const onboardingKey = (userId: string) => `hideOnboardingDialog:${userId}`;
export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
const [open, setOpen] = useState(false);
@@ -79,14 +80,18 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
}, [family?.childrenCount, user]);
useEffect(() => {
if (status.isComplete) return;
const hidden = localStorage.getItem(HIDE_ONBOARDING_DIALOG_KEY);
if (hidden !== "true") {
const timer = window.setTimeout(() => setOpen(true), 0);
return () => window.clearTimeout(timer);
let hidden: string | null = null;
try {
hidden = localStorage.getItem(onboardingKey(user.id));
} catch {
/* Unavailable browser storage does not prevent using the app. */
}
}, [status.isComplete]);
const timer = window.setTimeout(
() => setOpen(!status.isComplete && hidden !== "true"),
0,
);
return () => window.clearTimeout(timer);
}, [status.isComplete, user.id]);
if (status.isComplete) {
return null;
@@ -97,7 +102,11 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
: "Haushalt & Kinder einrichten";
function hidePermanently() {
localStorage.setItem(HIDE_ONBOARDING_DIALOG_KEY, "true");
try {
localStorage.setItem(onboardingKey(user.id), "true");
} catch {
/* Close for this visit if storage is unavailable. */
}
setOpen(false);
}
@@ -113,8 +122,8 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
Dein Kita-Profil ist fast bereit
</DialogTitle>
<DialogDescription className="text-emerald-50/80">
Vervollständige die wichtigsten Daten, damit Notdienst,
Adressbuch und Familienverwaltung reibungslos funktionieren.
Vervollständige die wichtigsten Daten, damit Notdienst, Adressbuch
und Familienverwaltung reibungslos funktionieren.
</DialogDescription>
</DialogHeader>
</div>
+58
View File
@@ -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>
);
}
+260
View File
@@ -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) } },
],
},
});
});
}