Harden authentication, tenant isolation and security boundaries
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { removeAttachmentFiles } from "@/lib/attachments";
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AuditTargetType, UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
@@ -26,6 +28,7 @@ type ActionResult = {
|
||||
|
||||
async function requireSuperadminActor() {
|
||||
const session = await requireRole([UserRole.SUPERADMIN]);
|
||||
if (Date.now() - session.user.authenticatedAt > 15 * 60_000) redirect("/login?reauth=1&callbackUrl=/admin");
|
||||
return {
|
||||
id: session.user.id,
|
||||
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
|
||||
@@ -92,7 +95,8 @@ export async function deleteKita(
|
||||
|
||||
const counts = await getKitaDeleteCounts(kita.id);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const attachmentIds = await prisma.$transaction(async (tx) => {
|
||||
const attachments = await tx.attachment.findMany({ where: { announcement: { kitaId: kita.id } }, select: { id: true } });
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
@@ -107,8 +111,10 @@ export async function deleteKita(
|
||||
});
|
||||
|
||||
await tx.kita.delete({ where: { id: kita.id } });
|
||||
return attachments.map(item => item.id);
|
||||
});
|
||||
|
||||
await removeAttachmentFiles(attachmentIds);
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/admin/audit");
|
||||
return { ok: true, redirectTo: "/admin" };
|
||||
@@ -182,7 +188,8 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const attachmentIds = await prisma.$transaction(async (tx) => {
|
||||
const attachments = await tx.attachment.findMany({ where: { announcement: { authorId: user.id } }, select: { id: true } });
|
||||
await tx.verificationToken.deleteMany({
|
||||
where: { identifier: user.id },
|
||||
});
|
||||
@@ -206,8 +213,10 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
|
||||
},
|
||||
});
|
||||
await tx.user.delete({ where: { id: user.id } });
|
||||
return attachments.map(item => item.id);
|
||||
});
|
||||
|
||||
await removeAttachmentFiles(attachmentIds);
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||
revalidatePath("/admin/audit");
|
||||
@@ -219,6 +228,7 @@ async function updateUserSupportState(
|
||||
action: "USER_LOCK" | "USER_UNLOCK" | "USER_FAILED_LOGIN_RESET",
|
||||
data: {
|
||||
lockedUntil?: Date | null;
|
||||
disabledAt?: Date | null;
|
||||
failedLoginAttempts?: number;
|
||||
},
|
||||
): Promise<ActionResult> {
|
||||
@@ -238,7 +248,7 @@ async function updateUserSupportState(
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data,
|
||||
data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
@@ -266,12 +276,14 @@ async function updateUserSupportState(
|
||||
|
||||
export async function lockUser(userId: string): Promise<ActionResult> {
|
||||
return updateUserSupportState(userId, "USER_LOCK", {
|
||||
disabledAt: new Date(),
|
||||
lockedUntil: new Date("2099-12-31T23:59:59.000Z"),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unlockUser(userId: string): Promise<ActionResult> {
|
||||
return updateUserSupportState(userId, "USER_UNLOCK", {
|
||||
disabledAt: null,
|
||||
lockedUntil: null,
|
||||
failedLoginAttempts: 0,
|
||||
});
|
||||
|
||||
@@ -1,34 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import { activeAlertTokenWhere } from "@/lib/alert-tokens";
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
export async function confirmAlertAction(token: string) {
|
||||
try {
|
||||
const alert = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
return { error: "Ungültiger Bestätigungs-Link." };
|
||||
}
|
||||
|
||||
if (alert.status !== NotdienstAlertStatus.PENDING) {
|
||||
return { error: "Dieser Alarm wurde bereits bestätigt oder abgebrochen." };
|
||||
}
|
||||
|
||||
await prisma.notdienstAlert.update({
|
||||
where: { id: alert.id },
|
||||
data: {
|
||||
status: NotdienstAlertStatus.CONFIRMED,
|
||||
confirmedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { error: "Fehler bei der Bestätigung." };
|
||||
}
|
||||
if (!isPublicToken(token)) return { error: "Ungültiger Bestätigungs-Link." };
|
||||
if (!(await limitPublicAction("alert-confirm", hashToken(token), 10, 900))) return { error: "Bitte versuche es später erneut." };
|
||||
const updated = await prisma.notdienstAlert.updateMany({
|
||||
where: activeAlertTokenWhere(token),
|
||||
data: { status: NotdienstAlertStatus.CONFIRMED, confirmedAt: new Date() },
|
||||
});
|
||||
if (updated.count !== 1) return { error: "Dieser Link ist abgelaufen oder wurde bereits verwendet." };
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { de } from "date-fns/locale";
|
||||
import { BellRing, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
|
||||
import { activeAlertTokenWhere } from "@/lib/alert-tokens";
|
||||
import { isPublicToken } from "@/lib/tokens";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
Card,
|
||||
@@ -14,7 +16,8 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { AlertConfirmForm } from "./alert-confirm-form";
|
||||
|
||||
export const metadata = { title: "Notdienst Alarm · Kita-Planer" };
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Notdienst Alarm · Kita-Planer", robots: { index: false, follow: false } };
|
||||
|
||||
export default async function AlertPage({
|
||||
params,
|
||||
@@ -23,8 +26,9 @@ export default async function AlertPage({
|
||||
}) {
|
||||
const { token } = await params;
|
||||
|
||||
const alert = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
if (!isPublicToken(token)) notFound();
|
||||
const alert = await prisma.notdienstAlert.findFirst({
|
||||
where: activeAlertTokenWhere(token),
|
||||
select: {
|
||||
status: true,
|
||||
parentUser: {
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { open } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { attachmentPath, attachmentDisposition, detectAttachmentType, MAX_ATTACHMENT_SIZE } from "@/lib/attachments";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function getUploadPath(attachmentId: string) {
|
||||
const uploadDir = path.resolve(
|
||||
process.env.UPLOAD_DIR ?? "uploads",
|
||||
"announcements",
|
||||
);
|
||||
return path.join(uploadDir, attachmentId);
|
||||
}
|
||||
|
||||
function contentDisposition(fileName: string) {
|
||||
const fallback = fileName.replace(/[^\w.\-]/g, "_");
|
||||
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth();
|
||||
const session = await getKitaSession();
|
||||
const user = session?.user;
|
||||
|
||||
if (!user?.id || !user.kitaId) {
|
||||
@@ -32,6 +20,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
try { attachmentPath(id); } catch { return NextResponse.json({ error: "Not found" }, { status: 404 }); }
|
||||
const attachment = await prisma.attachment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
@@ -51,12 +40,22 @@ export async function GET(
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await readFile(getUploadPath(attachment.id));
|
||||
const filePath = attachmentPath(attachment.id);
|
||||
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
let file: Buffer;
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile() || info.size > MAX_ATTACHMENT_SIZE) throw new Error("Invalid attachment");
|
||||
file = await handle.readFile();
|
||||
} finally { await handle.close(); }
|
||||
return new Response(new Uint8Array(file), {
|
||||
headers: {
|
||||
"Content-Type": attachment.fileType,
|
||||
"Content-Disposition": contentDisposition(attachment.fileName),
|
||||
"Cache-Control": "private, max-age=300",
|
||||
"Content-Type": detectAttachmentType(file) ?? "application/octet-stream",
|
||||
"Content-Disposition": attachmentDisposition(attachment.fileName),
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "crypto";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { createElement } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
@@ -19,6 +22,7 @@ export async function triggerAlertAction(assignmentId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
|
||||
if (!(await rateLimit(`alerts:${kitaId}:${session.user.id}`, 50, 3600)).allowed) return { error: "Zu viele Alarme. Bitte versuche es später erneut." };
|
||||
try {
|
||||
const [existing, assignment] = await Promise.all([
|
||||
// Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt
|
||||
@@ -26,17 +30,18 @@ export async function triggerAlertAction(assignmentId: string) {
|
||||
where: { assignmentId, kitaId },
|
||||
}),
|
||||
prisma.notdienstAssignment.findFirst({
|
||||
where: { id: assignmentId, kitaId },
|
||||
where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } },
|
||||
include: {
|
||||
child: {
|
||||
select: {
|
||||
id: true,
|
||||
familyId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
@@ -75,24 +80,22 @@ export async function triggerAlertAction(assignmentId: string) {
|
||||
locale: de,
|
||||
});
|
||||
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
|
||||
const alertJobs: { email: string; token: string }[] = [];
|
||||
|
||||
await prisma.$transaction(
|
||||
parents.map((parent) => {
|
||||
const token = crypto.randomUUID();
|
||||
alertJobs.push({ email: parent.email, token });
|
||||
return prisma.notdienstAlert.create({
|
||||
data: {
|
||||
kitaId,
|
||||
assignmentId,
|
||||
parentUserId: parent.id,
|
||||
triggeredById: session.user.id,
|
||||
status: NotdienstAlertStatus.PENDING,
|
||||
confirmationToken: token,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
const alertJobs = parents.map(parent => ({ email: parent.email, token: newToken(), parentId: parent.id }));
|
||||
await serializableTransaction(async tx => {
|
||||
if (await tx.notdienstAlert.findFirst({ where: { assignmentId, kitaId }, select: { id: true } })) {
|
||||
throw new UserFacingError("Für diese Einteilung wurde bereits ein Alarm ausgelöst.");
|
||||
}
|
||||
const current = await tx.notdienstAssignment.findFirst({ where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } }, select: { childId: true, date: true } });
|
||||
const today = new Date(); today.setUTCHours(0, 0, 0, 0);
|
||||
if (!current || current.childId !== assignment.child.id || current.date < today) throw new UserFacingError("Die Einteilung hat sich geändert oder liegt in der Vergangenheit. Bitte lade die Seite neu.");
|
||||
for (const job of alertJobs) {
|
||||
const parent = await tx.user.findFirst({ where: { id: job.parentId, kitaId, familyId: assignment.child.familyId,
|
||||
email: job.email, disabledAt: null, emailVerifiedAt: { not: null }, privacyPolicyAcceptedAt: { not: null } }, select: { id: true } });
|
||||
if (!parent) throw new UserFacingError("Die Empfänger haben sich geändert. Bitte lade die Seite neu.");
|
||||
await tx.notdienstAlert.create({ data: { kitaId, assignmentId, parentUserId: parent.id,
|
||||
triggeredById: session.user.id, status: NotdienstAlertStatus.PENDING, confirmationToken: hashToken(job.token) } });
|
||||
}
|
||||
});
|
||||
|
||||
for (const job of alertJobs) {
|
||||
const emailResult = await sendAppEmail({
|
||||
@@ -118,6 +121,6 @@ export async function triggerAlertAction(assignmentId: string) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { error: "Fehler beim Auslösen des Alarms." };
|
||||
return { error: publicActionError(error, "Fehler beim Auslösen des Alarms.") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { revalidatePath } from "next/cache";
|
||||
import { DutyAssignmentStatus, UserRole } from "@prisma/client";
|
||||
import { endOfWeek, format, startOfDay, startOfWeek } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { dateOnlySchema } from "@/lib/date-schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { DutyReminderEmail } from "@/emails/DutyReminderEmail";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const dutyTypeSchema = z.object({
|
||||
@@ -20,8 +22,8 @@ const dutyTypeSchema = z.object({
|
||||
const assignmentSchema = z.object({
|
||||
dutyTypeId: z.string().min(1),
|
||||
familyId: z.string().nullable(),
|
||||
startDate: z.string().min(1),
|
||||
endDate: z.string().min(1),
|
||||
startDate: dateOnlySchema,
|
||||
endDate: dateOnlySchema,
|
||||
});
|
||||
|
||||
async function requireDutyAdmin() {
|
||||
@@ -160,6 +162,7 @@ export async function upsertDutyAssignment(rawPayload: unknown) {
|
||||
|
||||
export async function checkAndSendDutyReminders() {
|
||||
const { kitaId } = await requireDutyAdmin();
|
||||
if (!(await rateLimit(`duty-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
const configError = getAppEmailConfigError();
|
||||
|
||||
if (configError) {
|
||||
@@ -195,6 +198,8 @@ export async function checkAndSendDutyReminders() {
|
||||
users: {
|
||||
where: {
|
||||
emailVerifiedAt: { not: null },
|
||||
disabledAt: null,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
|
||||
@@ -93,7 +93,7 @@ export function NewsForm() {
|
||||
/>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]">
|
||||
<UploadCloud className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
PDF, JPG und PNG bis 10 MB pro Datei.
|
||||
PDF, JPG und PNG: maximal 5 Dateien, je 10 MB und insgesamt 25 MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { resendParentInvite } from "../actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -245,6 +247,7 @@ export function FamiliesListClient({
|
||||
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{parent.email}
|
||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,6 +354,7 @@ export function FamiliesListClient({
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{parent.email}
|
||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,3 +420,11 @@ export function FamiliesListClient({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResendInviteButton({ userId }: { userId: string }) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
return <Button type="button" size="sm" variant="ghost" disabled={pending} onClick={() => startTransition(async () => {
|
||||
const result = await resendParentInvite(userId);
|
||||
if (result.error) toast.error(result.error); else toast.success("Einladung erneut gesendet.");
|
||||
})}>{pending ? "Wird gesendet…" : "Einladung erneut senden"}</Button>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "crypto";
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
||||
import { InviteEmail } from "@/emails/InviteEmail";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const INVITE_TOKEN_TTL_DAYS = 7;
|
||||
@@ -115,7 +116,7 @@ function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
|
||||
}
|
||||
|
||||
function createInviteToken() {
|
||||
const token = crypto.randomUUID();
|
||||
const token = newToken();
|
||||
const expires = new Date(
|
||||
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
|
||||
);
|
||||
@@ -153,6 +154,8 @@ export async function addFamilyAction(
|
||||
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
|
||||
}
|
||||
|
||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } };
|
||||
|
||||
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsedFamily.success) {
|
||||
return { errors: parsedFamily.error.flatten().fieldErrors };
|
||||
@@ -245,7 +248,7 @@ export async function addFamilyAction(
|
||||
await tx.verificationToken.create({
|
||||
data: {
|
||||
identifier: createdParent.id,
|
||||
token: invite.token,
|
||||
token: hashToken(invite.token),
|
||||
expires: invite.expires,
|
||||
},
|
||||
});
|
||||
@@ -320,6 +323,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
|
||||
const removedChildIdSet = new Set(removedChildIds);
|
||||
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
|
||||
if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." };
|
||||
if (activeChildren.length + newChildren.length === 0) {
|
||||
return { error: "Eine Familie benötigt mindestens ein Kind." };
|
||||
}
|
||||
@@ -347,7 +351,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
where: { id: familyId, kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
users: { select: { id: true } },
|
||||
users: { select: { id: true, email: true } },
|
||||
children: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
@@ -356,6 +360,13 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
return { error: "Familie wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
|
||||
if (needsEmail) {
|
||||
const configError = getAppEmailConfigError();
|
||||
if (configError) return { error: configError };
|
||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
|
||||
}
|
||||
|
||||
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
|
||||
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
|
||||
|
||||
@@ -382,14 +393,20 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
});
|
||||
|
||||
for (const parent of parents) {
|
||||
const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } });
|
||||
if (!current) throw new Error("Parent assignment changed");
|
||||
const emailChanged = current.email !== parent.email;
|
||||
await tx.user.update({
|
||||
where: { id: parent.id },
|
||||
data: {
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
},
|
||||
where: { id: parent.id, kitaId, familyId },
|
||||
data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email,
|
||||
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
|
||||
});
|
||||
if (emailChanged) {
|
||||
const invite = createInviteToken();
|
||||
await tx.verificationToken.deleteMany({ where: { identifier: parent.id } });
|
||||
await tx.verificationToken.create({ data: { identifier: parent.id, token: hashToken(invite.token), expires: invite.expires } });
|
||||
inviteJobs.push({ to: parent.email, parentName: `${parent.firstName} ${parent.lastName}`, inviteUrl: invite.inviteUrl });
|
||||
}
|
||||
}
|
||||
|
||||
for (const parent of newParents) {
|
||||
@@ -413,7 +430,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
await tx.verificationToken.create({
|
||||
data: {
|
||||
identifier: createdParent.id,
|
||||
token: invite.token,
|
||||
token: hashToken(invite.token),
|
||||
expires: invite.expires,
|
||||
},
|
||||
});
|
||||
@@ -421,7 +438,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
|
||||
for (const child of activeChildren) {
|
||||
await tx.child.update({
|
||||
where: { id: child.id },
|
||||
where: { id: child.id, kitaId, familyId },
|
||||
data: {
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
@@ -430,7 +447,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
}
|
||||
|
||||
for (const childId of removedChildIds) {
|
||||
await tx.child.delete({ where: { id: childId } });
|
||||
await tx.child.delete({ where: { id: childId, kitaId, familyId } });
|
||||
}
|
||||
|
||||
for (const child of newChildren) {
|
||||
@@ -469,3 +486,28 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function resendParentInvite(userId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
if (typeof userId !== "string" || !userId || userId.length > 64) return { error: "Ungültige Eingabedaten." };
|
||||
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
const user = await prisma.user.findFirst({ where: { id: userId, kitaId: session.user.kitaId, emailVerifiedAt: null, passwordHash: "", disabledAt: null },
|
||||
select: { id: true, email: true, firstName: true, lastName: true, kita: { select: { name: true } } } });
|
||||
if (!user?.kita) return { error: "Keine offene Einladung gefunden." };
|
||||
const configError = getAppEmailConfigError(); if (configError) return { error: configError };
|
||||
const invite = createInviteToken();
|
||||
const renewed = await prisma.$transaction(async tx => {
|
||||
// Lock and re-check the account before issuing a link for the selected mailbox.
|
||||
// A concurrent address change/activation must invalidate this resend.
|
||||
const eligible = await tx.user.updateMany({ where: { id: user.id, email: user.email, kitaId: session.user.kitaId,
|
||||
emailVerifiedAt: null, passwordHash: "", disabledAt: null }, data: { sessionVersion: { increment: 1 } } });
|
||||
if (eligible.count !== 1) return false;
|
||||
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
|
||||
await tx.verificationToken.create({ data: { identifier: user.id, token: hashToken(invite.token), expires: invite.expires } });
|
||||
return true;
|
||||
});
|
||||
if (!renewed) return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." };
|
||||
const error = await sendInviteJobs(user.kita.name, [{ to: user.email, parentName: `${user.firstName} ${user.lastName}`, inviteUrl: invite.inviteUrl }]);
|
||||
return error ? { error } : { success: true };
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
const dutySchema = z.object({
|
||||
name: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein").max(50),
|
||||
description: z.string().optional(),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
});
|
||||
|
||||
export async function createDuty(rawPayload: unknown) {
|
||||
|
||||
@@ -154,7 +154,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Familie aktualisiert.");
|
||||
toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
|
||||
setRemovedChildIds([]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
Prisma,
|
||||
NotdienstAlertStatus,
|
||||
NotdienstPlanStatus,
|
||||
NotdienstSwapStatus,
|
||||
@@ -17,6 +19,8 @@ import { z } from "zod";
|
||||
import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail";
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// =====================================================================
|
||||
@@ -24,8 +28,8 @@ import { prisma } from "@/lib/prisma";
|
||||
// =====================================================================
|
||||
|
||||
const terminSchema = z.object({
|
||||
title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
|
||||
description: z.string().trim().optional(),
|
||||
title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein").max(160),
|
||||
description: z.string().trim().max(20000).optional(),
|
||||
type: z.nativeEnum(TerminType),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
@@ -99,8 +103,9 @@ type SwapAssignmentWithFamily = {
|
||||
async function getSwapAssignment(
|
||||
assignmentId: string,
|
||||
kitaId: string,
|
||||
db: Prisma.TransactionClient = prisma,
|
||||
): Promise<SwapAssignmentWithFamily | null> {
|
||||
return prisma.notdienstAssignment.findFirst({
|
||||
return db.notdienstAssignment.findFirst({
|
||||
where: { id: assignmentId, kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -114,7 +119,7 @@ async function getSwapAssignment(
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: {
|
||||
email: true,
|
||||
firstName: true,
|
||||
@@ -295,6 +300,7 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
|
||||
|
||||
export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
||||
if (session.user.role !== UserRole.ELTERN) {
|
||||
return { error: "Nur Eltern können Notdienst-Tausche anfragen." };
|
||||
}
|
||||
@@ -311,54 +317,58 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||
const today = getTodayUtcDateOnly();
|
||||
|
||||
try {
|
||||
const [targetAssignment, offeredAssignment, kita, existingRequest] =
|
||||
await Promise.all([
|
||||
getSwapAssignment(targetAssignmentId, session.user.kitaId),
|
||||
getSwapAssignment(offeredAssignmentId, session.user.kitaId),
|
||||
prisma.kita.findUnique({
|
||||
where: { id: session.user.kitaId },
|
||||
select: { name: true },
|
||||
}),
|
||||
prisma.notdienstSwapRequest.findFirst({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: NotdienstSwapStatus.PENDING,
|
||||
OR: [
|
||||
{ targetAssignmentId },
|
||||
{ offeredAssignmentId },
|
||||
{ targetAssignmentId: offeredAssignmentId },
|
||||
{ offeredAssignmentId: targetAssignmentId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
const { targetAssignment, offeredAssignment, kita } = await serializableTransaction(async tx => {
|
||||
const [targetAssignment, offeredAssignment, kita, existingRequest] =
|
||||
await Promise.all([
|
||||
getSwapAssignment(targetAssignmentId, session.user.kitaId, tx),
|
||||
getSwapAssignment(offeredAssignmentId, session.user.kitaId, tx),
|
||||
tx.kita.findUnique({
|
||||
where: { id: session.user.kitaId },
|
||||
select: { name: true },
|
||||
}),
|
||||
tx.notdienstSwapRequest.findFirst({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: NotdienstSwapStatus.PENDING,
|
||||
OR: [
|
||||
{ targetAssignmentId },
|
||||
{ offeredAssignmentId },
|
||||
{ targetAssignmentId: offeredAssignmentId },
|
||||
{ offeredAssignmentId: targetAssignmentId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const validationError = validateSwapAssignmentPair({
|
||||
targetAssignment,
|
||||
offeredAssignment,
|
||||
requesterFamilyId: session.user.familyId,
|
||||
today,
|
||||
});
|
||||
if (validationError) {
|
||||
return { error: validationError };
|
||||
}
|
||||
if (!targetAssignment || !offeredAssignment || !kita) {
|
||||
return { error: "Notdienst nicht gefunden." };
|
||||
}
|
||||
if (existingRequest) {
|
||||
return { error: "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage." };
|
||||
}
|
||||
const validationError = validateSwapAssignmentPair({
|
||||
targetAssignment,
|
||||
offeredAssignment,
|
||||
requesterFamilyId: session.user.familyId!,
|
||||
today,
|
||||
});
|
||||
if (validationError) {
|
||||
throw new UserFacingError(validationError);
|
||||
}
|
||||
if (!targetAssignment || !offeredAssignment || !kita) {
|
||||
throw new UserFacingError("Notdienst nicht gefunden.");
|
||||
}
|
||||
if (existingRequest) {
|
||||
throw new UserFacingError("Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.");
|
||||
}
|
||||
|
||||
await prisma.notdienstSwapRequest.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
requesterUserId: session.user.id,
|
||||
requesterFamilyId: session.user.familyId,
|
||||
targetAssignmentId,
|
||||
offeredAssignmentId,
|
||||
message: message || null,
|
||||
},
|
||||
await tx.notdienstSwapRequest.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
requesterUserId: session.user.id,
|
||||
requesterFamilyId: session.user.familyId!,
|
||||
targetAssignmentId,
|
||||
offeredAssignmentId,
|
||||
message: message || null,
|
||||
},
|
||||
});
|
||||
|
||||
return { targetAssignment, offeredAssignment, kita };
|
||||
});
|
||||
|
||||
await notifyFamilyAboutSwap({
|
||||
@@ -375,7 +385,9 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||
revalidateCalendarAndNotdienst();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
|
||||
if (!(error instanceof Error && error.message === "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.")) {
|
||||
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
|
||||
}
|
||||
return { error: "Tauschanfrage konnte nicht erstellt werden." };
|
||||
}
|
||||
}
|
||||
@@ -394,7 +406,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
const today = getTodayUtcDateOnly();
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const result = await serializableTransaction(async (tx) => {
|
||||
const request = await tx.notdienstSwapRequest.findFirst({
|
||||
where: {
|
||||
id: parsed.data,
|
||||
@@ -419,7 +431,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -445,7 +457,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -462,11 +474,11 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen.");
|
||||
throw new UserFacingError("Tauschanfrage nicht gefunden oder nicht mehr offen.");
|
||||
}
|
||||
|
||||
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
||||
throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
|
||||
throw new UserFacingError("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
|
||||
}
|
||||
|
||||
const validationError = validateSwapAssignmentPair({
|
||||
@@ -476,7 +488,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
today,
|
||||
});
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
throw new UserFacingError(validationError);
|
||||
}
|
||||
|
||||
await tx.notdienstAssignment.update({
|
||||
@@ -487,14 +499,15 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
where: { id: request.offeredAssignmentId },
|
||||
data: { childId: request.targetAssignment.childId },
|
||||
});
|
||||
await tx.notdienstSwapRequest.update({
|
||||
where: { id: request.id },
|
||||
const responded = await tx.notdienstSwapRequest.updateMany({
|
||||
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
|
||||
data: {
|
||||
status: NotdienstSwapStatus.ACCEPTED,
|
||||
respondedById: session.user.id,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (responded.count !== 1) throw new UserFacingError("Diese Anfrage ist nicht mehr offen.");
|
||||
await tx.notdienstSwapRequest.updateMany({
|
||||
where: {
|
||||
id: { not: request.id },
|
||||
@@ -550,7 +563,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Tauschanfrage konnte nicht angenommen werden.",
|
||||
error: publicActionError(error, "Tauschanfrage konnte nicht angenommen werden."),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -585,7 +598,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -603,7 +616,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -622,14 +635,15 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
|
||||
}
|
||||
|
||||
await prisma.notdienstSwapRequest.update({
|
||||
where: { id: request.id },
|
||||
const responded = await prisma.notdienstSwapRequest.updateMany({
|
||||
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
|
||||
data: {
|
||||
status: NotdienstSwapStatus.DECLINED,
|
||||
respondedById: session.user.id,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." };
|
||||
|
||||
const kita = await prisma.kita.findUnique({
|
||||
where: { id: session.user.kitaId },
|
||||
@@ -694,14 +708,15 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
|
||||
}
|
||||
|
||||
await prisma.notdienstSwapRequest.update({
|
||||
where: { id: request.id },
|
||||
const responded = await prisma.notdienstSwapRequest.updateMany({
|
||||
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
|
||||
data: {
|
||||
status: NotdienstSwapStatus.CANCELLED,
|
||||
respondedById: session.user.id,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." };
|
||||
|
||||
revalidateCalendarAndNotdienst();
|
||||
return { success: true };
|
||||
@@ -713,6 +728,7 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
|
||||
export async function createTerminRequest(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
if (!(await rateLimit(`createTerminRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
||||
|
||||
const parsed = terminSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
@@ -914,11 +930,12 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
|
||||
|
||||
const mitbringselSchema = z.object({
|
||||
terminId: z.string(),
|
||||
content: z.string().min(1, "Eintrag darf nicht leer sein"),
|
||||
content: z.string().trim().min(1, "Eintrag darf nicht leer sein").max(500),
|
||||
});
|
||||
|
||||
export async function addMitbringsel(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
if (!(await rateLimit(`addMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
||||
|
||||
const parsed = mitbringselSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -239,6 +239,8 @@ export default async function KalenderPage({
|
||||
const allUserTermine: CalendarListItemDto[] = [
|
||||
...confirmedTermineRows.map((termin) => ({
|
||||
...termin,
|
||||
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
|
||||
? termin.participantInfo : null,
|
||||
kind: "termin" as const,
|
||||
showParticipantInfo:
|
||||
isAdmin ||
|
||||
@@ -249,6 +251,8 @@ export default async function KalenderPage({
|
||||
})),
|
||||
...myPendingTermineRows.map((termin) => ({
|
||||
...termin,
|
||||
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
|
||||
? termin.participantInfo : null,
|
||||
kind: "termin" as const,
|
||||
showParticipantInfo:
|
||||
isAdmin ||
|
||||
|
||||
@@ -18,13 +18,13 @@ import { getTargetMonthData } from "@/lib/date-utils";
|
||||
const dateKeyPattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const payloadSchema = z.object({
|
||||
childrenIds: z.array(z.string()).min(1),
|
||||
childrenIds: z.array(z.string().min(1).max(64)).min(1).max(20),
|
||||
dates: z.array(
|
||||
z.string().regex(dateKeyPattern).refine((value) => {
|
||||
const date = dateKeyToUtcDate(value);
|
||||
return value === date.toISOString().slice(0, 10);
|
||||
return Number.isFinite(date.getTime()) && value === date.toISOString().slice(0, 10);
|
||||
}),
|
||||
),
|
||||
).max(31),
|
||||
});
|
||||
|
||||
function dateKeyToUtcDate(dateKey: string) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
|
||||
import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
@@ -32,6 +35,8 @@ export async function generatePlanAction(year?: number, month?: number) {
|
||||
targetMonth = data.targetMonth;
|
||||
}
|
||||
|
||||
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
|
||||
|
||||
// Prüfen, ob schon ein Plan existiert
|
||||
const existingPlan = await prisma.notdienstPlan.findUnique({
|
||||
where: {
|
||||
@@ -156,6 +161,7 @@ export async function updateAssignmentAction(
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
|
||||
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
|
||||
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||
|
||||
@@ -166,21 +172,21 @@ export async function updateAssignmentAction(
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new Error("Plan nicht gefunden.");
|
||||
throw new UserFacingError("Plan nicht gefunden.");
|
||||
}
|
||||
|
||||
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
|
||||
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
|
||||
|
||||
if (!isDraft && !isPublished) {
|
||||
throw new Error("Plan kann nicht bearbeitet werden.");
|
||||
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
||||
}
|
||||
|
||||
if (isPublished) {
|
||||
const today = new Date();
|
||||
const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
|
||||
if (parsedDate < todayUtc) {
|
||||
throw new Error("Vergangene Notdienste können nicht geändert werden.");
|
||||
throw new UserFacingError("Vergangene Notdienste können nicht geändert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +198,7 @@ export async function updateAssignmentAction(
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -222,7 +228,7 @@ export async function updateAssignmentAction(
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -231,7 +237,7 @@ export async function updateAssignmentAction(
|
||||
});
|
||||
|
||||
if (!newChild) {
|
||||
throw new Error("Kind gehört nicht zu dieser Kita.");
|
||||
throw new UserFacingError("Kind gehört nicht zu dieser Kita.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +370,7 @@ export async function updateAssignmentAction(
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Update fehlgeschlagen.",
|
||||
error: publicActionError(error, "Update fehlgeschlagen."),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -379,6 +385,8 @@ export async function updateKitaNotdienstSettingsAction(
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 ||
|
||||
!Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
|
||||
try {
|
||||
await prisma.kita.update({
|
||||
where: { id: kitaId },
|
||||
@@ -398,6 +406,8 @@ export async function updateKitaNotdienstSettingsAction(
|
||||
|
||||
export async function sendNotdienstRemindersAction(year: number, month: number) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-reminders:${session.user.kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
@@ -426,7 +436,7 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
||||
},
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -513,11 +523,11 @@ export async function resetPlanAction(planId: string) {
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new Error("Plan nicht gefunden.");
|
||||
throw new UserFacingError("Plan nicht gefunden.");
|
||||
}
|
||||
|
||||
if (plan.status !== NotdienstPlanStatus.DRAFT) {
|
||||
throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
|
||||
throw new UserFacingError("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
|
||||
}
|
||||
|
||||
// Delete all assignments
|
||||
@@ -535,6 +545,6 @@ export async function resetPlanAction(planId: string) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Zurücksetzen des Plans:", error);
|
||||
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
|
||||
return { error: publicActionError(error, "Fehler beim Zurücksetzen des Plans.") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getChildBirthdateRange,
|
||||
parseDateInput,
|
||||
} from "@/lib/child-birthdate";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
|
||||
@@ -118,14 +119,18 @@ export async function createMyChild(rawPayload: unknown) {
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.child.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId,
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
|
||||
},
|
||||
await serializableTransaction(async tx => {
|
||||
const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } });
|
||||
if (count >= 20) throw new Error("Household child limit reached");
|
||||
await tx.child.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId!,
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/profil");
|
||||
@@ -158,10 +163,11 @@ export async function createMyFamily(rawPayload: unknown) {
|
||||
},
|
||||
});
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: session.user.id },
|
||||
const attached = await tx.user.updateMany({
|
||||
where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion },
|
||||
data: { familyId: family.id },
|
||||
});
|
||||
if (attached.count !== 1) throw new Error("Account already assigned or revoked");
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/profil");
|
||||
@@ -170,7 +176,9 @@ export async function createMyFamily(rawPayload: unknown) {
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
|
||||
if (!(error instanceof Error && error.message === "Account already assigned or revoked")) {
|
||||
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
|
||||
}
|
||||
return { error: "Der Haushalt konnte nicht angelegt werden." };
|
||||
}
|
||||
}
|
||||
@@ -239,7 +247,7 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
|
||||
}
|
||||
|
||||
await prisma.child.update({
|
||||
where: { id: childId },
|
||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
@@ -271,7 +279,7 @@ export async function deleteMyChild(childId: string) {
|
||||
}
|
||||
|
||||
await prisma.child.delete({
|
||||
where: { id: childId },
|
||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/profil");
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { passwordSchema } from "@/lib/password";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
import { hash } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// =====================================================================
|
||||
@@ -18,8 +21,8 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
const inviteSchema = z
|
||||
.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8, "Mindestens 8 Zeichen.").max(72, "Maximal 72 Zeichen."),
|
||||
token: z.string().refine(isPublicToken),
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string().min(1, "Passwort-Bestätigung fehlt."),
|
||||
acceptPrivacyPolicy: z.literal("on", {
|
||||
error: "Bitte die Datenschutzerklärung akzeptieren.",
|
||||
@@ -80,11 +83,12 @@ export async function acceptInviteAction(
|
||||
}
|
||||
|
||||
const { token, password, directoryOptIn } = parsed.data;
|
||||
if (!(await limitPublicAction("invite", hashToken(token), 10, 900))) return { attempt, values, errors: { _form: ["Bitte versuche es später erneut."] } };
|
||||
const now = new Date();
|
||||
|
||||
// ── 1. Token erneut DB-seitig validieren ──────────────────────────
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
where: { token },
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: { token: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] }, expires: { gt: new Date() } },
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
@@ -111,10 +115,10 @@ export async function acceptInviteAction(
|
||||
// ── 2. User validieren (existiert und hat noch kein Passwort) ──────
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, passwordHash: true },
|
||||
select: { id: true, email: true, passwordHash: true, disabledAt: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!user || user.disabledAt) {
|
||||
return {
|
||||
attempt,
|
||||
values,
|
||||
@@ -138,22 +142,22 @@ export async function acceptInviteAction(
|
||||
const passwordHash = await hash(password, 12);
|
||||
|
||||
// ── 4. User updaten + Token löschen (atomar) ──────────────────────
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
passwordHash,
|
||||
privacyPolicyAcceptedAt: now,
|
||||
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
|
||||
directoryOptInAt: directoryOptIn === "on" ? now : null,
|
||||
emailVerifiedAt: now,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
}),
|
||||
prisma.verificationToken.delete({
|
||||
where: { token },
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
await prisma.$transaction(async tx => {
|
||||
const consumed = await tx.verificationToken.deleteMany({ where: { token: verificationToken.token, expires: { gt: new Date() } } });
|
||||
if (consumed.count !== 1) throw new Error("INVITE_USED");
|
||||
const activated = await tx.user.updateMany({
|
||||
where: { id: userId, passwordHash: "", disabledAt: null },
|
||||
data: { passwordHash, privacyPolicyAcceptedAt: now, privacyPolicyVersion: PRIVACY_POLICY_VERSION,
|
||||
directoryOptInAt: directoryOptIn === "on" ? now : null, emailVerifiedAt: now, lastLoginAt: now,
|
||||
sessionVersion: { increment: 1 } },
|
||||
});
|
||||
if (activated.count !== 1) throw new Error("INVITE_USED");
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "INVITE_USED") return { attempt, values, errors: { _form: ["Dieser Link kann nicht mehr verwendet werden."] } };
|
||||
throw error;
|
||||
}
|
||||
|
||||
// ── 5. Direkt einloggen → wirft NEXT_REDIRECT ─────────────────────
|
||||
await signIn("credentials", {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Baby } from "lucide-react";
|
||||
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
Card,
|
||||
@@ -12,7 +13,8 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { InviteForm } from "./invite-form";
|
||||
|
||||
export const metadata = { title: "Konto aktivieren · Kita-Planer" };
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Konto aktivieren · Kita-Planer", robots: { index: false, follow: false } };
|
||||
|
||||
// =====================================================================
|
||||
// /invite/[token] · Öffentliche Seite (kein Auth erforderlich)
|
||||
@@ -30,9 +32,11 @@ export default async function InvitePage({
|
||||
}) {
|
||||
const { token } = await params;
|
||||
|
||||
if (!isPublicToken(token)) notFound();
|
||||
|
||||
// Token in DB nachschlagen
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
where: { token },
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: { token: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] }, expires: { gt: new Date() } },
|
||||
});
|
||||
|
||||
// Unbekannter Token → 404
|
||||
@@ -46,6 +50,7 @@ export default async function InvitePage({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: verificationToken.identifier },
|
||||
select: {
|
||||
disabledAt: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
emailVerifiedAt: true,
|
||||
@@ -53,7 +58,7 @@ export default async function InvitePage({
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) notFound();
|
||||
if (!user || user.disabledAt) notFound();
|
||||
|
||||
const userName = `${user.firstName} ${user.lastName}`;
|
||||
const alreadyAccepted = !!user.emailVerifiedAt;
|
||||
|
||||
+6
-2
@@ -1,3 +1,5 @@
|
||||
import { getSession } from "@/lib/auth-utils";
|
||||
import { headers } from "next/headers";
|
||||
import type { Metadata } from "next";
|
||||
import { Manrope } from "next/font/google";
|
||||
import "./globals.css";
|
||||
@@ -44,11 +46,13 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const [requestHeaders, session] = await Promise.all([headers(), getSession()]);
|
||||
const nonce = requestHeaders.get("x-nonce") ?? undefined;
|
||||
return (
|
||||
<html
|
||||
lang="de"
|
||||
@@ -56,7 +60,7 @@ export default function RootLayout({
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
{children}
|
||||
<UmamiAnalytics />
|
||||
<UmamiAnalytics nonce={nonce} enabled={!session?.user?.id} />
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AuthError } from "next-auth";
|
||||
import { z } from "zod";
|
||||
|
||||
import { safeCallbackUrl } from "@/lib/safe-redirect";
|
||||
import { signIn } from "@/auth";
|
||||
|
||||
// =====================================================================
|
||||
@@ -17,7 +18,7 @@ import { signIn } from "@/auth";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
|
||||
password: z.string().min(1, "Passwort fehlt."),
|
||||
password: z.string().min(1, "Passwort fehlt.").max(72),
|
||||
callbackUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -29,12 +30,6 @@ export type LoginState = {
|
||||
};
|
||||
};
|
||||
|
||||
function safeCallbackUrl(value: string | undefined) {
|
||||
if (!value) return "/dashboard";
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return "/dashboard";
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function loginAction(
|
||||
_prev: LoginState,
|
||||
formData: FormData,
|
||||
|
||||
+6
-13
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { safeCallbackUrl } from "@/lib/safe-redirect";
|
||||
import { auth } from "@/auth";
|
||||
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
|
||||
import {
|
||||
@@ -16,25 +17,17 @@ import { LoginForm } from "./login-form";
|
||||
export const metadata = { title: "Anmelden · Kita-Planer" };
|
||||
|
||||
type LoginPageProps = {
|
||||
searchParams?: Promise<{ callbackUrl?: string | string[] }>;
|
||||
searchParams?: Promise<{ callbackUrl?: string | string[]; reauth?: string }>;
|
||||
};
|
||||
|
||||
function getSafeCallbackUrl(value: string | string[] | undefined) {
|
||||
const callbackUrl = Array.isArray(value) ? value[0] : value;
|
||||
if (!callbackUrl) return "/dashboard";
|
||||
if (!callbackUrl.startsWith("/") || callbackUrl.startsWith("//")) {
|
||||
return "/dashboard";
|
||||
}
|
||||
return callbackUrl;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const session = await auth();
|
||||
if (session?.user?.id) {
|
||||
if (session?.user?.id && resolvedSearchParams?.reauth !== "1") {
|
||||
redirect(getPostLoginRedirect(session.user));
|
||||
}
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl);
|
||||
const raw = resolvedSearchParams?.callbackUrl;
|
||||
const callbackUrl = safeCallbackUrl(Array.isArray(raw) ? raw[0] : raw);
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { requireSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// =====================================================================
|
||||
@@ -23,9 +23,9 @@ const onboardingSchema = z
|
||||
.trim()
|
||||
.min(2, "Bitte mindestens 2 Zeichen angeben.")
|
||||
.max(120, "Maximal 120 Zeichen."),
|
||||
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false),
|
||||
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false),
|
||||
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false),
|
||||
notdienstMinPerChildPerMonth: z.coerce
|
||||
.number()
|
||||
.int("Bitte eine ganze Zahl angeben.")
|
||||
@@ -87,10 +87,10 @@ export async function completeOnboardingAction(
|
||||
formData: FormData,
|
||||
): Promise<OnboardingState> {
|
||||
const attempt = prev.attempt + 1;
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
const session = await requireSession();
|
||||
if (session.user.role !== UserRole.ADMIN) redirect("/forbidden");
|
||||
const founder = await prisma.user.findUnique({ where: { id: session.user.id } });
|
||||
if (!founder?.emailVerifiedAt || !founder.privacyPolicyAcceptedAt) redirect("/register");
|
||||
if (session.user.kitaId) {
|
||||
// Onboarding bereits abgeschlossen — verhindert doppeltes Einrichten
|
||||
// (z.B. durch zurück-navigieren oder Replay des Formulars).
|
||||
@@ -124,14 +124,15 @@ export async function completeOnboardingAction(
|
||||
// tatsächlich noch keine Kita hat (verhindert Race-Conditions bei
|
||||
// parallelen Onboarding-Submits).
|
||||
const updated = await tx.user.updateMany({
|
||||
where: { id: session.user.id, kitaId: null },
|
||||
where: { id: session.user.id, kitaId: null, role: UserRole.ADMIN, disabledAt: null,
|
||||
sessionVersion: session.user.sessionVersion, emailVerifiedAt: { not: null }, privacyPolicyAcceptedAt: { not: null } },
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
role: UserRole.ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
if (updated.count !== 1) {
|
||||
throw new Error("USER_ALREADY_HAS_KITA");
|
||||
}
|
||||
});
|
||||
|
||||
+34
-59
@@ -1,26 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { hash } from "bcryptjs";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// =====================================================================
|
||||
// /register · Server Action
|
||||
// ---------------------------------------------------------------------
|
||||
// Legt einen "heimatlosen" Admin-Account an (kitaId === null) und loggt
|
||||
// ihn direkt ein. Die Tenant-Erstellung folgt im Onboarding-Wizard.
|
||||
// Speichert eine kurzlebige Registrierung und versendet einen Bestätigungslink.
|
||||
// Konto und Passwort entstehen erst nach dem Nachweis des Postfachbesitzes.
|
||||
// =====================================================================
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
|
||||
// bcrypt akzeptiert max. 72 Bytes — wir capen davor zur Sicherheit
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Mindestens 8 Zeichen.")
|
||||
.max(72, "Maximal 72 Zeichen."),
|
||||
email: z.string().trim().max(254).email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase(),
|
||||
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
|
||||
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
|
||||
// HTML-Checkboxen senden "on" wenn aktiviert, sonst nichts
|
||||
@@ -29,12 +26,12 @@ const registerSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const PRIVACY_POLICY_VERSION = "2026-05-01";
|
||||
|
||||
export type RegisterState = {
|
||||
// Bumped after every action call so the client can key uncontrolled inputs
|
||||
// and force a remount with the echoed values from a failed submit.
|
||||
attempt: number;
|
||||
message?: string;
|
||||
// Echoed inputs (sans password) so failed submits don't wipe the form.
|
||||
values?: {
|
||||
firstName?: string;
|
||||
@@ -54,9 +51,9 @@ export type RegisterState = {
|
||||
|
||||
function echoValues(formData: FormData): RegisterState["values"] {
|
||||
return {
|
||||
firstName: String(formData.get("firstName") ?? ""),
|
||||
lastName: String(formData.get("lastName") ?? ""),
|
||||
email: String(formData.get("email") ?? ""),
|
||||
firstName: String(formData.get("firstName") ?? "").slice(0, 100),
|
||||
lastName: String(formData.get("lastName") ?? "").slice(0, 100),
|
||||
email: String(formData.get("email") ?? "").slice(0, 254),
|
||||
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
|
||||
};
|
||||
}
|
||||
@@ -74,50 +71,28 @@ export async function registerAction(
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
const { email, password, firstName, lastName } = parsed.data;
|
||||
|
||||
const passwordHash = await hash(password, 12);
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
firstName,
|
||||
lastName,
|
||||
// Gründer wird "heimatloser" Admin — beim Onboarding-Abschluss
|
||||
// wird die Rolle bestätigt und der User mit einer Kita verknüpft.
|
||||
role: UserRole.ADMIN,
|
||||
kitaId: null,
|
||||
privacyPolicyAcceptedAt: now,
|
||||
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
|
||||
emailVerifiedAt: now,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return {
|
||||
attempt,
|
||||
values: echoValues(formData),
|
||||
errors: {
|
||||
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
|
||||
},
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
const { email, firstName, lastName } = parsed.data;
|
||||
const message = "Falls die Adresse registriert werden kann, erhältst du eine E-Mail. Öffne den Link, um dein Passwort zu wählen und deine Kita einzurichten. Der Link ist 30 Minuten gültig.";
|
||||
if (!(await limitPublicAction("register", email))) {
|
||||
return { attempt, errors: { _form: ["Zu viele Anfragen. Bitte versuche es später erneut."] } };
|
||||
}
|
||||
|
||||
// Direkt einloggen → wirft NEXT_REDIRECT, das wir nicht abfangen.
|
||||
await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirectTo: "/onboarding",
|
||||
const existing = await prisma.user.findUnique({ where: { email }, select: { role: true, kitaId: true, emailVerifiedAt: true, disabledAt: true } });
|
||||
if (existing && (existing.role !== "ADMIN" || existing.kitaId || existing.emailVerifiedAt || existing.disabledAt)) return { attempt, message };
|
||||
const token = newToken();
|
||||
await prisma.pendingRegistration.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
await prisma.pendingRegistration.upsert({
|
||||
where: { email },
|
||||
create: { email, firstName, lastName, tokenHash: hashToken(token), expiresAt: new Date(Date.now() + 30 * 60_000) },
|
||||
update: { firstName, lastName, tokenHash: hashToken(token), expiresAt: new Date(Date.now() + 30 * 60_000) },
|
||||
});
|
||||
|
||||
// Unreachable – signIn redirected.
|
||||
return { attempt };
|
||||
const base = process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? "http://localhost:3000";
|
||||
const result = await sendAppEmail({
|
||||
to: email,
|
||||
subject: "E-Mail bestätigen · Kita-Planer",
|
||||
react: createElement("div", null,
|
||||
createElement("p", null, "Bestätige deine E-Mail-Adresse, um dein Konto einzurichten. Falls du dich nicht registriert hast, kannst du diese Nachricht ignorieren."),
|
||||
createElement("a", { href: `${base}/register/verify/${token}` }, "Registrierung abschließen")),
|
||||
});
|
||||
if (!result.success) return { attempt, errors: { _form: ["Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut."] } };
|
||||
return { attempt, message };
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default async function RegisterPage() {
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle>Kita kostenlos registrieren</CardTitle>
|
||||
<CardDescription>
|
||||
Lege deinen Account an. Im nächsten Schritt richtest du deine Kita ein.
|
||||
Bestätige deine E-Mail-Adresse. Danach wählst du dein Passwort und richtest deine Kita ein.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -22,6 +22,8 @@ export function RegisterForm() {
|
||||
// semantics would wipe them after a failed submit.
|
||||
const attemptKey = state.attempt;
|
||||
|
||||
if (state.message) return <p role="status" className="text-sm">{state.message}</p>;
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
@@ -59,18 +61,6 @@ export function RegisterForm() {
|
||||
error={state.errors?.email?.[0]}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
key={`password-${attemptKey}`}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
label="Passwort"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
helperText="Mindestens 8 Zeichen."
|
||||
error={state.errors?.password?.[0]}
|
||||
/>
|
||||
|
||||
<div className="flex items-start gap-3 pt-2">
|
||||
<Checkbox
|
||||
key={`acceptPrivacyPolicy-${attemptKey}`}
|
||||
@@ -122,7 +112,7 @@ export function RegisterForm() {
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Wird erstellt…" : "Account erstellen"}
|
||||
{pending ? "Wird gesendet…" : "Bestätigungs-E-Mail anfordern"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
import { hash } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { signIn } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { passwordSchema } from "@/lib/password";
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({ token: z.string().refine(isPublicToken), password: passwordSchema,
|
||||
confirmPassword: z.string().max(72), acceptPrivacyPolicy: z.literal("on") })
|
||||
.refine(d => d.password === d.confirmPassword, { message: "Passwörter stimmen nicht überein.", path: ["confirmPassword"] });
|
||||
export type VerificationState = { error?: string };
|
||||
export async function verifyRegistrationAction(_state: VerificationState, form: FormData): Promise<VerificationState> {
|
||||
const parsed = schema.safeParse(Object.fromEntries(form));
|
||||
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Bitte Eingaben prüfen." };
|
||||
const { token, password } = parsed.data;
|
||||
if (!(await limitPublicAction("verify-registration", hashToken(token), 10, 900))) return { error: "Bitte versuche es später erneut." };
|
||||
const draft = await prisma.pendingRegistration.findUnique({ where: { tokenHash: hashToken(token) } });
|
||||
if (!draft || draft.expiresAt <= new Date()) return { error: "Der Link ist ungültig oder abgelaufen. Bitte fordere eine neue E-Mail an." };
|
||||
const passwordHash = await hash(password, 12);
|
||||
try {
|
||||
await prisma.$transaction(async tx => {
|
||||
const used = await tx.pendingRegistration.deleteMany({ where: { tokenHash: hashToken(token), expiresAt: { gt: new Date() } } });
|
||||
if (used.count !== 1) throw new Error("TOKEN_USED");
|
||||
const existing = await tx.user.findUnique({ where: { email: draft.email }, select: { id: true } });
|
||||
const data = { firstName: draft.firstName, lastName: draft.lastName, passwordHash,
|
||||
emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date(), privacyPolicyVersion: "2026-05-01" };
|
||||
if (existing) {
|
||||
const changed = await tx.user.updateMany({ where: { id: existing.id, role: UserRole.ADMIN, kitaId: null,
|
||||
emailVerifiedAt: null, disabledAt: null }, data: { ...data, sessionVersion: { increment: 1 } } });
|
||||
if (changed.count !== 1) throw new Error("TOKEN_USED");
|
||||
} else {
|
||||
await tx.user.create({ data: { ...data, email: draft.email, role: UserRole.ADMIN } });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") ||
|
||||
(error instanceof Error && error.message === "TOKEN_USED")) return { error: "Der Link wurde bereits verwendet. Bitte melde dich an." };
|
||||
throw error;
|
||||
}
|
||||
await signIn("credentials", { email: draft.email, password, redirectTo: "/onboarding" });
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { VerifyForm } from "./verify-form";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "E-Mail bestätigen · Kita-Planer", robots: { index: false, follow: false } };
|
||||
export default async function VerifyPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
if (!isPublicToken(token)) notFound();
|
||||
const draft = await prisma.pendingRegistration.findUnique({ where: { tokenHash: hashToken(token) }, select: { expiresAt: true } });
|
||||
if (!draft || draft.expiresAt <= new Date()) return <main className="p-8">Dieser Link ist ungültig oder abgelaufen. <a href="/register" className="underline">Neue Bestätigungs-E-Mail anfordern</a></main>;
|
||||
return <main className="flex min-h-screen items-center justify-center p-4"><Card className="w-full max-w-md">
|
||||
<CardHeader><CardTitle>Registrierung abschließen</CardTitle></CardHeader>
|
||||
<CardContent><VerifyForm token={token} /></CardContent>
|
||||
</Card></main>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { verifyRegistrationAction, type VerificationState } from "./actions";
|
||||
export function VerifyForm({ token }: { token: string }) {
|
||||
const [state, action, pending] = useActionState(verifyRegistrationAction, {} as VerificationState);
|
||||
return <form action={action} className="space-y-4">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<div className="space-y-2"><Label htmlFor="password">Passwort</Label><Input id="password" name="password" type="password" autoComplete="new-password" minLength={8} required /></div>
|
||||
<div className="space-y-2"><Label htmlFor="confirmPassword">Passwort wiederholen</Label><Input id="confirmPassword" name="confirmPassword" type="password" autoComplete="new-password" required /></div>
|
||||
<label className="flex gap-2 text-sm"><input type="checkbox" name="acceptPrivacyPolicy" required />
|
||||
<span>Ich akzeptiere die <a className="underline" href="/datenschutz" target="_blank" rel="noreferrer">Datenschutzerklärung</a>.</span></label>
|
||||
{state.error && <p role="alert" className="text-sm text-destructive">{state.error}</p>}
|
||||
<Button type="submit" disabled={pending}>{pending ? "Wird eingerichtet…" : "E-Mail bestätigen und Konto erstellen"}</Button>
|
||||
</form>;
|
||||
}
|
||||
Reference in New Issue
Block a user