feat: show planning availability and manage Kita access roles

This commit is contained in:
t.indorf
2026-10-12 16:28:56 +02:00
parent 99d3a63eed
commit 061024d901
29 changed files with 2347 additions and 404 deletions
+91 -54
View File
@@ -7,6 +7,12 @@ import { AuditTargetType, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { serializableTransaction } from "@/lib/transaction";
import { UserFacingError } from "@/lib/action-error";
import { prisma } from "@/lib/prisma";
const deleteKitaSchema = z.object({
@@ -28,7 +34,8 @@ 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");
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",
@@ -96,7 +103,10 @@ export async function deleteKita(
const counts = await getKitaDeleteCounts(kita.id);
const attachmentIds = await prisma.$transaction(async (tx) => {
const attachments = await tx.attachment.findMany({ where: { announcement: { kitaId: kita.id } }, select: { id: true } });
const attachments = await tx.attachment.findMany({
where: { announcement: { kitaId: kita.id } },
select: { id: true },
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
@@ -111,7 +121,7 @@ export async function deleteKita(
});
await tx.kita.delete({ where: { id: kita.id } });
return attachments.map(item => item.id);
return attachments.map((item) => item.id);
});
await removeAttachmentFiles(attachmentIds);
@@ -156,13 +166,19 @@ async function findMutableTenantUser(userId: string, actorId: string) {
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;
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;
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 {
error: "Nur tenant-gebundene Benutzer können verwaltet werden.",
} as const;
}
return { user } as const;
@@ -188,34 +204,44 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
};
}
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 },
});
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,
let attachmentIds: string[];
try {
attachmentIds = await serializableTransaction(async (tx) => {
await lockKitaAccounts(tx, user.kitaId!);
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
const attachments = await tx.attachment.findMany({
where: { announcement: { authorId: user.id } },
select: { id: true },
});
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 } });
return attachments.map((item) => item.id);
});
await tx.user.delete({ where: { id: user.id } });
return attachments.map(item => item.id);
});
} catch (error) {
if (!(error instanceof UserFacingError)) throw error;
return { ok: false, error: error.message };
}
await removeAttachmentFiles(attachmentIds);
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
@@ -245,29 +271,41 @@ async function updateUserSupportState(
const { user } = lookup;
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) },
});
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,
try {
await serializableTransaction(async (tx) => {
await lockKitaAccounts(tx, user.kitaId!);
if (action === "USER_LOCK")
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
await tx.user.update({
where: { id: user.id },
data: {
...data,
...(action === "USER_LOCK"
? { sessionVersion: { increment: 1 } }
: {}),
},
},
});
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,
},
},
});
});
});
} catch (error) {
if (!(error instanceof UserFacingError)) throw error;
return { ok: false, error: error.message };
}
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit");
@@ -296,4 +334,3 @@ export async function resetFailedLoginAttempts(
failedLoginAttempts: 0,
});
}