Merge branch 'phase/025-zuweisungsmatrix'

This commit is contained in:
2026-10-12 13:06:33 +02:00
16 changed files with 1608 additions and 16 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+31 -4
View File
@@ -1,19 +1,46 @@
# #025 Zuweisungsmatrix # #025 Zuweisungsmatrix
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.4.0 **Meilenstein:** v0.4.0
**Labels:** ui **Labels:** ui
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0,5 h
## Ziel ## Ziel
Matrix Schlüssel × Host. Matrix Schlüssel × Host.
## Aufgaben ## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert - [x] `/zuweisungen` ersetzt die Platzhalterseite: Matrix aktive Schlüssel (Zeilen) × nicht archivierte Hosts (Spalten), jede Rolle darf lesen
- [x] Zelle: „P“ direkt, „P·G“ über Hostgruppe, „K“ Private Key; Zustand als Punkt + Kurztext, voller Text in `title` und für Screenreader; Zielbenutzer mono
- [x] Filter per GET: Hostgruppe, Kategorie, Person, Suche über Schlüssel- und Hostnamen, „nur Zeilen/Spalten mit Zuweisungen“
- [x] Eigener Scrollbereich mit sticky Kopfzeile und erster Spalte; unter `md` eine Liste je Schlüssel
- [x] Zell-Dialog: Verwendungen mit Zustand, Optionen/Pfad, Links zu Schlüssel und Host; „Public Key zuweisen“ (vorhandenes Formular mit Vorauswahl) und „Entfernen“ für `publicKeyAusrollen`; Link „Zum Dry-Run“
- [x] Kopf: Zähler (Schlüssel, Hosts, Zuweisungen, nicht synchron) und CSV-Export `/api/zuweisungen.csv`
- [x] Reine Matrix-Funktion in `src/lib/zuweisungsmatrix.ts`, CSV-Helfer in `src/lib/csv.ts`, Query in `src/server/queries/zuweisungen.ts`
- [x] Tests: `tests/zuweisungsmatrix.test.ts` (rein), `tests/zuweisungen.test.ts` (Query + CSV-Route), E2E `tests/e2e/zuweisungen.spec.ts`
- [x] Screenshots `zuweisungen-desktop.png` / `zuweisungen-mobil.png`
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert - [x] Gruppen-Zuweisungen erscheinen auf jedem Mitglied und sind von direkten unterscheidbar
- [x] Zustand nie nur über Farbe
- [x] Seite scrollt bei 390 px nicht horizontal (Screenshot-Skript prüft das)
- [x] Leser sehen die Matrix, aber weder Zuweisen noch Entfernen
- [x] CSV mit den Spalten Schlüssel, Fingerprint, Host, Zielbenutzer, Art, Über Gruppe, Optionen, Zustand; Formelbeginne `= + - @` mit `'` entschärft
- [x] Route Handler prüft `rechtErforderlich("lesen")` selbst (`tests/zugang.test.ts` grün)
## Abhängigkeiten ## Abhängigkeiten
#016 #016
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Die Matrix plant nur: Zuweisen und Entfernen nutzen die vorhandenen Aktionen, ausgerollt wird
weiter über den Dry-Run der Schlüsselseite (`AusrollenPanel`).
- Suche: trifft der Begriff Schlüssel, werden die Zeilen eingeschränkt; trifft er Hosts, die
Spalten; trifft er beides, beides. So zeigt „srv-pve“ alle Schlüssel auf diesen Hosts statt
einer leeren Matrix.
- Zähler „Zuweisungen“ zählt Zelleneinträge, eine Gruppen-Zuweisung also einmal je Mitglied –
dasselbe, was der CSV-Export als Zeilen liefert. „Nicht synchron“ ist alles außer `AUSGEROLLT`.
- Zellenzustand ist der dringendste der Einträge (Fehler > Drift > entfernen > geplant > synchron).
- CSV: Semikolon, UTF-8-BOM und CRLF für ein deutsches Excel; bei Private Keys steht der Pfad
als „Pfad: …“ in der Spalte Optionen. Der Export nimmt dieselben Filter wie die Seite.
- Deployment-Keys erscheinen als Zeilen, sind aber nicht zuweisbar (verwaltet sshkv selbst).
- Entfernen einer Gruppen-Zuweisung aus einer Zelle gilt für die ganze Gruppe; der Dialog sagt das.
- `PublicZuweisenFormular` hat eine optionale `vorauswahl`; `VerwendungsZustand` exportiert seine
Darstellungstabelle (`ZUSTAND_ANZEIGE`), damit die Zelle dieselben Punkte nutzt.
+3
View File
@@ -39,6 +39,9 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
{ name: "meine-schluessel", pfad: "/meine-schluessel" }, { name: "meine-schluessel", pfad: "/meine-schluessel" },
{ name: "hosts", pfad: "/hosts" }, { name: "hosts", pfad: "/hosts" },
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' }, { name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
// Matrix Schluessel × Host (#025): die Seite darf auf 390 px nicht seitwaerts scrollen –
// nur der Matrix-Container. „belegt=1“, damit das Bild Zellen zeigt statt leerer Zeilen.
{ name: "zuweisungen", pfad: "/zuweisungen?belegt=1" },
{ name: "vorlagen", pfad: "/vorlagen" }, { name: "vorlagen", pfad: "/vorlagen" },
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019). // Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
{ {
+153 -7
View File
@@ -1,18 +1,164 @@
import { Download, Network, Search } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; 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 { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { zielAuswahl } from "@/server/queries/ziele";
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
export const metadata: Metadata = { title: "Zuweisungen" }; export const metadata: Metadata = { title: "Zuweisungen" };
export default async function 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). // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich(); 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 ( return (
<Platzhalterseite <div className="flex min-w-0 flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Zuweisungen" titel="Zuweisungen"
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt" untertitel="Welcher Schlüssel auf welchem Host verwaltet ist"
phase={2} aktionen={
issue="#016" <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>
); );
} }
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { matrixCsv, matrixFilterAus } from "@/lib/zuweisungsmatrix";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
/**
* Zuweisungsmatrix als CSV (#025) mit denselben Filtern wie die Seite. Jede Rolle darf lesen;
* der Handler prueft das selbst, der Proxy laesst ohne Cookie ohnehin niemanden durch.
*/
export async function GET(anfrage: Request) {
try {
await rechtErforderlich("lesen");
} catch (fehler) {
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
throw fehler;
}
const filter = matrixFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
const { matrix } = await zuweisungsMatrix(filter);
return new NextResponse(matrixCsv(matrix), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="sshkv-zuweisungen.csv"',
"Cache-Control": "no-store",
},
});
}
@@ -14,10 +14,13 @@ export function PublicZuweisenFormular({
schluesselId, schluesselId,
auswahl, auswahl,
beiErfolg, beiErfolg,
vorauswahl,
}: { }: {
schluesselId: string; schluesselId: string;
auswahl: ZielAuswahl; auswahl: ZielAuswahl;
beiErfolg?: () => void; beiErfolg?: () => void;
/** Vorausgewaehltes Ziel als „host:<id>“ oder „gruppe:<id>“, z. B. aus der Matrix (#025). */
vorauswahl?: string;
}) { }) {
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => { const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
const ergebnis = await publicZuweisenAktion(vorher, formular); const ergebnis = await publicZuweisenAktion(vorher, formular);
@@ -28,7 +31,7 @@ export function PublicZuweisenFormular({
<form onSubmit={absenden} className="flex flex-col gap-4"> <form onSubmit={absenden} className="flex flex-col gap-4">
<input type="hidden" name="schluesselId" value={schluesselId} /> <input type="hidden" name="schluesselId" value={schluesselId} />
<Formularfehler text={zustand.fehler} /> <Formularfehler text={zustand.fehler} />
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue="" required> <Auswahlliste beschriftung="Ziel" name="ziel" defaultValue={vorauswahl ?? ""} required>
<option value="" disabled> <option value="" disabled>
Host oder Hostgruppe wählen … Host oder Hostgruppe wählen …
</option> </option>
@@ -2,7 +2,7 @@ import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums";
import { clsx } from "clsx"; import { clsx } from "clsx";
/** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */ /** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */
const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = { export const ZUSTAND_ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" }, GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" },
AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" }, AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" },
ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" }, ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" },
@@ -12,7 +12,7 @@ const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }>
}; };
export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) { export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) {
const anzeige = ANZEIGE[zustand]; const anzeige = ZUSTAND_ANZEIGE[zustand];
return ( return (
<span <span
className={clsx( className={clsx(
+189
View File
@@ -0,0 +1,189 @@
"use client";
import { ArrowRight, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
import { PublicZuweisenFormular } from "@/components/ausrollen/PublicZuweisenFormular";
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
import { Button } from "@/components/ui/Button";
import { InnereBox } from "@/components/ui/Karte";
import { KENNUNG_TEXT, type MatrixHost, type MatrixSchluessel, type Zelle } from "@/lib/zuweisungsmatrix";
import type { ZielAuswahl } from "@/server/queries/ziele";
export type ZellenAuswahl = { readonly schluesselId: string; readonly hostId: string | null };
/**
* Detail einer Matrixzelle (#025): was dort verwaltet ist, Entfernen und Zuweisen mit den
* vorhandenen Aktionen. Ausgerollt wird weiter nur ueber den Dry-Run der Schluesselseite –
* die Matrix plant, sie schreibt nichts auf Hosts.
*
* Die Zelle kommt aus den Props: nach `router.refresh()` zeigt der offene Dialog sofort den
* neuen Stand, ohne eigenen Zustand nachzufuehren.
*/
export function ZellenDialog({
schluessel,
host,
zelle,
darfZuweisen,
darfPrivatEntfernen,
auswahl,
schliessen,
}: {
schluessel: MatrixSchluessel;
host: MatrixHost | undefined;
zelle: Zelle | undefined;
darfZuweisen: boolean;
darfPrivatEntfernen: boolean;
auswahl?: ZielAuswahl;
schliessen: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const router = useRouter();
// Nach erfolgreichem Zuweisen ein frisches Formular – sonst schickt ein zweiter Klick
// dieselbe Zuweisung noch einmal und erntet „bereits zugewiesen“.
const [formularNr, setFormularNr] = useState(0);
useEffect(() => {
const element = dialog.current;
if (element && !element.open) element.showModal();
}, []);
const titel = host ? `${schluessel.name} auf ${host.name}` : schluessel.name;
const vorauswahl = host && auswahl?.hosts.some((h) => h.id === host.id) ? `host:${host.id}` : undefined;
const eintraege = zelle?.eintraege ?? [];
return (
<dialog
ref={dialog}
aria-label={titel}
onClose={schliessen}
className="border-border-strong bg-surface text-text rounded-karte m-auto max-h-[calc(100dvh-32px)] w-[calc(100%-32px)] overflow-y-auto overscroll-contain max-w-xl border p-5 backdrop:bg-black/70 sm:p-6"
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="font-display text-[18px] font-semibold break-words">{schluessel.name}</h2>
<p className="text-muted mt-0.5 text-[13.5px]">
{host ? (
<>
auf <span className="font-mono">{host.name}</span>
</>
) : (
"Einem Host oder einer Hostgruppe zuweisen"
)}
</p>
</div>
<button
type="button"
onClick={schliessen}
aria-label="Schließen"
className="text-muted hover:text-text hover:bg-raised rounded-feld -mt-2 -mr-2 flex size-11 shrink-0 items-center justify-center"
>
<X className="size-5" aria-hidden />
</button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[13.5px]">
<Link
href={`/schluessel/${schluessel.id}`}
className="text-accent inline-flex min-h-11 items-center hover:underline"
>
Zum Schlüssel
</Link>
{host && (
<Link
href={`/hosts/${host.id}`}
className="text-accent inline-flex min-h-11 items-center hover:underline"
>
Zum Host
</Link>
)}
</div>
{host && (
<section className="mt-3 flex flex-col gap-3" aria-label="Verwendungen in dieser Zelle">
{eintraege.length === 0 ? (
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-4 text-center text-[14px]">
Auf diesem Host ist der Schlüssel nicht zugewiesen.
</p>
) : (
eintraege.map((e) => (
<InnereBox
key={`${e.art}-${e.verwendungId}`}
className="flex items-start justify-between gap-3"
>
<div className="flex min-w-0 flex-col gap-1">
<p className="text-[14px] font-medium">
{KENNUNG_TEXT[e.kennung]}
{e.gruppe && <> „{e.gruppe.name}“</>}
</p>
<p className="text-muted text-[13px]">
Zielbenutzer <span className="text-text font-mono">{e.zielbenutzer}</span>
</p>
{e.art === "public" ? (
<p className="text-muted text-[13px] break-all">
Optionen <span className="text-text font-mono text-[12.5px]">{e.optionen ?? "–"}</span>
</p>
) : (
<p className="text-muted text-[13px] break-all">
Pfad <span className="text-text font-mono text-[12.5px]">{e.pfad}</span>
</p>
)}
<VerwendungsZustand zustand={e.zustand} />
{e.gruppe && darfZuweisen && (
<p className="text-muted text-[12.5px]">
Entfernen gilt für alle Hosts der Gruppe „{e.gruppe.name}“.
</p>
)}
</div>
<EintragAktionen
art={e.art}
verwendungId={e.verwendungId}
schluesselId={schluessel.id}
darfEntfernen={
e.zustand !== "ENTFERNEN" && (e.art === "public" ? darfZuweisen : darfPrivatEntfernen)
}
testbar={false}
/>
</InnereBox>
))
)}
</section>
)}
{darfZuweisen && auswahl && (
<section className="border-border mt-5 flex flex-col gap-3 border-t pt-5">
<h3 className="text-[15px] font-semibold">Public Key zuweisen</h3>
<PublicZuweisenFormular
key={formularNr}
schluesselId={schluessel.id}
auswahl={auswahl}
{...(vorauswahl ? { vorauswahl } : {})}
beiErfolg={() => {
setFormularNr((n) => n + 1);
router.refresh();
}}
/>
</section>
)}
{darfZuweisen && (
<div className="border-border mt-5 flex flex-col gap-2 border-t pt-4">
<p className="text-muted text-[13px]">
Zuweisen und Entfernen ändern auf dem Host noch nichts – erst der Dry-Run auf der Schlüsselseite
zeigt und schreibt die Änderung.
</p>
<Button
href={`/schluessel/${schluessel.id}`}
klein
className="self-start"
icon={<ArrowRight className="size-4" />}
>
Zum Dry-Run
</Button>
</div>
)}
</dialog>
);
}
@@ -0,0 +1,274 @@
"use client";
import { clsx } from "clsx";
import { Plus } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { ZUSTAND_ANZEIGE } from "@/components/ausrollen/VerwendungsZustand";
import { Button } from "@/components/ui/Button";
import {
KENNUNG_TEXT,
ZUSTAND_KURZ,
ZUSTAND_TEXT,
zellenSchluessel,
type MatrixHost,
type MatrixSchluessel,
type Zelle,
} from "@/lib/zuweisungsmatrix";
import type { ZielAuswahl } from "@/server/queries/ziele";
import { ZellenDialog, type ZellenAuswahl } from "./ZellenDialog";
/**
* Matrix Schluessel × Host (#025). Ab md eine Tabelle mit eigenem Scrollbereich – die Seite
* selbst scrollt nie seitwaerts –, darunter eine Liste je Schluessel, weil auf 390 px keine
* zweite Spalte neben den Schluesselnamen passt.
*/
export type MatrixProps = {
zeilen: readonly MatrixSchluessel[];
spalten: readonly MatrixHost[];
/** Nur belegte Zellen, Schluessel `${schluesselId}:${hostId}`. */
zellen: Readonly<Record<string, Zelle>>;
darfZuweisen: boolean;
darfPrivatEntfernen: boolean;
auswahl?: ZielAuswahl;
};
/** Langtext fuer title und Screenreader: Art, Gruppe, Benutzer und Zustand je Eintrag. */
function zellenText(zelle: Zelle): string {
return zelle.eintraege
.map(
(e) =>
`${KENNUNG_TEXT[e.kennung]}${e.gruppe ? ` ${e.gruppe.name}` : ""}, Benutzer ${e.zielbenutzer}, ${ZUSTAND_TEXT[e.zustand]}`,
)
.join("; ");
}
function zielbenutzerKurz(zelle: Zelle): string {
const [erster, ...rest] = zelle.zielbenutzer;
return rest.length > 0 ? `${erster} +${rest.length}` : (erster ?? "");
}
export function Zuweisungsmatrix({
zeilen,
spalten,
zellen,
darfZuweisen,
darfPrivatEntfernen,
auswahl,
}: MatrixProps) {
const [offen, setOffen] = useState<ZellenAuswahl>();
const zuweisbar = (s: MatrixSchluessel) => darfZuweisen && !!auswahl && s.kategorie !== "DEPLOYMENT";
const schluessel = offen ? zeilen.find((s) => s.id === offen.schluesselId) : undefined;
const host = offen?.hostId ? spalten.find((h) => h.id === offen.hostId) : undefined;
const zelle = offen?.hostId ? zellen[zellenSchluessel(offen.schluesselId, offen.hostId)] : undefined;
return (
<>
{/* Desktop: Kopfzeile und erste Spalte bleiben beim Scrollen stehen. Der Scrollbereich
braucht eine eigene Hoehe, sonst klebt die Kopfzeile am Container statt am Fenster. */}
<div className="border-border bg-surface relative hidden max-h-[calc(100dvh-11rem)] overflow-auto rounded-[12px] border md:block">
<table className="min-w-full border-separate border-spacing-0 text-left text-[13px]">
<caption className="sr-only">
Zuweisungen: Schlüssel in den Zeilen, Hosts in den Spalten. Eine Zelle öffnet die Details.
</caption>
<thead>
<tr>
<th
scope="col"
className="bg-surface border-border-strong text-muted sticky top-0 left-0 z-30 min-w-56 border-r border-b px-3 py-2.5 text-[13px] font-medium"
>
Schlüssel
</th>
{spalten.map((h) => (
<th
key={h.id}
scope="col"
className="bg-surface border-border-strong sticky top-0 z-20 border-b px-1 py-2.5 align-bottom font-medium"
>
<Link
href={`/hosts/${h.id}`}
title={h.name}
className="text-muted hover:text-accent block max-w-28 truncate px-1.5 font-mono text-[12.5px] transition-colors"
>
{h.name}
</Link>
</th>
))}
</tr>
</thead>
<tbody>
{zeilen.map((s) => (
<tr key={s.id} className="group">
<th
scope="row"
className="bg-surface border-border group-hover:bg-raised sticky left-0 z-10 max-w-72 border-r border-b px-3 py-1.5 font-normal transition-colors"
>
<Link
href={`/schluessel/${s.id}`}
title={s.name}
className="text-text hover:text-accent block truncate text-[13.5px] font-medium transition-colors"
>
{s.name}
</Link>
{s.person && <span className="text-muted block truncate text-[12px]">{s.person.name}</span>}
</th>
{spalten.map((h) => {
const z = zellen[zellenSchluessel(s.id, h.id)];
return (
<td key={h.id} className="border-border border-b p-0.5 align-middle">
{z ? (
<BelegteZelle
zelle={z}
schluessel={s}
host={h}
oeffnen={() => setOffen({ schluesselId: s.id, hostId: h.id })}
/>
) : zuweisbar(s) ? (
<button
type="button"
onClick={() => setOffen({ schluesselId: s.id, hostId: h.id })}
title={`${s.name} auf ${h.name} zuweisen`}
className="text-border-strong hover:text-accent hover:bg-raised rounded-feld flex min-h-11 w-full min-w-20 items-center justify-center transition-colors"
>
<span aria-hidden>·</span>
<span className="sr-only">
{s.name} auf {h.name}: nicht zugewiesen – Details und Zuweisen
</span>
</button>
) : (
<span className="text-border-strong flex min-h-11 min-w-20 items-center justify-center">
<span aria-hidden>·</span>
<span className="sr-only">nicht zugewiesen</span>
</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* Telefon: je Schluessel nur die Hosts, auf denen er verwaltet ist. */}
<ul className="flex flex-col gap-3 md:hidden">
{zeilen.map((s) => {
const belegt = spalten.flatMap((h) => {
const z = zellen[zellenSchluessel(s.id, h.id)];
return z ? [{ host: h, zelle: z }] : [];
});
return (
<li key={s.id} className="border-border bg-surface rounded-karte min-w-0 border p-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<Link
href={`/schluessel/${s.id}`}
className="text-text hover:text-accent block truncate text-[14.5px] font-medium"
>
{s.name}
</Link>
<span className="text-muted block text-[12.5px]">
{belegt.length === 1 ? "1 Host" : `${belegt.length} Hosts`}
{s.person && <> · {s.person.name}</>}
</span>
</div>
{zuweisbar(s) && (
<Button
klein
icon={<Plus className="size-4" />}
onClick={() => setOffen({ schluesselId: s.id, hostId: null })}
>
Zuweisen
<span className="sr-only"> – {s.name}</span>
</Button>
)}
</div>
{belegt.length > 0 && (
<ul className="mt-3 flex flex-col">
{belegt.map(({ host: h, zelle: z }) => {
const anzeige = ZUSTAND_ANZEIGE[z.zustand];
return (
<li key={h.id} className="border-border border-t">
<button
type="button"
onClick={() => setOffen({ schluesselId: s.id, hostId: h.id })}
className="hover:bg-raised/60 rounded-box -mx-2 flex min-h-11 w-[calc(100%+1rem)] min-w-0 items-center justify-between gap-3 px-2 py-2 text-left"
>
<span className="min-w-0">
<span className="text-text block truncate font-mono text-[13px]">{h.name}</span>
<span className="text-muted block truncate text-[12.5px]">
{z.kennungen.join(" ")} ·{" "}
<span className="font-mono">{zielbenutzerKurz(z)}</span>
</span>
</span>
<span
className={clsx(
"inline-flex shrink-0 items-center gap-1.5 text-[13px] whitespace-nowrap",
anzeige.klasse,
)}
>
<span aria-hidden>{anzeige.punkt}</span>
{ZUSTAND_TEXT[z.zustand]}
</span>
<span className="sr-only">: {zellenText(z)}</span>
</button>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
{offen && schluessel && (
<ZellenDialog
schluessel={schluessel}
host={host}
zelle={zelle}
darfZuweisen={zuweisbar(schluessel)}
darfPrivatEntfernen={darfPrivatEntfernen}
{...(auswahl ? { auswahl } : {})}
schliessen={() => setOffen(undefined)}
/>
)}
</>
);
}
function BelegteZelle({
zelle,
schluessel,
host,
oeffnen,
}: {
zelle: Zelle;
schluessel: MatrixSchluessel;
host: MatrixHost;
oeffnen: () => void;
}) {
const anzeige = ZUSTAND_ANZEIGE[zelle.zustand];
const text = zellenText(zelle);
return (
<button
type="button"
onClick={oeffnen}
title={text}
className="hover:bg-raised rounded-feld flex min-h-11 w-full min-w-20 flex-col items-start justify-center gap-0.5 px-2 py-1 text-left transition-colors"
>
<span aria-hidden className="flex items-center gap-1.5 whitespace-nowrap">
<span className={anzeige.klasse}>{anzeige.punkt}</span>
<span className="text-text font-medium">{zelle.kennungen.join(" ")}</span>
<span className={clsx("text-[11.5px]", anzeige.klasse)}>{ZUSTAND_KURZ[zelle.zustand]}</span>
</span>
<span aria-hidden className="text-muted block max-w-24 truncate font-mono text-[11.5px]">
{zielbenutzerKurz(zelle)}
</span>
<span className="sr-only">
{schluessel.name} auf {host.name}: {text}
</span>
</button>
);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* CSV fuer Excel (#025). Framework-frei, damit spaetere Exporte (Protokoll, Phase 5) dieselben
* Regeln nutzen.
*
* - Semikolon als Trenner und UTF-8-BOM: ein deutsches Excel oeffnet die Datei sonst als eine
* Spalte mit kaputten Umlauten.
* - Jeder Wert in Anfuehrungszeichen, damit Kommas in `authorized_keys`-Optionen und
* Zeilenumbrueche nichts verschieben.
* - Formel-Injektion: Excel und LibreOffice werten Zellen, die mit `= + - @` (oder Tab/CR)
* beginnen, als Formel aus – ein Schluesselname wie `=HYPERLINK(…)` wuerde beim Oeffnen
* ausgefuehrt. Ein vorangestelltes `'` macht daraus Text (OWASP „CSV Injection“).
*/
export const CSV_BOM = "";
export const CSV_TRENNER = ";";
const FORMELBEGINN = /^[=+\-@\t\r]/;
export function csvEntschaerfen(wert: string): string {
return FORMELBEGINN.test(wert) ? `'${wert}` : wert;
}
export function csvWert(wert: string | number | null | undefined): string {
const text = csvEntschaerfen(wert == null ? "" : String(wert));
return `"${text.replace(/"/g, '""')}"`;
}
export function csvZeile(werte: readonly (string | number | null | undefined)[]): string {
return werte.map(csvWert).join(CSV_TRENNER);
}
/** Ganze Datei: BOM, Kopfzeile, Datenzeilen, CRLF wie Excel selbst. */
export function csvDatei(
kopf: readonly string[],
zeilen: readonly (readonly (string | number | null | undefined)[])[],
): string {
return CSV_BOM + [kopf, ...zeilen].map(csvZeile).join("\r\n") + "\r\n";
}
+311
View File
@@ -0,0 +1,311 @@
import { csvDatei } from "@/lib/csv";
/**
* Zuweisungsmatrix Schluessel × Host (#025). Rein: bekommt die Zeilen aus der Datenbank und
* baut daraus Matrix, Zaehler und CSV – so laesst sich die Gruppenaufloesung ohne Datenbank
* testen, und Seite und CSV-Export sehen garantiert dasselbe.
*/
export type MatrixZustand =
"GEPLANT" | "AUSGEROLLT" | "ABWEICHUNG" | "ENTFERNEN" | "ENTFERNT" | "FEHLGESCHLAGEN";
export type MatrixKategorie = "PERSON" | "DIENST" | "DEPLOYMENT";
export type MatrixSchluessel = {
readonly id: string;
readonly name: string;
readonly fingerprint: string;
readonly kategorie: MatrixKategorie;
readonly person: { readonly id: string; readonly name: string } | null;
};
export type MatrixHost = {
readonly id: string;
readonly name: string;
readonly gruppenIds: readonly string[];
};
export type MatrixGruppe = { readonly id: string; readonly name: string };
export type PublicZeile = {
readonly id: string;
readonly schluesselId: string;
readonly hostId: string | null;
readonly hostgruppeId: string | null;
readonly zielbenutzer: string;
readonly optionen: string | null;
readonly zustand: MatrixZustand;
};
export type PrivatZeile = {
readonly id: string;
readonly schluesselId: string;
readonly hostId: string;
readonly zielbenutzer: string;
readonly pfad: string;
readonly zustand: MatrixZustand;
};
export type MatrixDaten = {
readonly schluessel: readonly MatrixSchluessel[];
readonly hosts: readonly MatrixHost[];
readonly gruppen: readonly MatrixGruppe[];
readonly oeffentlich: readonly PublicZeile[];
readonly privat: readonly PrivatZeile[];
};
export type MatrixFilter = {
readonly gruppeId?: string;
readonly kategorie?: string;
readonly personId?: string;
readonly q?: string;
/** Nur Zeilen und Spalten, in denen mindestens eine Zuweisung steht. */
readonly nurBelegt?: boolean;
};
/** „P“ direkt, „P·G“ ueber eine Hostgruppe, „K“ Private Key – Kurzformen fuer die Zelle. */
export type Kennung = "P" | "P·G" | "K";
export type ZellenEintrag = {
readonly verwendungId: string;
readonly art: "public" | "privat";
readonly kennung: Kennung;
readonly gruppe: MatrixGruppe | null;
readonly zielbenutzer: string;
readonly optionen: string | null;
readonly pfad: string | null;
readonly zustand: MatrixZustand;
};
export type Zelle = {
readonly schluesselId: string;
readonly hostId: string;
readonly eintraege: readonly ZellenEintrag[];
/** Der dringendste Zustand der Eintraege – er bestimmt Punkt und Kurztext. */
readonly zustand: MatrixZustand;
readonly kennungen: readonly Kennung[];
readonly zielbenutzer: readonly string[];
};
export type Matrix = {
readonly zeilen: readonly MatrixSchluessel[];
readonly spalten: readonly MatrixHost[];
/** Schluessel `${schluesselId}:${hostId}`; leere Zellen fehlen. */
readonly zellen: ReadonlyMap<string, Zelle>;
readonly zaehler: {
readonly schluessel: number;
readonly hosts: number;
readonly zuweisungen: number;
readonly nichtSynchron: number;
};
};
export const ZUSTAND_TEXT: Record<MatrixZustand, string> = {
GEPLANT: "noch nicht ausgerollt",
AUSGEROLLT: "synchron",
ABWEICHUNG: "Drift",
ENTFERNEN: "wird entfernt",
ENTFERNT: "entfernt",
FEHLGESCHLAGEN: "fehlgeschlagen",
};
/** Kurztext fuer die enge Zelle; der volle Text steht in `title` und fuer Screenreader. */
export const ZUSTAND_KURZ: Record<MatrixZustand, string> = {
GEPLANT: "geplant",
AUSGEROLLT: "sync",
ABWEICHUNG: "Drift",
ENTFERNEN: "entf.",
ENTFERNT: "entfernt",
FEHLGESCHLAGEN: "Fehler",
};
export const KENNUNG_TEXT: Record<Kennung, string> = {
P: "Public Key direkt",
"P·G": "Public Key über Hostgruppe",
K: "Private Key",
};
/** Was zuerst ins Auge fallen muss: Fehler und Drift vor offener Arbeit vor „alles gut“. */
const DRINGLICHKEIT: Record<MatrixZustand, number> = {
FEHLGESCHLAGEN: 5,
ABWEICHUNG: 4,
ENTFERNEN: 3,
GEPLANT: 2,
AUSGEROLLT: 1,
ENTFERNT: 0,
};
export function zellenSchluessel(schluesselId: string, hostId: string): string {
return `${schluesselId}:${hostId}`;
}
function enthaelt(text: string, q: string): boolean {
return text.toLowerCase().includes(q);
}
const KENNUNG_REIHENFOLGE: readonly Kennung[] = ["P", "P·G", "K"];
export function matrixAufbauen(daten: MatrixDaten, filter: MatrixFilter = {}): Matrix {
const gruppen = new Map(daten.gruppen.map((g) => [g.id, g]));
const hostsJeGruppe = new Map<string, string[]>();
for (const h of daten.hosts) {
for (const g of h.gruppenIds) hostsJeGruppe.set(g, [...(hostsJeGruppe.get(g) ?? []), h.id]);
}
// Alle Eintraege je Zelle sammeln – Gruppen-Verwendungen fuer jedes Mitglied einzeln.
const sammel = new Map<string, ZellenEintrag[]>();
const hinzu = (schluesselId: string, hostId: string, eintrag: ZellenEintrag) => {
const k = zellenSchluessel(schluesselId, hostId);
sammel.set(k, [...(sammel.get(k) ?? []), eintrag]);
};
for (const v of daten.oeffentlich) {
if (v.zustand === "ENTFERNT") continue;
const gruppe = v.hostgruppeId ? (gruppen.get(v.hostgruppeId) ?? null) : null;
const ziele = v.hostId ? [v.hostId] : v.hostgruppeId ? (hostsJeGruppe.get(v.hostgruppeId) ?? []) : [];
for (const hostId of ziele) {
hinzu(v.schluesselId, hostId, {
verwendungId: v.id,
art: "public",
kennung: v.hostId ? "P" : "P·G",
gruppe: v.hostId ? null : gruppe,
zielbenutzer: v.zielbenutzer,
optionen: v.optionen,
pfad: null,
zustand: v.zustand,
});
}
}
for (const v of daten.privat) {
if (v.zustand === "ENTFERNT") continue;
hinzu(v.schluesselId, v.hostId, {
verwendungId: v.id,
art: "privat",
kennung: "K",
gruppe: null,
zielbenutzer: v.zielbenutzer,
optionen: null,
pfad: v.pfad,
zustand: v.zustand,
});
}
// Filter: Schluessel-Merkmale schraenken Zeilen ein, die Hostgruppe die Spalten.
const q = filter.q?.trim().toLowerCase() ?? "";
let zeilen = daten.schluessel.filter(
(s) =>
(!filter.kategorie || s.kategorie === filter.kategorie) &&
(!filter.personId || s.person?.id === filter.personId),
);
let spalten = daten.hosts.filter((h) => !filter.gruppeId || h.gruppenIds.includes(filter.gruppeId));
if (q) {
// Eine Suche trifft Schluessel- oder Hostnamen: wer „srv-pve“ sucht, will alle Schluessel
// auf diesen Hosts sehen, nicht eine leere Matrix, weil kein Schluessel so heisst.
const zeilenTreffer = zeilen.filter((s) => enthaelt(s.name, q) || enthaelt(s.fingerprint, q));
const spaltenTreffer = spalten.filter((h) => enthaelt(h.name, q));
zeilen = zeilenTreffer.length > 0 || spaltenTreffer.length === 0 ? zeilenTreffer : zeilen;
spalten = spaltenTreffer.length > 0 || zeilenTreffer.length === 0 ? spaltenTreffer : spalten;
}
if (filter.nurBelegt) {
const belegt = (s: string, h: string) => sammel.has(zellenSchluessel(s, h));
zeilen = zeilen.filter((s) => spalten.some((h) => belegt(s.id, h.id)));
spalten = spalten.filter((h) => zeilen.some((s) => belegt(s.id, h.id)));
}
const zellen = new Map<string, Zelle>();
let zuweisungen = 0;
let nichtSynchron = 0;
for (const s of zeilen) {
for (const h of spalten) {
const k = zellenSchluessel(s.id, h.id);
const eintraege = sammel.get(k);
if (!eintraege) continue;
eintraege.sort(
(a, b) =>
KENNUNG_REIHENFOLGE.indexOf(a.kennung) - KENNUNG_REIHENFOLGE.indexOf(b.kennung) ||
a.zielbenutzer.localeCompare(b.zielbenutzer),
);
zuweisungen += eintraege.length;
nichtSynchron += eintraege.filter((e) => e.zustand !== "AUSGEROLLT").length;
zellen.set(k, {
schluesselId: s.id,
hostId: h.id,
eintraege,
zustand: eintraege.reduce<MatrixZustand>(
(schlimmster, e) =>
DRINGLICHKEIT[e.zustand] > DRINGLICHKEIT[schlimmster] ? e.zustand : schlimmster,
"ENTFERNT",
),
kennungen: KENNUNG_REIHENFOLGE.filter((kennung) => eintraege.some((e) => e.kennung === kennung)),
zielbenutzer: [...new Set(eintraege.map((e) => e.zielbenutzer))],
});
}
}
return {
zeilen,
spalten,
zellen,
zaehler: { schluessel: zeilen.length, hosts: spalten.length, zuweisungen, nichtSynchron },
};
}
/** Filter aus GET-Parametern; Unbekanntes faellt weg statt einen Fehler zu werfen. */
export function matrixFilterAus(parameter: Record<string, string | string[] | undefined>): MatrixFilter {
const einzeln = (name: string) => {
const wert = parameter[name];
return ((Array.isArray(wert) ? wert[0] : wert) ?? "").trim().slice(0, 200);
};
const kategorie = einzeln("kategorie");
return {
gruppeId: einzeln("gruppe"),
kategorie: ["PERSON", "DIENST", "DEPLOYMENT"].includes(kategorie) ? kategorie : "",
personId: einzeln("person"),
q: einzeln("q"),
nurBelegt: einzeln("belegt") === "1",
};
}
/** Dieselben Filter als Query-String, z. B. fuer den CSV-Link. */
export function matrixFilterQuery(filter: MatrixFilter): string {
const p = new URLSearchParams();
if (filter.gruppeId) p.set("gruppe", filter.gruppeId);
if (filter.kategorie) p.set("kategorie", filter.kategorie);
if (filter.personId) p.set("person", filter.personId);
if (filter.q) p.set("q", filter.q);
if (filter.nurBelegt) p.set("belegt", "1");
const text = p.toString();
return text ? `?${text}` : "";
}
export const CSV_KOPF = [
"Schlüssel",
"Fingerprint",
"Host",
"Zielbenutzer",
"Art",
"Über Gruppe",
"Optionen",
"Zustand",
] as const;
/** Eine Zeile je Zelleneintrag – Gruppen-Zuweisungen also einmal je Mitglied (#025). */
export function matrixCsv(matrix: Matrix): string {
const zeilen: string[][] = [];
for (const s of matrix.zeilen) {
for (const h of matrix.spalten) {
const zelle = matrix.zellen.get(zellenSchluessel(s.id, h.id));
for (const e of zelle?.eintraege ?? []) {
zeilen.push([
s.name,
s.fingerprint,
h.name,
e.zielbenutzer,
e.art === "public" ? "Public Key" : "Private Key",
e.gruppe?.name ?? "",
e.art === "public" ? (e.optionen ?? "") : `Pfad: ${e.pfad ?? ""}`,
ZUSTAND_TEXT[e.zustand],
]);
}
}
}
return csvDatei(CSV_KOPF, zeilen);
}
+69
View File
@@ -0,0 +1,69 @@
import { prisma } from "@/lib/db";
import { matrixAufbauen, type MatrixDaten, type MatrixFilter } from "@/lib/zuweisungsmatrix";
/**
* Lesemodell der Zuweisungsmatrix (#025). Jede angemeldete Rolle darf es sehen – wie die
* Verwendungen auf Schluessel- und Hostseite auch.
*
* Feste `select`-Listen ohne Chiffrat-Felder (CLAUDE.md). Nur aktive Schluessel und nicht
* archivierte Hosts: widerrufene oder rotierte Keys haben keine Zukunft auf einem Host, und
* ein archivierter Host wird nicht mehr abgeglichen.
*/
export async function zuweisungsDaten(): Promise<MatrixDaten> {
const nichtEntfernt = { zustand: { not: "ENTFERNT" as const } };
const [schluessel, hosts, gruppen, oeffentlich, privat] = await Promise.all([
prisma.schluessel.findMany({
where: { status: "AKTIV" },
select: {
id: true,
name: true,
fingerprint: true,
kategorie: true,
person: { select: { id: true, name: true } },
},
orderBy: [{ name: "asc" }, { id: "asc" }],
}),
prisma.host.findMany({
where: { archiviertAm: null },
select: { id: true, name: true, gruppen: { select: { hostgruppeId: true } } },
orderBy: { name: "asc" },
}),
prisma.hostgruppe.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
prisma.publicVerwendung.findMany({
where: { ...nichtEntfernt, schluessel: { status: "AKTIV" } },
select: {
id: true,
schluesselId: true,
hostId: true,
hostgruppeId: true,
zielbenutzer: true,
optionen: true,
zustand: true,
},
orderBy: { angelegtAm: "asc" },
}),
prisma.privateVerwendung.findMany({
where: { ...nichtEntfernt, schluessel: { status: "AKTIV" } },
select: { id: true, schluesselId: true, hostId: true, zielbenutzer: true, pfad: true, zustand: true },
orderBy: { angelegtAm: "asc" },
}),
]);
return {
schluessel,
hosts: hosts.map((h) => ({ id: h.id, name: h.name, gruppenIds: h.gruppen.map((g) => g.hostgruppeId) })),
gruppen,
oeffentlich,
privat,
};
}
export async function zuweisungsMatrix(filter: MatrixFilter = {}) {
const daten = await zuweisungsDaten();
// Personen fuer den Filter: nur die, denen ein aktiver Schluessel gehoert.
const personen = [
...new Map(
daten.schluessel.flatMap((s) => (s.person ? [[s.person.id, s.person] as const] : [])),
).values(),
].sort((a, b) => a.name.localeCompare(b.name, "de"));
return { matrix: matrixAufbauen(daten, filter), gruppen: daten.gruppen, personen };
}
+147
View File
@@ -0,0 +1,147 @@
import { randomUUID } from "node:crypto";
import { expect, test, type Page } from "@playwright/test";
import pg from "pg";
import "dotenv/config";
/**
* Zuweisungsmatrix (#025) im gebauten Stand. Kein Worker und kein SSH-Ziel noetig: die Matrix
* zeigt und plant nur. Schluessel, Hosts und Hostgruppe legt der Test direkt in der
* Entwicklungsdatenbank an (Praefix zz-e2e) und raeumt sie am Ende wieder weg.
*/
const name = `zz-e2e-mx${Date.now().toString(36)}`;
const ids = {
schluessel: randomUUID(),
gruppe: randomUUID(),
h1: randomUUID(),
h2: randomUUID(),
h3: randomUUID(),
};
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
await client.connect();
try {
return await arbeit(client);
} finally {
await client.end();
}
}
async function anmelden(page: Page, rolle: "admin" | "reader") {
await page.goto("/anmelden");
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator(`button[data-rolle="${rolle}"]`).click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
test.beforeAll(async () => {
await datenbank(async (client) => {
for (const [id, nr] of [
[ids.h1, 1],
[ids.h2, 2],
[ids.h3, 3],
] as const) {
await client.query(`INSERT INTO "Host" ("id","name","adresse","geaendertAm") VALUES ($1,$2,$3,now())`, [
id,
`${name}-h${nr}`,
`10.99.0.${nr}`,
]);
}
await client.query(`INSERT INTO "Hostgruppe" ("id","name") VALUES ($1,$2)`, [ids.gruppe, `${name}-g`]);
await client.query(`INSERT INTO "HostgruppeMitglied" ("hostId","hostgruppeId") VALUES ($1,$3),($2,$3)`, [
ids.h1,
ids.h2,
ids.gruppe,
]);
await client.query(
`INSERT INTO "Schluessel" ("id","name","slug","typ","kategorie","publicKey","fingerprint","kommentar","geaendertAm")
VALUES ($1,$2,$3,'ED25519','DIENST','ssh-ed25519 AAAA zz-e2e',$4,'zz-e2e',now())`,
[ids.schluessel, `${name} Backup`, name, `SHA256:${name}`],
);
await client.query(
`INSERT INTO "PublicVerwendung" ("id","schluesselId","hostgruppeId","zielbenutzer","optionen")
VALUES ($1,$2,$3,'root','restrict')`,
[randomUUID(), ids.schluessel, ids.gruppe],
);
});
});
test.afterAll(async () => {
await datenbank(async (client) => {
// Verwendungen und Mitgliedschaften haengen per Cascade daran.
await client.query(`DELETE FROM "Schluessel" WHERE "id" = $1`, [ids.schluessel]);
await client.query(`DELETE FROM "Hostgruppe" WHERE "id" = $1`, [ids.gruppe]);
await client.query(`DELETE FROM "Host" WHERE "id" = ANY($1)`, [[ids.h1, ids.h2, ids.h3]]);
});
});
test("Matrix zeigt Gruppen-Zuweisung, filtert, weist zu und exportiert", async ({ page }) => {
await anmelden(page, "admin");
// Die Suche trifft Schluessel und Hosts dieses Tests – fremde Daten bleiben draussen.
await page.goto(`/zuweisungen?q=${name}`);
const matrix = page.locator("table");
await expect(matrix.getByRole("columnheader", { name: `${name}-h3` })).toBeVisible();
// Gruppen-Zuweisung erscheint auf beiden Mitgliedern als „P·G“ mit Zustand.
for (const nr of [1, 2]) {
const zelle = matrix.getByRole("button", {
name: `${name} Backup auf ${name}-h${nr}: Public Key über Hostgruppe ${name}-g, Benutzer root, noch nicht ausgerollt`,
});
await expect(zelle).toBeVisible();
await expect(zelle).toContainText("P·G");
}
// Filter Hostgruppe: der dritte Host gehoert nicht dazu und verschwindet.
await page.getByLabel("Hostgruppe").selectOption({ label: `${name}-g` });
await page.getByRole("button", { name: "Filtern" }).click();
await expect(page).toHaveURL(/gruppe=/);
await expect(matrix.getByRole("columnheader", { name: `${name}-h1` })).toBeVisible();
await expect(matrix.getByRole("columnheader", { name: `${name}-h3` })).toHaveCount(0);
// Leere Zelle auf h3 → Dialog mit vorausgewaehltem Host → zuweisen.
await page.goto(`/zuweisungen?q=${name}`);
await matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: nicht zugewiesen`) }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByLabel("Ziel", { exact: true })).toHaveValue(`host:${ids.h3}`);
await dialog.getByLabel("Zielbenutzer").fill("deploy");
await dialog.getByRole("button", { name: "Zuweisen" }).click();
await expect(dialog.getByText("Public Key direkt")).toBeVisible();
await expect(dialog.getByRole("link", { name: "Zum Dry-Run" })).toBeVisible();
const angelegt = await datenbank((client) =>
client.query(
`SELECT "zielbenutzer" FROM "PublicVerwendung" WHERE "schluesselId" = $1 AND "hostId" = $2`,
[ids.schluessel, ids.h3],
),
);
expect(angelegt.rows).toEqual([{ zielbenutzer: "deploy" }]);
await dialog.getByRole("button", { name: "Schließen" }).click();
await expect(
matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: Public Key direkt`) }),
).toBeVisible();
// CSV mit derselben Sitzung: Gruppen-Zeile je Mitglied und die neue direkte Zuweisung.
const csv = await page.request.get(`/api/zuweisungen.csv?q=${name}`);
expect(csv.status()).toBe(200);
const text = await csv.text();
expect(text).toContain(
`"${name} Backup";"SHA256:${name}";"${name}-h2";"root";"Public Key";"${name}-g";"restrict";"noch nicht ausgerollt"`,
);
expect(text).toContain(`"${name}-h3";"deploy";"Public Key";""`);
});
test("Leser sieht die Matrix, aber keinen Zuweisen-Knopf", async ({ page }) => {
await anmelden(page, "reader");
await page.goto(`/zuweisungen?q=${name}`);
const matrix = page.locator("table");
await matrix
.getByRole("button", { name: new RegExp(`auf ${name}-h1: Public Key über Hostgruppe`) })
.click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Public Key über Hostgruppe")).toBeVisible();
await expect(dialog.getByRole("button", { name: "Zuweisen" })).toHaveCount(0);
await expect(dialog.getByRole("button", { name: "Zuweisung entfernen" })).toHaveCount(0);
await dialog.getByRole("button", { name: "Schließen" }).click();
// Leere Zellen sind fuer Leser keine Knoepfe.
await expect(matrix.getByRole("button", { name: /nicht zugewiesen/ })).toHaveCount(0);
});
+150
View File
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@/lib/db";
import { zellenSchluessel } from "@/lib/zuweisungsmatrix";
import { zuweisungsDaten, zuweisungsMatrix } from "@/server/queries/zuweisungen";
import { datenbankLeeren } from "./hilfen/db";
/**
* Zuweisungsmatrix (#025) gegen die Testdatenbank: Query und CSV-Route. Die Sitzung wird fuer
* die Route nachgebildet – geprueft wird, dass der Handler das Recht selbst verlangt.
*/
const recht = vi.hoisted(() => ({ erlaubt: true }));
vi.mock("@/server/auth/sitzungspflicht", async (original) => {
const echt = await original<typeof import("@/server/auth/sitzungspflicht")>();
return {
...echt,
rechtErforderlich: vi.fn(async () => {
if (!recht.erlaubt) throw new echt.RechteFehler("Nicht angemeldet.");
return { benutzer: { id: "b", rolle: "LESER" } };
}),
};
});
let zaehler = 0;
async function schluesselAnlegen(daten: {
name: string;
status?: "AKTIV" | "WIDERRUFEN";
mitPrivat?: boolean;
}) {
zaehler += 1;
return prisma.schluessel.create({
data: {
name: daten.name,
slug: `test-${zaehler}`,
typ: "ED25519",
kategorie: "DIENST",
status: daten.status ?? "AKTIV",
publicKey: "ssh-ed25519 AAAA test",
fingerprint: `SHA256:test${zaehler}`,
kommentar: "test",
...(daten.mitPrivat
? { privatVerschluesselt: Buffer.from("x"), dekVerschluesselt: Buffer.from("y"), masterKeyVersion: 1 }
: {}),
},
});
}
let ids: Record<string, string>;
beforeEach(async () => {
await datenbankLeeren();
recht.erlaubt = true;
zaehler = 0;
const gruppe = await prisma.hostgruppe.create({ data: { name: "pve" } });
const [pve1, pve2, alt] = await Promise.all([
prisma.host.create({ data: { name: "srv-pve01", adresse: "10.0.0.1" } }),
prisma.host.create({ data: { name: "srv-pve02", adresse: "10.0.0.2" } }),
prisma.host.create({ data: { name: "srv-alt", adresse: "10.0.0.3", archiviertAm: new Date() } }),
]);
await prisma.hostgruppeMitglied.createMany({
data: [pve1, pve2, alt].map((h) => ({ hostId: h.id, hostgruppeId: gruppe.id })),
});
const backup = await schluesselAnlegen({ name: "Backup", mitPrivat: true });
const formel = await schluesselAnlegen({ name: "=1+1" });
const widerrufen = await schluesselAnlegen({ name: "Alt", status: "WIDERRUFEN" });
await prisma.publicVerwendung.createMany({
data: [
{ schluesselId: backup.id, hostgruppeId: gruppe.id, zielbenutzer: "root", optionen: "restrict" },
{ schluesselId: formel.id, hostId: pve1.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
{ schluesselId: formel.id, hostId: pve2.id, zielbenutzer: "root", zustand: "ENTFERNT" },
{ schluesselId: widerrufen.id, hostId: pve1.id, zielbenutzer: "root" },
],
});
await prisma.privateVerwendung.create({
data: { schluesselId: backup.id, hostId: pve2.id, zielbenutzer: "backup", pfad: "~/.ssh/id_backup" },
});
ids = {
gruppe: gruppe.id,
pve1: pve1.id,
pve2: pve2.id,
alt: alt.id,
backup: backup.id,
formel: formel.id,
};
});
describe("zuweisungsDaten", () => {
it("liest nur aktive Schluessel, nicht archivierte Hosts und keine entfernten Verwendungen", async () => {
const daten = await zuweisungsDaten();
expect(daten.schluessel.map((s) => s.name)).toEqual(["=1+1", "Backup"]);
expect(daten.hosts.map((h) => h.name)).toEqual(["srv-pve01", "srv-pve02"]);
expect(daten.hosts[0]!.gruppenIds).toEqual([ids["gruppe"]]);
expect(daten.oeffentlich).toHaveLength(2);
expect(daten.privat).toHaveLength(1);
});
it("liefert nie Chiffrat-Felder", async () => {
const text = JSON.stringify(await zuweisungsDaten());
for (const feld of ["privatVerschluesselt", "dekVerschluesselt", "masterKeyVersion", "publicKey"]) {
expect(text).not.toContain(feld);
}
});
});
describe("zuweisungsMatrix", () => {
it("loest die Gruppe auf die nicht archivierten Mitglieder auf", async () => {
const { matrix, gruppen } = await zuweisungsMatrix();
expect(gruppen.map((g) => g.name)).toEqual(["pve"]);
expect(matrix.zellen.get(zellenSchluessel(ids["backup"]!, ids["pve1"]!))?.kennungen).toEqual(["P·G"]);
expect(matrix.zellen.get(zellenSchluessel(ids["backup"]!, ids["pve2"]!))?.kennungen).toEqual([
"P·G",
"K",
]);
expect(matrix.zellen.has(zellenSchluessel(ids["backup"]!, ids["alt"]!))).toBe(false);
expect(matrix.zaehler).toEqual({ schluessel: 2, hosts: 2, zuweisungen: 4, nichtSynchron: 3 });
});
});
describe("GET /api/zuweisungen.csv", () => {
async function abrufen(query = "") {
const { GET } = await import("@/app/api/zuweisungen.csv/route");
return GET(new Request(`http://localhost/api/zuweisungen.csv${query}`));
}
it("verweigert ohne Recht", async () => {
recht.erlaubt = false;
const antwort = await abrufen();
expect(antwort.status).toBe(403);
});
it("liefert die Matrix als Excel-sichere CSV", async () => {
const antwort = await abrufen();
expect(antwort.status).toBe(200);
expect(antwort.headers.get("content-type")).toContain("text/csv");
expect(antwort.headers.get("content-disposition")).toContain("sshkv-zuweisungen.csv");
const text = await antwort.text();
// Response.text() entfernt die BOM nicht immer – beides zulassen.
const zeilen = text.replace(/^/, "").trimEnd().split("\r\n");
expect(zeilen).toHaveLength(1 + 4);
expect(zeilen).toContain(
'"Backup";"SHA256:test1";"srv-pve01";"root";"Public Key";"pve";"restrict";"noch nicht ausgerollt"',
);
expect(zeilen).toContain(`"'=1+1";"SHA256:test2";"srv-pve01";"deploy";"Public Key";"";"";"synchron"`);
});
it("wendet die Filter der Seite an", async () => {
const text = await (await abrufen("?q=srv-pve02")).text();
expect(text).toContain("srv-pve02");
expect(text).not.toContain("srv-pve01");
});
});
+210
View File
@@ -0,0 +1,210 @@
import { describe, expect, it } from "vitest";
import { csvEntschaerfen, csvZeile } from "@/lib/csv";
import {
matrixAufbauen,
matrixCsv,
matrixFilterAus,
matrixFilterQuery,
zellenSchluessel,
type MatrixDaten,
type PublicZeile,
} from "@/lib/zuweisungsmatrix";
/** Zuweisungsmatrix (#025): reine Funktion, Gruppenaufloesung, Filter, CSV. */
const backup = {
id: "s-backup",
name: "Backup pve",
fingerprint: "SHA256:backup",
kategorie: "DIENST" as const,
person: null,
};
const anna = {
id: "s-anna",
name: "Anna Laptop",
fingerprint: "SHA256:anna",
kategorie: "PERSON" as const,
person: { id: "p-anna", name: "Anna" },
};
const leer = {
id: "s-leer",
name: "Unbenutzt",
fingerprint: "SHA256:leer",
kategorie: "DIENST" as const,
person: null,
};
const public_ = (
abweichung: Partial<PublicZeile> & Pick<PublicZeile, "id" | "schluesselId">,
): PublicZeile => ({
hostId: null,
hostgruppeId: null,
zielbenutzer: "root",
optionen: null,
zustand: "AUSGEROLLT",
...abweichung,
});
const DATEN: MatrixDaten = {
schluessel: [anna, backup, leer],
hosts: [
{ id: "h-pve1", name: "srv-pve01", gruppenIds: ["g-pve"] },
{ id: "h-pve2", name: "srv-pve02", gruppenIds: ["g-pve"] },
{ id: "h-web", name: "srv-web01", gruppenIds: [] },
],
gruppen: [{ id: "g-pve", name: "pve" }],
oeffentlich: [
public_({ id: "v-gruppe", schluesselId: "s-backup", hostgruppeId: "g-pve", optionen: "restrict" }),
public_({
id: "v-direkt",
schluesselId: "s-anna",
hostId: "h-pve1",
zielbenutzer: "anna",
zustand: "GEPLANT",
}),
public_({
id: "v-direkt2",
schluesselId: "s-anna",
hostId: "h-pve1",
zielbenutzer: "root",
zustand: "ABWEICHUNG",
}),
public_({ id: "v-weg", schluesselId: "s-anna", hostId: "h-web", zustand: "ENTFERNT" }),
],
privat: [
{
id: "k-1",
schluesselId: "s-backup",
hostId: "h-web",
zielbenutzer: "backup",
pfad: "~/.ssh/id_backup",
zustand: "FEHLGESCHLAGEN",
},
],
};
describe("matrixAufbauen", () => {
it("loest Gruppen-Zuweisungen je Mitglied auf und kennzeichnet sie", () => {
const m = matrixAufbauen(DATEN);
for (const host of ["h-pve1", "h-pve2"]) {
const zelle = m.zellen.get(zellenSchluessel("s-backup", host))!;
expect(zelle.kennungen).toEqual(["P·G"]);
expect(zelle.eintraege[0]).toMatchObject({
verwendungId: "v-gruppe",
gruppe: { name: "pve" },
optionen: "restrict",
});
}
});
it("unterscheidet direkt, Gruppe und Private Key", () => {
const m = matrixAufbauen(DATEN);
expect(m.zellen.get(zellenSchluessel("s-anna", "h-pve1"))!.kennungen).toEqual(["P"]);
const privat = m.zellen.get(zellenSchluessel("s-backup", "h-web"))!;
expect(privat.kennungen).toEqual(["K"]);
expect(privat.eintraege[0]).toMatchObject({ art: "privat", pfad: "~/.ssh/id_backup" });
});
it("zeigt den dringendsten Zustand und alle Zielbenutzer", () => {
const zelle = matrixAufbauen(DATEN).zellen.get(zellenSchluessel("s-anna", "h-pve1"))!;
expect(zelle.zustand).toBe("ABWEICHUNG");
expect(zelle.zielbenutzer).toEqual(["anna", "root"]);
});
it("laesst entfernte Verwendungen weg", () => {
expect(matrixAufbauen(DATEN).zellen.has(zellenSchluessel("s-anna", "h-web"))).toBe(false);
});
it("zaehlt Zuweisungen je Host und die nicht synchronen", () => {
expect(matrixAufbauen(DATEN).zaehler).toEqual({
schluessel: 3,
hosts: 3,
zuweisungen: 5,
nichtSynchron: 3,
});
});
it("filtert Spalten nach Hostgruppe und Zeilen nach Kategorie und Person", () => {
expect(matrixAufbauen(DATEN, { gruppeId: "g-pve" }).spalten.map((h) => h.id)).toEqual([
"h-pve1",
"h-pve2",
]);
expect(matrixAufbauen(DATEN, { kategorie: "PERSON" }).zeilen.map((s) => s.id)).toEqual(["s-anna"]);
expect(matrixAufbauen(DATEN, { personId: "p-anna" }).zeilen.map((s) => s.id)).toEqual(["s-anna"]);
});
it("sucht in Schluessel- und Hostnamen", () => {
const nachSchluessel = matrixAufbauen(DATEN, { q: "backup" });
expect(nachSchluessel.zeilen.map((s) => s.id)).toEqual(["s-backup"]);
expect(nachSchluessel.spalten).toHaveLength(3);
const nachHost = matrixAufbauen(DATEN, { q: "WEB01" });
expect(nachHost.zeilen).toHaveLength(3);
expect(nachHost.spalten.map((h) => h.id)).toEqual(["h-web"]);
const nichts = matrixAufbauen(DATEN, { q: "gibtsnicht" });
expect(nichts.zeilen).toHaveLength(0);
expect(nichts.spalten).toHaveLength(0);
});
it("blendet mit „nur belegt“ leere Zeilen und Spalten aus", () => {
const m = matrixAufbauen(DATEN, { nurBelegt: true, gruppeId: "g-pve" });
expect(m.zeilen.map((s) => s.id)).toEqual(["s-anna", "s-backup"]);
expect(m.spalten.map((h) => h.id)).toEqual(["h-pve1", "h-pve2"]);
expect(matrixAufbauen(DATEN, { nurBelegt: true, q: "unbenutzt" }).zeilen).toHaveLength(0);
});
it("zaehlt nur, was in der gefilterten Ansicht steht", () => {
expect(matrixAufbauen(DATEN, { gruppeId: "g-pve" }).zaehler.zuweisungen).toBe(4);
});
});
describe("Filter aus GET-Parametern", () => {
it("liest bekannte Werte und verwirft unbekannte Kategorien", () => {
const filter = matrixFilterAus({
gruppe: "g1",
kategorie: "QUATSCH",
person: ["p1", "p2"],
q: " pve ",
belegt: "1",
});
expect(filter).toEqual({ gruppeId: "g1", kategorie: "", personId: "p1", q: "pve", nurBelegt: true });
expect(matrixFilterQuery(filter)).toBe("?gruppe=g1&person=p1&q=pve&belegt=1");
expect(matrixFilterQuery(matrixFilterAus({}))).toBe("");
});
});
describe("CSV", () => {
it("entschaerft Formelbeginne fuer Excel", () => {
for (const wert of ["=1+1", "+49", "-x", "@SUMME(A1)", "\tx"])
expect(csvEntschaerfen(wert)).toBe(`'${wert}`);
expect(csvEntschaerfen("root")).toBe("root");
expect(csvZeile(['restrict,from="10.0.0.1"', null])).toBe('"restrict,from=""10.0.0.1""";""');
});
it("schreibt eine Zeile je Zelleneintrag mit Kopf und BOM", () => {
const csv = matrixCsv(matrixAufbauen(DATEN));
expect(csv.startsWith("")).toBe(true);
const zeilen = csv.slice(1).trimEnd().split("\r\n");
expect(zeilen[0]).toBe(
'"Schlüssel";"Fingerprint";"Host";"Zielbenutzer";"Art";"Über Gruppe";"Optionen";"Zustand"',
);
expect(zeilen).toHaveLength(1 + 5);
expect(zeilen).toContain(
'"Backup pve";"SHA256:backup";"srv-pve02";"root";"Public Key";"pve";"restrict";"synchron"',
);
expect(zeilen).toContain(
'"Backup pve";"SHA256:backup";"srv-web01";"backup";"Private Key";"";"Pfad: ~/.ssh/id_backup";"fehlgeschlagen"',
);
});
it("entschaerft boesartige Schluesselnamen im Export", () => {
const daten: MatrixDaten = {
...DATEN,
schluessel: [{ ...backup, name: '=HYPERLINK("http://x","klick")' }],
};
const csv = matrixCsv(matrixAufbauen(daten));
expect(csv).toContain(`"'=HYPERLINK(""http://x"",""klick"")"`);
expect(csv).not.toMatch(/(^|;|\n)"=/);
});
});