feat: add password recovery and everyday workflow improvements
This commit is contained in:
@@ -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 “{deleteEventTitle}” wirklich löschen? Alle
|
||||
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
|
||||
Möchtest du den Termin “{deleteEventTitle}” wirklich
|
||||
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
|
||||
gehen verloren.
|
||||
</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" && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user