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
@@ -1,15 +1,25 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { ZuweisungsFormular } from "@/components/hosts/ZuweisungsFormular";
import { Callout } from "@/components/ui/Callout";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { formatDatumZeit } from "@/lib/format";
import { kunden } from "@/mock/data";
import { PRODUKT_NAME, nichtZugeordneteHosts } from "@/mock/queries";
import { prisma } from "@/lib/db";
import { hostsOhneKunde } from "@/server/queries/hosts";
export const metadata = { title: "Nicht zugeordnete Hosts" };
export default function NichtZugeordnetSeite() {
const liste = nichtZugeordneteHosts();
export default async function NichtZugeordnetSeite() {
const [hosts, kunden] = await Promise.all([
hostsOhneKunde(),
prisma.kunde.findMany({
orderBy: { name: "asc" },
select: {
id: true,
name: true,
standorte: { select: { id: true, name: true }, orderBy: { name: "asc" } },
},
}),
]);
return (
<>
@@ -20,83 +30,40 @@ export default function NichtZugeordnetSeite() {
<div className="app-content">
<div className="container-fluid">
<Callout titel="Schnellzuweisung" farbe="info">
Mehrere Hosts auswählen und gemeinsam einem Kunden zuweisen. Im Prototyp ist das Formular noch
ohne Funktion – die Zuweisung folgt in #006.
</Callout>
{hosts.length === 0 ? (
<Callout titel="Alles zugeordnet" farbe="info">
Jeder gemeldete Host gehört zu einem Kunden. Neu registrierte Hosts erscheinen automatisch hier.
</Callout>
) : (
<Callout titel="Schnellzuweisung" farbe="warning">
Hosts auswählen, Kunde wählen und in einem Schritt zuweisen. Wer Hosts direkt vorzuordnen
möchte, erzeugt unter <Link href="/einstellungen/tokens">Agent-Tokens</Link> einen
Enrollment-Token mit Kundenbindung – damit landen neue Hosts sofort beim richtigen Kunden.
</Callout>
)}
<Card titel={`${liste.length} Hosts ohne Kunde`} rahmen="warning" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">
<input type="checkbox" className="form-check-input" aria-label="Alle auswählen" />
</th>
<th scope="col">Host</th>
<th scope="col">Produkt</th>
<th scope="col">Server-ID</th>
<th scope="col">Key</th>
<th scope="col">Erste Meldung</th>
</tr>
</thead>
<tbody>
{liste.map((host) => (
<tr key={host.id}>
<td>
<input
type="checkbox"
className="form-check-input"
aria-label={`${host.hostname} auswählen`}
defaultChecked
/>
</td>
<td className="fw-semibold">{host.hostname}</td>
<td>{PRODUKT_NAME[host.produkt]}</td>
<td>
<CopyableCode wert={host.serverId} />
</td>
<td>
<CopyableCode wert={host.subscription.key} />
</td>
<td>{formatDatumZeit(host.letzterReport)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{kunden.length === 0 ? (
<Callout titel="Noch kein Kunde vorhanden" farbe="danger">
Zum Zuweisen wird mindestens ein Kunde benötigt.{" "}
<Link href="/kunden/neu">Jetzt einen Kunden anlegen.</Link>
</Callout>
) : null}
<Card titel="Ausgewählte Hosts zuweisen">
<div className="row g-2 align-items-end">
<div className="col-md-5">
<label className="form-label" htmlFor="kunde">
Kunde
</label>
<select id="kunde" className="form-select">
{kunden.map((kunde) => (
<option key={kunde.id}>{kunde.name}</option>
))}
</select>
</div>
<div className="col-md-5">
<label className="form-label" htmlFor="standort">
Standort (optional)
</label>
<select id="standort" className="form-select">
<option>– kein Standort –</option>
{kunden[0].standorte.map((standort) => (
<option key={standort.id}>{standort.name}</option>
))}
</select>
</div>
<div className="col-md-2">
<button type="button" className="btn btn-primary w-100" disabled>
Zuweisen
</button>
</div>
</div>
</Card>
{hosts.length > 0 ? (
<Card titel={`${hosts.length} Hosts ohne Kunde`} rahmen="warning" tabelle>
<ZuweisungsFormular
hosts={hosts.map((host) => ({
id: host.id,
hostname: host.hostname,
produkt: host.produkt,
serverId: host.serverId,
subKey: host.subKey,
erstmalsGesehenAm: host.erstmalsGesehenAm.toISOString(),
}))}
kunden={kunden}
/>
</Card>
) : null}
</div>
</div>
</>