Merge branch 'phase/002-design-layout'

This commit is contained in:
2026-10-12 09:21:46 +02:00
61 changed files with 2258 additions and 14 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+36 -10
View File
@@ -1,25 +1,51 @@
# #002 Design-System und Layout
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** ui
**Geschätzt:** 4 h **Tatsächlich:** –
**Geschätzt:** 4 h **Tatsächlich:** 0,3 h (09:07–09:22)
## Ziel
Design laut Vorgabe (Tresor, Graphit + Messing): Tokens, Schriften, Logo, Favicon/PWA-Icons, Grundlayout mit Seitenleiste.
## Aufgaben
- [ ] Design-Tokens als CSS-Variablen + Tailwind-Theme
- [ ] Space Grotesk, IBM Plex Sans, JetBrains Mono selbst gehostet (`@fontsource`)
- [ ] `public/brand/` mit `logo.svg`, `logo-light.svg`, `logo-mono.svg`, `favicon.svg`; PNG-Icons + Manifest per Skript
- [ ] Seitenleiste (Logo, Navigation, Benutzer unten), einklappbar auf schmalen Bildschirmen
- [ ] Grundkomponenten: Karte, Button (primär/sekundär/Gefahr), Badge, Statuspunkt, Tabelle, Diff-Box, Kennzahl-Kachel
- [x] Design-Tokens als CSS-Variablen + Tailwind-Theme
- [x] Space Grotesk, IBM Plex Sans, JetBrains Mono selbst gehostet (`@fontsource`)
- [x] `public/brand/` mit `logo.svg`, `logo-light.svg`, `logo-mono.svg`, `favicon.svg`; PNG-Icons + Manifest per Skript
- [x] Seitenleiste (Logo, Navigation, Benutzer unten), einklappbar auf schmalen Bildschirmen
- [x] Grundkomponenten: Karte, Button (primär/sekundär/Gefahr), Badge, Statuspunkt, Tabelle, Diff-Box, Kennzahl-Kachel
## Akzeptanzkriterien
- [ ] Kontrast ≥ 4,5:1, Fokusrahmen in Messing
- [ ] Kein horizontales Scrollen bei 390 px
- [ ] Klickflächen ≥ 44 px
- [x] Kontrast ≥ 4,5:1, Fokusrahmen in Messing
- [x] Kein horizontales Scrollen bei 390 px
- [x] Klickflächen ≥ 44 px
## Abhängigkeiten
#001
## Notizen / Entscheidungen
- **Tokens:** CSS-Variablen in `:root`, an Tailwind per `@theme inline` gereicht (`bg-surface`,
`text-muted`, `border-border-strong`, `rounded-karte` …). So nutzen Inline-SVGs und Utilities
dieselbe Quelle. Nur dunkler Modus.
- **Schriften:** nur Latin-Ausschnitt und benutzte Gewichte, importiert in `src/app/layout.tsx`.
Zeichen außerhalb (z. B. „→“) fallen auf die Systemschrift zurück – unauffällig.
- **Kontrast** (gemessen): `--text-muted` auf `--surface` 5,8:1, auf `--raised` 5,3:1;
Messing auf `--accent-soft` 7,2:1; Koralle auf `--danger-soft` 5,8:1; Text auf Messing-Button 10,3:1.
- **Icons:** `pnpm icons` (`scripts/icons.mjs`) erzeugt `public/icons/*`, `src/app/favicon.ico`
(16/32/48, PNG-in-ICO), `src/app/icon.svg` und `src/app/apple-icon.png`; Ergebnisse sind
eingecheckt, damit Build und Laufzeitabbild kein sharp brauchen. App-Icons auf `#0D1013` mit
Rand (Logo 72 %), deshalb ohne `maskable`.
- **Aktiver Pfad:** Ein Layout kennt den Pfad nicht. `Huelle` nimmt `aktiverPfad` optional;
ohne Angabe ermittelt die Client-`Navigation` ihn per `usePathname()`.
- **Benutzer:** `src/app/(app)/huellen-benutzer.ts` → `huellenBenutzerErmitteln()` liefert bis
#004 einen festen Beispielbenutzer (TODO-Kommentar).
- **Mobil:** Unter 1024 px Kopfleiste mit Menü-Button, Leiste als Overlay (280 px) mit eigenem
Schließen-Knopf; Escape, Klick auf Abdunkler oder auf einen Link schließt. Geschlossen ist die
Leiste `invisible` und damit nicht per Tab erreichbar.
- **Desktop:** Seitenleiste als eigene Flex-Spalte, Inhalt darin `sticky` – Hintergrund und Rahmen
laufen auch bei langen Seiten (und in Full-Page-Screenshots) durch.
- **Tabellen** scrollen im eigenen Container; der Container ist `relative`, sonst entkommen
`sr-only`-Texte dem Scrollbereich und verbreitern die Seite (bei 390 px gefunden).
- **Prototyp:** Beispieldaten in `src/beispiel/` (Bezugszeit `BEISPIEL_JETZT`), Seiten sichtbar
als „Beispieldaten“ markiert. Platzhalterseiten nennen Phase und Issue.
- **Screenshots:** `pnpm exec tsx scripts/screenshots.ts` (ENV `SSHKV_URL`, `SSHKV_BILDER`,
`SSHKV_SEITEN`) → `docs/anleitung/bilder/`; meldet horizontalen Überlauf mit Exit-Code 1.
+5
View File
@@ -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

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SSHKeyVault">
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke="#14181C" stroke-width="3.5"/>
<circle cx="32" cy="32" r="19" fill="none" stroke="#14181C" stroke-opacity="0.25" stroke-width="2" stroke-dasharray="3 3.2"/>
<circle cx="13.5" cy="13.5" r="2.2" fill="#14181C"/>
<circle cx="50.5" cy="13.5" r="2.2" fill="#14181C"/>
<circle cx="13.5" cy="50.5" r="2.2" fill="#14181C"/>
<circle cx="50.5" cy="50.5" r="2.2" fill="#14181C"/>
<circle cx="32" cy="27" r="7.5" fill="#B9780F"/>
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill="#B9780F"/>
</svg>

