Add superadmin audit and Kita management
This commit is contained in:
+256
-39
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
|
||||
<Card className="w-full max-w-xl">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Systemadmin-Bereich</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Du bist als Superadmin angemeldet. Eine zentrale Systemverwaltung
|
||||
ist noch nicht umgesetzt, deshalb endet der Login hier statt auf
|
||||
einer nicht vorhandenen Seite.
|
||||
</CardDescription>
|
||||
<div className="px-8 py-8">
|
||||
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Kita-Verwaltung
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Systemweite Übersicht, read-only Diagnose und Support-Aktionen.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/audit">
|
||||
AuditLog ansehen
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={Building2}
|
||||
label="Kitas"
|
||||
value={health.kitas}
|
||||
description="Mandanten im System"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Users}
|
||||
label="Benutzer"
|
||||
value={totalUsers}
|
||||
description={`${numberFormat.format(totalChildren)} aktive Kinder`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={AlertTriangle}
|
||||
label="Hinweise"
|
||||
value={needsAttention}
|
||||
description="Sperren, Einladungen, Consent"
|
||||
attention={needsAttention > 0}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Lock}
|
||||
label="Gesperrt"
|
||||
value={health.lockedTenantUsers}
|
||||
description="Aktive Account-Sperren"
|
||||
attention={health.lockedTenantUsers > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{health.orphanTenantUsers > 0 && (
|
||||
<div className="mb-6 rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<MailWarning className="h-4 w-4" />
|
||||
{health.orphanTenantUsers} tenant-lose Nicht-Superadmin-Benutzer
|
||||
</div>
|
||||
<p className="mt-1 text-amber-800 dark:text-amber-300">
|
||||
Diese Konten sind keiner Kita zugeordnet und sollten separat
|
||||
bereinigt werden. V1 verwaltet nur tenant-gebundene Benutzer.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle as="h2" className="text-xl">
|
||||
Alle Kitas
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Diagnose-Links sind read-only; Löschungen benötigen eine exakte
|
||||
Namensbestätigung.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground">
|
||||
Angemeldet als{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{session.user.email ?? session.user.name ?? "Superadmin"}
|
||||
</span>
|
||||
</div>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/" });
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="outline">
|
||||
<LogOut className="h-4 w-4" />
|
||||
Abmelden
|
||||
</Button>
|
||||
</form>
|
||||
<CardContent>
|
||||
{kitas.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
Noch keine Kitas angelegt.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kita</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Counts</TableHead>
|
||||
<TableHead>Rollen</TableHead>
|
||||
<TableHead>Letzter Login</TableHead>
|
||||
<TableHead className="text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{kitas.map((kita) => {
|
||||
const warnings =
|
||||
kita.counts.lockedUsers +
|
||||
kita.counts.missingConsent +
|
||||
kita.counts.openInvitations +
|
||||
kita.counts.familiesWithoutUsers;
|
||||
return (
|
||||
<TableRow key={kita.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{kita.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{kita.slug}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{warnings === 0 ? (
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Unauffällig
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="warning">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
{warnings} Hinweise
|
||||
</Badge>
|
||||
)}
|
||||
{kita.counts.lockedUsers > 0 && (
|
||||
<Badge variant="outline">
|
||||
{kita.counts.lockedUsers} gesperrt
|
||||
</Badge>
|
||||
)}
|
||||
{kita.counts.openInvitations > 0 && (
|
||||
<Badge variant="outline">
|
||||
{kita.counts.openInvitations} Einladungen
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="grid min-w-48 grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{kita.counts.users} Benutzer</span>
|
||||
<span>{kita.counts.families} Familien</span>
|
||||
<span>{kita.counts.activeChildren} Kinder</span>
|
||||
<span>{kita.counts.termine} Termine</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{roleOrder.map((role) =>
|
||||
kita.roleCounts[role] > 0 ? (
|
||||
<Badge key={role} variant="secondary">
|
||||
{role}: {kita.roleCounts[role]}
|
||||
</Badge>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
{formatDate(kita.lastLoginAt)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={`/admin/kitas/${kita.id}`}
|
||||
prefetch={false}
|
||||
>
|
||||
Diagnose
|
||||
</Link>
|
||||
</Button>
|
||||
<KitaDeleteDialog
|
||||
kitaId={kita.id}
|
||||
kitaName={kita.name}
|
||||
counts={kita.counts}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
description,
|
||||
attention = false,
|
||||
}: {
|
||||
icon: typeof Building2;
|
||||
label: string;
|
||||
value: number;
|
||||
description: string;
|
||||
attention?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className={attention ? "border-amber-200 bg-amber-50/70" : ""}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{numberFormat.format(value)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user