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
+155 -9
View File
@@ -1,18 +1,164 @@
import { Download, Network, Search } from "lucide-react";
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 { zielAuswahl } from "@/server/queries/ziele";
import { zuweisungsMatrix } from "@/server/queries/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).
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 (
<Platzhalterseite
titel="Zuweisungen"
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt"
phase={2}
issue="#016"
/>
<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>
);
}
+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,
auswahl,
beiErfolg,
vorauswahl,
}: {
schluesselId: string;
auswahl: ZielAuswahl;
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 ergebnis = await publicZuweisenAktion(vorher, formular);
@@ -28,7 +31,7 @@ export function PublicZuweisenFormular({
<form onSubmit={absenden} className="flex flex-col gap-4">
<input type="hidden" name="schluesselId" value={schluesselId} />
<Formularfehler text={zustand.fehler} />
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue="" required>
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue={vorauswahl ?? ""} required>
<option value="" disabled>
Host oder Hostgruppe wählen …
</option>
@@ -2,7 +2,7 @@ import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums";
import { clsx } from "clsx";
/** 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: "○" },
AUSGEROLLT: { text: "synchron", klasse: "text-ok", 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 }) {
const anzeige = ANZEIGE[zustand];
const anzeige = ZUSTAND_ANZEIGE[zustand];
return (
<span
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 };
}