After

Width:  |  Height:  |  Size: 672 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SSHKeyVault">
<rect x="2.25" y="2.25" width="59.5" height="59.5" rx="15.75" fill="#F0B43C"/>
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke="#0D1013" stroke-width="3.5"/>
<circle cx="32" cy="32" r="19" fill="none" stroke="#0D1013" stroke-opacity="0.28" stroke-width="2" stroke-dasharray="3 3.2"/>
<circle cx="13.5" cy="13.5" r="2.2" fill="#0D1013"/>
<circle cx="50.5" cy="13.5" r="2.2" fill="#0D1013"/>
<circle cx="13.5" cy="50.5" r="2.2" fill="#0D1013"/>
<circle cx="50.5" cy="50.5" r="2.2" fill="#0D1013"/>
<circle cx="32" cy="27" r="7.5" fill="#0D1013"/>
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill="#0D1013"/>
</svg>

After

Width:  |  Height:  |  Size: 753 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SSHKeyVault">
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke="#E8ECEF" stroke-width="3.5"/>
<circle cx="32" cy="32" r="19" fill="none" stroke="#E8ECEF" stroke-opacity="0.28" stroke-width="2" stroke-dasharray="3 3.2"/>
<circle cx="13.5" cy="13.5" r="2.2" fill="#E8ECEF"/>
<circle cx="50.5" cy="13.5" r="2.2" fill="#E8ECEF"/>
<circle cx="13.5" cy="50.5" r="2.2" fill="#E8ECEF"/>
<circle cx="50.5" cy="50.5" r="2.2" fill="#E8ECEF"/>
<circle cx="32" cy="27" r="7.5" fill="#F0B43C"/>
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill="#F0B43C"/>
</svg>

After

