,
+) {
+ const result = await action();
+ if (!result.ok) {
+ toast.error(result.error ?? "Aktion konnte nicht ausgeführt werden.");
+ return;
+ }
+ toast.success(successMessage);
+ router.refresh();
+}
+
+export function UserSupportActions({
+ user,
+}: {
+ user: {
+ id: string;
+ email: string;
+ name: string;
+ isLocked: boolean;
+ failedLoginAttempts: number;
+ };
+}) {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+
+ return (
+
+ {user.isLocked ? (
+
+ startTransition(() =>
+ runSupportAction(
+ () => unlockUser(user.id),
+ "Benutzer wurde entsperrt.",
+ router,
+ ),
+ )
+ }
+ >
+
+ Entsperren
+
+ ) : (
+
+ startTransition(() =>
+ runSupportAction(
+ () => lockUser(user.id),
+ "Benutzer wurde gesperrt.",
+ router,
+ ),
+ )
+ }
+ >
+
+ Sperren
+
+ )}
+
+
+ startTransition(() =>
+ runSupportAction(
+ () => resetFailedLoginAttempts(user.id),
+ "Fehlversuche wurden zurückgesetzt.",
+ router,
+ ),
+ )
+ }
+ >
+
+ Reset
+
+
+
+
+ );
+}
+
+function UserDeleteDialog({
+ user,
+}: {
+ user: {
+ id: string;
+ email: string;
+ name: string;
+ };
+}) {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [confirmationEmail, setConfirmationEmail] = useState("");
+ const [isPending, startTransition] = useTransition();
+
+ const canSubmit = confirmationEmail === user.email && !isPending;
+
+ function onSubmit() {
+ startTransition(async () => {
+ const result = await deleteUser({
+ userId: user.id,
+ confirmationEmail,
+ });
+ if (!result.ok) {
+ toast.error(result.error ?? "Benutzer konnte nicht gelöscht werden.");
+ return;
+ }
+
+ toast.success("Benutzer wurde gelöscht.");
+ setOpen(false);
+ router.refresh();
+ });
+ }
+
+ return (
+
+
+
+
+ Löschen
+
+
+
+
+ Benutzer endgültig löschen
+
+ Löscht nur das Benutzerkonto von {user.name}. Familie und Kinder
+ bleiben erhalten.
+
+
+
+
+
+ Zur Bestätigung E-Mail-Adresse eingeben
+
+ setConfirmationEmail(event.target.value)}
+ placeholder={user.email}
+ autoComplete="off"
+ />
+
+
+
+ setOpen(false)}
+ disabled={isPending}
+ >
+ Abbrechen
+
+
+ {isPending ? "Lösche..." : "Endgültig löschen"}
+
+
+
+
+ );
+}
+
diff --git a/src/app/admin/_lib/data.ts b/src/app/admin/_lib/data.ts
new file mode 100644
index 0000000..a147fd1
--- /dev/null
+++ b/src/app/admin/_lib/data.ts
@@ -0,0 +1,438 @@
+import "server-only";
+
+import { AuditTargetType, InvitationStatus, UserRole } from "@prisma/client";
+
+import { prisma } from "@/lib/prisma";
+
+const roleOrder: UserRole[] = [
+ UserRole.ADMIN,
+ UserRole.KOORDINATOR,
+ UserRole.ERZIEHER,
+ UserRole.ELTERN,
+];
+
+export type KitaOverviewRow = {
+ id: string;
+ name: string;
+ slug: string;
+ createdAt: Date;
+ updatedAt: Date;
+ modules: {
+ notdienst: boolean;
+ termine: boolean;
+ adressbuch: boolean;
+ };
+ counts: {
+ users: number;
+ families: number;
+ activeChildren: number;
+ termine: number;
+ absences: number;
+ announcements: number;
+ openInvitations: number;
+ lockedUsers: number;
+ missingConsent: number;
+ familiesWithoutUsers: number;
+ };
+ lastLoginAt: Date | null;
+ roleCounts: Record;
+};
+
+export type GlobalAdminHealth = {
+ orphanTenantUsers: number;
+ lockedTenantUsers: number;
+ openInvitations: number;
+ missingConsent: number;
+ kitas: number;
+};
+
+export async function getGlobalAdminHealth(): Promise {
+ const now = new Date();
+ const [
+ orphanTenantUsers,
+ lockedTenantUsers,
+ openInvitations,
+ missingConsent,
+ kitas,
+ ] = await Promise.all([
+ prisma.user.count({
+ where: {
+ kitaId: null,
+ role: { not: UserRole.SUPERADMIN },
+ },
+ }),
+ prisma.user.count({
+ where: {
+ role: { not: UserRole.SUPERADMIN },
+ lockedUntil: { gt: now },
+ },
+ }),
+ prisma.invitation.count({
+ where: {
+ status: InvitationStatus.PENDING,
+ expiresAt: { gt: now },
+ },
+ }),
+ prisma.user.count({
+ where: {
+ role: { not: UserRole.SUPERADMIN },
+ privacyPolicyAcceptedAt: null,
+ },
+ }),
+ prisma.kita.count(),
+ ]);
+
+ return {
+ orphanTenantUsers,
+ lockedTenantUsers,
+ openInvitations,
+ missingConsent,
+ kitas,
+ };
+}
+
+export async function getKitaOverviewRows(): Promise {
+ const now = new Date();
+ const kitas = await prisma.kita.findMany({
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ notdienstModuleEnabled: true,
+ terminModuleEnabled: true,
+ adressbuchModuleEnabled: true,
+ createdAt: true,
+ updatedAt: true,
+ users: {
+ select: {
+ role: true,
+ lastLoginAt: true,
+ lockedUntil: true,
+ privacyPolicyAcceptedAt: true,
+ },
+ },
+ families: {
+ select: {
+ users: {
+ select: { id: true },
+ },
+ },
+ },
+ children: {
+ select: { active: true },
+ },
+ invitations: {
+ select: {
+ status: true,
+ expiresAt: true,
+ },
+ },
+ _count: {
+ select: {
+ termine: true,
+ absences: true,
+ announcements: true,
+ },
+ },
+ },
+ orderBy: { name: "asc" },
+ });
+
+ return kitas.map((kita) => {
+ const roleCounts = Object.fromEntries(
+ Object.values(UserRole).map((role) => [role, 0]),
+ ) as Record;
+
+ let lastLoginAt: Date | null = null;
+ let lockedUsers = 0;
+ let missingConsent = 0;
+
+ for (const user of kita.users) {
+ roleCounts[user.role] += 1;
+ if (user.lastLoginAt && (!lastLoginAt || user.lastLoginAt > lastLoginAt)) {
+ lastLoginAt = user.lastLoginAt;
+ }
+ if (user.lockedUntil && user.lockedUntil > now) {
+ lockedUsers += 1;
+ }
+ if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
+ missingConsent += 1;
+ }
+ }
+
+ return {
+ id: kita.id,
+ name: kita.name,
+ slug: kita.slug,
+ createdAt: kita.createdAt,
+ updatedAt: kita.updatedAt,
+ modules: {
+ notdienst: kita.notdienstModuleEnabled,
+ termine: kita.terminModuleEnabled,
+ adressbuch: kita.adressbuchModuleEnabled,
+ },
+ counts: {
+ users: kita.users.length,
+ families: kita.families.length,
+ activeChildren: kita.children.filter((child) => child.active).length,
+ termine: kita._count.termine,
+ absences: kita._count.absences,
+ announcements: kita._count.announcements,
+ openInvitations: kita.invitations.filter(
+ (invitation) =>
+ invitation.status === InvitationStatus.PENDING &&
+ invitation.expiresAt > now,
+ ).length,
+ lockedUsers,
+ missingConsent,
+ familiesWithoutUsers: kita.families.filter(
+ (family) => family.users.length === 0,
+ ).length,
+ },
+ lastLoginAt,
+ roleCounts,
+ };
+ });
+}
+
+export type KitaDiagnostics = {
+ kita: {
+ id: string;
+ name: string;
+ slug: string;
+ createdAt: Date;
+ updatedAt: Date;
+ modules: KitaOverviewRow["modules"];
+ };
+ counts: KitaOverviewRow["counts"] & {
+ inactiveChildren: number;
+ expiredPendingInvitations: number;
+ };
+ roleCounts: Record;
+ users: {
+ id: string;
+ email: string;
+ firstName: string;
+ lastName: string;
+ role: UserRole;
+ familyName: string | null;
+ emailVerifiedAt: Date | null;
+ privacyPolicyAcceptedAt: Date | null;
+ lastLoginAt: Date | null;
+ failedLoginAttempts: number;
+ lockedUntil: Date | null;
+ createdAt: Date;
+ }[];
+ diagnostics: {
+ lockedUsers: number;
+ missingConsent: number;
+ unverifiedUsers: number;
+ familiesWithoutUsers: number;
+ usersWithEmptyPassword: number;
+ };
+};
+
+export async function getKitaDiagnostics(
+ kitaId: string,
+): Promise {
+ const now = new Date();
+ const kita = await prisma.kita.findUnique({
+ where: { id: kitaId },
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ notdienstModuleEnabled: true,
+ terminModuleEnabled: true,
+ adressbuchModuleEnabled: true,
+ createdAt: true,
+ updatedAt: true,
+ users: {
+ select: {
+ id: true,
+ email: true,
+ firstName: true,
+ lastName: true,
+ role: true,
+ passwordHash: true,
+ emailVerifiedAt: true,
+ privacyPolicyAcceptedAt: true,
+ lastLoginAt: true,
+ failedLoginAttempts: true,
+ lockedUntil: true,
+ createdAt: true,
+ family: {
+ select: { name: true },
+ },
+ },
+ orderBy: [
+ { role: "asc" },
+ { lastName: "asc" },
+ { firstName: "asc" },
+ ],
+ },
+ families: {
+ select: {
+ users: { select: { id: true } },
+ },
+ },
+ children: {
+ select: { active: true },
+ },
+ invitations: {
+ select: {
+ status: true,
+ expiresAt: true,
+ },
+ },
+ _count: {
+ select: {
+ termine: true,
+ absences: true,
+ announcements: true,
+ },
+ },
+ },
+ });
+
+ if (!kita) {
+ return null;
+ }
+
+ const roleCounts = Object.fromEntries(
+ Object.values(UserRole).map((role) => [role, 0]),
+ ) as Record;
+
+ let lockedUsers = 0;
+ let missingConsent = 0;
+ let unverifiedUsers = 0;
+ let usersWithEmptyPassword = 0;
+
+ for (const user of kita.users) {
+ roleCounts[user.role] += 1;
+ if (user.lockedUntil && user.lockedUntil > now) {
+ lockedUsers += 1;
+ }
+ if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
+ missingConsent += 1;
+ }
+ if (!user.emailVerifiedAt) {
+ unverifiedUsers += 1;
+ }
+ if (!user.passwordHash) {
+ usersWithEmptyPassword += 1;
+ }
+ }
+
+ const activeChildren = kita.children.filter((child) => child.active).length;
+ const inactiveChildren = kita.children.length - activeChildren;
+ const familiesWithoutUsers = kita.families.filter(
+ (family) => family.users.length === 0,
+ ).length;
+
+ return {
+ kita: {
+ id: kita.id,
+ name: kita.name,
+ slug: kita.slug,
+ createdAt: kita.createdAt,
+ updatedAt: kita.updatedAt,
+ modules: {
+ notdienst: kita.notdienstModuleEnabled,
+ termine: kita.terminModuleEnabled,
+ adressbuch: kita.adressbuchModuleEnabled,
+ },
+ },
+ counts: {
+ users: kita.users.length,
+ families: kita.families.length,
+ activeChildren,
+ inactiveChildren,
+ termine: kita._count.termine,
+ absences: kita._count.absences,
+ announcements: kita._count.announcements,
+ openInvitations: kita.invitations.filter(
+ (invitation) =>
+ invitation.status === InvitationStatus.PENDING &&
+ invitation.expiresAt > now,
+ ).length,
+ expiredPendingInvitations: kita.invitations.filter(
+ (invitation) =>
+ invitation.status === InvitationStatus.PENDING &&
+ invitation.expiresAt <= now,
+ ).length,
+ lockedUsers,
+ missingConsent,
+ familiesWithoutUsers,
+ },
+ roleCounts,
+ users: kita.users.map((user) => ({
+ id: user.id,
+ email: user.email,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ role: user.role,
+ familyName: user.family?.name ?? null,
+ emailVerifiedAt: user.emailVerifiedAt,
+ privacyPolicyAcceptedAt: user.privacyPolicyAcceptedAt,
+ lastLoginAt: user.lastLoginAt,
+ failedLoginAttempts: user.failedLoginAttempts,
+ lockedUntil: user.lockedUntil,
+ createdAt: user.createdAt,
+ })),
+ diagnostics: {
+ lockedUsers,
+ missingConsent,
+ unverifiedUsers,
+ familiesWithoutUsers,
+ usersWithEmptyPassword,
+ },
+ };
+}
+
+export async function recordKitaDiagnosticView({
+ actorUserId,
+ actorEmail,
+ kitaId,
+ kitaName,
+ kitaSlug,
+}: {
+ actorUserId: string;
+ actorEmail: string;
+ kitaId: string;
+ kitaName: string;
+ kitaSlug: string;
+}) {
+ await prisma.auditLog.create({
+ data: {
+ actorUserId,
+ actorEmail,
+ action: "KITA_DIAGNOSTIC_VIEW",
+ targetType: AuditTargetType.KITA,
+ targetId: kitaId,
+ targetLabel: `${kitaName} (${kitaSlug})`,
+ targetKitaId: kitaId,
+ },
+ });
+}
+
+export async function getAuditLogRows() {
+ return prisma.auditLog.findMany({
+ select: {
+ id: true,
+ actorEmail: true,
+ action: true,
+ targetType: true,
+ targetId: true,
+ targetLabel: true,
+ targetKitaId: true,
+ metadata: true,
+ createdAt: true,
+ },
+ orderBy: { createdAt: "desc" },
+ take: 200,
+ });
+}
+
+export { roleOrder };
+
diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts
new file mode 100644
index 0000000..c344a86
--- /dev/null
+++ b/src/app/admin/actions.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ return updateUserSupportState(userId, "USER_LOCK", {
+ lockedUntil: new Date("2099-12-31T23:59:59.000Z"),
+ });
+}
+
+export async function unlockUser(userId: string): Promise {
+ return updateUserSupportState(userId, "USER_UNLOCK", {
+ lockedUntil: null,
+ failedLoginAttempts: 0,
+ });
+}
+
+export async function resetFailedLoginAttempts(
+ userId: string,
+): Promise {
+ return updateUserSupportState(userId, "USER_FAILED_LOGIN_RESET", {
+ failedLoginAttempts: 0,
+ });
+}
+
diff --git a/src/app/admin/audit/page.tsx b/src/app/admin/audit/page.tsx
new file mode 100644
index 0000000..95eb5fd
--- /dev/null
+++ b/src/app/admin/audit/page.tsx
@@ -0,0 +1,153 @@
+import Link from "next/link";
+import type { Metadata } from "next";
+import { ArrowLeft, ScrollText } from "lucide-react";
+import { UserRole } from "@prisma/client";
+
+import { requireRole } from "@/lib/auth-utils";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { getAuditLogRows } from "../_lib/data";
+
+export const metadata: Metadata = {
+ title: "AuditLog · Kita-Planer",
+};
+
+function formatDate(date: Date) {
+ return new Intl.DateTimeFormat("de-DE", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(date);
+}
+
+function actionLabel(action: string) {
+ switch (action) {
+ case "KITA_DIAGNOSTIC_VIEW":
+ return "Diagnose geöffnet";
+ case "KITA_DELETE":
+ return "Kita gelöscht";
+ case "USER_DELETE":
+ return "Benutzer gelöscht";
+ case "USER_LOCK":
+ return "Benutzer gesperrt";
+ case "USER_UNLOCK":
+ return "Benutzer entsperrt";
+ case "USER_FAILED_LOGIN_RESET":
+ return "Fehlversuche zurückgesetzt";
+ default:
+ return action;
+ }
+}
+
+function metadataPreview(metadata: unknown) {
+ if (!metadata) return "—";
+ const preview = JSON.stringify(metadata);
+ if (!preview) return "—";
+ return preview.length > 140 ? `${preview.slice(0, 140)}...` : preview;
+}
+
+export default async function AuditPage() {
+ await requireRole([UserRole.SUPERADMIN]);
+ const rows = await getAuditLogRows();
+
+ return (
+
+
+
+
+
+
+ Zur Kita-Übersicht
+
+
+
AuditLog
+
+ Die letzten 200 Superadmin-Diagnose- und Support-Aktionen.
+
+
+
+
+
+
+
+
+
+
+ Systemweite Aktionen
+
+
+ Audit-Einträge sind unabhängig von gelöschten Kitas und Benutzern.
+
+
+
+ {rows.length === 0 ? (
+
+ Noch keine Audit-Einträge vorhanden.
+
+ ) : (
+
+
+
+ Zeitpunkt
+ Aktion
+ Akteur
+ Ziel
+ Metadaten
+
+
+
+ {rows.map((row) => (
+
+
+ {formatDate(row.createdAt)}
+
+
+
+ {actionLabel(row.action)}
+
+
+
+ {row.actorEmail}
+
+
+ {row.targetLabel}
+
+ {row.targetType} · {row.targetId}
+
+
+
+
+ {metadataPreview(row.metadata)}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
+
diff --git a/src/app/admin/kitas/[kitaId]/page.tsx b/src/app/admin/kitas/[kitaId]/page.tsx
new file mode 100644
index 0000000..feb0a6f
--- /dev/null
+++ b/src/app/admin/kitas/[kitaId]/page.tsx
@@ -0,0 +1,359 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import type { Metadata } from "next";
+import {
+ AlertTriangle,
+ ArrowLeft,
+ Baby,
+ CheckCircle2,
+ Clock,
+ Lock,
+ MailCheck,
+ MailWarning,
+ ShieldAlert,
+ Users,
+} from "lucide-react";
+import { UserRole } from "@prisma/client";
+
+import { requireRole } from "@/lib/auth-utils";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ getKitaDiagnostics,
+ recordKitaDiagnosticView,
+ roleOrder,
+} from "../../_lib/data";
+import { KitaDeleteDialog } from "../../_components/kita-delete-dialog";
+import { UserSupportActions } from "../../_components/user-support-actions";
+
+type PageProps = {
+ params: Promise<{ kitaId: string }>;
+};
+
+export const metadata: Metadata = {
+ title: "Kita-Diagnose · Kita-Planer",
+};
+
+function formatDate(date: Date | null) {
+ if (!date) return "Nie";
+ return new Intl.DateTimeFormat("de-DE", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(date);
+}
+
+function isLocked(lockedUntil: Date | null) {
+ return !!lockedUntil && lockedUntil > new Date();
+}
+
+export default async function KitaDiagnosticsPage({ params }: PageProps) {
+ const [{ kitaId }, session] = await Promise.all([
+ params,
+ requireRole([UserRole.SUPERADMIN]),
+ ]);
+
+ const diagnostics = await getKitaDiagnostics(kitaId);
+ if (!diagnostics) {
+ notFound();
+ }
+
+ await recordKitaDiagnosticView({
+ actorUserId: session.user.id,
+ actorEmail: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
+ kitaId: diagnostics.kita.id,
+ kitaName: diagnostics.kita.name,
+ kitaSlug: diagnostics.kita.slug,
+ });
+
+ const issueCards = [
+ {
+ label: "Gesperrte Konten",
+ value: diagnostics.diagnostics.lockedUsers,
+ icon: Lock,
+ },
+ {
+ label: "Fehlender Consent",
+ value: diagnostics.diagnostics.missingConsent,
+ icon: ShieldAlert,
+ },
+ {
+ label: "Unverifizierte E-Mails",
+ value: diagnostics.diagnostics.unverifiedUsers,
+ icon: MailWarning,
+ },
+ {
+ label: "Familien ohne Benutzer",
+ value: diagnostics.diagnostics.familiesWithoutUsers,
+ icon: Users,
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+ Zur Kita-Übersicht
+
+
+
+ {diagnostics.kita.name}
+
+
+ {diagnostics.kita.slug} · angelegt am{" "}
+ {formatDate(diagnostics.kita.createdAt)}
+
+
+
+
+
+
+
+
+
+ 0}
+ />
+
+
+
+ {issueCards.map(({ label, value, icon: Icon }) => (
+
0 ? "border-amber-200 bg-amber-50/70" : ""}
+ >
+
+
+ {label}
+
+
+ {value}
+
+
+ ))}
+
+
+
+
+
+
+ Rollenverteilung
+
+
+ Nur tenant-gebundene Benutzer dieser Kita.
+
+
+
+
+ {roleOrder.map((role) => (
+
+ {role}: {diagnostics.roleCounts[role]}
+
+ ))}
+
+
+
+
+
+
+
+ Modulstatus
+
+
+ V1 zeigt Module nur read-only, keine Konfigurationsänderungen.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Benutzer
+
+
+ Support-Aktionen sind auf Löschen, Sperren, Entsperren und
+ Fehlversuche zurücksetzen begrenzt.
+
+
+
+
+
+
+ Benutzer
+ Rolle / Familie
+ Status
+ Login
+ Aktionen
+
+
+
+ {diagnostics.users.map((user) => {
+ const userIsLocked = isLocked(user.lockedUntil);
+ const userName = `${user.firstName} ${user.lastName}`.trim();
+ return (
+
+
+ {userName}
+
+ {user.email}
+
+
+
+
+ {user.role}
+
+ {user.familyName ?? "Keine Familie"}
+
+
+
+
+
+ {userIsLocked ? (
+
+
+ Gesperrt
+
+ ) : (
+
+
+ Aktiv
+
+ )}
+ {!user.emailVerifiedAt && (
+ E-Mail offen
+ )}
+ {!user.privacyPolicyAcceptedAt && (
+ Consent fehlt
+ )}
+ {user.failedLoginAttempts > 0 && (
+
+ {user.failedLoginAttempts} Fehlversuche
+
+ )}
+
+
+
+
+
{formatDate(user.lastLoginAt)}
+ {userIsLocked && (
+
+ bis {formatDate(user.lockedUntil)}
+
+ )}
+
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+function MetricCard({
+ icon: Icon,
+ label,
+ value,
+ description,
+ attention = false,
+}: {
+ icon: typeof Users;
+ label: string;
+ value: number;
+ description: string;
+ attention?: boolean;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {value}
+
+
+ {description}
+
+
+ );
+}
+
+function ModuleBadge({ label, enabled }: { label: string; enabled: boolean }) {
+ return (
+
+ {enabled ? (
+
+ ) : (
+
+ )}
+ {label}: {enabled ? "aktiv" : "inaktiv"}
+
+ );
+}
+
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
new file mode 100644
index 0000000..17a20d4
--- /dev/null
+++ b/src/app/admin/layout.tsx
@@ -0,0 +1,80 @@
+import type { Metadata } from "next";
+import { LogOut, ShieldCheck } from "lucide-react";
+import { UserRole } from "@prisma/client";
+
+import { signOut } from "@/auth";
+import { requireRole } from "@/lib/auth-utils";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { AdminNav } from "./_components/admin-nav";
+
+export const metadata: Metadata = {
+ title: "Systemadmin · Kita-Planer",
+ robots: {
+ index: false,
+ follow: false,
+ },
+};
+
+export default async function AdminLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const session = await requireRole([UserRole.SUPERADMIN]);
+
+ return (
+
+
+
+
+
+
+
+
+ Systemadmin
+
+
+ Kita-Planer
+
+
+
+
+
+
+
+
+
+
+ {session.user.name ?? session.user.email}
+
+
+ SUPERADMIN
+
+
+
+
+
+
+
{children}
+
+ );
+}
+
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 53a95b8..950f42b 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -1,9 +1,19 @@
+import Link from "next/link";
import type { Metadata } from "next";
-import { LogOut, ShieldCheck } from "lucide-react";
+import {
+ AlertTriangle,
+ ArrowRight,
+ Building2,
+ CheckCircle2,
+ Clock,
+ Lock,
+ MailWarning,
+ Users,
+} from "lucide-react";
import { UserRole } from "@prisma/client";
-import { signOut } from "@/auth";
import { requireRole } from "@/lib/auth-utils";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -12,54 +22,261 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ getGlobalAdminHealth,
+ getKitaOverviewRows,
+ roleOrder,
+} from "./_lib/data";
+import { KitaDeleteDialog } from "./_components/kita-delete-dialog";
export const metadata: Metadata = {
- title: "Systemadmin · Kita-Planer",
- robots: {
- index: false,
- follow: false,
- },
+ title: "Superadmin · Kita-Planer",
};
+const numberFormat = new Intl.NumberFormat("de-DE");
+
+function formatDate(date: Date | null) {
+ if (!date) return "Nie";
+ return new Intl.DateTimeFormat("de-DE", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(date);
+}
+
export default async function AdminPage() {
- const session = await requireRole([UserRole.SUPERADMIN]);
+ await requireRole([UserRole.SUPERADMIN]);
+
+ const [health, kitas] = await Promise.all([
+ getGlobalAdminHealth(),
+ getKitaOverviewRows(),
+ ]);
+
+ const totalUsers = kitas.reduce((sum, kita) => sum + kita.counts.users, 0);
+ const totalChildren = kitas.reduce(
+ (sum, kita) => sum + kita.counts.activeChildren,
+ 0,
+ );
+ const needsAttention =
+ health.lockedTenantUsers +
+ health.missingConsent +
+ health.openInvitations +
+ health.orphanTenantUsers;
return (
-
-
-
-
-
-
-
-
Systemadmin-Bereich
-
- Du bist als Superadmin angemeldet. Eine zentrale Systemverwaltung
- ist noch nicht umgesetzt, deshalb endet der Login hier statt auf
- einer nicht vorhandenen Seite.
-
+
+
+
+
+ Kita-Verwaltung
+
+
+ Systemweite Übersicht, read-only Diagnose und Support-Aktionen.
+
+
+
+
+ AuditLog ansehen
+
+
+
+
+
+
+
+
+ 0}
+ />
+ 0}
+ />
+
+
+ {health.orphanTenantUsers > 0 && (
+
+
+
+ {health.orphanTenantUsers} tenant-lose Nicht-Superadmin-Benutzer
+
+ Diese Konten sind keiner Kita zugeordnet und sollten separat
+ bereinigt werden. V1 verwaltet nur tenant-gebundene Benutzer.
+
+
+ )}
+
+
+
+
+ Alle Kitas
+
+
+ Diagnose-Links sind read-only; Löschungen benötigen eine exakte
+ Namensbestätigung.
+
-
-
- Angemeldet als{" "}
-
- {session.user.email ?? session.user.name ?? "Superadmin"}
-
-
-
+
+ {kitas.length === 0 ? (
+
+ Noch keine Kitas angelegt.
+
+ ) : (
+
+
+
+ Kita
+ Status
+ Counts
+ Rollen
+ Letzter Login
+ Aktionen
+
+
+
+ {kitas.map((kita) => {
+ const warnings =
+ kita.counts.lockedUsers +
+ kita.counts.missingConsent +
+ kita.counts.openInvitations +
+ kita.counts.familiesWithoutUsers;
+ return (
+
+
+ {kita.name}
+
+ {kita.slug}
+
+
+
+
+ {warnings === 0 ? (
+
+
+ Unauffällig
+
+ ) : (
+
+
+ {warnings} Hinweise
+
+ )}
+ {kita.counts.lockedUsers > 0 && (
+
+ {kita.counts.lockedUsers} gesperrt
+
+ )}
+ {kita.counts.openInvitations > 0 && (
+
+ {kita.counts.openInvitations} Einladungen
+
+ )}
+
+
+
+
+ {kita.counts.users} Benutzer
+ {kita.counts.families} Familien
+ {kita.counts.activeChildren} Kinder
+ {kita.counts.termine} Termine
+
+
+
+
+ {roleOrder.map((role) =>
+ kita.roleCounts[role] > 0 ? (
+
+ {role}: {kita.roleCounts[role]}
+
+ ) : null,
+ )}
+
+
+
+
+
+ {formatDate(kita.lastLoginAt)}
+
+
+
+
+
+
+ Diagnose
+
+
+
+
+
+
+ );
+ })}
+
+
+ )}
);
}
+
+function MetricCard({
+ icon: Icon,
+ label,
+ value,
+ description,
+ attention = false,
+}: {
+ icon: typeof Building2;
+ label: string;
+ value: number;
+ description: string;
+ attention?: boolean;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {numberFormat.format(value)}
+
+
+ {description}
+
+
+ );
+}