From 90a41dc55d0ed5e04a42636114201a1975ae50bc Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:50:34 +0200 Subject: [PATCH] feat(hosts): Seiten fuer Hosts und Hostgruppen (#008) Ersetzt die Platzhalter: Hostliste mit Suche und Gruppenfilter per GET (teilbare Links), Formulare fuer Hosts und Gruppen, Hostdetail als Geruest und Gruppendetail mit Mitgliederauswahl. Bearbeiten-Knoepfe entscheidet der Server per darf(); jede Seite prueft die Sitzung selbst. Die Detailseite rendert HostKeyAktionen (#009) und BootstrapAktionen (#010) als feste Platzhalter mit fertigen Props, damit die Folge-Issues nur ihre eigene Datei ersetzen. Formulare senden per onSubmit, weil React ein
nach jeder Antwort zuruecksetzt und Eingaben bei Feldfehlern sonst verloren gingen. Unter md zeigt die Hostliste Karten statt Tabelle, damit Status und Host-Key auf 390 px ohne Wischen sichtbar bleiben. Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/hostgruppen/[id]/page.tsx | 95 ++++++++ src/app/(app)/hostgruppen/neu/page.tsx | 31 +++ src/app/(app)/hostgruppen/page.tsx | 68 +++++- .../(app)/hosts/[id]/BootstrapAktionen.tsx | 19 ++ src/app/(app)/hosts/[id]/HostKeyAktionen.tsx | 19 ++ src/app/(app)/hosts/[id]/bearbeiten/page.tsx | 44 ++++ src/app/(app)/hosts/[id]/page.tsx | 227 ++++++++++++++++++ src/app/(app)/hosts/neu/page.tsx | 23 ++ src/app/(app)/hosts/page.tsx | 220 ++++++++++++++++- src/components/hosts/Anzeige.tsx | 47 ++++ src/components/hosts/ArchivierenDialog.tsx | 51 ++++ src/components/hosts/GruppenFormular.tsx | 79 ++++++ src/components/hosts/HostFormular.tsx | 197 +++++++++++++++ src/components/hosts/useFormularAktion.ts | 21 ++ src/components/ui/Feld.tsx | 157 +++++++++++- src/components/ui/index.ts | 2 +- 16 files changed, 1272 insertions(+), 28 deletions(-) create mode 100644 src/app/(app)/hostgruppen/[id]/page.tsx create mode 100644 src/app/(app)/hostgruppen/neu/page.tsx create mode 100644 src/app/(app)/hosts/[id]/BootstrapAktionen.tsx create mode 100644 src/app/(app)/hosts/[id]/HostKeyAktionen.tsx create mode 100644 src/app/(app)/hosts/[id]/bearbeiten/page.tsx create mode 100644 src/app/(app)/hosts/[id]/page.tsx create mode 100644 src/app/(app)/hosts/neu/page.tsx create mode 100644 src/components/hosts/Anzeige.tsx create mode 100644 src/components/hosts/ArchivierenDialog.tsx create mode 100644 src/components/hosts/GruppenFormular.tsx create mode 100644 src/components/hosts/HostFormular.tsx create mode 100644 src/components/hosts/useFormularAktion.ts diff --git a/src/app/(app)/hostgruppen/[id]/page.tsx b/src/app/(app)/hostgruppen/[id]/page.tsx new file mode 100644 index 0000000..79627a3 --- /dev/null +++ b/src/app/(app)/hostgruppen/[id]/page.tsx @@ -0,0 +1,95 @@ +import { Server } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { adresseMitPort } from "@/components/hosts/Anzeige"; +import { GruppenFormular } from "@/components/hosts/GruppenFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Statuspunkt } from "@/components/ui/Statuspunkt"; +import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; +import { datum } from "@/lib/format"; +import { darf } from "@/server/auth/rechte"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { gruppeDetail, hostAuswahl } from "@/server/queries/hosts"; + +export const metadata: Metadata = { title: "Hostgruppe" }; + +/** Gruppendetail mit Mitgliedern; wer Hosts verwalten darf, bearbeitet hier auch (#008). */ +export default async function HostgruppeSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { benutzer } = await sitzungErforderlich("lesen", `/hostgruppen/${id}`); + const gruppe = await gruppeDetail(id); + if (!gruppe) notFound(); + const verwalten = darf(benutzer.rolle, "hostsVerwalten"); + const hosts = verwalten ? await hostAuswahl() : []; + const anzahl = gruppe.mitglieder.length; + + return ( +
+ } + titel={gruppe.name} + untertitel={ + <> + {gruppe.beschreibung && {gruppe.beschreibung}} + {anzahl === 1 ? "1 Mitglied" : `${anzahl} Mitglieder`} · angelegt am {datum(gruppe.angelegtAm)} + + } + /> + +
+ + {anzahl === 0 ? ( + } + titel="Keine Mitglieder" + text={ + verwalten + ? "Hosts lassen sich unter „Bearbeiten“ auswählen." + : "Dieser Gruppe ist noch kein Host zugeordnet." + } + /> + ) : ( + + {gruppe.mitglieder.map((h) => ( + + + + {h.name} + +

+ {adresseMitPort(h.adresse, h.port)} +

+
+ + + +
+ ))} +
+ )} +
+ + {verwalten && ( + + m.id), + }} + hosts={hosts} + /> + + )} +
+
+ ); +} diff --git a/src/app/(app)/hostgruppen/neu/page.tsx b/src/app/(app)/hostgruppen/neu/page.tsx new file mode 100644 index 0000000..71f4f86 --- /dev/null +++ b/src/app/(app)/hostgruppen/neu/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { GruppenFormular } from "@/components/hosts/GruppenFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Karte } from "@/components/ui/Karte"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { hostAuswahl } from "@/server/queries/hosts"; + +export const metadata: Metadata = { title: "Hostgruppe anlegen" }; + +export default async function HostgruppeNeu() { + await sitzungErforderlich("hostsVerwalten", "/hostgruppen/neu"); + const hosts = await hostAuswahl(); + return ( +
+ + } + titel="Hostgruppe anlegen" + /> + + + +
+ ); +} diff --git a/src/app/(app)/hostgruppen/page.tsx b/src/app/(app)/hostgruppen/page.tsx index 055f298..26748c4 100644 --- a/src/app/(app)/hostgruppen/page.tsx +++ b/src/app/(app)/hostgruppen/page.tsx @@ -1,18 +1,70 @@ +import { Boxes, Plus } from "lucide-react"; import type { Metadata } from "next"; -import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import Link from "next/link"; +import { Button } from "@/components/ui/Button"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; +import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { gruppenListe } from "@/server/queries/hosts"; export const metadata: Metadata = { title: "Hostgruppen" }; +/** Hostgruppen mit Mitgliederzahl (#008). */ export default async function Hostgruppen() { // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); + const { benutzer } = await sitzungErforderlich("lesen", "/hostgruppen"); + const gruppen = await gruppenListe(); + const verwalten = darf(benutzer.rolle, "hostsVerwalten"); + const anlegen = verwalten && ( + + ); + return ( - +
+ + {gruppen.length === 0 ? ( + } + titel="Noch keine Hostgruppen" + text="Gruppen wie „Proxmox“ oder „DMZ“ fassen Hosts zusammen – ein Schlüssel lässt sich dann einer ganzen Gruppe zuweisen." + aktion={anlegen} + /> + ) : ( + + + {gruppen.map((g) => ( + + {/* Beschreibung unter dem Namen statt als eigene Spalte: so bleibt die + Mitgliederzahl auch auf 390 px sichtbar (#008). */} + + + {g.name} + + {g.beschreibung &&

{g.beschreibung}

} +
+ + {g.mitglieder} + +
+ ))} +
+
+ )} +
); } diff --git a/src/app/(app)/hosts/[id]/BootstrapAktionen.tsx b/src/app/(app)/hosts/[id]/BootstrapAktionen.tsx new file mode 100644 index 0000000..245f258 --- /dev/null +++ b/src/app/(app)/hosts/[id]/BootstrapAktionen.tsx @@ -0,0 +1,19 @@ +export type BootstrapAktionenProps = { + readonly hostId: string; + /** Recht `hostsVerwalten` – serverseitig per `darf()` ermittelt. */ + readonly darfVerwalten: boolean; + /** `bootstrapAm` ist gesetzt: sshkv arbeitet bereits schluesselbasiert. */ + readonly eingerichtet: boolean; + /** Mindestens ein vertrauter Host-Key – Voraussetzung fuer jede Verbindung. */ + readonly hostKeyVertraut: boolean; +}; + +/** + * Platzhalter fuer „Zugang einrichten“ (Bootstrap, #010). Die Detailseite rendert diese + * Komponente unter der Info-Leiste; #010 ersetzt nur diese Datei. + */ +export function BootstrapAktionen(props: BootstrapAktionenProps) { + // Die Props stehen schon fest, damit die Detailseite bis #009/#010 unveraendert bleibt. + void props; + return null; +} diff --git a/src/app/(app)/hosts/[id]/HostKeyAktionen.tsx b/src/app/(app)/hosts/[id]/HostKeyAktionen.tsx new file mode 100644 index 0000000..0086179 --- /dev/null +++ b/src/app/(app)/hosts/[id]/HostKeyAktionen.tsx @@ -0,0 +1,19 @@ +import type { HostKeyZusammenfassung } from "@/lib/hosts"; + +export type HostKeyAktionenProps = { + readonly hostId: string; + /** Recht `hostKeyEntscheiden` – serverseitig per `darf()` ermittelt. */ + readonly darfEntscheiden: boolean; + /** Zaehlung der Host-Keys nach Status und zusammengefasster Zustand. */ + readonly status: HostKeyZusammenfassung; +}; + +/** + * Platzhalter fuer „Host-Key abrufen“ / „Bestätigen“ / „Ablehnen“ (#009). Die Detailseite + * rendert diese Komponente im Kopf der Karte „Host-Key“; #009 ersetzt nur diese Datei. + */ +export function HostKeyAktionen(props: HostKeyAktionenProps) { + // Die Props stehen schon fest, damit die Detailseite bis #009/#010 unveraendert bleibt. + void props; + return null; +} diff --git a/src/app/(app)/hosts/[id]/bearbeiten/page.tsx b/src/app/(app)/hosts/[id]/bearbeiten/page.tsx new file mode 100644 index 0000000..a0f7ab4 --- /dev/null +++ b/src/app/(app)/hosts/[id]/bearbeiten/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { HostFormular } from "@/components/hosts/HostFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { gruppenAuswahl, hostFormularWerte, kundenNamen } from "@/server/queries/hosts"; + +export const metadata: Metadata = { title: "Host bearbeiten" }; + +export default async function HostBearbeiten({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await sitzungErforderlich("hostsVerwalten", `/hosts/${id}/bearbeiten`); + const [werte, gruppen, kunden] = await Promise.all([ + hostFormularWerte(id), + gruppenAuswahl(), + kundenNamen(), + ]); + if (!werte) notFound(); + // Archivierte Hosts sind schreibgeschuetzt; die Detailseite sagt warum. + if (werte.archiviert) redirect(`/hosts/${id}`); + + return ( +
+ + } + titel={ + <> + {werte.name} bearbeiten + + } + /> + +
+ ); +} diff --git a/src/app/(app)/hosts/[id]/page.tsx b/src/app/(app)/hosts/[id]/page.tsx new file mode 100644 index 0000000..368720a --- /dev/null +++ b/src/app/(app)/hosts/[id]/page.tsx @@ -0,0 +1,227 @@ +import { KeyRound, Pencil, ShieldQuestion } from "lucide-react"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; +import { ArchivierenDialog } from "@/components/hosts/ArchivierenDialog"; +import { adresseMitPort, AnmeldeartBadge, HostSchluesselStatusBadge } from "@/components/hosts/Anzeige"; +import { Badge } from "@/components/ui/Badge"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Button } from "@/components/ui/Button"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { MonoKopieren } from "@/components/ui/MonoKopieren"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Statuspunkt } from "@/components/ui/Statuspunkt"; +import { datum, datumZeit, relativ } from "@/lib/format"; +import { darf } from "@/server/auth/rechte"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { hostDetail, type HostDetail } from "@/server/queries/hosts"; +import { BootstrapAktionen } from "./BootstrapAktionen"; +import { HostKeyAktionen } from "./HostKeyAktionen"; + +export const metadata: Metadata = { title: "Host" }; + +function Zeitpunkt({ wert, jetzt, sonst }: { wert: Date | null; jetzt: Date; sonst: string }) { + if (!wert) return {sonst}; + return ( + + ); +} + +function Info({ titel, children }: { titel: string; children: ReactNode }) { + return ( +
+
{titel}
+
{children}
+
+ ); +} + +function Erreichbarkeit({ host, jetzt }: { host: HostDetail; jetzt: Date }) { + const pruefung = host.zuletztGeprueftAm ? ` · ${relativ(host.zuletztGeprueftAm, jetzt)}` : ""; + if (host.erreichbar === true) return ; + if (host.erreichbar === false) + return ; + return ; +} + +/** `sshd -T`-Auszug (#010 fuellt ihn); hier nur flach als Schluessel/Wert gezeigt. */ +function SshdBefund({ befund }: { befund: unknown }) { + if (!befund || typeof befund !== "object" || Array.isArray(befund)) return null; + const eintraege = Object.entries(befund as Record); + if (eintraege.length === 0) return null; + return ( +
+

sshd-Befund

+
+ {eintraege.map(([k, v]) => ( +
+
{k}
+
{String(v)}
+
+ ))} +
+
+ ); +} + +/** Hostdetail (#008): Gerüst, in das #009 (Host-Key), #010 (Bootstrap) und #016 einziehen. */ +export default async function HostSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { benutzer } = await sitzungErforderlich("lesen", `/hosts/${id}`); + const host = await hostDetail(id); + if (!host) notFound(); + + const archiviert = host.archiviertAm !== null; + const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert; + const jetzt = new Date(); + + return ( +
+ } + titel={{host.name}} + untertitel={ + <> + + {adresseMitPort(host.adresse, host.port)} + + {host.beschreibung && {host.beschreibung}} + + } + badges={ + <> + {archiviert && host.archiviertAm && ( + archiviert am {datum(host.archiviertAm)} + )} + + {host.gruppen.map((g) => ( + {g.name} + ))} + {host.tags.map((t) => ( + + #{t} + + ))} + + } + aktionen={ + verwalten && ( + + ) + } + /> + + +
+ + + + + {host.bootstrapAm ? ( + + ) : ( + noch nicht eingerichtet + )} + + + + + {host.letzterAbgleichAm && } + + + {host.kunde ? host.kunde.name : –} +
+ + 0} + /> +
+ + + } + > + {host.hostSchluessel.length === 0 ? ( + } + titel="Noch kein Host-Key abgerufen" + text="Ohne bestätigten Host-Key verbindet sich sshkv nicht mit diesem Host." + /> + ) : ( +
    + {host.hostSchluessel.map((k) => ( +
  • + + {k.algorithmus} + + + + + + + + zuerst gesehen {datumZeit(k.erstmalsGesehen)} · zuletzt {relativ(k.zuletztGesehen, jetzt)} + + + + + +
  • + ))} +
+ )} +
+ + + {host.zielkonten.length > 0 && ( +

+ Zielkonten:{" "} + {host.zielkonten.map((z, i) => ( + + {i > 0 && ", "} + {z.benutzername} + + ))} +

+ )} + } + titel="Folgt mit #016" + text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet." + /> +
+ + {verwalten && ( + +
+

+ Archivierte Hosts verschwinden aus Listen und Hostgruppen. Name und Protokoll bleiben erhalten. +

+ +
+
+ )} +
+ ); +} diff --git a/src/app/(app)/hosts/neu/page.tsx b/src/app/(app)/hosts/neu/page.tsx new file mode 100644 index 0000000..fb44f90 --- /dev/null +++ b/src/app/(app)/hosts/neu/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { HostFormular, LEERER_HOST } from "@/components/hosts/HostFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { gruppenAuswahl, kundenNamen } from "@/server/queries/hosts"; + +export const metadata: Metadata = { title: "Host anlegen" }; + +export default async function HostNeu() { + await sitzungErforderlich("hostsVerwalten", "/hosts/neu"); + const [gruppen, kunden] = await Promise.all([gruppenAuswahl(), kundenNamen()]); + return ( +
+ } + titel="Host anlegen" + untertitel="Danach ruft sshkv den Host-Key ab; eingerichtet wird der Zugang im nächsten Schritt." + /> + +
+ ); +} diff --git a/src/app/(app)/hosts/page.tsx b/src/app/(app)/hosts/page.tsx index 654d3e0..a85efcc 100644 --- a/src/app/(app)/hosts/page.tsx +++ b/src/app/(app)/hosts/page.tsx @@ -1,18 +1,218 @@ +import { Plus, Search, Server } from "lucide-react"; import type { Metadata } from "next"; -import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import Link from "next/link"; +import { adresseMitPort, HostKeyBadge } from "@/components/hosts/Anzeige"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Auswahlliste, Feld } from "@/components/ui/Feld"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Statuspunkt } from "@/components/ui/Statuspunkt"; +import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; +import { datumZeit, relativ } from "@/lib/format"; +import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { gruppenAuswahl, hostListe, type HostListenEintrag } from "@/server/queries/hosts"; export const metadata: Metadata = { title: "Hosts" }; -export default async function Hosts() { - // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); +type Suchparameter = Promise>; + +function einzeln(wert: string | string[] | undefined): string { + return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? ""; +} + +function statusText(h: HostListenEintrag): string | undefined { + if (h.status !== "drift" || h.offeneAbweichungen === 0) return undefined; + return h.offeneAbweichungen === 1 ? "1 Abweichung" : `${h.offeneAbweichungen} Abweichungen`; +} + +function Abgleich({ wert, jetzt }: { wert: Date | null; jetzt: Date }) { + if (!wert) return <>noch nie; return ( - + + ); +} + +/** Hostliste mit Suche und Gruppenfilter (#008). Filter per GET, damit Links teilbar sind. */ +export default async function Hosts({ searchParams }: { searchParams: Suchparameter }) { + // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). + const { benutzer } = await sitzungErforderlich("lesen", "/hosts"); + const parameter = await searchParams; + const q = einzeln(parameter["q"]); + const gruppeId = einzeln(parameter["gruppe"]); + const [hosts, gruppen] = await Promise.all([ + hostListe({ q, gruppeId: gruppeId || undefined }), + gruppenAuswahl(), + ]); + const verwalten = darf(benutzer.rolle, "hostsVerwalten"); + const gefiltert = q !== "" || gruppeId !== ""; + const jetzt = new Date(); + + return ( +
+ }> + Host anlegen + + ) + } + /> + + + + + + {gruppen.map((g) => ( + + ))} + +
+ + {gefiltert && ( + + )} +
+ + + {hosts.length === 0 ? ( + gefiltert ? ( + } + titel="Keine Treffer" + text="Kein Host passt zu Suche und Gruppe." + aktion={} + /> + ) : ( + } + titel="Noch keine Hosts" + text="Legen Sie den ersten Server an. Danach ruft sshkv seinen Host-Key ab und richtet den Zugang ein." + aktion={ + verwalten && ( + + ) + } + /> + ) + ) : ( + + {/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px, und + Status und Host-Key sollen ohne seitliches Wischen sichtbar sein (#008). */} +
    + {hosts.map((h) => ( +
  • + + + + {h.name} + + + {adresseMitPort(h.adresse, h.port)} + + + + + + {h.gruppen.map((g) => ( + {g.name} + ))} + + + Letzter Abgleich: + + +
  • + ))} +
+ + {hosts.map((h) => ( + + + + {h.name} + + + + {adresseMitPort(h.adresse, h.port)} + + + {h.gruppen.length === 0 ? ( + – + ) : ( + + {h.gruppen.map((g) => ( + {g.name} + ))} + + )} + + + + + + + + + + + + ))} + +
+ )} +
); } diff --git a/src/components/hosts/Anzeige.tsx b/src/components/hosts/Anzeige.tsx new file mode 100644 index 0000000..6871ffc --- /dev/null +++ b/src/components/hosts/Anzeige.tsx @@ -0,0 +1,47 @@ +import { Badge } from "@/components/ui/Badge"; +import type { HostKeyZustand } from "@/lib/hosts"; + +/** Kleine Anzeigebausteine fuer Hosts (#008) – reine Darstellung. */ + +const HOSTKEY: Record = { + bestaetigt: { text: "bestätigt", variante: "ok" }, + // Messing statt Koralle: eine offene Entscheidung, noch kein Fehler. + ausstehend: { text: "Bestätigung ausstehend", variante: "messing" }, + keiner: { text: "nicht abgerufen", variante: "neutral" }, +}; + +export function HostKeyBadge({ zustand }: { zustand: HostKeyZustand }) { + const d = HOSTKEY[zustand]; + return {d.text}; +} + +const HOSTSCHLUESSEL_STATUS: Record< + string, + { text: string; variante: "ok" | "messing" | "neutral" | "gefahr" } +> = { + VERTRAUT: { text: "vertraut", variante: "ok" }, + AUSSTEHEND: { text: "ausstehend", variante: "messing" }, + ABGELEHNT: { text: "abgelehnt", variante: "gefahr" }, + ERSETZT: { text: "ersetzt", variante: "neutral" }, +}; + +export function HostSchluesselStatusBadge({ status }: { status: string }) { + const d = HOSTSCHLUESSEL_STATUS[status] ?? { text: status, variante: "neutral" as const }; + return {d.text}; +} + +export function AnmeldeartBadge({ anmeldeart }: { anmeldeart: "DIENSTBENUTZER" | "ROOT" }) { + return anmeldeart === "ROOT" ? ( + + root + + ) : ( + sshkv + sudo + ); +} + +/** `host:port`, IPv6 in Klammern – so, wie man es in ssh-Befehlen wiedererkennt. */ +export function adresseMitPort(adresse: string, port: number): string { + const host = adresse.includes(":") ? `[${adresse}]` : adresse; + return `${host}:${port}`; +} diff --git a/src/components/hosts/ArchivierenDialog.tsx b/src/components/hosts/ArchivierenDialog.tsx new file mode 100644 index 0000000..cea1db6 --- /dev/null +++ b/src/components/hosts/ArchivierenDialog.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Archive } from "lucide-react"; +import { useActionState, useRef } from "react"; +import { Button, Formularfehler } from "@/components/ui"; +import type { FormularZustand } from "@/server/actions/formular"; +import { hostArchivierenAktion } from "@/server/actions/hosts"; + +/** + * Archivieren mit Rueckfrage (#008). Ein nativer `` bringt Fokusfalle, Escape und + * Hintergrundsperre mit – ohne eigene Dialog-Bibliothek. + */ +export function ArchivierenDialog({ hostId, hostName }: { hostId: string; hostName: string }) { + const dialog = useRef(null); + const [zustand, aktion, laeuft] = useActionState(hostArchivierenAktion, {}); + + return ( + <> + + +
+ +

+ {hostName} archivieren? +

+

+ Der Host verschwindet aus Listen und Hostgruppen. sshkv verbindet sich nicht mehr mit ihm; auf dem + Host selbst ändert sich nichts. Das Protokoll bleibt erhalten. +

+ +
+ + +
+ +
+ + ); +} diff --git a/src/components/hosts/GruppenFormular.tsx b/src/components/hosts/GruppenFormular.tsx new file mode 100644 index 0000000..a359291 --- /dev/null +++ b/src/components/hosts/GruppenFormular.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Textbereich } from "@/components/ui"; +import { hostgruppeAnlegenAktion, hostgruppeBearbeitenAktion } from "@/server/actions/hostgruppen"; +import { useFormularAktion } from "./useFormularAktion"; + +export type GruppenFormularVorgabe = { + id?: string; + name: string; + beschreibung: string; + hostIds: string[]; +}; + +/** Name, Beschreibung und Mitglieder einer Hostgruppe (#008). */ +export function GruppenFormular({ + vorgabe, + hosts, + abbrechenZiel, +}: { + vorgabe: GruppenFormularVorgabe; + hosts: { id: string; name: string; adresse: string }[]; + abbrechenZiel?: string; +}) { + const bearbeiten = Boolean(vorgabe.id); + const { zustand, absenden, laeuft } = useFormularAktion( + bearbeiten ? hostgruppeBearbeitenAktion : hostgruppeAnlegenAktion, + ); + const f = zustand.feldfehler ?? {}; + + return ( +
+ {vorgabe.id && } + + + + + {hosts.length > 0 && ( +
+ {hosts.map((h) => ( + {h.name}} + erklaerung={{h.adresse}} + /> + ))} +
+ )} +
+
+ + {abbrechenZiel && } +
+ + ); +} diff --git a/src/components/hosts/HostFormular.tsx b/src/components/hosts/HostFormular.tsx new file mode 100644 index 0000000..976c273 --- /dev/null +++ b/src/components/hosts/HostFormular.tsx @@ -0,0 +1,197 @@ +"use client"; + +import Link from "next/link"; +import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Karte, Textbereich } from "@/components/ui"; +import { hostAnlegenAktion, hostBearbeitenAktion } from "@/server/actions/hosts"; +import { useFormularAktion } from "./useFormularAktion"; + +export type HostFormularVorgabe = { + id?: string; + name: string; + adresse: string; + port: number; + tags: string[]; + beschreibung: string; + kundeName: string; + anmeldeart: "DIENSTBENUTZER" | "ROOT"; + gruppenIds: string[]; +}; + +export const LEERER_HOST: HostFormularVorgabe = { + name: "", + adresse: "", + port: 22, + tags: [], + beschreibung: "", + kundeName: "", + anmeldeart: "DIENSTBENUTZER", + gruppenIds: [], +}; + +/** Formular fuer neue und bestehende Hosts (#008). Regeln prueft der Server. */ +export function HostFormular({ + vorgabe, + gruppen, + kunden, + abbrechenZiel, +}: { + vorgabe: HostFormularVorgabe; + gruppen: { id: string; name: string }[]; + kunden: string[]; + abbrechenZiel: string; +}) { + const bearbeiten = Boolean(vorgabe.id); + const { zustand, absenden, laeuft } = useFormularAktion( + bearbeiten ? hostBearbeitenAktion : hostAnlegenAktion, + ); + const f = zustand.feldfehler ?? {}; + + return ( +
+ {vorgabe.id && } + + + +
+ +
+ + +
+
+
+ + + + + Dienstbenutzer sshkv mit sudo – empfohlen + + } + erklaerung="Ein eigener Benutzer mit /etc/sudoers.d/sshkv; root-Login per SSH bleibt aus." + /> + + + + + +
+ + Noch keine Hostgruppen –{" "} + + Gruppe anlegen + + . + + ) : undefined + } + > + {gruppen.length > 0 && ( +
+ {gruppen.map((g) => ( + + ))} +
+ )} +
+
+ + + + {kunden.map((k) => ( + +
+ +
+
+ +
+ + +
+ + ); +} diff --git a/src/components/hosts/useFormularAktion.ts b/src/components/hosts/useFormularAktion.ts new file mode 100644 index 0000000..f1c6021 --- /dev/null +++ b/src/components/hosts/useFormularAktion.ts @@ -0,0 +1,21 @@ +"use client"; + +import { startTransition, useActionState, type FormEvent } from "react"; +import type { FormularZustand } from "@/server/actions/formular"; + +type Aktion = (vorher: FormularZustand, formular: FormData) => Promise; + +/** + * `useActionState` mit eigenem Absenden (#008). Ueber `
` setzte React das + * Formular nach jeder Antwort zurueck – bei einer Feldmeldung waeren alle Eingaben weg. + * Mit `onSubmit` und `startTransition` bleiben sie stehen. + */ +export function useFormularAktion(aktion: Aktion) { + const [zustand, ausfuehren, laeuft] = useActionState(aktion, {}); + function absenden(ereignis: FormEvent) { + ereignis.preventDefault(); + const daten = new FormData(ereignis.currentTarget); + startTransition(() => ausfuehren(daten)); + } + return { zustand, absenden, laeuft } as const; +} diff --git a/src/components/ui/Feld.tsx b/src/components/ui/Feld.tsx index 22b72ea..6ba5a5c 100644 --- a/src/components/ui/Feld.tsx +++ b/src/components/ui/Feld.tsx @@ -1,37 +1,176 @@ -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { clsx } from "clsx"; +const EINGABE_KLASSEN = clsx( + "bg-bg border-border-strong text-text rounded-feld min-h-11 w-full min-w-0 border px-3 text-[15px]", + "placeholder:text-muted/70 focus-visible:border-accent", + "aria-[invalid=true]:border-danger-border", +); + +/** Hinweis und Fehlermeldung unter einem Feld; die Ids verknuepft `aria-describedby` (#008). */ +function FeldFuss({ feldId, hinweis, fehler }: { feldId: string; hinweis?: ReactNode; fehler?: string }) { + return ( + <> + {/* Bei einem Fehler ersetzt die Meldung den Hinweis – meist sagt sie dasselbe genauer. */} + {hinweis && !fehler && ( +

+ {hinweis} +

+ )} + {fehler && ( +

+ {fehler} +

+ )} + + ); +} + +function beschreibtDurch(feldId: string, hinweis?: ReactNode, fehler?: string): string | undefined { + if (fehler) return `${feldId}-fehler`; + return hinweis ? `${feldId}-hinweis` : undefined; +} + /** Eingabefeld mit Beschriftung (#004). Mindesthoehe 44 px wie alle Klickflaechen. */ export function Feld({ beschriftung, hinweis, + fehler, mono = false, className, id, name, ...rest -}: ComponentProps<"input"> & { beschriftung: string; hinweis?: string; mono?: boolean }) { +}: ComponentProps<"input"> & { beschriftung: string; hinweis?: ReactNode; fehler?: string; mono?: boolean }) { const feldId = id ?? `feld-${name}`; return ( -
+
- {hinweis &&

{hinweis}

} +
); } +/** Mehrzeiliges Textfeld, z. B. fuer Beschreibungen (#008). */ +export function Textbereich({ + beschriftung, + hinweis, + fehler, + className, + id, + name, + ...rest +}: ComponentProps<"textarea"> & { beschriftung: string; hinweis?: ReactNode; fehler?: string }) { + const feldId = id ?? `feld-${name}`; + return ( +
+ +