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 <form action> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Hostgruppen", href: "/hostgruppen" }, { titel: gruppe.name }]} />}
|
||||||
|
titel={gruppe.name}
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
{gruppe.beschreibung && <span className="mb-1 block">{gruppe.beschreibung}</span>}
|
||||||
|
{anzahl === 1 ? "1 Mitglied" : `${anzahl} Mitglieder`} · angelegt am {datum(gruppe.angelegtAm)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
|
||||||
|
<Karte titel="Mitglieder">
|
||||||
|
{anzahl === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Server className="size-5" />}
|
||||||
|
titel="Keine Mitglieder"
|
||||||
|
text={
|
||||||
|
verwalten
|
||||||
|
? "Hosts lassen sich unter „Bearbeiten“ auswählen."
|
||||||
|
: "Dieser Gruppe ist noch kein Host zugeordnet."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tabelle beschriftung="Mitglieder" spalten={[{ titel: "Host" }, { titel: "Status" }]}>
|
||||||
|
{gruppe.mitglieder.map((h) => (
|
||||||
|
<TabellenZeile key={h.id}>
|
||||||
|
<TabellenZelle className="min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/hosts/${h.id}`}
|
||||||
|
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center font-mono text-[13.5px] font-medium break-all transition-colors"
|
||||||
|
>
|
||||||
|
{h.name}
|
||||||
|
</Link>
|
||||||
|
<p className="text-muted font-mono text-[12.5px] break-all">
|
||||||
|
{adresseMitPort(h.adresse, h.port)}
|
||||||
|
</p>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Statuspunkt status={h.status} />
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{verwalten && (
|
||||||
|
<Karte titel="Bearbeiten" untertitel="Name, Beschreibung und Mitglieder">
|
||||||
|
<GruppenFormular
|
||||||
|
vorgabe={{
|
||||||
|
id: gruppe.id,
|
||||||
|
name: gruppe.name,
|
||||||
|
beschreibung: gruppe.beschreibung ?? "",
|
||||||
|
hostIds: gruppe.mitglieder.map((m) => m.id),
|
||||||
|
}}
|
||||||
|
hosts={hosts}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb pfad={[{ titel: "Hostgruppen", href: "/hostgruppen" }, { titel: "Neue Gruppe" }]} />
|
||||||
|
}
|
||||||
|
titel="Hostgruppe anlegen"
|
||||||
|
/>
|
||||||
|
<Karte>
|
||||||
|
<GruppenFormular
|
||||||
|
vorgabe={{ name: "", beschreibung: "", hostIds: [] }}
|
||||||
|
hosts={hosts}
|
||||||
|
abbrechenZiel="/hostgruppen"
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,70 @@
|
|||||||
|
import { Boxes, Plus } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
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 { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { gruppenListe } from "@/server/queries/hosts";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Hostgruppen" };
|
export const metadata: Metadata = { title: "Hostgruppen" };
|
||||||
|
|
||||||
|
/** Hostgruppen mit Mitgliederzahl (#008). */
|
||||||
export default async function Hostgruppen() {
|
export default async function Hostgruppen() {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
// 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 && (
|
||||||
|
<Button href="/hostgruppen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Gruppe anlegen
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
titel="Hostgruppen"
|
titel="Hostgruppen"
|
||||||
untertitel="Hosts bündeln, um Schlüssel gemeinsam zuzuweisen"
|
untertitel="Hosts bündeln, um Schlüssel gemeinsam zuzuweisen"
|
||||||
phase={1}
|
aktionen={anlegen}
|
||||||
issue="#008"
|
|
||||||
/>
|
/>
|
||||||
|
{gruppen.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Boxes className="size-5" />}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Karte>
|
||||||
|
<Tabelle
|
||||||
|
beschriftung="Hostgruppen"
|
||||||
|
spalten={[{ titel: "Gruppe" }, { titel: "Mitglieder", rechts: true }]}
|
||||||
|
>
|
||||||
|
{gruppen.map((g) => (
|
||||||
|
<TabellenZeile key={g.id}>
|
||||||
|
{/* Beschreibung unter dem Namen statt als eigene Spalte: so bleibt die
|
||||||
|
Mitgliederzahl auch auf 390 px sichtbar (#008). */}
|
||||||
|
<TabellenZelle>
|
||||||
|
<Link
|
||||||
|
href={`/hostgruppen/${g.id}`}
|
||||||
|
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</Link>
|
||||||
|
{g.beschreibung && <p className="text-muted text-[13.5px]">{g.beschreibung}</p>}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle rechts className="tabular-nums">
|
||||||
|
{g.mitglieder}
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb
|
||||||
|
pfad={[
|
||||||
|
{ titel: "Hosts", href: "/hosts" },
|
||||||
|
{ titel: werte.name, href: `/hosts/${id}` },
|
||||||
|
{ titel: "Bearbeiten" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
titel={
|
||||||
|
<>
|
||||||
|
<span className="font-mono">{werte.name}</span> bearbeiten
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<HostFormular vorgabe={werte} gruppen={gruppen} kunden={kunden} abbrechenZiel={`/hosts/${id}`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <span className="text-muted">{sonst}</span>;
|
||||||
|
return (
|
||||||
|
<time dateTime={wert.toISOString()} title={datumZeit(wert)}>
|
||||||
|
{relativ(wert, jetzt)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Info({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||||
|
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Erreichbarkeit({ host, jetzt }: { host: HostDetail; jetzt: Date }) {
|
||||||
|
const pruefung = host.zuletztGeprueftAm ? ` · ${relativ(host.zuletztGeprueftAm, jetzt)}` : "";
|
||||||
|
if (host.erreichbar === true) return <Statuspunkt status="synchron" text={`erreichbar${pruefung}`} />;
|
||||||
|
if (host.erreichbar === false)
|
||||||
|
return <Statuspunkt status="unerreichbar" text={`nicht erreichbar${pruefung}`} />;
|
||||||
|
return <Statuspunkt status="unbekannt" text="noch nicht geprüft" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `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<string, unknown>);
|
||||||
|
if (eintraege.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="border-border mt-5 border-t pt-4">
|
||||||
|
<p className="text-muted text-[13px] font-medium">sshd-Befund</p>
|
||||||
|
<dl className="mt-2 grid gap-x-6 gap-y-1 font-mono text-[13px] sm:grid-cols-2">
|
||||||
|
{eintraege.map(([k, v]) => (
|
||||||
|
<div key={k} className="flex min-w-0 gap-2">
|
||||||
|
<dt className="text-muted shrink-0">{k}</dt>
|
||||||
|
<dd className="text-text min-w-0 break-all">{String(v)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Hosts", href: "/hosts" }, { titel: host.name }]} />}
|
||||||
|
titel={<span className="font-mono">{host.name}</span>}
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
<span className="text-text font-mono text-[13.5px] break-all">
|
||||||
|
{adresseMitPort(host.adresse, host.port)}
|
||||||
|
</span>
|
||||||
|
{host.beschreibung && <span className="mt-1.5 block">{host.beschreibung}</span>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
{archiviert && host.archiviertAm && (
|
||||||
|
<Badge variante="gefahr">archiviert am {datum(host.archiviertAm)}</Badge>
|
||||||
|
)}
|
||||||
|
<AnmeldeartBadge anmeldeart={host.anmeldeart} />
|
||||||
|
{host.gruppen.map((g) => (
|
||||||
|
<Badge key={g.id}>{g.name}</Badge>
|
||||||
|
))}
|
||||||
|
{host.tags.map((t) => (
|
||||||
|
<Badge key={t} mono className="text-muted">
|
||||||
|
#{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button href={`/hosts/${host.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte as="div">
|
||||||
|
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 min-[480px]:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Info titel="Erreichbar">
|
||||||
|
<Erreichbarkeit host={host} jetzt={jetzt} />
|
||||||
|
</Info>
|
||||||
|
<Info titel="Bootstrap">
|
||||||
|
{host.bootstrapAm ? (
|
||||||
|
<time dateTime={host.bootstrapAm.toISOString()}>{datumZeit(host.bootstrapAm)}</time>
|
||||||
|
) : (
|
||||||
|
<span className="text-accent">noch nicht eingerichtet</span>
|
||||||
|
)}
|
||||||
|
</Info>
|
||||||
|
<Info titel="Letzter Abgleich">
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<Zeitpunkt wert={host.letzterAbgleichAm} jetzt={jetzt} sonst="noch nie" />
|
||||||
|
{host.letzterAbgleichAm && <Statuspunkt status={host.status} className="text-[13px]" />}
|
||||||
|
</span>
|
||||||
|
</Info>
|
||||||
|
<Info titel="Kunde">{host.kunde ? host.kunde.name : <span className="text-muted">–</span>}</Info>
|
||||||
|
</dl>
|
||||||
|
<SshdBefund befund={host.sshdBefund} />
|
||||||
|
<BootstrapAktionen
|
||||||
|
hostId={host.id}
|
||||||
|
darfVerwalten={verwalten}
|
||||||
|
eingerichtet={host.bootstrapAm !== null}
|
||||||
|
hostKeyVertraut={host.hostKey.vertraut > 0}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Host-Key"
|
||||||
|
untertitel="Fingerprints, gegen die sshkv jede Verbindung prüft"
|
||||||
|
aktionen={
|
||||||
|
<HostKeyAktionen
|
||||||
|
hostId={host.id}
|
||||||
|
darfEntscheiden={darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert}
|
||||||
|
status={host.hostKey}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{host.hostSchluessel.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<ShieldQuestion className="size-5" />}
|
||||||
|
titel="Noch kein Host-Key abgerufen"
|
||||||
|
text="Ohne bestätigten Host-Key verbindet sich sshkv nicht mit diesem Host."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{host.hostSchluessel.map((k) => (
|
||||||
|
<li
|
||||||
|
key={k.id}
|
||||||
|
className="border-border grid gap-x-4 gap-y-1 border-b py-3 first:pt-0 last:border-b-0 last:pb-0 md:grid-cols-[12rem_minmax(0,1fr)_auto] md:items-center"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-3 md:block">
|
||||||
|
<span className="text-text font-mono text-[13px]">{k.algorithmus}</span>
|
||||||
|
<span className="md:hidden">
|
||||||
|
<HostSchluesselStatusBadge status={k.status} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<MonoKopieren wert={k.fingerprint} beschriftung="Fingerprint" umbrechen />
|
||||||
|
<span className="text-muted block text-[12.5px]">
|
||||||
|
zuerst gesehen {datumZeit(k.erstmalsGesehen)} · zuletzt {relativ(k.zuletztGesehen, jetzt)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="hidden md:block">
|
||||||
|
<HostSchluesselStatusBadge status={k.status} />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Schlüssel auf diesem Host"
|
||||||
|
untertitel={`${host.publicVerwendungen} Public-Key-Verwendungen · ${host.privateVerwendungen} Private-Key-Verwendungen`}
|
||||||
|
>
|
||||||
|
{host.zielkonten.length > 0 && (
|
||||||
|
<p className="text-muted mb-4 text-[14px]">
|
||||||
|
Zielkonten:{" "}
|
||||||
|
{host.zielkonten.map((z, i) => (
|
||||||
|
<span key={z.id}>
|
||||||
|
{i > 0 && ", "}
|
||||||
|
<code className="text-text font-mono text-[13px]">{z.benutzername}</code>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
titel="Folgt mit #016"
|
||||||
|
text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet."
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{verwalten && (
|
||||||
|
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-muted max-w-xl text-[14px]">
|
||||||
|
Archivierte Hosts verschwinden aus Listen und Hostgruppen. Name und Protokoll bleiben erhalten.
|
||||||
|
</p>
|
||||||
|
<ArchivierenDialog hostId={host.id} hostName={host.name} />
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Hosts", href: "/hosts" }, { titel: "Neuer Host" }]} />}
|
||||||
|
titel="Host anlegen"
|
||||||
|
untertitel="Danach ruft sshkv den Host-Key ab; eingerichtet wird der Zugang im nächsten Schritt."
|
||||||
|
/>
|
||||||
|
<HostFormular vorgabe={LEERER_HOST} gruppen={gruppen} kunden={kunden} abbrechenZiel="/hosts" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,218 @@
|
|||||||
|
import { Plus, Search, Server } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
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 { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { gruppenAuswahl, hostListe, type HostListenEintrag } from "@/server/queries/hosts";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Hosts" };
|
export const metadata: Metadata = { title: "Hosts" };
|
||||||
|
|
||||||
export default async function Hosts() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
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 (
|
return (
|
||||||
<Platzhalterseite
|
<time dateTime={wert.toISOString()} title={datumZeit(wert)}>
|
||||||
|
{relativ(wert, jetzt)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
titel="Hosts"
|
titel="Hosts"
|
||||||
untertitel="Verwaltete Server, Host-Keys und Erreichbarkeit"
|
untertitel={
|
||||||
phase={1}
|
gefiltert
|
||||||
issue="#008"
|
? `${hosts.length} Treffer`
|
||||||
|
: `${hosts.length} ${hosts.length === 1 ? "verwalteter Server" : "verwaltete Server"}`
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button href="/hosts/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Host anlegen
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="get"
|
||||||
|
role="search"
|
||||||
|
className="grid items-end gap-3 sm:grid-cols-[minmax(0,1fr)_14rem_auto]"
|
||||||
|
>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Name, Adresse oder Tag"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<Auswahlliste beschriftung="Hostgruppe" name="gruppe" defaultValue={gruppeId}>
|
||||||
|
<option value="">Alle Gruppen</option>
|
||||||
|
{gruppen.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<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="/hosts" className="flex-1 sm:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{hosts.length === 0 ? (
|
||||||
|
gefiltert ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Search className="size-5" />}
|
||||||
|
titel="Keine Treffer"
|
||||||
|
text="Kein Host passt zu Suche und Gruppe."
|
||||||
|
aktion={<Button href="/hosts">Filter zurücksetzen</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Server className="size-5" />}
|
||||||
|
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 && (
|
||||||
|
<Button
|
||||||
|
href="/hosts/neu"
|
||||||
|
variante="primaer"
|
||||||
|
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||||
|
>
|
||||||
|
Host anlegen
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Karte>
|
||||||
|
{/* 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). */}
|
||||||
|
<ul className="flex flex-col md:hidden">
|
||||||
|
{hosts.map((h) => (
|
||||||
|
<li key={h.id} className="border-border border-b last:border-b-0">
|
||||||
|
<Link
|
||||||
|
href={`/hosts/${h.id}`}
|
||||||
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-2 px-2 py-3 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="text-text block truncate font-mono text-[14px] font-medium">
|
||||||
|
{h.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted block truncate font-mono text-[12.5px]">
|
||||||
|
{adresseMitPort(h.adresse, h.port)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
|
<Statuspunkt status={h.status} text={statusText(h)} />
|
||||||
|
<HostKeyBadge zustand={h.hostKey.zustand} />
|
||||||
|
{h.gruppen.map((g) => (
|
||||||
|
<Badge key={g.id}>{g.name}</Badge>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-[13px]">
|
||||||
|
Letzter Abgleich: <Abgleich wert={h.letzterAbgleichAm} jetzt={jetzt} />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Tabelle
|
||||||
|
className="hidden md:block"
|
||||||
|
beschriftung="Hosts"
|
||||||
|
spalten={[
|
||||||
|
{ titel: "Name" },
|
||||||
|
{ titel: "Adresse" },
|
||||||
|
{ titel: "Gruppen" },
|
||||||
|
{ titel: "Status" },
|
||||||
|
{ titel: "Host-Key" },
|
||||||
|
{ titel: "Letzter Abgleich" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{hosts.map((h) => (
|
||||||
|
<TabellenZeile key={h.id}>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Link
|
||||||
|
href={`/hosts/${h.id}`}
|
||||||
|
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center font-mono text-[13.5px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{h.name}
|
||||||
|
</Link>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||||
|
{adresseMitPort(h.adresse, h.port)}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle>
|
||||||
|
{h.gruppen.length === 0 ? (
|
||||||
|
<span className="text-muted">–</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex flex-wrap gap-1.5">
|
||||||
|
{h.gruppen.map((g) => (
|
||||||
|
<Badge key={g.id}>{g.name}</Badge>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Statuspunkt status={h.status} text={statusText(h)} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<HostKeyBadge zustand={h.hostKey.zustand} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="text-muted whitespace-nowrap">
|
||||||
|
<Abgleich wert={h.letzterAbgleichAm} jetzt={jetzt} />
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<HostKeyZustand, { text: string; variante: "ok" | "messing" | "neutral" }> = {
|
||||||
|
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 <Badge variante={d.variante}>{d.text}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <Badge variante={d.variante}>{d.text}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnmeldeartBadge({ anmeldeart }: { anmeldeart: "DIENSTBENUTZER" | "ROOT" }) {
|
||||||
|
return anmeldeart === "ROOT" ? (
|
||||||
|
<Badge variante="messing" mono>
|
||||||
|
root
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge mono>sshkv + sudo</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `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}`;
|
||||||
|
}
|
||||||
@@ -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 `<dialog>` bringt Fokusfalle, Escape und
|
||||||
|
* Hintergrundsperre mit – ohne eigene Dialog-Bibliothek.
|
||||||
|
*/
|
||||||
|
export function ArchivierenDialog({ hostId, hostName }: { hostId: string; hostName: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(hostArchivierenAktion, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Archive className="size-4" />}
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
>
|
||||||
|
Host archivieren
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-labelledby="archivieren-titel"
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="id" value={hostId} />
|
||||||
|
<h2 id="archivieren-titel" className="text-[17px] font-semibold">
|
||||||
|
<span className="font-mono">{hostName}</span> archivieren?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" variante="gefahr" disabled={laeuft}>
|
||||||
|
{laeuft ? "Archiviere …" : "Archivieren"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-5">
|
||||||
|
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<Feld
|
||||||
|
beschriftung="Name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={vorgabe.name}
|
||||||
|
required
|
||||||
|
maxLength={64}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Proxmox"
|
||||||
|
fehler={f["name"]}
|
||||||
|
/>
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Beschreibung (optional)"
|
||||||
|
name="beschreibung"
|
||||||
|
defaultValue={vorgabe.beschreibung}
|
||||||
|
maxLength={500}
|
||||||
|
fehler={f["beschreibung"]}
|
||||||
|
/>
|
||||||
|
<Feldgruppe
|
||||||
|
legende="Mitglieder"
|
||||||
|
fehler={f["hostIds"]}
|
||||||
|
hinweis={hosts.length === 0 ? "Noch keine Hosts angelegt." : undefined}
|
||||||
|
>
|
||||||
|
{hosts.length > 0 && (
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
{hosts.map((h) => (
|
||||||
|
<Ankreuzfeld
|
||||||
|
key={h.id}
|
||||||
|
name="hostIds"
|
||||||
|
value={h.id}
|
||||||
|
defaultChecked={vorgabe.hostIds.includes(h.id)}
|
||||||
|
beschriftung={<span className="font-mono text-[13.5px]">{h.name}</span>}
|
||||||
|
erklaerung={<span className="font-mono text-[12.5px] break-all">{h.adresse}</span>}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Feldgruppe>
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Gruppe anlegen"}
|
||||||
|
</Button>
|
||||||
|
{abbrechenZiel && <Button href={abbrechenZiel}>Abbrechen</Button>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||||
|
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
|
||||||
|
<Karte titel="Verbindung">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={vorgabe.name}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
maxLength={64}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="srv-pve01"
|
||||||
|
hinweis="Buchstaben, Ziffern, Punkt, Unterstrich, Bindestrich."
|
||||||
|
fehler={f["name"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_6.5rem] gap-3 sm:col-span-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Adresse"
|
||||||
|
name="adresse"
|
||||||
|
defaultValue={vorgabe.adresse}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
maxLength={253}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="10.0.0.11 oder pve01.intern"
|
||||||
|
fehler={f["adresse"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Port"
|
||||||
|
name="port"
|
||||||
|
defaultValue={vorgabe.port}
|
||||||
|
mono
|
||||||
|
inputMode="numeric"
|
||||||
|
required
|
||||||
|
fehler={f["port"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Anmeldung" untertitel="Wie sich sshkv auf diesem Host anmeldet">
|
||||||
|
<Feldgruppe legende="Anmeldeart" fehler={f["anmeldeart"]}>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="anmeldeart"
|
||||||
|
value="DIENSTBENUTZER"
|
||||||
|
defaultChecked={vorgabe.anmeldeart === "DIENSTBENUTZER"}
|
||||||
|
beschriftung={
|
||||||
|
<>
|
||||||
|
Dienstbenutzer <code className="font-mono text-[13.5px]">sshkv</code> mit sudo – empfohlen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
erklaerung="Ein eigener Benutzer mit /etc/sudoers.d/sshkv; root-Login per SSH bleibt aus."
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="anmeldeart"
|
||||||
|
value="ROOT"
|
||||||
|
defaultChecked={vorgabe.anmeldeart === "ROOT"}
|
||||||
|
beschriftung="Direkt als root"
|
||||||
|
erklaerung="Nur wenn kein Dienstbenutzer möglich ist (z. B. Appliances). Verlangt PermitRootLogin."
|
||||||
|
/>
|
||||||
|
</Feldgruppe>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Einordnung">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Feldgruppe
|
||||||
|
legende="Hostgruppen"
|
||||||
|
fehler={f["gruppenIds"]}
|
||||||
|
hinweis={
|
||||||
|
gruppen.length === 0 ? (
|
||||||
|
<>
|
||||||
|
Noch keine Hostgruppen –{" "}
|
||||||
|
<Link href="/hostgruppen/neu" className="text-accent hover:underline">
|
||||||
|
Gruppe anlegen
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{gruppen.length > 0 && (
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
{gruppen.map((g) => (
|
||||||
|
<Ankreuzfeld
|
||||||
|
key={g.id}
|
||||||
|
name="gruppenIds"
|
||||||
|
value={g.id}
|
||||||
|
defaultChecked={vorgabe.gruppenIds.includes(g.id)}
|
||||||
|
beschriftung={g.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Feldgruppe>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Tags"
|
||||||
|
name="tags"
|
||||||
|
defaultValue={vorgabe.tags.join(", ")}
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="pve, produktion"
|
||||||
|
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
|
||||||
|
fehler={f["tags"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Kunde (optional)"
|
||||||
|
name="kundeName"
|
||||||
|
defaultValue={vorgabe.kundeName}
|
||||||
|
list="kunden-vorschlaege"
|
||||||
|
autoComplete="off"
|
||||||
|
hinweis="Ein neuer Name legt den Kunden an."
|
||||||
|
fehler={f["kundeName"]}
|
||||||
|
/>
|
||||||
|
<datalist id="kunden-vorschlaege">
|
||||||
|
{kunden.map((k) => (
|
||||||
|
<option key={k} value={k} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Beschreibung (optional)"
|
||||||
|
name="beschreibung"
|
||||||
|
defaultValue={vorgabe.beschreibung}
|
||||||
|
maxLength={500}
|
||||||
|
fehler={f["beschreibung"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Host anlegen"}
|
||||||
|
</Button>
|
||||||
|
<Button href={abbrechenZiel}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<FormularZustand>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `useActionState` mit eigenem Absenden (#008). Ueber `<form action>` 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<FormularZustand, FormData>(aktion, {});
|
||||||
|
function absenden(ereignis: FormEvent<HTMLFormElement>) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
const daten = new FormData(ereignis.currentTarget);
|
||||||
|
startTransition(() => ausfuehren(daten));
|
||||||
|
}
|
||||||
|
return { zustand, absenden, laeuft } as const;
|
||||||
|
}
|
||||||
+148
-9
@@ -1,37 +1,176 @@
|
|||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { clsx } from "clsx";
|
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 && (
|
||||||
|
<p id={`${feldId}-hinweis`} className="text-muted text-[12.5px]">
|
||||||
|
{hinweis}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{fehler && (
|
||||||
|
<p id={`${feldId}-fehler`} className="text-danger text-[13px]">
|
||||||
|
{fehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
/** Eingabefeld mit Beschriftung (#004). Mindesthoehe 44 px wie alle Klickflaechen. */
|
||||||
export function Feld({
|
export function Feld({
|
||||||
beschriftung,
|
beschriftung,
|
||||||
hinweis,
|
hinweis,
|
||||||
|
fehler,
|
||||||
mono = false,
|
mono = false,
|
||||||
className,
|
className,
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
...rest
|
...rest
|
||||||
}: ComponentProps<"input"> & { beschriftung: string; hinweis?: string; mono?: boolean }) {
|
}: ComponentProps<"input"> & { beschriftung: string; hinweis?: ReactNode; fehler?: string; mono?: boolean }) {
|
||||||
const feldId = id ?? `feld-${name}`;
|
const feldId = id ?? `feld-${name}`;
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex flex-col gap-1.5", className)}>
|
<div className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||||
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
||||||
{beschriftung}
|
{beschriftung}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={feldId}
|
id={feldId}
|
||||||
name={name}
|
name={name}
|
||||||
|
aria-invalid={fehler ? true : undefined}
|
||||||
|
aria-describedby={beschreibtDurch(feldId, hinweis, fehler)}
|
||||||
{...rest}
|
{...rest}
|
||||||
className={clsx(
|
className={clsx(EINGABE_KLASSEN, mono && "font-mono tracking-wide")}
|
||||||
"bg-bg border-border-strong text-text rounded-feld min-h-11 border px-3 text-[15px]",
|
|
||||||
"placeholder:text-muted/70 focus-visible:border-accent",
|
|
||||||
mono && "font-mono tracking-wide",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
{hinweis && <p className="text-muted text-[12.5px]">{hinweis}</p>}
|
<FeldFuss feldId={feldId} hinweis={hinweis} fehler={fehler} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||||
|
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
||||||
|
{beschriftung}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={feldId}
|
||||||
|
name={name}
|
||||||
|
rows={3}
|
||||||
|
aria-invalid={fehler ? true : undefined}
|
||||||
|
aria-describedby={beschreibtDurch(feldId, hinweis, fehler)}
|
||||||
|
{...rest}
|
||||||
|
className={clsx(EINGABE_KLASSEN, "py-2.5 leading-normal")}
|
||||||
|
/>
|
||||||
|
<FeldFuss feldId={feldId} hinweis={hinweis} fehler={fehler} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auswahlliste im Stil der Eingabefelder (#008). */
|
||||||
|
export function Auswahlliste({
|
||||||
|
beschriftung,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: ComponentProps<"select"> & { beschriftung: string }) {
|
||||||
|
const feldId = id ?? `feld-${name}`;
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||||
|
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
||||||
|
{beschriftung}
|
||||||
|
</label>
|
||||||
|
<select id={feldId} name={name} {...rest} className={EINGABE_KLASSEN}>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkbox oder Radio mit Beschriftung; die ganze Zeile ist Klickflaeche (44 px), weil das
|
||||||
|
* Kaestchen allein auf dem Telefon zu klein ist (#008).
|
||||||
|
*/
|
||||||
|
export function Ankreuzfeld({
|
||||||
|
beschriftung,
|
||||||
|
erklaerung,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: Omit<ComponentProps<"input">, "type"> & {
|
||||||
|
type?: "checkbox" | "radio";
|
||||||
|
beschriftung: ReactNode;
|
||||||
|
erklaerung?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
"hover:bg-raised/60 rounded-box flex min-h-11 min-w-0 cursor-pointer items-start gap-3 px-2 py-2.5 transition-colors",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input type="checkbox" {...rest} className="accent-accent mt-0.5 size-[18px] shrink-0 cursor-pointer" />
|
||||||
|
<span className="min-w-0 text-[14.5px] leading-snug">
|
||||||
|
<span className="text-text block break-words">{beschriftung}</span>
|
||||||
|
{erklaerung && <span className="text-muted mt-0.5 block text-[13px]">{erklaerung}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gruppe von Ankreuzfeldern mit Legende und Fehler (#008). */
|
||||||
|
export function Feldgruppe({
|
||||||
|
legende,
|
||||||
|
hinweis,
|
||||||
|
fehler,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
legende: string;
|
||||||
|
hinweis?: ReactNode;
|
||||||
|
fehler?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||||
|
<legend className="text-muted mb-1.5 text-[13px] font-medium">{legende}</legend>
|
||||||
|
{hinweis && <p className="text-muted -mt-1 text-[12.5px]">{hinweis}</p>}
|
||||||
|
{children}
|
||||||
|
{fehler && (
|
||||||
|
<p role="alert" className="text-danger text-[13px]">
|
||||||
|
{fehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Fehlermeldung eines Formulars – mit role=alert, damit Screenreader sie vorlesen. */
|
/** Fehlermeldung eines Formulars – mit role=alert, damit Screenreader sie vorlesen. */
|
||||||
export function Formularfehler({ text }: { text?: string }) {
|
export function Formularfehler({ text }: { text?: string }) {
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ export { MonoKopieren } from "./MonoKopieren";
|
|||||||
export { Seitenkopf } from "./Seitenkopf";
|
export { Seitenkopf } from "./Seitenkopf";
|
||||||
export { Statuspunkt, type HostStatus } from "./Statuspunkt";
|
export { Statuspunkt, type HostStatus } from "./Statuspunkt";
|
||||||
export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle";
|
export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle";
|
||||||
export { Feld, Formularfehler } from "./Feld";
|
export { Ankreuzfeld, Auswahlliste, Feld, Feldgruppe, Formularfehler, Textbereich } from "./Feld";
|
||||||
|
|||||||
Reference in New Issue
Block a user