Merge family absence flows while preserving security hardening

This commit is contained in:
t.indorf
2026-10-10 07:08:41 +02:00
30 changed files with 2822 additions and 644 deletions
+125 -144
View File
@@ -1,45 +1,54 @@
"use server";
import { hashToken, newToken } from "@/lib/tokens";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client";
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 {
deliverFamilyInvitations,
queueFamilyInvitation,
resendFamilyInvitation,
} from "@/lib/family-invitations";
import { prisma } from "@/lib/prisma";
const INVITE_TOKEN_TTL_DAYS = 7;
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const childSchema = z.object({
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
firstName: z.string().trim().min(1, "Vorname des Kindes fehlt.").max(100),
lastName: z.string().trim().min(1, "Nachname des Kindes fehlt.").max(100),
});
const parentInputSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100),
lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100),
email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.trim()
.toLowerCase()
.trim(),
.email("Bitte eine gültige E-Mail-Adresse angeben."),
});
const addFamilySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parent1FirstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
parent1LastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
familyName: z
.string()
.trim()
.min(1, "Familienname ist erforderlich.")
.max(120),
parent1FirstName: z
.string()
.trim()
.min(1, "Vorname ist erforderlich.")
.max(100),
parent1LastName: z
.string()
.trim()
.min(1, "Nachname ist erforderlich.")
.max(100),
parent1Email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.trim()
.toLowerCase()
.trim(),
.email("Bitte eine gültige E-Mail-Adresse angeben."),
parent2FirstName: z.string().max(100).trim().optional(),
parent2LastName: z.string().max(100).trim().optional(),
parent2Email: z.string().trim().optional(),
@@ -52,12 +61,19 @@ const addFamilySchema = z.object({
const updateFamilySchema = z.object({
familyId: z.string().min(1),
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parents: z.array(
parentInputSchema.extend({
id: z.string().min(1),
}),
).min(1).max(2),
familyName: z
.string()
.trim()
.min(1, "Familienname ist erforderlich.")
.max(120),
parents: z
.array(
parentInputSchema.extend({
id: z.string().min(1),
}),
)
.min(1)
.max(2),
newParents: z.array(parentInputSchema).max(1).default([]),
children: z
.array(
@@ -83,12 +99,7 @@ export type AddFamilyState = {
_form?: string[];
};
success?: boolean;
};
type InviteJob = {
to: string;
parentName: string;
inviteUrl: string;
warning?: string;
};
function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
@@ -115,32 +126,14 @@ function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
return { ok: true as const, parent: parsed.data };
}
function createInviteToken() {
const token = newToken();
const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
);
return { token, expires, inviteUrl: `${BASE_URL}/invite/${token}` };
}
async function sendInviteJobs(kitaName: string, jobs: InviteJob[]) {
for (const job of jobs) {
const result = await sendAppEmail({
to: job.to,
subject: `Einladung zu ${kitaName} im Kita-Planer`,
react: createElement(InviteEmail, {
parentName: job.parentName,
kitaName,
inviteLink: job.inviteUrl,
}),
});
if (!result.success) {
return result.error;
}
async function sendInviteJobs(ids: string[]) {
try {
const result = await deliverFamilyInvitations(ids);
if (!result.failed) return undefined;
} catch {
// A committed family remains successful even if delivery/status storage fails.
}
return null;
return "Familie gespeichert. Mindestens eine Einladung konnte nicht versendet werden. Den Versand kannst du beim jeweiligen Elternteil erneut versuchen.";
}
export async function addFamilyAction(
@@ -185,11 +178,6 @@ export async function addFamilyAction(
childrenRaw.push(parsed.data);
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { errors: { _form: [mailConfigError] } };
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
@@ -211,12 +199,14 @@ export async function addFamilyAction(
if (emailSet.size !== parents.length) {
return {
errors: {
_form: ["Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein."],
_form: [
"Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.",
],
},
};
}
const inviteJobs: InviteJob[] = [];
const inviteJobs: string[] = [];
try {
await prisma.$transaction(async (tx) => {
@@ -239,19 +229,12 @@ export async function addFamilyAction(
role: UserRole.ELTERN,
},
});
const invite = createInviteToken();
inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: hashToken(invite.token),
expires: invite.expires,
},
});
const invite = await queueFamilyInvitation(
tx,
createdParent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
await tx.child.createMany({
@@ -274,26 +257,25 @@ export async function addFamilyAction(
},
};
}
throw err;
}
const emailError = await sendInviteJobs(kita.name, inviteJobs);
if (emailError) {
console.error("Familie konnte nicht angelegt werden:", err);
return {
errors: {
_form: [
`Familie wurde angelegt, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
"Familie konnte nicht angelegt werden. Bitte erneut versuchen.",
],
},
};
}
const warning = await sendInviteJobs(inviteJobs);
revalidatePath("/dashboard/families");
revalidatePath("/dashboard");
return { success: true };
return { success: true, warning };
}
export async function updateFamilyAction(rawPayload: unknown) {
export async function updateFamilyAction(
rawPayload: unknown,
): Promise<{ success?: boolean; error?: string; warning?: string }> {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
@@ -316,9 +298,13 @@ export async function updateFamilyAction(rawPayload: unknown) {
removedChildIds,
} = parsed.data;
const parentEmails = [...parents, ...newParents].map((parent) => parent.email);
const parentEmails = [...parents, ...newParents].map(
(parent) => parent.email,
);
if (new Set(parentEmails).size !== parentEmails.length) {
return { error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein." };
return {
error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.",
};
}
const removedChildIdSet = new Set(removedChildIds);
@@ -328,13 +314,6 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Eine Familie benötigt mindestens ein Kind." };
}
if (newParents.length > 0) {
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
@@ -344,7 +323,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Kita wurde nicht gefunden." };
}
const inviteJobs: InviteJob[] = [];
const inviteJobs: string[] = [];
try {
const existingFamily = await prisma.family.findFirst({
@@ -362,8 +341,6 @@ export async function updateFamilyAction(rawPayload: unknown) {
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." };
}
@@ -371,7 +348,9 @@ export async function updateFamilyAction(rawPayload: unknown) {
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
return { error: "Mindestens ein Elternteil gehört nicht zu dieser Familie." };
return {
error: "Mindestens ein Elternteil gehört nicht zu dieser Familie.",
};
}
if (!children.every((child) => existingChildIds.has(child.id))) {
@@ -402,10 +381,12 @@ export async function updateFamilyAction(rawPayload: unknown) {
...(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 });
const invite = await queueFamilyInvitation(
tx,
parent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
}
@@ -421,19 +402,12 @@ export async function updateFamilyAction(rawPayload: unknown) {
role: UserRole.ELTERN,
},
});
const invite = createInviteToken();
inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: hashToken(invite.token),
expires: invite.expires,
},
});
const invite = await queueFamilyInvitation(
tx,
createdParent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
for (const child of activeChildren) {
@@ -473,41 +447,48 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Familie konnte nicht aktualisiert werden." };
}
const emailError = await sendInviteJobs(kita.name, inviteJobs);
if (emailError) {
return {
error: `Familie wurde aktualisiert, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
};
}
const warning = await sendInviteJobs(inviteJobs);
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard");
return { success: true };
return { success: true, warning };
}
export async function resendFamilyInvitationAction(
userId: string,
): Promise<{ success?: boolean; error?: string; warning?: string }> {
const session = await requireRole([UserRole.ADMIN]);
if (!session.user.kitaId || typeof userId !== "string" || !userId || userId.length > 64)
return { error: "Ungültiges Elternkonto." };
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 recipient = await prisma.user.findFirst({
where: { id: userId, kitaId: session.user.kitaId, role: UserRole.ELTERN,
emailVerifiedAt: null, passwordHash: "", disabledAt: null },
select: { email: true },
});
if (!recipient) return { error: "Keine offene Einladung gefunden." };
let result;
try {
result = await resendFamilyInvitation(
session.user.kitaId,
userId,
session.user.id,
recipient.email,
);
} catch {
result = {
error:
"Einladung konnte nicht erneut versendet werden. Bitte den Status prüfen und erneut versuchen.",
};
}
revalidatePath("/dashboard/families");
return result;
}
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 };
return resendFamilyInvitationAction(userId);
}