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 ( +
+ +