Files
sshkeyvault/src/app/(app)/zuweisungen/page.tsx
T
florian.wildeandClaude Opus 5.5 a47cae4082 feat(zuweisungen): Matrix Schluessel x Host mit CSV-Export (#025)
Bisher liess sich nur je Schluessel oder je Host nachsehen, wo ein Key
verwaltet ist. Die Matrix zeigt das auf einen Blick, loest
Hostgruppen-Zuweisungen je Mitglied auf (P vs. P·G) und macht nicht
synchrone Zustaende ohne Farbe erkennbar.

Die Matrix plant nur: Zuweisen und Entfernen laufen ueber die
vorhandenen Aktionen, ausgerollt wird weiter per Dry-Run auf der
Schluesselseite. Der Aufbau ist eine reine Funktion in src/lib, damit
Seite und CSV-Export garantiert dasselbe zeigen und die
Gruppenaufloesung ohne Datenbank testbar ist. Der CSV-Export entschaerft
Formelbeginne, weil Schluesselnamen frei waehlbar sind und Excel sie
sonst ausfuehren wuerde.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:05:12 +02:00

165 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Download, Network, Search } from "lucide-react";
import type { Metadata } from "next";
import { Zuweisungsmatrix } from "@/components/zuweisungen/Zuweisungsmatrix";
import { Button, buttonKlassen } from "@/components/ui/Button";
import { Ankreuzfeld, Auswahlliste, Feld } from "@/components/ui/Feld";
import { Kennzahl } from "@/components/ui/Kennzahl";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { KATEGORIE_TEXT } from "@/lib/schluessel";
import { matrixFilterAus, matrixFilterQuery } from "@/lib/zuweisungsmatrix";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { zielAuswahl } from "@/server/queries/ziele";
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
export const metadata: Metadata = { title: "Zuweisungen" };
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
/**
* Zuweisungsmatrix Schluessel × Host (#025). Filter per GET, damit Links und der CSV-Export
* denselben Ausschnitt zeigen. Die Matrix plant nur – ausgerollt wird ueber den Dry-Run.
*/
export default async function Zuweisungen({ searchParams }: { searchParams: Suchparameter }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
const { benutzer } = await sitzungErforderlich("lesen", "/zuweisungen");
const filter = matrixFilterAus(await searchParams);
const darfZuweisen = darf(benutzer.rolle, "publicKeyAusrollen");
const [{ matrix, gruppen, personen }, auswahl] = await Promise.all([
zuweisungsMatrix(filter),
darfZuweisen ? zielAuswahl() : Promise.resolve(undefined),
]);
const gefiltert = Boolean(
filter.gruppeId || filter.kategorie || filter.personId || filter.q || filter.nurBelegt,
);
const { zaehler } = matrix;
const leer = matrix.zeilen.length === 0 || matrix.spalten.length === 0;
return (
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Zuweisungen"
untertitel="Welcher Schlüssel auf welchem Host verwaltet ist"
aktionen={
<a
href={`/api/zuweisungen.csv${matrixFilterQuery(filter)}`}
download
className={buttonKlassen("sekundaer")}
>
<Download className="size-4" aria-hidden />
CSV exportieren
</a>
}
/>
<div className="grid grid-cols-2 gap-3 sm:gap-5 xl:grid-cols-4">
<Kennzahl titel="Schlüssel" wert={zaehler.schluessel} hinweis="aktiv, in der Ansicht" />
<Kennzahl titel="Hosts" wert={zaehler.hosts} hinweis="nicht archiviert" />
<Kennzahl titel="Zuweisungen" wert={zaehler.zuweisungen} hinweis="Gruppen je Host gezählt" />
<Kennzahl
titel="Nicht synchron"
wert={zaehler.nichtSynchron}
variante={zaehler.nichtSynchron > 0 ? "messing" : "neutral"}
hinweis="geplant, Drift, entfernen, Fehler"
/>
</div>
<form
method="get"
role="search"
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_10rem_11rem]"
>
<Feld
beschriftung="Suche"
name="q"
type="search"
defaultValue={filter.q}
placeholder="Schlüssel- oder Hostname"
autoComplete="off"
spellCheck={false}
className="sm:col-span-2 lg:col-span-1"
/>
<Auswahlliste beschriftung="Hostgruppe" name="gruppe" defaultValue={filter.gruppeId}>
<option value="">Alle Hosts</option>
{gruppen.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={filter.kategorie}>
<option value="">Alle</option>
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Person" name="person" defaultValue={filter.personId}>
<option value="">Alle Personen</option>
{personen.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Auswahlliste>
<div className="flex flex-col gap-3 sm:col-span-2 sm:flex-row sm:items-center sm:justify-between lg:col-span-4">
<Ankreuzfeld
name="belegt"
value="1"
defaultChecked={filter.nurBelegt}
beschriftung="Nur Zeilen und Spalten mit Zuweisungen"
className="-mx-2"
/>
<div className="flex gap-2.5">
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
Filtern
</Button>
{gefiltert && (
<Button href="/zuweisungen" className="flex-1 sm:flex-none">
Zurücksetzen
</Button>
)}
</div>
</div>
</form>
{leer ? (
gefiltert ? (
<Leerzustand
icon={<Search className="size-5" />}
titel="Keine Treffer"
text="Kein Schlüssel und kein Host passen zu Suche und Filter."
aktion={<Button href="/zuweisungen">Filter zurücksetzen</Button>}
/>
) : (
<Leerzustand
icon={<Network className="size-5" />}
titel="Noch nichts zuzuordnen"
text="Die Matrix braucht mindestens einen aktiven Schlüssel und einen Host."
/>
)
) : (
<section className="flex min-w-0 flex-col gap-3" aria-label="Matrix">
<p className="text-muted text-[13px]">
<span className="text-text font-medium">P</span> Public Key direkt ·{" "}
<span className="text-text font-medium">P·G</span> über Hostgruppe ·{" "}
<span className="text-text font-medium">K</span> Private Key · <span className="text-ok">●</span>{" "}
synchron · <span className="text-accent">○</span> geplant oder wird entfernt ·{" "}
<span className="text-danger">●</span> Drift oder Fehler
</p>
<Zuweisungsmatrix
zeilen={matrix.zeilen}
spalten={matrix.spalten}
zellen={Object.fromEntries(matrix.zellen)}
darfZuweisen={darfZuweisen}
darfPrivatEntfernen={darf(benutzer.rolle, "privateKeyAusrollen")}
{...(auswahl ? { auswahl } : {})}
/>
</section>
)}
</div>
);
}