diff --git a/docs/anleitung/bilder/host-detail-desktop.png b/docs/anleitung/bilder/host-detail-desktop.png new file mode 100644 index 0000000..b15493b Binary files /dev/null and b/docs/anleitung/bilder/host-detail-desktop.png differ diff --git a/docs/anleitung/bilder/host-detail-mobil.png b/docs/anleitung/bilder/host-detail-mobil.png new file mode 100644 index 0000000..7f4e23c Binary files /dev/null and b/docs/anleitung/bilder/host-detail-mobil.png differ diff --git a/docs/anleitung/bilder/hosts-desktop.png b/docs/anleitung/bilder/hosts-desktop.png new file mode 100644 index 0000000..55217cd Binary files /dev/null and b/docs/anleitung/bilder/hosts-desktop.png differ diff --git a/docs/anleitung/bilder/hosts-mobil.png b/docs/anleitung/bilder/hosts-mobil.png new file mode 100644 index 0000000..f513d08 Binary files /dev/null and b/docs/anleitung/bilder/hosts-mobil.png differ diff --git a/docs/issues/008-hosts-gruppen.md b/docs/issues/008-hosts-gruppen.md index 4fcc785..d972475 100644 --- a/docs/issues/008-hosts-gruppen.md +++ b/docs/issues/008-hosts-gruppen.md @@ -1,22 +1,56 @@ # #008 Hosts und Hostgruppen -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** backend, ui -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 0,5 h ## Ziel Hosts und Hostgruppen anlegen, bearbeiten, archivieren; Kunden und Tags. ## Aufgaben -- [ ] Liste mit Status (erreichbar, letzter Abgleich, Drift) -- [ ] Formular: Name, Adresse, Port, Gruppen, Tags, Beschreibung, Kunde, Anmeldebenutzer (`sshkv`/`root`) -- [ ] Hostgruppen mit Mitgliedern -- [ ] Hostdetail-Gerüst +- [x] Liste mit Status (erreichbar, letzter Abgleich, Drift) +- [x] Formular: Name, Adresse, Port, Gruppen, Tags, Beschreibung, Kunde, Anmeldebenutzer (`sshkv`/`root`) +- [x] Hostgruppen mit Mitgliedern +- [x] Hostdetail-Gerüst ## Akzeptanzkriterien -- [ ] Leser sieht, Operator/Admin bearbeiten +- [x] Leser sieht, Operator/Admin bearbeiten ## Abhängigkeiten #004 ## Notizen / Entscheidungen +- **Schichtung:** Regeln (zod-Schemas, Status-Ableitung) framework-frei in `src/lib/hosts.ts`; + Services `src/server/services/hosts.ts` / `hostgruppen.ts`, Lesemodelle + `src/server/queries/hosts.ts`, Aktionen `src/server/actions/hosts.ts` / `hostgruppen.ts` + (gemeinsame Hilfen in `actions/formular.ts`, ohne `"use server"`). Die Action prüft vorab für + Feldmeldungen, der Service prüft erneut (Seed, Skripte, spätere Assistenten). +- **Archivieren statt Löschen** (`archiviertAm`). Dabei fallen die Gruppenmitgliedschaften weg + (im Protokoll festgehalten), damit Zuweisungen an Gruppen nie einen archivierten Host + treffen. Archivierte Hosts sind schreibgeschützt und erscheinen in keiner Liste; der Name + bleibt belegt, damit das Protokoll eindeutig bleibt. Wiederherstellen gibt es noch nicht. +- **Hostgruppen löschen** bewusst noch nicht: `PublicVerwendung` hängt per Cascade an der + Gruppe; das Löschen klärt #016 zusammen mit den Verwendungen. +- **Kunde** per Name: unbekannter Name legt den Kunden an (`kunde.angelegt` im Protokoll), + leer = kein Kunde. Gruppen- und Kundennamen erlauben Leerzeichen/Umlaute (reine Anzeige). +- **Protokoll:** `host.angelegt`, `host.geaendert` (nur geänderte Felder als + `{ vorher, nachher }`, ohne Änderung kein Eintrag), `host.archiviert`, + `hostgruppe.angelegt`, `hostgruppe.geaendert` (Mitglieder als `hinzu`/`entfernt`), + `kunde.angelegt`. Demo-Seed schreibt bewusst keine Einträge. +- **Status in der Liste:** nicht erreichbar > Drift (auch bei offenen Abweichungen) > + synchron > unbekannt. Host-Key: ein ausstehender Key geht vor einem vertrauten, damit ein + geänderter Key nicht hinter „bestätigt“ verschwindet. +- **Formulare** senden per `onSubmit` + `startTransition` statt `
`: sonst setzt + React das Formular nach jeder Antwort zurück und Eingaben gingen bei Feldfehlern verloren. + `Feld` hat dafür `fehler` bekommen; neu sind `Textbereich`, `Auswahlliste`, `Ankreuzfeld`, + `Feldgruppe`. +- **Mobil:** Hostliste unter `md` als Kartenliste statt Tabelle (Status und Host-Key ohne + seitliches Wischen sichtbar); Gruppenliste zeigt die Beschreibung unter dem Namen. +- **Platzhalter für Folge-Issues:** `src/app/(app)/hosts/[id]/HostKeyAktionen.tsx` + (`hostId`, `darfEntscheiden`, `status: HostKeyZusammenfassung`, #009) im Kopf der Karte + „Host-Key“ und `BootstrapAktionen.tsx` (`hostId`, `darfVerwalten`, `eingerichtet`, + `hostKeyVertraut`, #010) unter der Info-Leiste – beide geben vorerst `null` zurück. +- **Demo-Daten:** `SSHKV_SEED_DEMO=1 pnpm db:seed` legt 6 Hosts, 3 Gruppen und einen Kunden an + (Upsert nach Name, idempotent). +- **Screenshots:** `scripts/screenshots.ts` kennt `hosts` und `host-detail` (folgt dem ersten + Host der Liste). diff --git a/prisma/seed.ts b/prisma/seed.ts index 809ff6b..8764e19 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -5,7 +5,7 @@ import { PASSWORT_MINDESTLAENGE, passwortHashen } from "../src/lib/passwort-hash import { EINGEBAUTE_VORLAGEN } from "./vorlagen"; /** - * Grundausstattung (#003): Notfall-Admin und eingebaute Vorlagen. + * Grundausstattung (#003): Notfall-Admin und eingebaute Vorlagen; optional Demo-Hosts (#008). * * Laeuft bei jedem Start des migrate-Dienstes und ist deshalb idempotent. Ein bestehender * Notfall-Admin wird nie angefasst – sonst setzte jeder Neustart ein in der Oberflaeche @@ -45,9 +45,89 @@ async function vorlagen(): Promise { console.log(`${EINGEBAUTE_VORLAGEN.length} eingebaute Vorlagen geprüft.`); } +/** + * Demo-Hosts fuer Entwicklung, Screenshots und Handbuch (#008) – nur mit SSHKV_SEED_DEMO=1, + * damit eine echte Instanz nie Phantom-Hosts bekommt. Upsert nach Name: ein zweiter Lauf + * aendert nichts, und in der Oberflaeche geaenderte Demo-Hosts bleiben, wie sie sind. + * Bewusst ohne Protokolleintraege: Seed-Daten sind keine Handlung eines Menschen. + */ +const DEMO_GRUPPEN = [ + { name: "Proxmox", beschreibung: "Virtualisierungs-Cluster im Rechenzentrum" }, + { name: "DMZ", beschreibung: "Von außen erreichbare Systeme" }, + { name: "Kunde X", beschreibung: "Systeme, die wir für Kunde X betreiben" }, +] as const; + +const DEMO_HOSTS: { + name: string; + adresse: string; + port?: number; + tags: string[]; + beschreibung?: string; + anmeldeart?: "DIENSTBENUTZER" | "ROOT"; + kunde?: string; + gruppen: string[]; +}[] = [ + { name: "srv-pve01", adresse: "10.0.10.11", tags: ["pve", "produktion"], gruppen: ["Proxmox"] }, + { name: "srv-pve02", adresse: "10.0.10.12", tags: ["pve", "produktion"], gruppen: ["Proxmox"] }, + { + name: "srv-backup01", + adresse: "10.0.10.30", + tags: ["backup"], + beschreibung: "Proxmox Backup Server, zieht nachts von pve01/pve02", + gruppen: ["Proxmox"], + }, + { + name: "srv-app01", + adresse: "app01.kunde-x.example", + port: 2222, + tags: ["web"], + kunde: "Kunde X", + gruppen: ["Kunde X"], + }, + { + name: "fw-edge01", + adresse: "192.168.1.1", + tags: ["firewall"], + anmeldeart: "ROOT", + beschreibung: "Appliance ohne sudo – Anmeldung direkt als root", + gruppen: ["DMZ"], + }, + { name: "srv-rz-claude-linux", adresse: "fd00:10::42", tags: ["test"], gruppen: [] }, +]; + +async function demoDaten(): Promise { + if (process.env["SSHKV_SEED_DEMO"] !== "1") return; + const gruppen = new Map(); + for (const g of DEMO_GRUPPEN) { + const gruppe = await prisma.hostgruppe.upsert({ where: { name: g.name }, create: g, update: {} }); + gruppen.set(g.name, gruppe.id); + } + for (const h of DEMO_HOSTS) { + const kunde = h.kunde + ? await prisma.kunde.upsert({ where: { name: h.kunde }, create: { name: h.kunde }, update: {} }) + : null; + await prisma.host.upsert({ + where: { name: h.name }, + update: {}, + create: { + name: h.name, + adresse: h.adresse, + port: h.port ?? 22, + tags: h.tags, + beschreibung: h.beschreibung ?? null, + anmeldeart: h.anmeldeart ?? "DIENSTBENUTZER", + kundeId: kunde?.id ?? null, + gruppen: { create: h.gruppen.map((name) => ({ hostgruppeId: gruppen.get(name) ?? "" })) }, + }, + }); + } + console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`); +} + try { await notfallAdmin(); await vorlagen(); + await demoDaten(); } finally { await prisma.$disconnect(); } diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index f9a0e01..1b806cd 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -19,11 +19,17 @@ import { chromium } from "@playwright/test"; const BASIS = (process.env["SSHKV_URL"] ?? "http://localhost:3400").replace(/\/$/, ""); const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/bilder"); -/** `oeffentlich`: ohne Anmeldung aufnehmen. Alle anderen nach Anmeldung ueber den Mock-OIDC (#004). */ -const SEITEN: { name: string; pfad: string; oeffentlich?: boolean }[] = [ +/** + * `oeffentlich`: ohne Anmeldung aufnehmen. Alle anderen nach Anmeldung ueber den Mock-OIDC (#004). + * `folgen`: Seiten mit wechselnder ID (#008) – erst `pfad` laden, dann dem ersten Link folgen, + * der zum Selektor passt (z. B. der erste Host der Liste, bei SSHKV_SEED_DEMO=1 ein Demo-Host). + */ +const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [ { name: "anmelden", pfad: "/anmelden", oeffentlich: true }, { name: "uebersicht", pfad: "/" }, { name: "schluessel-detail", pfad: "/schluessel/beispiel" }, + { name: "hosts", pfad: "/hosts" }, + { name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' }, ]; const ANSICHTEN = [ @@ -62,7 +68,16 @@ async function main() { await seite.waitForURL(`${BASIS}/`); angemeldet = true; } - const antwort = await seite.goto(`${BASIS}${s.pfad}`, { waitUntil: "networkidle" }); + let antwort = await seite.goto(`${BASIS}${s.pfad}`, { waitUntil: "networkidle" }); + if (s.folgen && antwort?.ok()) { + const links = seite.locator(s.folgen); + const ziel = (await links.count()) > 0 ? await links.first().getAttribute("href") : null; + if (!ziel) { + fehler.push(`${s.pfad}: kein Link für „${s.name}“ (${s.folgen}) – Demo-Daten geseedet?`); + continue; + } + antwort = await seite.goto(`${BASIS}${ziel}`, { waitUntil: "networkidle" }); + } if (!antwort?.ok()) { fehler.push(`${s.pfad}: HTTP ${antwort?.status() ?? "keine Antwort"}`); continue; 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 ( +
+ +