288 lines
7.4 KiB
TypeScript
288 lines
7.4 KiB
TypeScript
"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,
|
|
});
|
|
}
|
|
|