Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { createTenant } from "@/server/actions/admin";
|
||||
import { getMailStatus } from "@/server/actions/mail";
|
||||
import { MailStatusPanel } from "@/components/mail-status-panel";
|
||||
import { resolveInboundReview } from "@/server/actions/incident-intake-admin";
|
||||
import { intakeAddress } from "@/server/incident-inbound/parse";
|
||||
import { Siren } from "lucide-react";
|
||||
|
||||
const REVIEW_REASON_LABEL: Record<string, string> = {
|
||||
no_token: "kein Token",
|
||||
unknown_token: "unbekannter Token",
|
||||
allowlist_failed: "Absender nicht in Allowlist",
|
||||
dkim_failed: "DKIM fehlgeschlagen",
|
||||
};
|
||||
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
|
||||
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
|
||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
const params = await searchParams;
|
||||
|
||||
const [tenants, mailStatus, pendingIntake, openReviews] = await Promise.all([
|
||||
prisma.tenant.findMany({
|
||||
include: { _count: { select: { users: true } }, modules: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
// SEC1: Betriebszustand der Mail-Strecke + Testversand.
|
||||
getMailStatus(),
|
||||
// IM-D: Kunden, deren E-Mail-Weiterleitung noch nicht verifiziert ist.
|
||||
prisma.incidentIntakeConfig.findMany({
|
||||
where: { status: "weiterleitung_ausstehend" },
|
||||
select: { tenantId: true, token: true },
|
||||
}),
|
||||
// IM-D: Inbound-Mails ohne/mit unbekanntem Token → Betreiber-Sichtung.
|
||||
prisma.incidentInboundReview.findMany({
|
||||
where: { status: "offen" },
|
||||
orderBy: { receivedAt: "desc" },
|
||||
take: 50,
|
||||
select: { id: true, sender: true, subject: true, recipient: true, reason: true, receivedAt: true },
|
||||
}),
|
||||
]);
|
||||
// Mandantennamen zu den offenen Verifizierungen (mandantenübergreifende Betreiber-Sicht).
|
||||
const pendingTenantNames = new Map(
|
||||
tenants.filter((t) => pendingIntake.some((p) => p.tenantId === t.id)).map((t) => [t.id, t.name]),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Plattform-Betrieb"
|
||||
title="Admin-Konsole — Mandantenverwaltung"
|
||||
sub="Kunden (Mandanten) anlegen, provisionieren, Module & Lebenszyklus verwalten"
|
||||
actions={
|
||||
<span className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admins" />}>Administratoren</Button>
|
||||
<Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}>
|
||||
<Plus className="size-4" /> Neuer Kunde
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{params.new && (
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">Neuen Kunden anlegen</p>
|
||||
<form action={createTenant} className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="name">Firmenname *</Label>
|
||||
<Input id="name" name="name" required className="mt-1" placeholder="Muster GmbH" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="short">Kurzname</Label>
|
||||
<Input id="short" name="short" className="mt-1" placeholder="Muster" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slug">Kürzel/Slug (optional)</Label>
|
||||
<Input id="slug" name="slug" className="mt-1" placeholder="wird aus Name erzeugt" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sector">Sektor</Label>
|
||||
<Input id="sector" name="sector" className="mt-1" placeholder="z. B. Automotive-Zulieferer" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adminEmail">Admin-E-Mail *</Label>
|
||||
<Input id="adminEmail" name="adminEmail" type="email" required className="mt-1" placeholder="admin@muster.example" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adminName">Admin-Name</Label>
|
||||
<Input id="adminName" name="adminName" className="mt-1" placeholder="Vor- und Nachname" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adminPassword">Initial-Passwort * (min. 8)</Label>
|
||||
<Input id="adminPassword" name="adminPassword" type="text" required className="mt-1" placeholder="wird dem Admin mitgeteilt" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="tisaxLevel">TISAX-Level</Label>
|
||||
<select id="tisaxLevel" name="tisaxLevel" defaultValue="AL2" className={`${inputCls} mt-1`}>
|
||||
<option value="AL2">AL2 (MUSS · SOLL · HOCH)</option>
|
||||
<option value="AL3">AL3 (+ SEHR HOCH)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>Rahmenwerk(e)</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-4 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="fw_tisax" defaultChecked /> TISAX / VDA ISA
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="fw_iso" /> ISO/IEC 27001
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">
|
||||
Mind. eines wählen (nichts gewählt = TISAX). Bei beiden ist TISAX das Primär-Framework; die Anforderungssichten koexistieren je Dokument.
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm md:col-span-2">
|
||||
<input type="checkbox" name="seedPolicies" defaultChecked /> Richtlinienpaket beim Anlegen ausrollen (je gewähltem Rahmenwerk)
|
||||
</label>
|
||||
<div className="flex gap-2 md:col-span-2">
|
||||
<Button type="submit">Kunde anlegen & provisionieren</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/admin" />}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Beim Anlegen werden automatisch ausgerollt: Standard-Rollen, erster Admin-User, alle Module, Mandanten-Einstellungen und der TISAX-Default (idempotent, protokolliert).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kunde</TableHead>
|
||||
<TableHead>Kürzel</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Nutzer</TableHead>
|
||||
<TableHead>Aktive Module</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tenants.map((t) => {
|
||||
const active = t.modules.filter((m) => m.enabled).length;
|
||||
return (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell>
|
||||
<Link href={`/admin/${t.id}`} className="font-bold hover:underline">{t.name}</Link>
|
||||
{t.sector && <div className="text-xs text-muted-foreground">{t.sector}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
|
||||
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
|
||||
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* IM-D: E-Mail-Eingang für Vorfälle — Provisionierungsstatus + Review-Queue. */}
|
||||
{(pendingIntake.length > 0 || openReviews.length > 0) && (
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Siren className="size-4 text-[var(--primary)]" />
|
||||
<p className="font-heading text-sm font-semibold">E-Mail-Eingang für Vorfälle</p>
|
||||
</div>
|
||||
|
||||
{pendingIntake.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="mb-1.5 text-[12.5px] font-semibold text-muted-foreground">
|
||||
Weiterleitung ausstehend ({pendingIntake.length})
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{pendingIntake.map((p) => (
|
||||
<li key={p.tenantId} className="flex flex-wrap items-center gap-2 text-[12.5px]">
|
||||
<Pill tone="warn">ausstehend</Pill>
|
||||
<Link href={`/admin/${p.tenantId}`} className="font-semibold hover:underline">
|
||||
{pendingTenantNames.get(p.tenantId) ?? p.tenantId}
|
||||
</Link>
|
||||
<code className="font-mono text-muted-foreground">{intakeAddress(p.token)}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground">
|
||||
Der Status wechselt automatisch auf „verifiziert“, sobald die erste weitergeleitete Test-Mail als Vorfall ankommt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openReviews.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<p className="mb-1.5 text-[12.5px] font-semibold text-muted-foreground">
|
||||
Zu prüfende Eingänge ({openReviews.length})
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{openReviews.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-[12px]">
|
||||
<Pill tone="mut">{REVIEW_REASON_LABEL[r.reason] ?? r.reason}</Pill>
|
||||
<span className="font-mono">{r.sender}</span>
|
||||
<span className="text-muted-foreground">{r.subject || "(ohne Betreff)"}</span>
|
||||
{r.recipient && <span className="text-muted-foreground">→ {r.recipient}</span>}
|
||||
<span className="ml-auto flex gap-1.5">
|
||||
<form action={resolveInboundReview.bind(null, r.id, "zugeordnet")}>
|
||||
<Button type="submit" variant="outline" size="sm">Zugeordnet</Button>
|
||||
</form>
|
||||
<form action={resolveInboundReview.bind(null, r.id, "erledigt")}>
|
||||
<Button type="submit" variant="ghost" size="sm">Erledigt</Button>
|
||||
</form>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MailStatusPanel status={mailStatus} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user