Width:  |  Height:  |  Size: 672 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+99
View File
@@ -0,0 +1,99 @@
// Erzeugt alle Raster-Icons aus den SVG-Vorlagen in public/brand/ (#002).
//
// Die PNGs und das favicon.ico werden eingecheckt, damit Build und Docker-Abbild
// kein sharp zur Laufzeit brauchen. Nach jeder Logo-Aenderung: `pnpm icons`.
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), "..");
const BRAND = join(WURZEL, "public/brand");
const ICONS = join(WURZEL, "public/icons");
const APP = join(WURZEL, "src/app");
// Seitenhintergrund (--bg); App-Icons stehen auf dieser Flaeche statt transparent,
// weil iOS und Android transparente Bereiche sonst schwarz oder weiss fuellen.
const HINTERGRUND = "#0D1013";
/** Rendert ein SVG scharf in der Zielgroesse (density skaliert die Vektoren statt Pixel). */
async function rendern(svg, groesse) {
return sharp(svg, { density: Math.ceil((72 * groesse) / 32) * 2 })
.resize(groesse, groesse)
.png()
.toBuffer();
}
/** Logo mittig auf --bg mit Rand, damit es auf Homescreens nicht an die Kante stoesst. */
async function aufFlaeche(svg, groesse, anteil) {
const innen = Math.round(groesse * anteil);
const logo = await rendern(svg, innen);
return sharp({
create: { width: groesse, height: groesse, channels: 4, background: HINTERGRUND },
})
.composite([{ input: logo, gravity: "center" }])
.png()
.toBuffer();
}
/** Minimaler ICO-Container mit PNG-Eintraegen (seit Windows Vista von allen Browsern gelesen). */
function icoBauen(bilder) {
const kopf = Buffer.alloc(6);
kopf.writeUInt16LE(0, 0);
kopf.writeUInt16LE(1, 2);
kopf.writeUInt16LE(bilder.length, 4);
const eintraege = [];
let versatz = 6 + 16 * bilder.length;
for (const { groesse, daten } of bilder) {
const e = Buffer.alloc(16);
e.writeUInt8(groesse >= 256 ? 0 : groesse, 0);
e.writeUInt8(groesse >= 256 ? 0 : groesse, 1);
e.writeUInt8(0, 2);
e.writeUInt8(0, 3);
e.writeUInt16LE(1, 4);
e.writeUInt16LE(32, 6);
e.writeUInt32LE(daten.length, 8);
e.writeUInt32LE(versatz, 12);
versatz += daten.length;
eintraege.push(e);
}
return Buffer.concat([kopf, ...eintraege, ...bilder.map((b) => b.daten)]);
}
async function main() {
await mkdir(ICONS, { recursive: true });
const logo = await readFile(join(BRAND, "logo.svg"));
// Unter 32 px wird die reduzierte Variante ohne Zahlenring und Bolzen genutzt,
// sonst verschwimmen die Details zu grauem Rauschen.
const favicon = await readFile(join(BRAND, "favicon.svg"));
const icon192 = await aufFlaeche(logo, 192, 0.72);
const icon512 = await aufFlaeche(logo, 512, 0.72);
const apple = await aufFlaeche(logo, 180, 0.72);
const fav32 = await rendern(favicon, 32);
const fav16 = await rendern(favicon, 16);
const fav48 = await rendern(favicon, 48);
await writeFile(join(ICONS, "icon-192.png"), icon192);
await writeFile(join(ICONS, "icon-512.png"), icon512);
await writeFile(join(ICONS, "apple-touch-icon.png"), apple);
await writeFile(join(ICONS, "favicon-32.png"), fav32);
await writeFile(join(ICONS, "favicon-16.png"), fav16);
// Next-Metadaten-Dateien: favicon.ico, icon.svg und apple-icon.png in src/app
// erzeugen die <link>-Tags automatisch.
await writeFile(
join(APP, "favicon.ico"),
icoBauen([
{ groesse: 16, daten: fav16 },
{ groesse: 32, daten: fav32 },
{ groesse: 48, daten: fav48 },
]),
);
await copyFile(join(BRAND, "favicon.svg"), join(APP, "icon.svg"));
await writeFile(join(APP, "apple-icon.png"), apple);
console.log("Icons erzeugt: public/icons/*, src/app/favicon.ico, src/app/icon.svg, src/app/apple-icon.png");
}
await main();
+82
View File
@@ -0,0 +1,82 @@
/**
* Screenshots der Oberfläche für Anleitung und Design-Abnahme (#002).
*
* pnpm build && pnpm start # erstes Terminal
* pnpm exec tsx scripts/screenshots.ts # zweites Terminal
*
* Umgebung:
* SSHKV_URL Basis-URL (Standard http://localhost:3400)
* SSHKV_BILDER Zielordner (Standard docs/anleitung/bilder)
* SSHKV_SEITEN Kommagetrennte Auswahl der Seitennamen (Standard: alle)
*
* Prüft nebenbei, dass keine Seite horizontal scrollt (Design-Vorgabe ab 390 px), und
* endet dann mit Fehlercode – so taugt das Skript auch als schneller Layout-Test.
*/
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { chromium } from "@playwright/test";
const BASIS = (process.env["SSHKV_URL"] ?? "http://localhost:3400").replace(/\/$/, "");
const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/bilder");
const SEITEN = [
{ name: "uebersicht", pfad: "/" },
{ name: "schluessel-detail", pfad: "/schluessel/beispiel" },
];
const ANSICHTEN = [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobil", width: 390, height: 844 },
];
async function main() {
const auswahl = process.env["SSHKV_SEITEN"]?.split(",").map((s) => s.trim());
const seiten = auswahl ? SEITEN.filter((s) => auswahl.includes(s.name)) : SEITEN;
await mkdir(ZIEL, { recursive: true });
const browser = await chromium.launch();
const fehler: string[] = [];
try {
for (const ansicht of ANSICHTEN) {
const kontext = await browser.newContext({
viewport: { width: ansicht.width, height: ansicht.height },
deviceScaleFactor: 1,
locale: "de-DE",
timezoneId: "Europe/Berlin",
colorScheme: "dark",
});
const seite = await kontext.newPage();
for (const s of seiten) {
const antwort = await seite.goto(`${BASIS}${s.pfad}`, { waitUntil: "networkidle" });
if (!antwort?.ok()) {
fehler.push(`${s.pfad}: HTTP ${antwort?.status() ?? "keine Antwort"}`);
continue;
}
// Erst nach dem Laden der selbst gehosteten Schriften fotografieren, sonst
// stehen Fallback-Schriften im Bild.
await seite.evaluate(() => document.fonts.ready);
const ueberlauf = await seite.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
if (ueberlauf > 0) {
fehler.push(`${s.pfad} @ ${ansicht.width}px: ${ueberlauf}px horizontaler Überlauf`);
}
const datei = join(ZIEL, `${s.name}-${ansicht.name}.png`);
await seite.screenshot({ path: datei, fullPage: true });
console.log(`✓ ${datei}`);
}
await kontext.close();
}
} finally {
await browser.close();
}
if (fehler.length > 0) {
console.error(`\n${fehler.length} Problem(e):\n- ${fehler.join("\n- ")}`);
process.exitCode = 1;
}
}
await main();
+15
View File
@@ -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"
/>
);
}
+15
View File
@@ -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"
/>
);
}
+15
View File
@@ -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"
/>
);
}
+13
View File
@@ -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" };
}
+9
View File
@@ -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>;
}
+187
View File
@@ -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>
);
}
+10
View File
@@ -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" />
);
}
+15
View File
@@ -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"
/>
);
}
+181
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+15
View File
@@ -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"
/>
);
}
+15
View File
@@ -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

