Add superadmin audit and Kita management

This commit is contained in:
t.indorf
2026-05-16 12:02:59 +02:00
parent 0b80c62beb
commit 380c67963b
10 changed files with 2007 additions and 39 deletions
+287
View File
@@ -0,0 +1,287 @@
"use server";
import { revalidatePath } from "next/cache";
import { AuditTargetType, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const deleteKitaSchema = z.object({
kitaId: z.string().min(1),
confirmationName: z.string().min(1).trim(),
});
const deleteUserSchema = z.object({
userId: z.string().min(1),
confirmationEmail: z.string().email().toLowerCase().trim(),
});
const userIdSchema = z.string().min(1);
type ActionResult = {
ok: boolean;
error?: string;
};
async function requireSuperadminActor() {
const session = await requireRole([UserRole.SUPERADMIN]);
return {
id: session.user.id,
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
};
}
async function getKitaDeleteCounts(kitaId: string) {
const [
users,
families,
children,
termine,
absences,
announcements,
invitations,
eventParticipations,
] = await Promise.all([
prisma.user.count({ where: { kitaId } }),
prisma.family.count({ where: { kitaId } }),
prisma.child.count({ where: { kitaId } }),
prisma.termin.count({ where: { kitaId } }),
prisma.absence.count({ where: { kitaId } }),
prisma.announcement.count({ where: { kitaId } }),
prisma.invitation.count({ where: { kitaId } }),
prisma.eventParticipation.count({ where: { kitaId } }),
]);
return {
users,
families,
children,
termine,
absences,
announcements,
invitations,
eventParticipations,
};
}
export async function deleteKita(
rawPayload: unknown,
): Promise<ActionResult & { redirectTo?: string }> {
const actor = await requireSuperadminActor();
const parsed = deleteKitaSchema.safeParse(rawPayload);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const kita = await prisma.kita.findUnique({
where: { id: parsed.data.kitaId },
select: { id: true, name: true, slug: true },
});
if (!kita) {
return { ok: false, error: "Kita wurde nicht gefunden." };
}
if (parsed.data.confirmationName !== kita.name) {
return {
ok: false,
error: "Die Bestätigung muss exakt dem Kita-Namen entsprechen.",
};
}
const counts = await getKitaDeleteCounts(kita.id);
await prisma.$transaction(async (tx) => {
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action: "KITA_DELETE",
targetType: AuditTargetType.KITA,
targetId: kita.id,
targetLabel: `${kita.name} (${kita.slug})`,
targetKitaId: kita.id,
metadata: counts,
},
});
await tx.kita.delete({ where: { id: kita.id } });
});
revalidatePath("/admin");
revalidatePath("/admin/audit");
return { ok: true, redirectTo: "/admin" };
}
async function findMutableTenantUser(userId: string, actorId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
kitaId: true,
familyId: true,
failedLoginAttempts: true,
lockedUntil: true,
kita: {
select: {
name: true,
slug: true,
},
},
family: {
select: {
name: true,
_count: {
select: {
users: true,
},
},
},
},
},
});
if (!user) {
return { error: "Benutzer wurde nicht gefunden." } as const;
}
if (user.id === actorId) {
return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const;
}
if (user.role === UserRole.SUPERADMIN) {
return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const;
}
if (!user.kitaId) {
return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const;
}
return { user } as const;
}
export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
const actor = await requireSuperadminActor();
const parsed = deleteUserSchema.safeParse(rawPayload);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const lookup = await findMutableTenantUser(parsed.data.userId, actor.id);
if ("error" in lookup) {
return { ok: false, error: lookup.error };
}
const { user } = lookup;
if (parsed.data.confirmationEmail !== user.email) {
return {
ok: false,
error: "Die Bestätigung muss exakt der Benutzer-E-Mail entsprechen.",
};
}
await prisma.$transaction(async (tx) => {
await tx.verificationToken.deleteMany({
where: { identifier: user.id },
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action: "USER_DELETE",
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
kitaName: user.kita?.name ?? null,
kitaSlug: user.kita?.slug ?? null,
familyId: user.familyId,
familyName: user.family?.name ?? null,
familyUserCountBeforeDelete: user.family?._count.users ?? null,
},
},
});
await tx.user.delete({ where: { id: user.id } });
});
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit");
return { ok: true };
}
async function updateUserSupportState(
userId: string,
action: "USER_LOCK" | "USER_UNLOCK" | "USER_FAILED_LOGIN_RESET",
data: {
lockedUntil?: Date | null;
failedLoginAttempts?: number;
},
): Promise<ActionResult> {
const actor = await requireSuperadminActor();
const parsed = userIdSchema.safeParse(userId);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const lookup = await findMutableTenantUser(parsed.data, actor.id);
if ("error" in lookup) {
return { ok: false, error: lookup.error };
}
const { user } = lookup;
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data,
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action,
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
previousLockedUntil: user.lockedUntil?.toISOString() ?? null,
previousFailedLoginAttempts: user.failedLoginAttempts,
},
},
});
});
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit");
return { ok: true };
}
export async function lockUser(userId: string): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_LOCK", {
lockedUntil: new Date("2099-12-31T23:59:59.000Z"),
});
}
export async function unlockUser(userId: string): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_UNLOCK", {
lockedUntil: null,
failedLoginAttempts: 0,
});
}
export async function resetFailedLoginAttempts(
userId: string,
): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_FAILED_LOGIN_RESET", {
failedLoginAttempts: 0,
});
}