Merge branch 'phase/002-design-layout'
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export default function Einstellungen() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Einstellungen"
|
||||
untertitel="Deployment-Key, eigene Vorlagen, Benachrichtigungen"
|
||||
phase={3}
|
||||
issue="#019"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Hostgruppen" };
|
||||
|
||||
export default function Hostgruppen() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Hostgruppen"
|
||||
untertitel="Hosts bündeln, um Schlüssel gemeinsam zuzuweisen"
|
||||
phase={1}
|
||||
issue="#008"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Hosts" };
|
||||
|
||||
export default function Hosts() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Hosts"
|
||||
untertitel="Verwaltete Server, Host-Keys und Erreichbarkeit"
|
||||
phase={1}
|
||||
issue="#008"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { HuellenBenutzer } from "@/components/huelle/typen";
|
||||
|
||||
/**
|
||||
* Liefert den Benutzer, den die Seitenleiste anzeigt.
|
||||
*
|
||||
* TODO (#004): durch die echte Sitzung ersetzen (Benutzer + Rolle aus der Datenbank,
|
||||
* Herkunft „authentik“ oder „Notfall-Admin“) und ohne Sitzung zur Anmeldung umleiten.
|
||||
* Bis dahin ein fester Beispielbenutzer, damit der Prototyp ohne Anmeldung läuft.
|
||||
* async schon jetzt, damit sich die Aufrufer beim Umstieg nicht ändern müssen.
|
||||
*/
|
||||
export async function huellenBenutzerErmitteln(): Promise<HuellenBenutzer> {
|
||||
return { name: "Florian Wilde", rolle: "ADMIN", herkunft: "authentik" };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Huelle } from "@/components/huelle/Huelle";
|
||||
import { huellenBenutzerErmitteln } from "./huellen-benutzer";
|
||||
|
||||
/** Alle angemeldeten Seiten teilen sich die Hülle mit Seitenleiste (#002). */
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const benutzer = await huellenBenutzerErmitteln();
|
||||
return <Huelle benutzer={benutzer}>{children}</Huelle>;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { ArrowRight, Plus, RefreshCw, Route } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
abweichungen,
|
||||
BEISPIEL_JETZT,
|
||||
baldAblaufend,
|
||||
kennzahlen,
|
||||
letzteAktionen,
|
||||
letzterAbgleich,
|
||||
type AktionsArt,
|
||||
} from "@/beispiel/uebersicht";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { Kennzahl } from "@/components/ui/Kennzahl";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
|
||||
|
||||
export const metadata: Metadata = { title: "Übersicht" };
|
||||
|
||||
const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
|
||||
ausgerollt: { text: "Ausgerollt", farbe: "text-ok" },
|
||||
widerrufen: { text: "Widerrufen", farbe: "text-danger" },
|
||||
erstellt: { text: "Erstellt", farbe: "text-text" },
|
||||
abgleich: { text: "Abgleich", farbe: "text-info" },
|
||||
rotiert: { text: "Rotiert", farbe: "text-accent" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
|
||||
* folgt in #026; Aufbau und Komponenten bleiben.
|
||||
*/
|
||||
export default function Uebersicht() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Übersicht"
|
||||
badges={<Badge variante="messing">Prototyp · Beispieldaten</Badge>}
|
||||
untertitel={
|
||||
<>
|
||||
Letzter Abgleich {relativ(letzterAbgleich, BEISPIEL_JETZT)} ·{" "}
|
||||
<time dateTime={letzterAbgleich} className="font-mono text-[13px]">
|
||||
{datumZeit(letzterAbgleich)}
|
||||
</time>
|
||||
</>
|
||||
}
|
||||
aktionen={
|
||||
<>
|
||||
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
||||
<Button href="/zuweisungen" icon={<Route className="size-4" />}>
|
||||
Host-zu-Host-Assistent
|
||||
</Button>
|
||||
<Button
|
||||
href="/schluessel"
|
||||
variante="primaer"
|
||||
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||
>
|
||||
Neuer Schlüssel
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-5 xl:grid-cols-4">
|
||||
<Kennzahl
|
||||
titel="Hosts"
|
||||
wert={kennzahlen.hosts.wert}
|
||||
hinweis={kennzahlen.hosts.hinweis}
|
||||
href="/hosts"
|
||||
/>
|
||||
<Kennzahl
|
||||
titel="Schlüssel"
|
||||
wert={kennzahlen.schluessel.wert}
|
||||
hinweis={kennzahlen.schluessel.hinweis}
|
||||
href="/schluessel"
|
||||
/>
|
||||
<Kennzahl
|
||||
titel="Abweichungen"
|
||||
variante="gefahr"
|
||||
wert={kennzahlen.abweichungen.wert}
|
||||
hinweis={kennzahlen.abweichungen.hinweis}
|
||||
href="/hosts"
|
||||
/>
|
||||
<Kennzahl
|
||||
titel="Läuft in 30 Tagen ab"
|
||||
variante="messing"
|
||||
wert={kennzahlen.ablaufend.wert}
|
||||
hinweis={kennzahlen.ablaufend.hinweis}
|
||||
href="/schluessel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
|
||||
<Karte titel="Hosts mit Abweichung" untertitel="Ergebnis des letzten Abgleichs">
|
||||
<Tabelle
|
||||
beschriftung="Hosts mit Abweichung"
|
||||
spalten={[
|
||||
{ titel: "Host" },
|
||||
{ titel: "Benutzer" },
|
||||
{ titel: "Befund" },
|
||||
{ titel: "Aktion", rechts: true, versteckt: true },
|
||||
]}
|
||||
>
|
||||
{abweichungen.map((a) => (
|
||||
<TabellenZeile key={`${a.host}-${a.benutzer}`}>
|
||||
<TabellenZelle mono className="whitespace-nowrap">
|
||||
{a.host}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<Statuspunkt status={a.status} text={a.befund} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle rechts>
|
||||
<Link
|
||||
href="/hosts"
|
||||
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
|
||||
>
|
||||
Prüfen<span className="sr-only"> – {a.host}</span>
|
||||
</Link>
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen">
|
||||
<ul className="flex flex-col">
|
||||
{baldAblaufend.map((s) => {
|
||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
||||
return (
|
||||
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
||||
<Link
|
||||
href="/schluessel/beispiel"
|
||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="text-text block truncate text-[14.5px] font-medium">{s.zweck}</span>
|
||||
<span className="text-muted block truncate text-[13px]">
|
||||
{s.person} · <span className="font-mono">{s.typ}</span>
|
||||
</span>
|
||||
</span>
|
||||
<Badge variante="messing" className="shrink-0">
|
||||
<time dateTime={s.ablauf}>{tage === 1 ? "morgen" : `in ${tage} Tagen`}</time>
|
||||
</Badge>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Karte>
|
||||
</div>
|
||||
|
||||
<Karte
|
||||
titel="Letzte Aktionen"
|
||||
aktionen={
|
||||
<Link
|
||||
href="/protokoll"
|
||||
className="text-accent inline-flex min-h-11 items-center gap-1.5 text-[14px] font-medium hover:underline"
|
||||
>
|
||||
Zum Audit-Log <ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<ol className="flex flex-col">
|
||||
{letzteAktionen.map((a) => (
|
||||
<li
|
||||
key={a.zeit}
|
||||
className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline"
|
||||
>
|
||||
<time dateTime={a.zeit} className="text-muted font-mono text-[12.5px] whitespace-nowrap">
|
||||
{datum(a.zeit)} {uhrzeit(a.zeit)}
|
||||
</time>
|
||||
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>
|
||||
<span className="text-text col-span-2 min-w-0 md:col-span-1">{a.beschreibung}</span>
|
||||
<span className="text-muted col-span-2 text-[13px] md:col-span-1 md:text-right">
|
||||
{a.benutzer}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Karte>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Personen" };
|
||||
|
||||
export default function Personen() {
|
||||
return (
|
||||
<Platzhalterseite titel="Personen" untertitel="Wem welche Schlüssel gehören" phase={2} issue="#013" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Audit-Log" };
|
||||
|
||||
export default function Protokoll() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Audit-Log"
|
||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
|
||||
phase={5}
|
||||
issue="#027"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Ban, Check, Lock, PlugZap, Plus, RotateCw } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
beispielSchluessel as s,
|
||||
dryRunDatei,
|
||||
dryRunDiff,
|
||||
privatAusgerollt,
|
||||
publicAusgerollt,
|
||||
zusatzaktionen,
|
||||
type Ausrollung,
|
||||
} from "@/beispiel/schluessel";
|
||||
import { BEISPIEL_JETZT } from "@/beispiel/uebersicht";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DiffBox } from "@/components/ui/DiffBox";
|
||||
import { InnereBox, Karte } from "@/components/ui/Karte";
|
||||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||
import { datum, datumZeit, tageBis } from "@/lib/format";
|
||||
|
||||
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
|
||||
|
||||
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
|
||||
export default function SchluesselBeispiel() {
|
||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.zweck }]} />}
|
||||
titel={s.zweck}
|
||||
badges={
|
||||
<>
|
||||
<Badge mono>{s.typ}</Badge>
|
||||
<Badge>{s.kategorie}</Badge>
|
||||
<Badge>Vorlage: {s.vorlage}</Badge>
|
||||
<Badge variante="messing">
|
||||
<span>
|
||||
läuft ab am <time dateTime={s.ablauf}>{datum(s.ablauf)}</time> · in {tage} Tagen
|
||||
</span>
|
||||
</Badge>
|
||||
<Badge variante="neutral" className="text-muted">
|
||||
Beispieldaten
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
aktionen={
|
||||
<>
|
||||
<Button icon={<PlugZap className="size-4" />}>Verbindung testen</Button>
|
||||
<Button icon={<RotateCw className="size-4" />}>Rotieren</Button>
|
||||
<Button variante="gefahr" icon={<Ban className="size-4" />}>
|
||||
Widerrufen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Karte as="div" innen="ohne">
|
||||
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
||||
<InfoFeld titel="Fingerprint">
|
||||
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
||||
</InfoFeld>
|
||||
<InfoFeld titel="Erstellt">
|
||||
<time dateTime={s.erstellt} className="font-mono text-[13px]">
|
||||
{datumZeit(s.erstellt)}
|
||||
</time>
|
||||
<span className="text-muted block text-[13px]">von {s.erstelltVon}</span>
|
||||
</InfoFeld>
|
||||
<InfoFeld titel="Private Key">
|
||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
||||
<Lock className="text-ok size-3.5" aria-hidden />
|
||||
verschlüsselt gespeichert ·
|
||||
<button
|
||||
type="button"
|
||||
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
|
||||
>
|
||||
anzeigen (Re-Auth)
|
||||
</button>
|
||||
</span>
|
||||
</InfoFeld>
|
||||
</dl>
|
||||
</Karte>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<AusrollKarte
|
||||
titel="Private Key ausgerollt auf"
|
||||
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||||
eintraege={privatAusgerollt}
|
||||
/>
|
||||
<AusrollKarte
|
||||
titel="Public Key ausgerollt auf"
|
||||
untertitel="Konten, die diesen Schlüssel annehmen"
|
||||
eintraege={publicAusgerollt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Karte
|
||||
titel="Dry-Run-Vorschau"
|
||||
untertitel={
|
||||
<>
|
||||
Abgleich für <span className="font-mono">backup@srv-pve02</span> – noch nichts geschrieben
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DiffBox datei={dryRunDatei} zeilen={dryRunDiff} />
|
||||
<fieldset className="mt-5">
|
||||
<legend className="text-muted mb-1 text-[13px] font-medium">Zusätzlich ausführen</legend>
|
||||
<ul className="flex flex-col">
|
||||
{zusatzaktionen.map((z) => (
|
||||
<li key={z.id}>
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-[14.5px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={z.id}
|
||||
defaultChecked={z.an}
|
||||
className="accent-accent size-[18px] shrink-0"
|
||||
/>
|
||||
<span>{z.text}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</fieldset>
|
||||
<div className="border-border mt-5 flex flex-wrap gap-2.5 border-t pt-5">
|
||||
<Button variante="primaer" icon={<Check className="size-4" strokeWidth={2.4} />}>
|
||||
Änderung anwenden
|
||||
</Button>
|
||||
<Button>Verwerfen</Button>
|
||||
</div>
|
||||
</Karte>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 px-5 py-4 sm:px-6">
|
||||
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
||||
<dd className="min-w-0">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AusrollKarte({
|
||||
titel,
|
||||
untertitel,
|
||||
eintraege,
|
||||
}: {
|
||||
titel: string;
|
||||
untertitel: string;
|
||||
eintraege: Ausrollung[];
|
||||
}) {
|
||||
return (
|
||||
<Karte
|
||||
titel={titel}
|
||||
untertitel={untertitel}
|
||||
aktionen={
|
||||
<Button klein icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||
Ausrollen
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{eintraege.map((e) => (
|
||||
<li key={`${e.ziel}${e.pfad}`}>
|
||||
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-text font-mono text-[13.5px] font-medium break-all">{e.ziel}</p>
|
||||
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
||||
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
||||
</div>
|
||||
<Statuspunkt status={e.status} text={e.statusText} className="shrink-0 sm:pt-0.5" />
|
||||
</InnereBox>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Karte>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Schlüssel" };
|
||||
|
||||
export default function Schluessel() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Platzhalterseite
|
||||
titel="Schlüssel"
|
||||
untertitel="SSH-Schlüssel erzeugen, importieren und ausrollen"
|
||||
phase={2}
|
||||
issue="#012"
|
||||
/>
|
||||
<p className="text-muted text-[14px]">
|
||||
Prototyp ansehen:{" "}
|
||||
<Link href="/schluessel/beispiel" className="text-accent font-medium hover:underline">
|
||||
Schlüsseldetail mit Beispieldaten
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Vorlagen" };
|
||||
|
||||
export default function Vorlagen() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Vorlagen"
|
||||
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup"
|
||||
phase={3}
|
||||
issue="#019"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Zuweisungen" };
|
||||
|
||||
export default function Zuweisungen() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Zuweisungen"
|
||||
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt"
|
||||
phase={2}
|
||||
issue="#016"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1 +1,104 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Design „Tresor“ (#002): Graphit-Flächen, eine Markenfarbe (Messing), Monospace für alles
|
||||
* Technische. Die Werte stehen als CSS-Variablen in :root, damit auch Inline-SVGs und
|
||||
* Stile ausserhalb von Tailwind dieselben Tokens nutzen; @theme reicht sie an Tailwind
|
||||
* weiter (bg-surface, text-muted, border-strong …).
|
||||
*
|
||||
* Es gibt bewusst nur den dunklen Modus. Ein heller folgt in einer eigenen Phase (PLAN §5).
|
||||
*/
|
||||
:root {
|
||||
--bg: #0d1013;
|
||||
--sidebar: #11151a;
|
||||
--surface: #151a1f;
|
||||
--raised: #1d232a;
|
||||
--border: #222930;
|
||||
--border-strong: #2a323b;
|
||||
--text: #e8ecef;
|
||||
--text-muted: #8b96a3;
|
||||
--accent: #f0b43c;
|
||||
--accent-soft: #3a2e14;
|
||||
--ok: #3cc9a7;
|
||||
--danger: #f2714b;
|
||||
--danger-soft: #2a1814;
|
||||
--danger-border: #6b3324;
|
||||
--info: #6aa8ff;
|
||||
|
||||
--schrift-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||
--schrift-text: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
--schrift-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||
|
||||
--seitenleiste-breite: 248px;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* inline: Tailwind-Klassen verweisen auf die Variablen statt die Werte zu kopieren. */
|
||||
@theme inline {
|
||||
--color-bg: var(--bg);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-surface: var(--surface);
|
||||
--color-raised: var(--raised);
|
||||
--color-border: var(--border);
|
||||
--color-border-strong: var(--border-strong);
|
||||
--color-text: var(--text);
|
||||
--color-muted: var(--text-muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-ok: var(--ok);
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-soft: var(--danger-soft);
|
||||
--color-danger-border: var(--danger-border);
|
||||
--color-info: var(--info);
|
||||
|
||||
--font-display: var(--schrift-display);
|
||||
--font-sans: var(--schrift-text);
|
||||
--font-mono: var(--schrift-mono);
|
||||
|
||||
--radius-karte: 16px;
|
||||
--radius-box: 12px;
|
||||
--radius-feld: 10px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--schrift-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
background: var(--bg);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--schrift-display);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Fokus immer sichtbar und in Messing – auch auf Elementen ohne eigenen Fokusstil. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="7" fill="none" stroke="#E8ECEF" stroke-width="3"/>
|
||||
<circle cx="16" cy="13.5" r="4.6" fill="#F0B43C"/>
|
||||
<path d="M13.8 15.5 L12 23.5 H20 L18.2 15.5 Z" fill="#F0B43C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
+18
-1
@@ -1,10 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Schriften selbst gehostet (#002): keine CDNs, nur die Gewichte, die das Design nutzt,
|
||||
// und nur der Latin-Ausschnitt (enthält die deutschen Umlaute und ß).
|
||||
import "@fontsource/space-grotesk/latin-500.css";
|
||||
import "@fontsource/space-grotesk/latin-600.css";
|
||||
import "@fontsource/space-grotesk/latin-700.css";
|
||||
import "@fontsource/ibm-plex-sans/latin-400.css";
|
||||
import "@fontsource/ibm-plex-sans/latin-500.css";
|
||||
import "@fontsource/ibm-plex-sans/latin-600.css";
|
||||
import "@fontsource/jetbrains-mono/latin-400.css";
|
||||
import "@fontsource/jetbrains-mono/latin-500.css";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: "SSHKeyVault", template: "%s · SSHKeyVault" },
|
||||
description: "Zentrale Verwaltung von SSH-Schlüsseln",
|
||||
applicationName: "SSHKeyVault",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#0D1013",
|
||||
colorScheme: "dark",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
/** Web-App-Manifest; die Icons erzeugt scripts/icons.mjs (#002). */
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "SSHKeyVault",
|
||||
short_name: "sshkv",
|
||||
description: "Zentrale Verwaltung von SSH-Schlüsseln",
|
||||
lang: "de",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#0D1013",
|
||||
theme_color: "#0D1013",
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
{ src: "/brand/favicon.svg", sizes: "any", type: "image/svg+xml" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Startseite() {
|
||||
return <main>SSHKeyVault</main>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* BEISPIELDATEN für den Prototyp des Schlüsseldetails (#002) – kein echter Schlüssel.
|
||||
* Ersetzt durch das Lesemodell aus #012/#016/#017.
|
||||
*/
|
||||
import type { HostStatus } from "@/components/ui/Statuspunkt";
|
||||
import type { DiffZeile } from "@/components/ui/DiffBox";
|
||||
|
||||
export const beispielSchluessel = {
|
||||
zweck: "Backup srv-backup01 → srv-pve01",
|
||||
typ: "ed25519",
|
||||
kategorie: "Dienst",
|
||||
vorlage: "Backup-Zugang",
|
||||
ablauf: "2026-10-30T10:00:00Z",
|
||||
fingerprint: "SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k",
|
||||
erstellt: "2026-04-30T08:12:00Z",
|
||||
erstelltVon: "Florian Wilde",
|
||||
};
|
||||
|
||||
export type Ausrollung = {
|
||||
ziel: string;
|
||||
pfad: string;
|
||||
zusatz?: string;
|
||||
status: HostStatus;
|
||||
statusText?: string;
|
||||
};
|
||||
|
||||
export const privatAusgerollt: Ausrollung[] = [
|
||||
{
|
||||
ziel: "backup@srv-backup01",
|
||||
pfad: "~/.ssh/id_ed25519_pve01",
|
||||
zusatz: "config: Host srv-pve01",
|
||||
status: "synchron",
|
||||
},
|
||||
];
|
||||
|
||||
export const publicAusgerollt: Ausrollung[] = [
|
||||
{
|
||||
ziel: "backup@srv-pve01",
|
||||
pfad: "~/.ssh/authorized_keys",
|
||||
zusatz: 'from="10.20.0.15",no-pty,no-agent-forwarding',
|
||||
status: "synchron",
|
||||
},
|
||||
{
|
||||
ziel: "backup@srv-pve02",
|
||||
pfad: "~/.ssh/authorized_keys",
|
||||
zusatz: 'from="10.20.0.15",no-pty',
|
||||
status: "drift",
|
||||
statusText: "Optionen geändert",
|
||||
},
|
||||
];
|
||||
|
||||
export const dryRunDatei = "backup@srv-pve02:~/.ssh/authorized_keys";
|
||||
|
||||
export const dryRunDiff: DiffZeile[] = [
|
||||
{ art: "kontext", text: "# --- sshkv verwaltet: Anfang ---" },
|
||||
{
|
||||
art: "kontext",
|
||||
text: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMvq2Lr7… monitoring@zabbix",
|
||||
},
|
||||
{
|
||||
art: "entfernt",
|
||||
text: 'from="10.20.0.15",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01',
|
||||
},
|
||||
{
|
||||
art: "hinzu",
|
||||
text: 'from="10.20.0.15",no-pty,no-agent-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01',
|
||||
},
|
||||
{ art: "kontext", text: "# --- sshkv verwaltet: Ende ---" },
|
||||
];
|
||||
|
||||
export const zusatzaktionen = [
|
||||
{ id: "backup", text: "Backup der Datei anlegen (authorized_keys.sshkv-bak)", an: true },
|
||||
{ id: "config", text: "~/.ssh/config-Eintrag auf srv-backup01 anlegen", an: true },
|
||||
{ id: "knownhosts", text: "known_hosts auf srv-backup01 aktualisieren", an: false },
|
||||
];
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* BEISPIELDATEN für den klickbaren Prototyp (#002) – keine echten Hosts oder Schlüssel.
|
||||
* Die echten Lesemodelle kommen in #021 (Abgleich) und #026 (Übersicht); dann entfällt
|
||||
* diese Datei. Alle Zeiten beziehen sich auf BEISPIEL_JETZT, damit der statisch gebaute
|
||||
* Prototyp nicht mit jedem Build andere Relativangaben zeigt.
|
||||
*/
|
||||
import type { HostStatus } from "@/components/ui/Statuspunkt";
|
||||
|
||||
export const BEISPIEL_JETZT = "2026-10-12T07:00:00Z"; // 09:00 Berliner Zeit
|
||||
|
||||
export const letzterAbgleich = "2026-10-12T06:42:00Z";
|
||||
|
||||
export const kennzahlen = {
|
||||
hosts: { wert: 42, hinweis: "38 synchron · 1 nicht erreichbar" },
|
||||
schluessel: { wert: 117, hinweis: "davon 9 mit Private Key" },
|
||||
abweichungen: { wert: 3, hinweis: "auf 3 Hosts" },
|
||||
ablaufend: { wert: 5, hinweis: "in den nächsten 30 Tagen" },
|
||||
};
|
||||
|
||||
export type Abweichung = {
|
||||
host: string;
|
||||
benutzer: string;
|
||||
status: HostStatus;
|
||||
befund: string;
|
||||
};
|
||||
|
||||
export const abweichungen: Abweichung[] = [
|
||||
{ host: "srv-web03.intern", benutzer: "root", status: "drift", befund: "1 fremder Schlüssel" },
|
||||
{ host: "srv-db01.intern", benutzer: "deploy", status: "drift", befund: "Schlüssel fehlt" },
|
||||
{ host: "fw-edge01.intern", benutzer: "sshkv", status: "drift", befund: "Optionen geändert" },
|
||||
{ host: "srv-legacy02.intern", benutzer: "root", status: "unerreichbar", befund: "nicht erreichbar" },
|
||||
];
|
||||
|
||||
export type AblaufEintrag = { zweck: string; person: string; typ: string; ablauf: string };
|
||||
|
||||
export const baldAblaufend: AblaufEintrag[] = [
|
||||
{ zweck: "Laptop Florian", person: "Florian Wilde", typ: "ed25519", ablauf: "2026-10-16T10:00:00Z" },
|
||||
{ zweck: "CI-Deployment Shop", person: "Dienst", typ: "ed25519", ablauf: "2026-10-21T10:00:00Z" },
|
||||
{
|
||||
zweck: "Backup srv-backup01 → srv-pve01",
|
||||
person: "Dienst",
|
||||
typ: "ed25519",
|
||||
ablauf: "2026-10-30T10:00:00Z",
|
||||
},
|
||||
{ zweck: "Notebook Sandra", person: "Sandra Keller", typ: "rsa-4096", ablauf: "2026-11-04T10:00:00Z" },
|
||||
{ zweck: "Monitoring Zabbix", person: "Dienst", typ: "ed25519", ablauf: "2026-11-09T10:00:00Z" },
|
||||
];
|
||||
|
||||
export type AktionsArt = "ausgerollt" | "widerrufen" | "erstellt" | "abgleich" | "rotiert";
|
||||
|
||||
export type LetzteAktion = {
|
||||
zeit: string;
|
||||
art: AktionsArt;
|
||||
beschreibung: string;
|
||||
benutzer: string;
|
||||
};
|
||||
|
||||
export const letzteAktionen: LetzteAktion[] = [
|
||||
{
|
||||
zeit: "2026-10-12T06:42:00Z",
|
||||
art: "abgleich",
|
||||
beschreibung: "Abgleich aller Hosts: 3 Abweichungen",
|
||||
benutzer: "System",
|
||||
},
|
||||
{
|
||||
zeit: "2026-10-12T06:15:00Z",
|
||||
art: "ausgerollt",
|
||||
beschreibung: "Public Key „Backup srv-backup01 → srv-pve01“ auf backup@srv-pve01",
|
||||
benutzer: "Florian Wilde",
|
||||
},
|
||||
{
|
||||
zeit: "2026-10-11T15:30:00Z",
|
||||
art: "widerrufen",
|
||||
beschreibung: "Schlüssel „Laptop alt Thomas“ von 6 Hosts entfernt",
|
||||
benutzer: "Florian Wilde",
|
||||
},
|
||||
{
|
||||
zeit: "2026-10-11T12:04:00Z",
|
||||
art: "rotiert",
|
||||
beschreibung: "CI-Deployment Shop durch neuen ed25519-Schlüssel ersetzt",
|
||||
benutzer: "Sandra Keller",
|
||||
},
|
||||
{
|
||||
zeit: "2026-10-11T09:20:00Z",
|
||||
art: "erstellt",
|
||||
beschreibung: "Schlüssel „Monitoring Zabbix“ aus Vorlage Deployment-Zugang",
|
||||
benutzer: "Sandra Keller",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,91 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Bildmarke, Kurzname, Marke, Wortmarke } from "@/components/marke";
|
||||
import { VERSION } from "@/lib/version";
|
||||
import { Navigation } from "./Navigation";
|
||||
import { SeitenleistenRahmen } from "./SeitenleistenRahmen";
|
||||
import { ROLLEN_TEXT, type HuellenBenutzer } from "./typen";
|
||||
|
||||
/**
|
||||
* Grundlayout (#002): feste Seitenleiste links (248 px), Hauptbereich rechts.
|
||||
* Benutzer und aktiver Pfad kommen als Props – die Hülle kennt weder Sitzung noch
|
||||
* Datenbank, damit sie in Tests und Prototypen ohne Anmeldung rendert.
|
||||
*/
|
||||
export function Huelle({
|
||||
benutzer,
|
||||
aktiverPfad,
|
||||
children,
|
||||
}: {
|
||||
benutzer: HuellenBenutzer;
|
||||
/** Ohne Angabe ermittelt die Navigation den Pfad im Browser. */
|
||||
aktiverPfad?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-bg min-h-dvh lg:flex">
|
||||
<a
|
||||
href="#inhalt"
|
||||
className="bg-accent text-bg rounded-box sr-only z-[60] px-4 py-3 font-semibold focus:not-sr-only focus:fixed focus:top-3 focus:left-3"
|
||||
>
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
|
||||
<SeitenleistenRahmen
|
||||
kopf={
|
||||
<Link href="/" className="rounded-box flex min-h-11 items-center gap-2 px-1">
|
||||
<Bildmarke groesse={28} />
|
||||
<Wortmarke groesse={19} />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-y-auto px-4 pt-6 pb-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-box mb-8 flex items-center px-2 py-1"
|
||||
aria-label="SSHKeyVault – Übersicht"
|
||||
>
|
||||
<Marke bildGroesse={34} textGroesse={22} />
|
||||
</Link>
|
||||
|
||||
<Navigation aktiverPfad={aktiverPfad} />
|
||||
|
||||
<div className="mt-auto pt-6">
|
||||
<BenutzerKasten benutzer={benutzer} />
|
||||
<p className="text-muted mt-3 px-2 font-mono text-[11.5px]">
|
||||
<Kurzname /> v{VERSION}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SeitenleistenRahmen>
|
||||
|
||||
<main id="inhalt" tabIndex={-1} className="min-w-0 flex-1 outline-none">
|
||||
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-8 sm:py-8 xl:px-10">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BenutzerKasten({ benutzer }: { benutzer: HuellenBenutzer }) {
|
||||
const kuerzel = benutzer.name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((t) => t[0]?.toUpperCase())
|
||||
.join("");
|
||||
return (
|
||||
<div className="border-border bg-surface rounded-box flex items-center gap-3 border p-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="bg-raised text-accent font-display flex size-9 shrink-0 items-center justify-center rounded-full text-[13px] font-semibold"
|
||||
>
|
||||
{kuerzel}
|
||||
</span>
|
||||
<div className="min-w-0 text-[13px] leading-snug">
|
||||
<p className="text-text truncate text-[14px] font-medium">{benutzer.name}</p>
|
||||
<p className="text-muted truncate">
|
||||
{ROLLEN_TEXT[benutzer.rolle]} · über {benutzer.herkunft}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { HAUPTNAVIGATION, istAktiv, NEBENNAVIGATION, type NavPunkt } from "./nav-punkte";
|
||||
|
||||
/**
|
||||
* Navigation der Seitenleiste. Client-Komponente, weil ein Layout den aktuellen Pfad
|
||||
* nicht kennt: Ohne `aktiverPfad` nimmt sie ihn aus usePathname(); Seiten oder Tests
|
||||
* können ihn ausdrücklich vorgeben.
|
||||
*/
|
||||
export function Navigation({ aktiverPfad }: { aktiverPfad?: string }) {
|
||||
const routerPfad = usePathname();
|
||||
const pfad = aktiverPfad ?? routerPfad ?? "/";
|
||||
return (
|
||||
<nav aria-label="Hauptnavigation" className="flex flex-col gap-1">
|
||||
<NavListe punkte={HAUPTNAVIGATION} pfad={pfad} />
|
||||
<hr className="border-border mx-2 my-3" />
|
||||
<NavListe punkte={NEBENNAVIGATION} pfad={pfad} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function NavListe({ punkte, pfad }: { punkte: NavPunkt[]; pfad: string }) {
|
||||
return (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{punkte.map(({ href, titel, icon: Icon }) => {
|
||||
const aktiv = istAktiv(href, pfad);
|
||||
return (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
aria-current={aktiv ? "page" : undefined}
|
||||
className={clsx(
|
||||
"rounded-box flex min-h-11 items-center gap-3 px-3 text-[14.5px] transition-colors",
|
||||
aktiv
|
||||
? "bg-raised text-accent font-semibold"
|
||||
: "text-muted hover:bg-raised/60 hover:text-text font-medium",
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden className="size-[18px] shrink-0" strokeWidth={aktiv ? 2.2 : 1.8} />
|
||||
{titel}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Construction } from "lucide-react";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
|
||||
/**
|
||||
* Für Navigationsziele, die erst in späteren Phasen gebaut werden (#002) – damit die
|
||||
* Navigation nie auf eine 404 führt.
|
||||
*/
|
||||
export function Platzhalterseite({
|
||||
titel,
|
||||
untertitel,
|
||||
phase,
|
||||
issue,
|
||||
}: {
|
||||
titel: string;
|
||||
untertitel?: string;
|
||||
phase: number;
|
||||
/** Issue-Nummer, z. B. "#008". */
|
||||
issue?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-7">
|
||||
<Seitenkopf titel={titel} untertitel={untertitel} />
|
||||
<Leerzustand
|
||||
icon={<Construction className="size-5" />}
|
||||
titel={`Folgt in Phase ${phase}`}
|
||||
text={
|
||||
<>
|
||||
Diese Ansicht ist noch nicht gebaut
|
||||
{issue && (
|
||||
<>
|
||||
{" "}
|
||||
– geplant in <span className="font-mono">{issue}</span>
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Schaltet die Seitenleiste unter 1024 px ein und aus (#002). Der Inhalt der Leiste kommt
|
||||
* als Server-gerenderte children; nur das Auf/Zu lebt im Browser.
|
||||
*
|
||||
* Geschlossen ist die Leiste mobil `invisible` und damit auch aus der Tab-Reihenfolge –
|
||||
* nur verschieben würde unsichtbare Links fokussierbar lassen.
|
||||
*/
|
||||
export function SeitenleistenRahmen({ kopf, children }: { kopf: ReactNode; children: ReactNode }) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const knopf = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!offen) return;
|
||||
function taste(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setOffen(false);
|
||||
knopf.current?.focus();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", taste);
|
||||
return () => window.removeEventListener("keydown", taste);
|
||||
}, [offen]);
|
||||
|
||||
// Ein Klick auf einen Link in der Leiste navigiert – danach soll das Menü zu sein.
|
||||
function klickInLeiste(e: MouseEvent<HTMLElement>) {
|
||||
if ((e.target as HTMLElement).closest("a")) setOffen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Kopfleiste nur auf schmalen Bildschirmen */}
|
||||
<div className="border-border bg-sidebar sticky top-0 z-30 flex h-14 items-center gap-2 border-b px-2 lg:hidden">
|
||||
<button
|
||||
ref={knopf}
|
||||
type="button"
|
||||
onClick={() => setOffen((o) => !o)}
|
||||
aria-expanded={offen}
|
||||
aria-controls="seitenleiste"
|
||||
aria-label={offen ? "Menü schließen" : "Menü öffnen"}
|
||||
className="text-text hover:bg-raised rounded-box inline-flex size-11 items-center justify-center"
|
||||
>
|
||||
{offen ? <X className="size-5" aria-hidden /> : <Menu className="size-5" aria-hidden />}
|
||||
</button>
|
||||
{kopf}
|
||||
</div>
|
||||
|
||||
{offen && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="fixed inset-0 z-40 bg-black/60 lg:hidden"
|
||||
onClick={() => setOffen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ab 1024 px eine eigene Spalte über die volle Seitenhöhe; die Leiste darin klebt
|
||||
oben (sticky), damit Hintergrund und Rahmen auch bei langen Seiten durchlaufen. */}
|
||||
<div className="lg:border-border lg:bg-sidebar lg:w-[var(--seitenleiste-breite)] lg:shrink-0 lg:border-r">
|
||||
<aside
|
||||
id="seitenleiste"
|
||||
onClick={klickInLeiste}
|
||||
className={clsx(
|
||||
"border-border bg-sidebar fixed inset-y-0 left-0 z-50 flex w-[280px] max-w-[85vw] flex-col border-r",
|
||||
"transition-[transform,visibility] duration-200 lg:visible lg:translate-x-0 lg:transition-none",
|
||||
"lg:sticky lg:top-0 lg:bottom-auto lg:h-dvh lg:w-full lg:max-w-none lg:border-r-0",
|
||||
offen ? "visible translate-x-0" : "invisible -translate-x-full",
|
||||
)}
|
||||
>
|
||||
{/* Der Abdunkler verdeckt den Menü-Button oben, daher ein eigener Schließen-Knopf. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOffen(false);
|
||||
knopf.current?.focus();
|
||||
}}
|
||||
aria-label="Menü schließen"
|
||||
className="text-muted hover:bg-raised hover:text-text rounded-box absolute top-4 right-2 inline-flex size-11 items-center justify-center lg:hidden"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
{children}
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Huelle } from "./Huelle";
|
||||
export { Platzhalterseite } from "./Platzhalterseite";
|
||||
export { ROLLEN_TEXT, type HuellenBenutzer, type Rolle } from "./typen";
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Boxes,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LayoutTemplate,
|
||||
Network,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export type NavPunkt = { href: string; titel: string; icon: LucideIcon };
|
||||
|
||||
/** Hauptnavigation (#002). Reihenfolge folgt dem Arbeitsablauf: Hosts → Schlüssel → Zuweisen. */
|
||||
export const HAUPTNAVIGATION: NavPunkt[] = [
|
||||
{ href: "/", titel: "Übersicht", icon: LayoutDashboard },
|
||||
{ href: "/hosts", titel: "Hosts", icon: Server },
|
||||
{ href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes },
|
||||
{ href: "/schluessel", titel: "Schlüssel", icon: KeyRound },
|
||||
{ href: "/personen", titel: "Personen", icon: Users },
|
||||
{ href: "/zuweisungen", titel: "Zuweisungen", icon: Network },
|
||||
{ href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate },
|
||||
];
|
||||
|
||||
/** Unter dem Trenner: Nachvollziehen und Verwalten statt Tagesgeschäft. */
|
||||
export const NEBENNAVIGATION: NavPunkt[] = [
|
||||
{ href: "/protokoll", titel: "Audit-Log", icon: ScrollText },
|
||||
{ href: "/einstellungen", titel: "Einstellungen", icon: Settings },
|
||||
];
|
||||
|
||||
/** „/“ nur exakt, sonst zählt auch jede Unterseite (/schluessel/abc → Schlüssel). */
|
||||
export function istAktiv(href: string, pfad: string): boolean {
|
||||
if (href === "/") return pfad === "/";
|
||||
return pfad === href || pfad.startsWith(`${href}/`);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type Rolle = "ADMIN" | "OPERATOR" | "LESER";
|
||||
|
||||
/**
|
||||
* Was die Hülle vom angemeldeten Benutzer wissen muss – bewusst nur Anzeigedaten,
|
||||
* damit die Hülle unabhängig von Sitzung und Datenbank bleibt (#002, #004).
|
||||
*/
|
||||
export type HuellenBenutzer = { name: string; rolle: Rolle; herkunft: string };
|
||||
|
||||
export const ROLLEN_TEXT: Record<Rolle, string> = {
|
||||
ADMIN: "Admin",
|
||||
OPERATOR: "Operator",
|
||||
LESER: "Leser",
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Bildmarke (Tresortür mit Schlüsselloch) als Inline-SVG (#002).
|
||||
*
|
||||
* Inline statt <img src="/brand/logo.svg">, damit die Marke ohne zusätzliche Anfrage
|
||||
* sofort steht und die Farben aus den CSS-Tokens kommen. Geometrie identisch mit
|
||||
* public/brand/logo.svg – Änderungen bitte an beiden Stellen und `pnpm icons` laufen lassen.
|
||||
*/
|
||||
export function Bildmarke({
|
||||
groesse = 34,
|
||||
titel,
|
||||
className,
|
||||
}: {
|
||||
/** Kantenlänge in px. */
|
||||
groesse?: number;
|
||||
/** Zugänglicher Name; ohne Titel gilt die Marke als Schmuck (neben der Wortmarke). */
|
||||
titel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const linie = "var(--text)";
|
||||
const messing = "var(--accent)";
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 64 64"
|
||||
width={groesse}
|
||||
height={groesse}
|
||||
className={className}
|
||||
{...(titel ? { role: "img", "aria-label": titel } : { "aria-hidden": true, focusable: false })}
|
||||
>
|
||||
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke={linie} strokeWidth="3.5" />
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="19"
|
||||
fill="none"
|
||||
stroke={linie}
|
||||
strokeOpacity="0.28"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="3 3.2"
|
||||
/>
|
||||
<circle cx="13.5" cy="13.5" r="2.2" fill={linie} />
|
||||
<circle cx="50.5" cy="13.5" r="2.2" fill={linie} />
|
||||
<circle cx="13.5" cy="50.5" r="2.2" fill={linie} />
|
||||
<circle cx="50.5" cy="50.5" r="2.2" fill={linie} />
|
||||
<circle cx="32" cy="27" r="7.5" fill={messing} />
|
||||
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill={messing} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { clsx } from "clsx";
|
||||
import { Bildmarke } from "./Bildmarke";
|
||||
|
||||
/** „SSHKey“ in Textfarbe, „Vault“ in Messing – Space Grotesk 700 (#002). */
|
||||
export function Wortmarke({ groesse = 22, className }: { groesse?: number; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={clsx("font-display leading-none font-bold whitespace-nowrap", className)}
|
||||
style={{ fontSize: groesse, letterSpacing: "-0.02em" }}
|
||||
>
|
||||
<span className="text-text">SSHKey</span>
|
||||
<span className="text-accent">Vault</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kurzname immer kleingeschrieben in JetBrains Mono. */
|
||||
export function Kurzname({ className }: { className?: string }) {
|
||||
return <span className={clsx("font-mono lowercase", className)}>sshkv</span>;
|
||||
}
|
||||
|
||||
/** Bildmarke + Wortmarke nebeneinander, Standardgrößen wie in der Seitenleiste (34/22 px). */
|
||||
export function Marke({
|
||||
bildGroesse = 34,
|
||||
textGroesse = 22,
|
||||
className,
|
||||
}: {
|
||||
bildGroesse?: number;
|
||||
textGroesse?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={clsx("inline-flex items-center gap-2.5", className)}>
|
||||
<Bildmarke groesse={bildGroesse} />
|
||||
<Wortmarke groesse={textGroesse} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Bildmarke } from "./Bildmarke";
|
||||
export { Kurzname, Marke, Wortmarke } from "./Wortmarke";
|
||||
@@ -0,0 +1,38 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type BadgeVariante = "neutral" | "messing" | "ok" | "gefahr";
|
||||
|
||||
const VARIANTEN: Record<BadgeVariante, string> = {
|
||||
neutral: "bg-raised text-text border-border-strong",
|
||||
// Messing auf --accent-soft: Ablaufhinweise und Vorlagen-Kennzeichnung.
|
||||
messing: "bg-accent-soft text-accent border-[#5a4519]",
|
||||
ok: "bg-[#12261f] text-ok border-[#1f4a3d]",
|
||||
gefahr: "bg-danger-soft text-danger border-danger-border",
|
||||
};
|
||||
|
||||
/** Pillenförmiges Etikett; `mono` für technische Werte wie Schlüsseltypen (#002). */
|
||||
export function Badge({
|
||||
variante = "neutral",
|
||||
mono = false,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
variante?: BadgeVariante;
|
||||
mono?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12.5px] leading-5 whitespace-nowrap",
|
||||
mono ? "font-mono font-medium" : "font-medium",
|
||||
VARIANTEN[variante],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
|
||||
export type BreadcrumbEintrag = { titel: string; href?: string };
|
||||
|
||||
/** Brotkrumen-Navigation; der letzte Eintrag ist die aktuelle Seite (#002). */
|
||||
export function Breadcrumb({ pfad }: { pfad: BreadcrumbEintrag[] }) {
|
||||
return (
|
||||
<nav aria-label="Brotkrumen" className="text-muted text-[13.5px]">
|
||||
<ol className="flex flex-wrap items-center gap-x-1 gap-y-1">
|
||||
{pfad.map((e, i) => {
|
||||
const letzter = i === pfad.length - 1;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<li className="min-w-0">
|
||||
{e.href && !letzter ? (
|
||||
<Link href={e.href} className="hover:text-text rounded transition-colors">
|
||||
{e.titel}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-current={letzter ? "page" : undefined} className={letzter ? "text-text" : ""}>
|
||||
{e.titel}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
{!letzter && (
|
||||
<li aria-hidden className="flex">
|
||||
<ChevronRight className="size-3.5" />
|
||||
</li>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { clsx } from "clsx";
|
||||
import Link from "next/link";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
export type ButtonVariante = "primaer" | "sekundaer" | "gefahr";
|
||||
|
||||
const VARIANTEN: Record<ButtonVariante, string> = {
|
||||
primaer: "bg-accent text-bg font-semibold border-accent hover:bg-[#f5c25c]",
|
||||
sekundaer: "bg-surface text-text font-medium border-border-strong hover:bg-raised",
|
||||
gefahr: "bg-transparent text-danger font-medium border-danger-border hover:bg-danger-soft",
|
||||
};
|
||||
|
||||
/** Gemeinsame Klassen – auch für Elemente, die wie ein Button aussehen sollen. */
|
||||
export function buttonKlassen(variante: ButtonVariante = "sekundaer", klein = false, className?: string) {
|
||||
return clsx(
|
||||
// 44 px Mindesthöhe als Klickfläche (WCAG 2.5.5), auch in der kleinen Variante.
|
||||
"inline-flex min-h-11 items-center justify-center gap-2 rounded-box border px-4 text-[14px] leading-tight whitespace-nowrap transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
klein && "px-3 text-[13px]",
|
||||
VARIANTEN[variante],
|
||||
className,
|
||||
);
|
||||
}
|
||||
|
||||
type GemeinsameProps = {
|
||||
variante?: ButtonVariante;
|
||||
/** Lucide-Icon o. Ä. vor dem Text. */
|
||||
icon?: ReactNode;
|
||||
klein?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AlsButton = GemeinsameProps & Omit<ComponentProps<"button">, keyof GemeinsameProps> & { href?: never };
|
||||
type AlsLink = GemeinsameProps & Omit<ComponentProps<typeof Link>, keyof GemeinsameProps> & { href: string };
|
||||
|
||||
/** Button in den Varianten primär/sekundär/Gefahr; mit `href` wird daraus ein Link. */
|
||||
export function Button(props: AlsButton | AlsLink) {
|
||||
const { variante = "sekundaer", icon, klein = false, className, children, ...rest } = props;
|
||||
const klassen = buttonKlassen(variante, klein, className);
|
||||
const inhalt = (
|
||||
<>
|
||||
{icon && (
|
||||
<span aria-hidden className="-ml-0.5 inline-flex shrink-0">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
if ("href" in rest && rest.href !== undefined) {
|
||||
return (
|
||||
<Link {...(rest as Omit<AlsLink, keyof GemeinsameProps>)} className={klassen}>
|
||||
{inhalt}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
const buttonRest = rest as Omit<AlsButton, keyof GemeinsameProps>;
|
||||
return (
|
||||
<button type="button" {...buttonRest} className={klassen}>
|
||||
{inhalt}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type DiffZeile = { art: "hinzu" | "entfernt" | "kontext"; text: string };
|
||||
|
||||
const ZEICHEN = { hinzu: "+", entfernt: "−", kontext: " " } as const;
|
||||
const LESBAR = { hinzu: "hinzugefügt", entfernt: "entfernt", kontext: "unverändert" } as const;
|
||||
|
||||
/**
|
||||
* Diff-Vorschau (Dry-Run) für authorized_keys, ~/.ssh/config & Co. (#002).
|
||||
* Entfernte Zeilen Koralle auf --danger-soft, hinzugefügte in --ok. Lange Schlüsselzeilen
|
||||
* brechen um statt die Seite zu verbreitern – ein Public Key ist sonst 400+ Zeichen breit.
|
||||
*/
|
||||
export function DiffBox({
|
||||
zeilen,
|
||||
datei,
|
||||
fuss,
|
||||
className,
|
||||
}: {
|
||||
zeilen: DiffZeile[];
|
||||
/** Pfad der Datei als Kopfzeile, z. B. „backup@srv-pve01:~/.ssh/authorized_keys“. */
|
||||
datei?: string;
|
||||
fuss?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<figure className={clsx("border-border bg-bg rounded-box min-w-0 overflow-hidden border", className)}>
|
||||
{datei && (
|
||||
<figcaption className="border-border text-muted border-b px-4 py-2.5 font-mono text-[12.5px] break-all">
|
||||
{datei}
|
||||
</figcaption>
|
||||
)}
|
||||
<ol className="py-2 font-mono text-[12.5px] leading-[1.6]">
|
||||
{zeilen.map((z, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={clsx(
|
||||
"grid grid-cols-[1.75rem_1fr] px-2",
|
||||
z.art === "entfernt" && "bg-danger-soft text-danger",
|
||||
z.art === "hinzu" && "bg-[#0f1f1b] text-ok",
|
||||
z.art === "kontext" && "text-muted",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="text-center select-none">
|
||||
{ZEICHEN[z.art]}
|
||||
</span>
|
||||
<span className="sr-only">{LESBAR[z.art]}: </span>
|
||||
<span className="min-w-0 pr-2 break-all whitespace-pre-wrap">{z.text || " "}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{fuss && <div className="border-border border-t px-4 py-3">{fuss}</div>}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Karte: --surface, 1 px Rahmen, 16 px Radius, 22–24 px Innenabstand (#002).
|
||||
* Mit `titel` entsteht ein Kopf mit optionalen Aktionen rechts.
|
||||
*/
|
||||
export function Karte({
|
||||
titel,
|
||||
untertitel,
|
||||
aktionen,
|
||||
children,
|
||||
className,
|
||||
innen = "normal",
|
||||
as: Element = "section",
|
||||
}: {
|
||||
titel?: ReactNode;
|
||||
untertitel?: ReactNode;
|
||||
aktionen?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
/** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */
|
||||
innen?: "normal" | "ohne";
|
||||
as?: "section" | "div" | "article";
|
||||
}) {
|
||||
return (
|
||||
<Element
|
||||
className={clsx(
|
||||
"border-border bg-surface min-w-0 rounded-karte border",
|
||||
innen === "normal" && "p-5 sm:p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{(titel || aktionen) && (
|
||||
<KartenKopf titel={titel} untertitel={untertitel} aktionen={aktionen} rand={innen === "ohne"} />
|
||||
)}
|
||||
{children}
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
function KartenKopf({
|
||||
titel,
|
||||
untertitel,
|
||||
aktionen,
|
||||
rand,
|
||||
}: {
|
||||
titel?: ReactNode;
|
||||
untertitel?: ReactNode;
|
||||
aktionen?: ReactNode;
|
||||
rand: boolean;
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
className={clsx(
|
||||
"mb-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-2",
|
||||
rand && "px-5 pt-5 sm:px-6 sm:pt-6",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
{titel && <h2 className="text-text text-[17px] leading-snug font-semibold">{titel}</h2>}
|
||||
{untertitel && <p className="text-muted mt-0.5 text-sm">{untertitel}</p>}
|
||||
</div>
|
||||
{aktionen && <div className="flex shrink-0 flex-wrap items-center gap-2">{aktionen}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/** Innere Box (--sidebar, 12 px Radius) – für Einträge innerhalb einer Karte. */
|
||||
export function InnereBox({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={clsx("border-border bg-sidebar rounded-box min-w-0 border p-4", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { clsx } from "clsx";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type KennzahlVariante = "neutral" | "gefahr" | "messing";
|
||||
|
||||
const WERTFARBE: Record<KennzahlVariante, string> = {
|
||||
neutral: "text-text",
|
||||
gefahr: "text-danger",
|
||||
messing: "text-accent",
|
||||
};
|
||||
|
||||
/** Kennzahl-Kachel: große Zahl in Space Grotesk, Bezeichnung darüber, Hinweis darunter (#002). */
|
||||
export function Kennzahl({
|
||||
titel,
|
||||
wert,
|
||||
hinweis,
|
||||
variante = "neutral",
|
||||
href,
|
||||
}: {
|
||||
titel: string;
|
||||
wert: ReactNode;
|
||||
hinweis?: ReactNode;
|
||||
variante?: KennzahlVariante;
|
||||
/** Macht die ganze Kachel zum Link (z. B. auf die gefilterte Liste). */
|
||||
href?: string;
|
||||
}) {
|
||||
const inhalt = (
|
||||
<>
|
||||
<p className="text-muted text-[14px] font-medium">{titel}</p>
|
||||
<p
|
||||
className={clsx(
|
||||
"font-display mt-2 text-[34px] leading-none font-semibold tracking-tight tabular-nums",
|
||||
WERTFARBE[variante],
|
||||
)}
|
||||
>
|
||||
{wert}
|
||||
</p>
|
||||
{hinweis && <p className="text-muted mt-2.5 text-[13px]">{hinweis}</p>}
|
||||
</>
|
||||
);
|
||||
const klassen = "border-border bg-surface block min-w-0 rounded-karte border p-5 sm:p-6";
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={clsx(klassen, "hover:border-border-strong transition-colors")}>
|
||||
{inhalt}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <div className={klassen}>{inhalt}</div>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Hinweis für leere Listen und noch nicht gebaute Seiten (#002). */
|
||||
export function Leerzustand({
|
||||
titel,
|
||||
text,
|
||||
icon,
|
||||
aktion,
|
||||
}: {
|
||||
titel: string;
|
||||
text?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
aktion?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-border-strong rounded-karte flex flex-col items-center border border-dashed px-6 py-12 text-center">
|
||||
{icon && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="bg-raised text-muted rounded-box mb-4 flex size-12 items-center justify-center"
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<p className="font-display text-text text-[18px] font-semibold">{titel}</p>
|
||||
{text && <div className="text-muted mt-1.5 max-w-md text-[14.5px]">{text}</div>}
|
||||
{aktion && <div className="mt-5">{aktion}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Technischer Wert in Monospace mit Kopier-Button (#002). Client-Komponente, weil die
|
||||
* Zwischenablage nur im Browser erreichbar ist. Nur für öffentliche Werte gedacht
|
||||
* (Fingerprints, Public Keys, Befehle) – Private Keys gehören nie in Client-Props.
|
||||
*/
|
||||
export function MonoKopieren({
|
||||
wert,
|
||||
beschriftung = "Wert",
|
||||
umbrechen = false,
|
||||
className,
|
||||
}: {
|
||||
wert: string;
|
||||
/** Wofür der Button kopiert, für Screenreader: „Fingerprint kopieren“. */
|
||||
beschriftung?: string;
|
||||
/** Lange Werte umbrechen statt abschneiden. */
|
||||
umbrechen?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [kopiert, setKopiert] = useState(false);
|
||||
const zeitgeber = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
async function kopieren() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(wert);
|
||||
setKopiert(true);
|
||||
if (zeitgeber.current) clearTimeout(zeitgeber.current);
|
||||
zeitgeber.current = setTimeout(() => setKopiert(false), 1800);
|
||||
} catch {
|
||||
// Ohne sichere Verbindung (http im LAN) gibt es keine Clipboard-API; der Wert
|
||||
// bleibt markierbar, daher kein Fehlerdialog.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={clsx("inline-flex max-w-full min-w-0 items-start gap-1", className)}>
|
||||
<code
|
||||
className={clsx(
|
||||
"text-text min-w-0 py-[11px] font-mono text-[13px] leading-[1.45]",
|
||||
umbrechen ? "break-all" : "truncate",
|
||||
)}
|
||||
title={umbrechen ? undefined : wert}
|
||||
>
|
||||
{wert}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={kopieren}
|
||||
className="text-muted hover:bg-raised hover:text-text rounded-feld inline-flex size-11 shrink-0 items-center justify-center transition-colors"
|
||||
aria-label={`${beschriftung} kopieren`}
|
||||
>
|
||||
{kopiert ? <Check className="text-ok size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
|
||||
</button>
|
||||
<span role="status" className="sr-only">
|
||||
{kopiert ? `${beschriftung} kopiert` : ""}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Kopf jeder Seite: optional Brotkrumen, Titel, Badges, Untertitel und Aktionen rechts (#002). */
|
||||
export function Seitenkopf({
|
||||
titel,
|
||||
untertitel,
|
||||
aktionen,
|
||||
oben,
|
||||
badges,
|
||||
}: {
|
||||
titel: ReactNode;
|
||||
untertitel?: ReactNode;
|
||||
aktionen?: ReactNode;
|
||||
/** Über dem Titel, typischerweise eine Breadcrumb. */
|
||||
oben?: ReactNode;
|
||||
/** Unter dem Titel, z. B. Typ/Kategorie/Ablauf. */
|
||||
badges?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
{oben && <div className="mb-3">{oben}</div>}
|
||||
<h1 className="text-text text-[26px] leading-tight font-semibold tracking-tight break-words sm:text-[30px]">
|
||||
{titel}
|
||||
</h1>
|
||||
{badges && <div className="mt-3 flex flex-wrap items-center gap-2">{badges}</div>}
|
||||
{untertitel && <div className="text-muted mt-1.5 text-[14.5px]">{untertitel}</div>}
|
||||
</div>
|
||||
{aktionen && <div className="flex shrink-0 flex-wrap gap-2.5">{aktionen}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
export type HostStatus = "synchron" | "drift" | "unerreichbar" | "unbekannt";
|
||||
|
||||
/*
|
||||
* Status nie nur über Farbe (#002): Punktform und Text tragen die Information mit,
|
||||
* damit sie auch bei Farbfehlsichtigkeit und in Graustufen-Ausdrucken lesbar bleibt.
|
||||
*/
|
||||
const DARSTELLUNG: Record<HostStatus, { text: string; punkt: string; farbe: string }> = {
|
||||
synchron: { text: "synchron", punkt: "bg-ok", farbe: "text-ok" },
|
||||
drift: { text: "Drift", punkt: "bg-danger", farbe: "text-danger" },
|
||||
unerreichbar: {
|
||||
text: "nicht erreichbar",
|
||||
punkt: "border-[1.5px] border-muted bg-transparent",
|
||||
farbe: "text-muted",
|
||||
},
|
||||
unbekannt: {
|
||||
text: "unbekannt",
|
||||
punkt: "border-[1.5px] border-dashed border-muted bg-transparent",
|
||||
farbe: "text-muted",
|
||||
},
|
||||
};
|
||||
|
||||
export function Statuspunkt({
|
||||
status,
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
status: HostStatus;
|
||||
/** Abweichender Text (z. B. „2 fremde Schlüssel“); ohne ihn der Standardtext des Status. */
|
||||
text?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const d = DARSTELLUNG[status];
|
||||
return (
|
||||
<span className={clsx("inline-flex items-center gap-2 text-[14px]", d.farbe, className)}>
|
||||
<span aria-hidden className={clsx("size-2 shrink-0 rounded-full", d.punkt)} />
|
||||
<span>{text ?? d.text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type Spalte = {
|
||||
titel: ReactNode;
|
||||
/** Rechtsbündig für Zahlen und Aktionen. */
|
||||
rechts?: boolean;
|
||||
/** Nur für Screenreader (z. B. Aktionsspalte ohne sichtbare Überschrift). */
|
||||
versteckt?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tabelle mit eigenem horizontalem Scrollbereich (#002): Breite Inhalte schieben nie die
|
||||
* ganze Seite, auf 390 px scrollt nur die Tabelle. Kopf mit --border-strong-Linie.
|
||||
*/
|
||||
export function Tabelle({
|
||||
spalten,
|
||||
children,
|
||||
beschriftung,
|
||||
className,
|
||||
}: {
|
||||
spalten: Spalte[];
|
||||
children: ReactNode;
|
||||
/** Zugänglicher Name der Tabelle (caption, visuell versteckt). */
|
||||
beschriftung?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
// relative: sonst entkommen absolut positionierte Kinder (sr-only-Texte) dem
|
||||
// Scrollbereich und verbreitern die ganze Seite.
|
||||
<div className={clsx("relative -mx-1 overflow-x-auto px-1", className)}>
|
||||
<table className="w-full border-collapse text-left text-[14px]">
|
||||
{beschriftung && <caption className="sr-only">{beschriftung}</caption>}
|
||||
<thead>
|
||||
<tr className="border-border-strong border-b">
|
||||
{spalten.map((s, i) => (
|
||||
<th
|
||||
key={i}
|
||||
scope="col"
|
||||
className={clsx(
|
||||
"text-muted px-3 pb-2.5 text-[13px] font-medium whitespace-nowrap first:pl-0 last:pr-0",
|
||||
s.rechts && "text-right",
|
||||
s.className,
|
||||
)}
|
||||
>
|
||||
{s.versteckt ? <span className="sr-only">{s.titel}</span> : s.titel}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabellenZeile({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <tr className={clsx("border-border border-b last:border-b-0", className)}>{children}</tr>;
|
||||
}
|
||||
|
||||
export function TabellenZelle({
|
||||
children,
|
||||
rechts,
|
||||
mono,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
rechts?: boolean;
|
||||
mono?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
className={clsx(
|
||||
"px-3 py-3 align-middle first:pl-0 last:pr-0",
|
||||
rechts && "text-right",
|
||||
mono && "font-mono text-[13px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { Badge, type BadgeVariante } from "./Badge";
|
||||
export { Breadcrumb, type BreadcrumbEintrag } from "./Breadcrumb";
|
||||
export { Button, buttonKlassen, type ButtonVariante } from "./Button";
|
||||
export { DiffBox, type DiffZeile } from "./DiffBox";
|
||||
export { InnereBox, Karte } from "./Karte";
|
||||
export { Kennzahl, type KennzahlVariante } from "./Kennzahl";
|
||||
export { Leerzustand } from "./Leerzustand";
|
||||
export { MonoKopieren } from "./MonoKopieren";
|
||||
export { Seitenkopf } from "./Seitenkopf";
|
||||
export { Statuspunkt, type HostStatus } from "./Statuspunkt";
|
||||
export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle";
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Datums- und Zeitformate der Oberfläche (#002): TT.MM.JJJJ, 24-h-Zeit, deutsch.
|
||||
*
|
||||
* Die Zeitzone ist fest Europe/Berlin und nicht die des Prozesses: Server (UTC im
|
||||
* Container) und Browser sollen dieselbe Zeit anzeigen, sonst springen Zeitstempel
|
||||
* zwischen Server- und Client-Rendering um Stunden.
|
||||
*/
|
||||
|
||||
export const ZEITZONE = "Europe/Berlin";
|
||||
|
||||
type Eingabe = Date | string | number;
|
||||
|
||||
const DATUM = new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: ZEITZONE,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const UHRZEIT = new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: ZEITZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
function alsDatum(wert: Eingabe): Date {
|
||||
const d = wert instanceof Date ? wert : new Date(wert);
|
||||
if (Number.isNaN(d.getTime())) throw new RangeError(`Ungültiges Datum: ${String(wert)}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** „12.10.2026“ */
|
||||
export function datum(wert: Eingabe): string {
|
||||
return DATUM.format(alsDatum(wert));
|
||||
}
|
||||
|
||||
/** „08:42“ */
|
||||
export function uhrzeit(wert: Eingabe): string {
|
||||
return UHRZEIT.format(alsDatum(wert));
|
||||
}
|
||||
|
||||
/** „12.10.2026, 08:42“ */
|
||||
export function datumZeit(wert: Eingabe): string {
|
||||
const d = alsDatum(wert);
|
||||
return `${DATUM.format(d)}, ${UHRZEIT.format(d)}`;
|
||||
}
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const STUNDE = 60 * MINUTE;
|
||||
const TAG = 24 * STUNDE;
|
||||
|
||||
/**
|
||||
* Grobe Relativangabe für Listen: „gerade eben“, „vor 5 Min.“, „vor 3 Std.“,
|
||||
* „vor 2 Tagen“, „in 12 Tagen“. Ab 30 Tagen Abstand wird das Datum gezeigt, weil
|
||||
* „vor 47 Tagen“ schlechter einzuordnen ist als ein Datum.
|
||||
*
|
||||
* `jetzt` ist ein Parameter, damit Tests und statisch gerenderte Seiten einen
|
||||
* festen Bezugspunkt haben.
|
||||
*/
|
||||
export function relativ(wert: Eingabe, jetzt: Eingabe = new Date()): string {
|
||||
const abstand = alsDatum(wert).getTime() - alsDatum(jetzt).getTime();
|
||||
const betrag = Math.abs(abstand);
|
||||
const zukunft = abstand > 0;
|
||||
|
||||
if (betrag < MINUTE) return "gerade eben";
|
||||
if (betrag >= 30 * TAG) return `am ${datum(wert)}`;
|
||||
|
||||
let text: string;
|
||||
if (betrag < STUNDE) {
|
||||
text = `${Math.floor(betrag / MINUTE)} Min.`;
|
||||
} else if (betrag < TAG) {
|
||||
text = `${Math.floor(betrag / STUNDE)} Std.`;
|
||||
} else {
|
||||
const tage = Math.floor(betrag / TAG);
|
||||
// „vor“ und „in“ verlangen beide den Dativ: „vor 1 Tag“, „in 2 Tagen“.
|
||||
text = tage === 1 ? "1 Tag" : `${tage} Tagen`;
|
||||
}
|
||||
return zukunft ? `in ${text}` : `vor ${text}`;
|
||||
}
|
||||
|
||||
/** Ganze Kalendertage bis `wert` (negativ, wenn vorbei) – Grundlage für Ablaufhinweise. */
|
||||
export function tageBis(wert: Eingabe, jetzt: Eingabe = new Date()): number {
|
||||
const tagesnummer = (d: Date) => {
|
||||
const [t, m, j] = DATUM.format(d).split(".").map(Number);
|
||||
return Date.UTC(j ?? 0, (m ?? 1) - 1, t ?? 1) / TAG;
|
||||
};
|
||||
return Math.round(tagesnummer(alsDatum(wert)) - tagesnummer(alsDatum(jetzt)));
|
||||
}
|
||||
Reference in New Issue
Block a user