feat(kunden): Kundenverwaltung, Hostzuweisung und Token-Verwaltung (#006)

Kunden und Standorte pflegen, nicht zugeordnete Hosts in einem Schritt zuweisen,
Enrollment- und Host-Tokens verwalten, Audit-Log. Dashboard, Kundenseiten und
globale Suche laufen jetzt auf der Datenbank. 45 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:02:44 +02:00
co-authored by Claude Opus 5
parent f591b45aac
commit f9a5ad2daa
40 changed files with 2060 additions and 415 deletions
+157 -16
View File
@@ -1,34 +1,175 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { TokenFormular } from "@/components/tokens/TokenFormular";
import { Badge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { formatDatum, formatDatumZeit } from "@/lib/format";
import { prisma } from "@/lib/db";
import { enrollmentTokenWiderrufen, hostTokenWiderrufen } from "@/server/actions/tokens";
export const metadata = { title: "Agent-Tokens" };
export default function TokensSeite() {
export default async function TokensSeite() {
const dashboardUrl = (process.env.APP_URL ?? "https://dashboard.example.de").replace(/\/$/, "");
const [kunden, enrollmentTokens, hostTokens] = await Promise.all([
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
prisma.enrollmentToken.findMany({
orderBy: { erstelltAm: "desc" },
include: { kunde: { select: { name: true } } },
}),
prisma.agentToken.findMany({
orderBy: { erstelltAm: "desc" },
include: { host: { select: { id: true, hostname: true } } },
}),
]);
const jetzt = new Date();
function zustand(token: {
widerrufenAm: Date | null;
gueltigBis: Date | null;
maxNutzungen: number | null;
nutzungen: number;
}) {
if (token.widerrufenAm) return { text: "Widerrufen", farbe: "neutral" as const };
if (token.gueltigBis && token.gueltigBis < jetzt) return { text: "Abgelaufen", farbe: "danger" as const };
if (token.maxNutzungen !== null && token.nutzungen >= token.maxNutzungen)
return { text: "Aufgebraucht", farbe: "warning" as const };
return { text: "Gültig", farbe: "success" as const };
}
return (
<>
<ContentHeader
titel="Agent-Tokens"
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Agent-Tokens" }]}
/>
<div className="app-content">
<div className="container-fluid">
<Card titel="Agent installieren" rahmen="primary">
<p>
Auf dem Proxmox-Host als root ausführen. Der Enrollment-Token gilt bis zum angegebenen Datum und
kann mehrfach verwendet werden.
<Card titel="Neuen Enrollment-Token erzeugen" rahmen="primary">
<p className="text-body-secondary">
Mit einem Enrollment-Token registriert sich ein Agent einmalig und erhält dabei seinen eigenen
Host-Token. Ist ein Kunde hinterlegt, sind neue Hosts sofort zugeordnet und tauchen nicht unter{" "}
<Link href="/hosts/nicht-zugeordnet">Nicht zugeordnet</Link> auf.
</p>
<pre className="bg-body-secondary p-3 rounded">
<code className="pl-mono">
curl -fsSL https://proxledger.example.de/install.sh | bash -s -- --token plx_e_BEISPIEL
</code>
</pre>
<CopyableCode wert="plx_e_BEISPIEL" />
<TokenFormular kunden={kunden} dashboardUrl={dashboardUrl} />
</Card>
<Card titel="Vorhandene Tokens" rahmen="info">
<p className="text-body-secondary mb-0">
Ausgabe, Ablauf und Widerruf von Enrollment- und Host-Tokens folgen in #006.
</p>
<Card titel={`Enrollment-Tokens (${enrollmentTokens.length})`} rahmen="info" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Bezeichnung</th>
<th scope="col">Anfang</th>
<th scope="col">Kunde</th>
<th scope="col">Gültig bis</th>
<th scope="col">Nutzungen</th>
<th scope="col">Zustand</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{enrollmentTokens.map((token) => {
const status = zustand(token);
return (
<tr key={token.id}>
<td>{token.bezeichnung ?? "–"}</td>
<td className="pl-mono">{token.praefix}…</td>
<td>{token.kunde?.name ?? "–"}</td>
<td>{formatDatum(token.gueltigBis)}</td>
<td>
{token.nutzungen}
{token.maxNutzungen !== null ? ` / ${token.maxNutzungen}` : ""}
</td>
<td>
<Badge farbe={status.farbe}>{status.text}</Badge>
</td>
<td className="text-end">
{token.widerrufenAm ? null : (
<form action={enrollmentTokenWiderrufen}>
<input type="hidden" name="id" value={token.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Widerrufen
</button>
</form>
)}
</td>
</tr>
);
})}
{enrollmentTokens.length === 0 ? (
<tr>
<td colSpan={7} className="text-center text-body-secondary py-4">
Noch kein Enrollment-Token erzeugt.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
<Card titel={`Host-Tokens (${hostTokens.length})`} rahmen="info" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Host</th>
<th scope="col">Anfang</th>
<th scope="col">Erstellt</th>
<th scope="col">Zuletzt benutzt</th>
<th scope="col">Zustand</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{hostTokens.map((token) => (
<tr key={token.id}>
<td>
<Link href={`/hosts/${token.host.id}`} className="text-decoration-none">
{token.host.hostname}
</Link>
</td>
<td className="pl-mono">{token.praefix}…</td>
<td>{formatDatum(token.erstelltAm)}</td>
<td>{formatDatumZeit(token.letzteNutzungAm)}</td>
<td>
{token.widerrufenAm ? (
<Badge farbe="neutral">Widerrufen</Badge>
) : (
<Badge farbe="success">Aktiv</Badge>
)}
</td>
<td className="text-end">
{token.widerrufenAm ? null : (
<form action={hostTokenWiderrufen}>
<input type="hidden" name="id" value={token.id} />
<input type="hidden" name="hostId" value={token.host.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Widerrufen
</button>
</form>
)}
</td>
</tr>
))}
{hostTokens.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-body-secondary py-4">
Noch kein Host registriert.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
</div>
</div>