diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a6e1f33..6f34ee3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,6 +68,11 @@ enum EventParticipationStatus { NOT_ATTENDING } +enum AuditTargetType { + KITA + USER +} + enum NotdienstPlanStatus { DRAFT PUBLISHED @@ -222,6 +227,7 @@ model User { mitbringselItems MitbringselItem[] invitationsCreated Invitation[] @relation("InvitationCreator") + auditLogsAuthored AuditLog[] @relation("AuditLogActor") @@index([kitaId]) @@index([familyId]) @@ -687,6 +693,36 @@ model NotdienstAlert { @@map("notdienst_alerts") } +// ===================================================================== +// AUDIT LOGS (Systemweite Superadmin-Aktionen) +// ===================================================================== + +/// Persistente Spur fuer Superadmin-Support-Aktionen. +/// Zielobjekte werden bewusst nur als Snapshot gespeichert, damit Logs auch +/// nach einer Kita- oder Benutzerloeschung erhalten bleiben. +model AuditLog { + id String @id @default(cuid()) + + actorUserId String? + actorUser User? @relation("AuditLogActor", fields: [actorUserId], references: [id], onDelete: SetNull) + actorEmail String + + action String + targetType AuditTargetType + targetId String + targetLabel String + targetKitaId String? + + metadata Json? + createdAt DateTime @default(now()) + + @@index([actorUserId]) + @@index([targetType, targetId]) + @@index([targetKitaId]) + @@index([createdAt]) + @@map("audit_logs") +} + // ===================================================================== // AUTH-HILFSTABELLE // ===================================================================== diff --git a/src/app/admin/_components/admin-nav.tsx b/src/app/admin/_components/admin-nav.tsx new file mode 100644 index 0000000..dd1eb46 --- /dev/null +++ b/src/app/admin/_components/admin-nav.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Activity, Building2, ScrollText } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const items = [ + { + href: "/admin", + label: "Kitas", + icon: Building2, + exact: true, + }, + { + href: "/admin/audit", + label: "AuditLog", + icon: ScrollText, + exact: false, + }, +]; + +export function AdminNav() { + const pathname = usePathname(); + + return ( + + ); +} + diff --git a/src/app/admin/_components/kita-delete-dialog.tsx b/src/app/admin/_components/kita-delete-dialog.tsx new file mode 100644 index 0000000..5be8231 --- /dev/null +++ b/src/app/admin/_components/kita-delete-dialog.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { AlertTriangle, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { deleteKita } from "../actions"; + +type DeleteCounts = { + users: number; + families: number; + activeChildren: number; + termine: number; + absences: number; + announcements: number; +}; + +export function KitaDeleteDialog({ + kitaId, + kitaName, + counts, +}: { + kitaId: string; + kitaName: string; + counts: DeleteCounts; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [confirmationName, setConfirmationName] = useState(""); + const [isPending, startTransition] = useTransition(); + + const canSubmit = confirmationName === kitaName && !isPending; + + function onSubmit() { + startTransition(async () => { + const result = await deleteKita({ kitaId, confirmationName }); + if (!result.ok) { + toast.error(result.error ?? "Kita konnte nicht gelöscht werden."); + return; + } + + toast.success("Kita wurde gelöscht."); + setOpen(false); + router.push(result.redirectTo ?? "/admin"); + router.refresh(); + }); + } + + return ( + + + + + + + Kita endgültig löschen + + Diese Aktion löscht alle mandantengebundenen Daten dieser Kita + unwiderruflich. Das AuditLog bleibt erhalten. + + + +
+
+ + Betroffene Daten +
+
+ Benutzer: {counts.users} + Familien: {counts.families} + Aktive Kinder: {counts.activeChildren} + Termine: {counts.termine} + Abwesenheiten: {counts.absences} + News: {counts.announcements} +
+
+ +
+ + setConfirmationName(event.target.value)} + placeholder={kitaName} + autoComplete="off" + /> +
+ + + + + +
+
+ ); +} + diff --git a/src/app/admin/_components/user-support-actions.tsx b/src/app/admin/_components/user-support-actions.tsx new file mode 100644 index 0000000..a554d9a --- /dev/null +++ b/src/app/admin/_components/user-support-actions.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Lock, RotateCcw, Trash2, Unlock } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + deleteUser, + lockUser, + resetFailedLoginAttempts, + unlockUser, +} from "../actions"; + +type UserActionResult = { + ok: boolean; + error?: string; +}; + +async function runSupportAction( + action: () => Promise, + successMessage: string, + router: ReturnType, +) { + 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 ? ( + + ) : ( + + )} + + + + +
+ ); +} + +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 ( + + + + + + + Benutzer endgültig löschen + + Löscht nur das Benutzerkonto von {user.name}. Familie und Kinder + bleiben erhalten. + + + +
+ + setConfirmationEmail(event.target.value)} + placeholder={user.email} + autoComplete="off" + /> +
+ + + + + +
+
+ ); +} + 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 ( +
+
+
+ +

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 ( +
+
+
+ +

+ {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 ( +
+ + +
{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. +

+
+ +
+ +
+ + + 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"} - -
-
{ - "use server"; - await signOut({ redirectTo: "/" }); - }} - > - -
+ + {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)} +
+
+ +
+ + +
+
+
+ ); + })} +
+
+ )}
); } + +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}

+
+
+ ); +}