+103
View File
@@ -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;
}
}
+5
View File
@@ -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
View File
@@ -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 }) {
+20
View File
@@ -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" },
],
};
}
-3
View File
@@ -1,3 +0,0 @@
export default function Startseite() {
return <main>SSHKeyVault</main>;
}
+75
View File
@@ -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 },
];
+89
View File
@@ -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",
},
];
+91
View File
@@ -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>
);
}
+50
View File
@@ -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>
</>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { Huelle } from "./Huelle";
export { Platzhalterseite } from "./Platzhalterseite";
export { ROLLEN_TEXT, type HuellenBenutzer, type Rolle } from "./typen";
+37
View File
@@ -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}/`);
}
+13
View File
@@ -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",
};
+49
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { Bildmarke } from "./Bildmarke";
export { Kurzname, Marke, Wortmarke } from "./Wortmarke";
+38
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+51
View File
@@ -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>;
}
+30
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+85
View File
@@ -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>
);
}
+11
View File
@@ -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";
+89
View File
@@ -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)));
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { datum, datumZeit, relativ, tageBis, uhrzeit, ZEITZONE } from "@/lib/format";
// Alle Erwartungen gelten für Europe/Berlin, unabhängig von der Zeitzone des Testprozesses.
describe("Formate (#002)", () => {
it("nutzt fest Europe/Berlin", () => {
expect(ZEITZONE).toBe("Europe/Berlin");
});
describe("datum()", () => {
it("schreibt TT.MM.JJJJ mit führenden Nullen", () => {
expect(datum("2026-03-05T10:00:00Z")).toBe("05.03.2026");
});
it("rechnet in Berliner Zeit – 23:30 UTC ist dort schon der nächste Tag", () => {
expect(datum("2026-10-12T23:30:00Z")).toBe("13.10.2026");
});
it("nimmt Date, Zeitstempel und ISO-Text", () => {
const d = new Date("2026-01-01T12:00:00Z");
expect(datum(d)).toBe("01.01.2026");
expect(datum(d.getTime())).toBe("01.01.2026");
});
it("lehnt ungültige Eingaben ab", () => {
expect(() => datum("kein Datum")).toThrow(RangeError);
});
});
describe("uhrzeit() und datumZeit()", () => {
it("zeigt 24-h-Zeit in Sommerzeit (UTC+2)", () => {
expect(uhrzeit("2026-07-01T13:05:00Z")).toBe("15:05");
});
it("zeigt 24-h-Zeit in Winterzeit (UTC+1)", () => {
expect(uhrzeit("2026-12-01T22:59:00Z")).toBe("23:59");
});
it("Mitternacht ist 00:00, nicht 24:00", () => {
expect(uhrzeit("2026-12-01T23:00:00Z")).toBe("00:00");
});
it("verbindet Datum und Zeit", () => {
expect(datumZeit("2026-10-12T06:42:00Z")).toBe("12.10.2026, 08:42");
});
it("berücksichtigt die Zeitumstellung am 25.10.2026", () => {
expect(datumZeit("2026-10-25T00:30:00Z")).toBe("25.10.2026, 02:30");
expect(datumZeit("2026-10-25T01:30:00Z")).toBe("25.10.2026, 02:30");
});
});
describe("relativ()", () => {
const jetzt = new Date("2026-10-12T10:00:00Z");
const vor = (ms: number) => new Date(jetzt.getTime() - ms);
const MIN = 60_000;
const STD = 60 * MIN;
const TAG = 24 * STD;
it("unter einer Minute: gerade eben", () => {
expect(relativ(vor(30_000), jetzt)).toBe("gerade eben");
expect(relativ(jetzt, jetzt)).toBe("gerade eben");
});
it("Minuten", () => {
expect(relativ(vor(5 * MIN), jetzt)).toBe("vor 5 Min.");
expect(relativ(vor(59 * MIN), jetzt)).toBe("vor 59 Min.");
});
it("Stunden", () => {
expect(relativ(vor(3 * STD), jetzt)).toBe("vor 3 Std.");
expect(relativ(vor(23 * STD + 59 * MIN), jetzt)).toBe("vor 23 Std.");
});
it("Tage mit Einzahl und Mehrzahl", () => {
expect(relativ(vor(TAG), jetzt)).toBe("vor 1 Tag");
expect(relativ(vor(4 * TAG), jetzt)).toBe("vor 4 Tagen");
});
it("Zukunft", () => {
expect(relativ(vor(-12 * TAG), jetzt)).toBe("in 12 Tagen");
expect(relativ(vor(-2 * STD), jetzt)).toBe("in 2 Std.");
});
it("ab 30 Tagen das Datum", () => {
expect(relativ(vor(40 * TAG), jetzt)).toBe("am 02.09.2026");
});
it("nimmt ohne Bezugspunkt die aktuelle Zeit", () => {
expect(relativ(new Date())).toBe("gerade eben");
});
});
describe("tageBis()", () => {
const jetzt = "2026-10-12T21:00:00Z"; // in Berlin schon 23:00 am 12.10.
it("zählt Kalendertage in Berliner Zeit", () => {
expect(tageBis("2026-10-12T22:30:00Z", jetzt)).toBe(1); // 00:30 am 13.10.
expect(tageBis("2026-11-11T12:00:00Z", jetzt)).toBe(30);
});
it("ist negativ für Vergangenes", () => {
expect(tageBis("2026-10-10T12:00:00Z", jetzt)).toBe(-2);
});
});
});