Add superadmin audit and Kita management
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user