Merge branch 'phase/025-zuweisungsmatrix'
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user