Harden authentication, tenant isolation and security boundaries

This commit is contained in:
t.indorf
2026-10-09 22:49:55 +02:00
parent ec3f4f933d
commit 656ada80bd
71 changed files with 3107 additions and 1200 deletions
+4 -3
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache";
import { AbsenceReason, UserRole } from "@prisma/client";
import { dateOnlySchema } from "@/lib/date-schema";
import { z } from "zod";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
@@ -9,14 +10,14 @@ import { prisma } from "@/lib/prisma";
const reportAbsenceSchema = z.object({
childId: z.string().min(1),
startDate: z.string().min(1),
endDate: z.string().min(1),
startDate: dateOnlySchema,
endDate: dateOnlySchema,
reason: z.nativeEnum(AbsenceReason),
note: z.string().trim().max(500).optional(),
});
const dailyAbsenceSchema = z.object({
date: z.string().min(1),
date: dateOnlySchema,
});
export type DailyAbsenceDto = {
+62 -69
View File
@@ -2,7 +2,9 @@
import { randomUUID } from "crypto";
import { mkdir, rm, writeFile } from "fs/promises";
import path from "path";
import { attachmentDirectory, attachmentPath, MAX_ATTACHMENT_SIZE, detectAttachmentType, attachmentExtensionMatches } from "@/lib/attachments";
import { serializableTransaction } from "@/lib/transaction";
import { rateLimit } from "@/lib/rate-limit";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client";
@@ -13,15 +15,6 @@ import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
["application/pdf", ".pdf"],
["image/jpeg", ".jpg"],
["image/png", ".png"],
]);
const ALLOWED_EXTENSIONS = new Set([".pdf", ".jpg", ".jpeg", ".png"]);
const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
const announcementSchema = z.object({
@@ -31,30 +24,10 @@ const announcementSchema = z.object({
audience: audienceSchema,
});
function getUploadDir() {
return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements");
}
function getSafeFileName(name: string) {
return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160);
}
function validateAttachment(file: File) {
const extension = path.extname(file.name).toLowerCase();
const typeAllowed = ALLOWED_TYPES.has(file.type);
const extensionAllowed = ALLOWED_EXTENSIONS.has(extension);
if (!typeAllowed && !extensionAllowed) {
return "Nur PDF, JPG und PNG sind erlaubt.";
}
if (file.size > MAX_ATTACHMENT_SIZE) {
return "Anhänge dürfen maximal 10 MB groß sein.";
}
return null;
}
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
const where: Prisma.UserWhereInput =
audience === "ELTERN"
@@ -76,7 +49,7 @@ async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSc
};
const users = await prisma.user.findMany({
where,
where: { ...where, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true },
});
@@ -106,56 +79,75 @@ export async function createAnnouncement(formData: FormData) {
.getAll("attachments")
.filter((value): value is File => value instanceof File && value.size > 0);
if (attachmentFiles.length > 5 || attachmentFiles.some(file => file.size > MAX_ATTACHMENT_SIZE) ||
attachmentFiles.reduce((total, file) => total + file.size, 0) > 25 * 1024 * 1024) {
return { error: "Maximal 5 Anhänge, je 10 MB und insgesamt 25 MB sind erlaubt." };
}
if (!(await rateLimit(`announcements:${kitaId}:${session.user.id}`, 20, 3600)).allowed) {
return { error: "Zu viele Ankündigungen. Bitte versuche es später erneut." };
}
const attachmentInputs: { id: string; bytes: Buffer; fileName: string; fileType: string; fileUrl: string }[] = [];
for (const file of attachmentFiles) {
const error = validateAttachment(file);
if (error) {
return { error };
const bytes = Buffer.from(await file.arrayBuffer());
const fileType = detectAttachmentType(bytes);
if (!fileType || !attachmentExtensionMatches(file.name, fileType) || (file.type && file.type !== fileType)) {
return { error: "Nur PDF-, JPG- und PNG-Dateien mit passenden Dateiinhalten sind erlaubt." };
}
const id = randomUUID();
attachmentInputs.push({ id, bytes, fileName: getSafeFileName(file.name), fileType,
fileUrl: `/api/announcements/attachments/${id}` });
}
const attachmentInputs = attachmentFiles.map((file) => {
const id = randomUUID();
return {
id,
file,
fileName: getSafeFileName(file.name),
fileType: file.type || "application/octet-stream",
fileUrl: `/api/announcements/attachments/${id}`,
};
});
const announcement = await prisma.announcement.create({
data: {
kitaId,
title: parsed.data.title,
content: parsed.data.content,
authorId: session.user.id,
attachments: {
create: attachmentInputs.map((attachment) => ({
id: attachment.id,
fileName: attachment.fileName,
fileType: attachment.fileType,
fileUrl: attachment.fileUrl,
})),
const announcement = await serializableTransaction(async tx => {
if (attachmentInputs.length) {
const pending = await tx.attachment.count({ where: { sizeBytes: null } });
if (pending) return null;
const [tenantUsage, globalUsage] = await Promise.all([
tx.attachment.aggregate({ where: { announcement: { kitaId } }, _sum: { sizeBytes: true } }),
tx.attachment.aggregate({ _sum: { sizeBytes: true } }),
]);
const bytes = attachmentInputs.reduce((total, item) => total + item.bytes.length, 0);
const quota = (value: string | undefined, fallback: number) => {
const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
};
if ((tenantUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) ||
(globalUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)) return null;
}
return tx.announcement.create({
data: {
kitaId,
title: parsed.data.title,
content: parsed.data.content,
authorId: session.user.id,
attachments: {
create: attachmentInputs.map((attachment) => ({
id: attachment.id,
fileName: attachment.fileName,
fileType: attachment.fileType,
sizeBytes: attachment.bytes.length,
fileUrl: attachment.fileUrl,
})),
},
},
},
select: {
id: true,
title: true,
content: true,
},
select: {
id: true,
title: true,
content: true,
},
});
});
if (!announcement) return { error: "Anhänge können momentan nicht gespeichert werden. Bitte wende dich an die Administration." };
if (attachmentInputs.length > 0) {
const uploadDir = getUploadDir();
const uploadDir = attachmentDirectory();
try {
await mkdir(uploadDir, { recursive: true });
await Promise.all(
attachmentInputs.map(async (attachment) => {
const arrayBuffer = await attachment.file.arrayBuffer();
await writeFile(
path.join(uploadDir, attachment.id),
Buffer.from(arrayBuffer),
attachmentPath(attachment.id),
attachment.bytes,
{ flag: "wx", mode: 0o600 },
);
}),
);
@@ -163,7 +155,7 @@ export async function createAnnouncement(formData: FormData) {
await prisma.announcement.delete({ where: { id: announcement.id } });
await Promise.all(
attachmentInputs.map((attachment) =>
rm(path.join(uploadDir, attachment.id), { force: true }),
rm(attachmentPath(attachment.id), { force: true }),
),
);
console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error);
@@ -232,6 +224,7 @@ export async function markAnnouncementRead(announcementId: string) {
},
},
create: {
kitaId: session.user.kitaId,
userId: session.user.id,
announcementId: announcement.id,
},
+5 -27
View File
@@ -1,7 +1,7 @@
"use server";
import { createElement } from "react";
import { headers } from "next/headers";
import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail";
import { ContactRequestEmail } from "@/emails/ContactRequestEmail";
@@ -11,39 +11,17 @@ import {
type ContactSubmissionPayload,
} from "@/lib/contact-schema";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
import { limitPublicAction } from "@/lib/rate-limit";
const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local";
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60;
async function resolveClientIp(): Promise<string> {
const h = await headers();
const forwarded = h.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0]?.trim() || "unknown";
}
return h.get("x-real-ip") ?? "unknown";
}
export async function submitContactRequest(data: ContactSubmissionPayload) {
// Honeypot: silently absorb bot submissions without informing them.
if (data.website && data.website.trim().length > 0) {
if (typeof data?.website === "string" && data.website.trim().length > 0) {
return { success: true };
}
const ip = await resolveClientIp();
const limit = rateLimit(
`contact:${ip}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_SECONDS,
);
if (!limit.allowed) {
const minutes = Math.max(1, Math.ceil(limit.retryAfterSeconds / 60));
return {
success: false,
error: `Zu viele Anfragen. Bitte versuche es in ${minutes} Minuten erneut.`,
};
if (!(await limitPublicAction("contact", String(data.email ?? "").trim().toLowerCase()))) {
return { success: false, error: "Zu viele Anfragen. Bitte versuche es später erneut." };
}
const parsed = contactRequestSchema.safeParse(data);
+15 -3
View File
@@ -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,
});
+12 -28
View File
@@ -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 };
}
+7 -3
View File
@@ -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) {
+25 -22
View File
@@ -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.") };
}
}
+7 -2
View File
@@ -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,
+1 -1
View File
@@ -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>;
}
+55 -13
View File
@@ -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 };
}
+1 -1
View File
@@ -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([]);
});
}
+84 -67
View File
@@ -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) {
+4
View File
@@ -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 ||
+3 -3
View File
@@ -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) {
+21 -11
View File
@@ -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.") };
}
}
+21 -13
View File
@@ -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");
+26 -22
View File
@@ -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", {
+9 -4
View File
@@ -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
View File
@@ -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>
+2 -7
View File
@@ -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
View File
@@ -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;
+11 -10
View File
@@ -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
View File
@@ -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 };
}
+1 -1
View File
@@ -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">
+3 -13
View File
@@ -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 {};
}
+17
View File
@@ -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>;
}
+45 -42
View File
@@ -1,6 +1,7 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { limitPublicAction } from "@/lib/rate-limit";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
@@ -14,12 +15,12 @@ import { prisma } from "@/lib/prisma";
// =====================================================================
const credentialsSchema = z.object({
email: z.string().email().toLowerCase().trim(),
password: z.string().min(1),
email: z.string().trim().max(254).email().toLowerCase(),
password: z.string().min(1).refine(value => Buffer.byteLength(value, "utf8") <= 72),
});
// Brute-Force-Schutz auf Account-Ebene (planung.md §5.5).
// Globales Rate-Limiting ergänzen wir später per Middleware.
// Zusätzlich greifen atomare, instanzübergreifende DB-Limits vor bcrypt.
const MAX_FAILED_ATTEMPTS = 10;
const LOCKOUT_MINUTES = 15;
@@ -40,8 +41,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (!parsed.success) return null;
const { email, password } = parsed.data;
if (!(await limitPublicAction("login", email, 20, 900))) return null;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
if (!user) {
// Match the cost for nonexistent accounts without exposing account existence.
await compare(password, "$2b$12$KbQiLuL6QbybTw/WNx4Ige2yCZcjVccoruuDx3vmkJt8YYUpphdIu");
return null;
}
if (user.disabledAt || !user.emailVerifiedAt) return null;
// Account temporär gesperrt → Login pauschal ablehnen.
if (user.lockedUntil && user.lockedUntil > new Date()) {
@@ -50,23 +57,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const passwordOk = await compare(password, user.passwordHash);
if (!passwordOk) {
const nextAttempts = user.failedLoginAttempts + 1;
await prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: nextAttempts,
lockedUntil:
nextAttempts >= MAX_FAILED_ATTEMPTS
? new Date(Date.now() + LOCKOUT_MINUTES * 60_000)
: user.lockedUntil,
},
});
await prisma.$executeRaw`
UPDATE users SET "failedLoginAttempts" = "failedLoginAttempts" + 1,
"lockedUntil" = CASE WHEN "failedLoginAttempts" + 1 >= ${MAX_FAILED_ATTEMPTS}
THEN NOW() + make_interval(mins => ${LOCKOUT_MINUTES}::int) ELSE "lockedUntil" END
WHERE id = ${user.id}`;
return null;
}
// Erfolgreicher Login → Counter zurücksetzen, lastLogin aktualisieren.
await prisma.user.update({
where: { id: user.id },
const admitted = await prisma.user.updateMany({
where: { id: user.id, disabledAt: null, sessionVersion: user.sessionVersion, passwordHash: user.passwordHash, emailVerifiedAt: { not: null },
OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }] },
data: {
failedLoginAttempts: 0,
lockedUntil: null,
@@ -74,7 +76,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
},
});
if (admitted.count !== 1) return null;
return {
sessionVersion: user.sessionVersion,
authenticatedAt: Date.now(),
id: user.id,
email: user.email,
name: `${user.firstName} ${user.lastName}`.trim(),
@@ -86,6 +91,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
}),
],
callbacks: {
async redirect({ url, baseUrl }) {
if (/[\\\x00-\x20]/.test(url)) return baseUrl;
try {
const destination = new URL(url, baseUrl);
return destination.origin === new URL(baseUrl).origin ? destination.href : baseUrl;
} catch { return baseUrl; }
},
/**
* Wird beim Login (mit `user`) und bei jedem Token-Refresh (ohne `user`)
* aufgerufen. Beim Login schreiben wir die mandantenrelevanten Felder
@@ -96,31 +108,20 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
token.kitaId = user.kitaId;
token.familyId = user.familyId;
return token;
token.sessionVersion = user.sessionVersion;
token.authenticatedAt = user.authenticatedAt;
}
const tokenUserId = token.id ?? token.sub;
if (tokenUserId) {
const fresh = await prisma.user.findUnique({
where: { id: tokenUserId },
select: { role: true, kitaId: true, familyId: true },
});
if (!fresh) {
// User wurde gelöscht → Token entwerten.
// (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.)
delete (token as Partial<typeof token>).id;
delete (token as Partial<typeof token>).sub;
return token;
}
token.id = tokenUserId;
token.role = fresh.role;
token.kitaId = fresh.kitaId;
token.familyId = fresh.familyId;
}
const id = token.id ?? token.sub;
if (typeof id !== "string" || !id || id.length > 64 || !Number.isSafeInteger(token.sessionVersion) || !Number.isFinite(token.authenticatedAt) || token.authenticatedAt > Date.now() + 60_000) return null;
const fresh = await prisma.user.findUnique({
where: { id },
select: { role: true, kitaId: true, familyId: true, disabledAt: true, sessionVersion: true, emailVerifiedAt: true },
});
if (!fresh || fresh.disabledAt || !fresh.emailVerifiedAt || fresh.sessionVersion !== token.sessionVersion) return null;
token.id = id;
token.role = fresh.role;
token.kitaId = fresh.kitaId;
token.familyId = fresh.familyId;
return token;
},
@@ -131,7 +132,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
*/
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id ?? token.sub;
session.user.id = token.id;
session.user.sessionVersion = token.sessionVersion;
session.user.authenticatedAt = token.authenticatedAt;
session.user.role = token.role;
session.user.kitaId = token.kitaId;
session.user.familyId = token.familyId;
+36 -6
View File
@@ -1,3 +1,7 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import Script from "next/script";
import { marketingFeatureSlugs } from "@/lib/marketing/features";
@@ -6,30 +10,55 @@ const publicAnalyticsPaths = new Set([
"/",
"/impressum",
"/datenschutz",
"/login",
"/register",
...marketingFeatureSlugs.map((slug) => `/features/${slug}`),
]);
export function UmamiAnalytics() {
declare global { interface Window { kitaPlanerAnalyticsLoaded?: boolean } }
export function UmamiAnalytics({ nonce, enabled }: { nonce?: string; enabled: boolean }) {
const pathname = usePathname();
useEffect(() => {
// A full document change discards an SDK already loaded on a public page.
if ((!enabled || !publicAnalyticsPaths.has(pathname)) && window.kitaPlanerAnalyticsLoaded) {
window.location.replace(window.location.href);
return;
}
if (!enabled || !publicAnalyticsPaths.has(pathname)) return;
// Auth and token pages get a fresh document before any private data is fetched.
const navigate = (event: MouseEvent) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
if (!(link instanceof HTMLAnchorElement)) return;
const destination = new URL(link.href, window.location.origin);
if (destination.origin === window.location.origin && !publicAnalyticsPaths.has(destination.pathname)) {
event.preventDefault(); event.stopPropagation();
window.location.assign(destination.href);
}
};
document.addEventListener("click", navigate, true);
return () => document.removeEventListener("click", navigate, true);
}, [pathname, enabled]);
const scriptUrl = process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL;
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
const domains = process.env.NEXT_PUBLIC_UMAMI_DOMAINS;
if (!scriptUrl || !websiteId) {
if (!enabled || !scriptUrl || !websiteId || !publicAnalyticsPaths.has(pathname)) {
return null;
}
return (
<>
<Script id="umami-before-send" strategy="afterInteractive">
<Script nonce={nonce} id="umami-before-send" strategy="afterInteractive">
{`
window.kitaPlanerAnalyticsLoaded = true;
window.kitaPlanerUmamiBeforeSend = function (_type, payload) {
try {
var url = new URL(payload && payload.url ? payload.url : window.location.href, window.location.origin);
var path = url.pathname.length > 1 && url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
var allowedPaths = ${JSON.stringify(Array.from(publicAnalyticsPaths))};
return allowedPaths.indexOf(path) === -1 ? false : payload;
if (allowedPaths.indexOf(path) === -1) return false;
var clean = Object.assign({}, payload, { url: url.origin + path, referrer: "" });
return clean;
} catch (_error) {
return false;
}
@@ -37,6 +66,7 @@ export function UmamiAnalytics() {
`}
</Script>
<Script
nonce={nonce}
src={scriptUrl}
strategy="afterInteractive"
data-website-id={websiteId}
+4
View File
@@ -0,0 +1,4 @@
export class UserFacingError extends Error {}
export function publicActionError(error: unknown, fallback: string): string {
return error instanceof UserFacingError ? error.message : fallback;
}
+12
View File
@@ -0,0 +1,12 @@
import { NotdienstAlertStatus } from "@prisma/client";
import { hashToken } from "@/lib/tokens";
export function activeAlertTokenWhere(token: string) {
const today = new Date(); today.setUTCHours(0, 0, 0, 0);
return {
confirmationToken: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] },
status: NotdienstAlertStatus.PENDING,
triggeredAt: { gt: new Date(Date.now() - 24 * 60 * 60_000) },
assignment: { date: { gte: today } },
parentUser: { disabledAt: null },
};
}
+37
View File
@@ -0,0 +1,37 @@
import { rm } from "node:fs/promises";
import path from "node:path";
export const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
export function attachmentDirectory(): string {
// Preserve the existing storage layout, including explicitly configured roots.
return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements");
}
export function attachmentPath(id: string): string {
if (!/^(?:[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|c[a-z0-9]{24,32})$/i.test(id)) throw new Error("Invalid attachment ID");
return path.join(attachmentDirectory(), id);
}
export function detectAttachmentType(bytes: Buffer): string | null {
if (bytes.length < 12 || bytes.length > MAX_ATTACHMENT_SIZE) return null;
if (bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) &&
bytes.subarray(-12).equals(Buffer.from([0,0,0,0,73,69,78,68,174,66,96,130]))) return "image/png";
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 && bytes.subarray(-2).equals(Buffer.from([255,217]))) return "image/jpeg";
if (bytes.subarray(0, 5).toString("ascii") === "%PDF-" && bytes.subarray(-1024).toString("ascii").trimEnd().endsWith("%%EOF")) return "application/pdf";
return null;
}
export function attachmentExtensionMatches(name: string, type: string): boolean {
const extension = path.extname(name).toLowerCase();
return type === "image/jpeg" ? [".jpg", ".jpeg"].includes(extension) :
type === "image/png" ? extension === ".png" : type === "application/pdf" && extension === ".pdf";
}
export function attachmentDisposition(name: string): string {
const safe = name.replace(/[\r\n]/g, "").slice(0, 160);
const encoded = encodeURIComponent(safe).replace(/['()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
return `attachment; filename="${safe.replace(/[^\w.\-]/g, "_")}"; filename*=UTF-8''${encoded}`;
}
export async function removeAttachmentFiles(ids: string[]): Promise<void> {
await Promise.all(ids.map(async id => {
try { await rm(attachmentPath(id), { force: true }); }
catch (error) { console.error("Attachment cleanup failed; run the orphan cleanup command", id, error); }
}));
}
+17 -23
View File
@@ -1,3 +1,4 @@
import { cache } from "react";
import { redirect } from "next/navigation";
import type { Session } from "next-auth";
import { UserRole } from "@prisma/client";
@@ -28,12 +29,14 @@ export type KitaSession = AuthenticatedSession & {
user: AuthenticatedSession["user"] & { kitaId: string };
};
const readRequestSession = cache(() => auth());
export async function getSession(): Promise<Session | null> {
return auth();
return readRequestSession();
}
export async function requireSession(): Promise<AuthenticatedSession> {
const session = await auth();
const session = await getSession();
if (!session?.user?.id) {
redirect("/login");
}
@@ -46,17 +49,10 @@ export async function requireSession(): Promise<AuthenticatedSession> {
* für alle Tenant-gebundenen Server Actions / Page Components.
*/
export async function requireKitaSession(): Promise<KitaSession> {
if (process.env.TEST_MODE === "true") {
return {
user: {
id: process.env.TEST_USER_ID || "test-user-id",
role: (process.env.TEST_USER_ROLE as UserRole) || UserRole.ELTERN,
kitaId: process.env.TEST_KITA_ID || "test-kita-id",
familyId: process.env.TEST_FAMILY_ID || "test-family-id",
},
} as any;
}
const session = await requireSession();
return enforceKitaSession(await requireSession());
}
async function enforceKitaSession(session: AuthenticatedSession): Promise<KitaSession> {
if (!session.user.kitaId) {
// Superadmins haben keine Kita → eigene Oberfläche.
@@ -80,20 +76,11 @@ export async function requireKitaSession(): Promise<KitaSession> {
export async function requireRole(
allowed: UserRole[],
): Promise<AuthenticatedSession> {
if (process.env.TEST_MODE === "true") {
return {
user: {
id: process.env.TEST_USER_ID || "test-user-id",
role: (process.env.TEST_USER_ROLE as UserRole) || UserRole.ADMIN,
kitaId: process.env.TEST_KITA_ID || "test-kita-id",
familyId: process.env.TEST_FAMILY_ID || "test-family-id",
},
} as any;
}
const session = await requireSession();
if (!allowed.includes(session.user.role)) {
redirect("/forbidden");
}
if (session.user.role !== UserRole.SUPERADMIN) return enforceKitaSession(session);
return session;
}
@@ -113,3 +100,10 @@ async function consentCheck(userId: string | undefined): Promise<boolean> {
});
return !!user?.privacyPolicyAcceptedAt;
}
// Route handlers use this non-redirecting counterpart of the same consent gate.
export async function getKitaSession(): Promise<KitaSession | null> {
const session = await getSession();
if (!session?.user?.id || !session.user.kitaId || !(await consentCheck(session.user.id))) return null;
return session as KitaSession;
}
+9
View File
@@ -0,0 +1,9 @@
import { z } from "zod";
export const dateOnlySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(value => {
const date = new Date(`${value}T00:00:00Z`);
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
});
export function validPlanMonth(year: unknown, month: unknown): boolean {
return typeof year === "number" && Number.isInteger(year) && year >= 2020 && year <= 2100 &&
typeof month === "number" && Number.isInteger(month) && month >= 1 && month <= 12;
}
+15 -9
View File
@@ -20,11 +20,11 @@ export function getAppEmailConfigError() {
return null;
}
if (!process.env.RESEND_API_KEY) {
return "RESEND_API_KEY ist nicht konfiguriert.";
return "E-Mail-Versand ist derzeit nicht verfügbar.";
}
if (!process.env.EMAIL_FROM) {
return "EMAIL_FROM ist nicht konfiguriert.";
return "E-Mail-Versand ist derzeit nicht verfügbar.";
}
return null;
@@ -37,6 +37,15 @@ export async function sendAppEmail({
from = process.env.EMAIL_FROM,
replyTo,
}: SendAppEmailInput): Promise<SendAppEmailResult> {
if (Array.isArray(to)) {
let id: string | undefined;
for (const recipient of new Set(to)) {
const result = await sendAppEmail({ to: recipient, subject, react, from, replyTo });
if (!result.success) return result;
id = result.id;
}
return { success: true, id };
}
if (!process.env.RESEND_API_KEY) {
if (process.env.NODE_ENV === "development") {
console.log("\n✉️ [DEV EMAIL SIMULATION]");
@@ -50,14 +59,14 @@ export async function sendAppEmail({
}
return {
success: false,
error: "RESEND_API_KEY ist nicht konfiguriert.",
error: "E-Mail-Versand ist derzeit nicht verfügbar.",
};
}
if (!from) {
return {
success: false,
error: "EMAIL_FROM ist nicht konfiguriert.",
error: "E-Mail-Versand ist derzeit nicht verfügbar.",
};
}
@@ -75,7 +84,7 @@ export async function sendAppEmail({
console.error("Resend konnte die E-Mail nicht versenden:", error);
return {
success: false,
error: error.message ?? "E-Mail-Versand fehlgeschlagen.",
error: "E-Mail-Versand fehlgeschlagen.",
};
}
@@ -84,10 +93,7 @@ export async function sendAppEmail({
console.error("Unerwarteter Fehler beim E-Mail-Versand:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Unerwarteter Fehler beim E-Mail-Versand.",
error: "E-Mail-Versand fehlgeschlagen.",
};
}
}
+5
View File
@@ -0,0 +1,5 @@
import { z } from "zod";
export const passwordSchema = z.string().min(8, "Mindestens 8 Zeichen.")
.refine(value => Buffer.byteLength(value, "utf8") <= 72,
"Das Passwort darf höchstens 72 UTF-8-Bytes umfassen.");
+3
View File
@@ -1,3 +1,6 @@
if (process.env.NODE_ENV === "production" && process.env.TEST_MODE === "true") {
throw new Error("TEST_MODE is forbidden in production");
}
import { PrismaClient } from "@prisma/client";
// Singleton-Pattern: Im Dev-Modus erzeugt Next.js bei jedem Hot-Reload
+33 -31
View File
@@ -1,39 +1,41 @@
type Bucket = {
count: number;
resetAt: number;
};
const buckets = new Map<string, Bucket>();
import { createHash } from "node:crypto";
import { isIP } from "node:net";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
export type RateLimitResult =
| { allowed: true; remaining: number; resetAt: number }
| { allowed: false; retryAfterSeconds: number };
export function rateLimit(
key: string,
limit: number,
windowSeconds: number,
): RateLimitResult {
const now = Date.now();
const windowMs = windowSeconds * 1000;
const existing = buckets.get(key);
// PostgreSQL performs the increment atomically across all application instances.
export async function rateLimit(key: string, limit: number, windowSeconds: number): Promise<RateLimitResult> {
const bucket = createHash("sha256").update(key).digest("hex");
const [row] = await prisma.$queryRaw<{ count: number; resetAt: Date }[]>`
INSERT INTO rate_limit_buckets (key, count, "resetAt")
VALUES (${bucket}, 1, NOW() + make_interval(secs => ${windowSeconds}))
ON CONFLICT (key) DO UPDATE SET
count = CASE WHEN rate_limit_buckets."resetAt" <= NOW() THEN 1
ELSE LEAST(rate_limit_buckets.count + 1, ${limit + 1}) END,
"resetAt" = CASE WHEN rate_limit_buckets."resetAt" <= NOW()
THEN NOW() + make_interval(secs => ${windowSeconds}) ELSE rate_limit_buckets."resetAt" END
RETURNING count, "resetAt"`;
// Indexed, bounded cleanup avoids an unbounded memory map or growing stale buckets.
await prisma.$executeRaw`DELETE FROM rate_limit_buckets WHERE key IN
(SELECT key FROM rate_limit_buckets WHERE "resetAt" < NOW() LIMIT 100)`;
return row.count <= limit
? { allowed: true, remaining: limit - row.count, resetAt: row.resetAt.getTime() }
: { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((row.resetAt.getTime() - Date.now()) / 1000)) };
}
if (!existing || existing.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, remaining: limit - 1, resetAt: now + windowMs };
export async function limitPublicAction(scope: string, identity: string, limit = 5, seconds = 3600): Promise<boolean> {
if (!(await rateLimit(`${scope}:burst`, 100, 60)).allowed) return false;
if (!(await rateLimit(`${scope}:global`, 1000, seconds)).allowed) return false;
if (!(await rateLimit(`${scope}:identity:${identity}`, limit, seconds)).allowed) return false;
// Enable only behind a reverse proxy that REPLACES this header. Never trust arbitrary forwarded lists.
const header = process.env.TRUSTED_CLIENT_IP_HEADER;
if (header && ["x-real-ip", "cf-connecting-ip"].includes(header)) {
const ip = (await headers()).get(header)?.trim();
if (ip && isIP(ip) && !(await rateLimit(`${scope}:ip:${ip}`, limit * 3, seconds)).allowed) return false;
}
if (existing.count >= limit) {
return {
allowed: false,
retryAfterSeconds: Math.ceil((existing.resetAt - now) / 1000),
};
}
existing.count += 1;
return {
allowed: true,
remaining: limit - existing.count,
resetAt: existing.resetAt,
};
return true;
}
+7
View File
@@ -0,0 +1,7 @@
export function safeCallbackUrl(value: string | undefined): string {
if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\x00-\x20]/.test(value)) return "/dashboard";
try {
const url = new URL(value, "https://kita.invalid");
return url.origin === "https://kita.invalid" ? `${url.pathname}${url.search}${url.hash}` : "/dashboard";
} catch { return "/dashboard"; }
}
+9
View File
@@ -0,0 +1,9 @@
import { createHash, randomBytes } from "node:crypto";
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function newToken(): string { return randomBytes(32).toString("hex"); }
export function isPublicToken(token: unknown): token is string {
return typeof token === "string" && /^(?:[a-f0-9]{64}|[a-f0-9-]{36})$/i.test(token);
}
+11
View File
@@ -0,0 +1,11 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export async function serializableTransaction<T>(operation: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) {
try { return await prisma.$transaction(operation, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); }
catch (error) {
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== "P2034" || attempt >= 2) throw error;
}
}
}
+41 -7
View File
@@ -1,3 +1,4 @@
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { auth } from "@/auth";
@@ -5,7 +6,7 @@ import { auth } from "@/auth";
// =====================================================================
// Kita-Planer · Proxy (ehem. Middleware, seit Next.js 16 umbenannt)
// ---------------------------------------------------------------------
// Optimistischer Guard — prüft den JWT-Cookie direkt, ohne DB-Call.
// Routing-Guard; auth() validiert das Konto und die Sitzungsfassung gegen die DB.
// Der sichere DB-seitige Check (Consent, Rolle) findet in den
// jeweiligen Server Components via `requireKitaSession()` statt.
//
@@ -30,18 +31,18 @@ function getPostLoginPath(user: { role?: string; kitaId?: string | null }) {
return user.kitaId ? "/dashboard" : ONBOARDING_ROUTE;
}
export async function proxy(request: NextRequest) {
async function routeRequest(request: NextRequest, requestHeaders: Headers) {
const { pathname } = request.nextUrl;
const isPublicRoute =
PUBLIC_PATHS.has(pathname) ||
PUBLIC_PREFIX.some((prefix) => pathname.startsWith(prefix));
if (isPublicRoute) {
return NextResponse.next();
return NextResponse.next({ request: { headers: requestHeaders } });
}
// Auth.js liest den Session-JWT aus dem Cookie —
// kein DB-Call, pure Token-Verifikation (Edge-sicher).
// Die Token-Callbacks prüfen auch Sperren und gelöschte Konten.
const session = await auth();
const user = session?.user;
const hasValidUser = !!user?.id;
@@ -62,7 +63,7 @@ export async function proxy(request: NextRequest) {
if (hasValidUser && user) {
// 2a. Eingeloggter User auf Login/Register-Seite → direkt zum Ziel,
// damit die öffentliche Startseite stabil indexierbar bleibt.
if (pathname === "/login" || pathname === "/register") {
if ((pathname === "/login" && request.nextUrl.searchParams.get("reauth") !== "1") || pathname === "/register") {
return NextResponse.redirect(
new URL(getPostLoginPath(user), request.nextUrl),
);
@@ -84,12 +85,12 @@ export async function proxy(request: NextRequest) {
}
// 2c. Hat kitaId und will /onboarding aufrufen → direkt ins Dashboard.
if (user.kitaId && pathname.startsWith(ONBOARDING_ROUTE)) {
if (user.kitaId && pathname === ONBOARDING_ROUTE) {
return NextResponse.redirect(new URL("/dashboard", request.nextUrl));
}
}
return NextResponse.next();
return NextResponse.next({ request: { headers: requestHeaders } });
}
// Proxy auf alle sinnvollen Routen anwenden; statische Dateien,
@@ -99,3 +100,36 @@ export const config = {
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
export async function proxy(request: NextRequest) {
if (request.method === "POST") {
const expectedOrigin = new URL(process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? request.nextUrl.origin).origin;
if (request.headers.get("origin") !== expectedOrigin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
}
const nonce = randomBytes(16).toString("base64");
const dev = process.env.NODE_ENV === "development";
let analyticsOrigin = "";
try { analyticsOrigin = new URL(process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ?? "").origin; } catch { /* analytics disabled */ }
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${dev ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://images.unsplash.com",
"font-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'",
`connect-src 'self' ${analyticsOrigin}${dev ? " ws: wss:" : ""}`,
...(!dev ? ["upgrade-insecure-requests"] : []),
].join("; ");
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const response = await routeRequest(request, requestHeaders);
response.headers.set("Content-Security-Policy", csp);
response.headers.set("Referrer-Policy", "no-referrer");
if (request.nextUrl.pathname.startsWith("/alert/") || request.nextUrl.pathname.startsWith("/invite/") || request.nextUrl.pathname.startsWith("/register/verify/") || PROTECTED_PREFIX.some(p => request.nextUrl.pathname.startsWith(p))) {
response.headers.set("Cache-Control", "private, no-store");
response.headers.set("X-Robots-Tag", "noindex, nofollow");
}
return response;
}
+6
View File
@@ -15,6 +15,8 @@ import type { UserRole } from "@prisma/client";
declare module "next-auth" {
interface User extends DefaultUser {
id: string;
sessionVersion: number;
authenticatedAt: number;
role: UserRole;
kitaId: string | null;
familyId: string | null;
@@ -23,6 +25,8 @@ declare module "next-auth" {
interface Session {
user: {
id: string;
sessionVersion: number;
authenticatedAt: number;
role: UserRole;
kitaId: string | null;
familyId: string | null;
@@ -36,6 +40,8 @@ declare module "next-auth" {
declare module "@auth/core/jwt" {
interface JWT {
id: string;
sessionVersion: number;
authenticatedAt: number;
role: UserRole;
kitaId: string | null;
familyId: string | null;