Files
sshkeyvault/src/app/(app)/einstellungen/page.tsx
T
florian.wildeandClaude Opus 5.5 cab6f45510 feat(deploymentkey): geführte Rotation des Deployment-Keys (#031)
Bisher ließ sich der Instanz-Key nur von Hand auf jedem Host tauschen.
Die Rotation läuft jetzt in fünf Schritten: Nachfolger erzeugen, per
Dry-Run neben den alten verteilen, Anmeldung je Host prüfen, umstellen,
alten Key aus allen Blöcken entfernen und seinen Private Key verwerfen.

Der Nachfolger steht während der Rotation in Block und Pflichtliste,
damit kein Host ohne funktionierenden Key dasteht. Umgestellt wird nur
nach erfolgreicher Prüfung oder bewusstem Übergehen eines Hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:27:09 +02:00

208 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Inbox } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import type { ReactNode } from "react";
import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular";
import { Badge } from "@/components/ui/Badge";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { datumZeit } from "@/lib/format";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungenSeite, type Zeitplan, type ZustellungZeile } from "@/server/queries/einstellungen";
export const metadata: Metadata = { title: "Einstellungen" };
function Info({ titel, children }: { titel: string; children: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
</div>
);
}
function Abschnitt({ titel, id, children }: { titel: string; id: string; children: ReactNode }) {
return (
<section aria-labelledby={id} className="flex min-w-0 flex-col gap-4">
<h2 id={id} className="text-muted text-[12.5px] font-semibold tracking-[0.08em] uppercase">
{titel}
</h2>
{children}
</section>
);
}
function ZeitplanAnzeige({ plan }: { plan: Zeitplan }) {
return (
<span className="flex flex-col gap-0.5">
{plan.beschreibung && <span>{plan.beschreibung}</span>}
<code className="text-muted font-mono text-[13px]">{plan.ausdruck}</code>
</span>
);
}
const KANAL_TEXT = { EMAIL: "E-Mail", WEBHOOK: "Webhook" } as const;
function Zustellungen({ zeilen }: { zeilen: readonly ZustellungZeile[] }) {
if (zeilen.length === 0) {
return (
<Leerzustand
icon={<Inbox className="size-5" />}
titel="Noch nichts versendet"
text="Hier erscheinen die letzten Zustellungen samt Ergebnis – auch Testnachrichten."
/>
);
}
return (
<Tabelle
beschriftung="Letzte Zustellungen"
spalten={[{ titel: "Zeitpunkt" }, { titel: "Kanal" }, { titel: "Nachricht" }, { titel: "Ergebnis" }]}
>
{zeilen.map((z) => (
<TabellenZeile key={z.sendung}>
<TabellenZelle className="whitespace-nowrap">
<time dateTime={z.zeitpunkt.toISOString()}>{datumZeit(z.zeitpunkt)}</time>
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">{KANAL_TEXT[z.kanal]}</TabellenZelle>
<TabellenZelle className="min-w-[16rem]">
<span className="block">{z.betreff}</span>
<span className="text-muted block text-[12.5px]">
{z.anlaesse === 1 ? "1 Anlass" : `${z.anlaesse} Anlässe`}
{z.versuche > 1 && ` · ${z.versuche}. Versuch`}
</span>
</TabellenZelle>
<TabellenZelle className="min-w-[12rem]">
{z.ergebnis === "GESENDET" ? (
<Badge variante="ok">gesendet</Badge>
) : (
<span className="flex flex-col items-start gap-1">
<Badge variante="gefahr">fehlgeschlagen</Badge>
{z.fehler && <span className="text-danger text-[12.5px] break-words">{z.fehler}</span>}
<span className="text-muted text-[12.5px]">
{z.naechsterVersuchAm
? `neuer Versuch ab ${datumZeit(z.naechsterVersuchAm)}`
: "kein weiterer Versuch"}
</span>
</span>
)}
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
);
}
/** Einstellungen (#028): lesen duerfen alle, aendern nur `einstellungenVerwalten` (Admin). */
export default async function Einstellungen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
const { benutzer } = await sitzungErforderlich("lesen", "/einstellungen");
const darfAendern = darf(benutzer.rolle, "einstellungenVerwalten");
const seite = await einstellungenSeite();
// Eine Webhook-Adresse traegt oft ein Token im Pfad – Leser sehen nur Schema und Host.
const ansicht = darfAendern
? seite.benachrichtigung
: {
...seite.benachrichtigung,
webhook: { ...seite.benachrichtigung.webhook, url: seite.webhookZiel ?? "" },
};
const { instanz } = seite;
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Einstellungen"
untertitel={
darfAendern
? "Benachrichtigungen, Zeitpläne und Angaben zu dieser Instanz"
: "Nur Ansicht – ändern dürfen Administratoren."
}
/>
<div className="grid min-w-0 gap-6 sm:gap-7 xl:grid-cols-[minmax(0,1fr)_22rem] xl:items-start">
<Abschnitt titel="Benachrichtigungen" id="abschnitt-benachrichtigungen">
<BenachrichtigungFormular ansicht={ansicht} darfAendern={darfAendern} />
<Karte
titel="Letzte Zustellungen"
untertitel="Fehlgeschlagene Zustellungen wiederholt der Worker bis zu fünfmal."
>
<Zustellungen zeilen={seite.zustellungen} />
</Karte>
</Abschnitt>
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
<Abschnitt titel="Abgleich" id="abschnitt-abgleich">
<Karte as="div">
<dl className="grid gap-4">
<Info titel="Abgleich aller Hosts">
<ZeitplanAnzeige plan={seite.abgleich} />
</Info>
<Info titel="Tägliche Zusammenfassung">
<ZeitplanAnzeige plan={seite.meldung} />
</Info>
<Info titel="Geänderte Host-Keys">alle 5 Minuten geprüft</Info>
</dl>
<p className="text-muted mt-4 text-[13px]">
Zeitzone Europe/Berlin. Gesetzt über <code className="font-mono">SSHKV_SYNC_CRON</code> und{" "}
<code className="font-mono">SSHKV_MELDUNG_CRON</code> in der Umgebung des Workers.
</p>
</Karte>
</Abschnitt>
{/* Platz fuer den Abschnitt „Syslog“ – folgt in einem eigenen Issue. */}
<Abschnitt titel="Instanz" id="abschnitt-instanz">
<Karte as="div">
<dl className="grid gap-4">
<Info titel="Version">
<span className="font-mono text-[13.5px]">{instanz.version}</span>
</Info>
<Info titel="Deployment-Key">
{instanz.deploymentKey ? (
<>
<MonoKopieren
wert={instanz.deploymentKey.fingerprint}
beschriftung="Fingerprint des Deployment-Keys"
umbrechen
/>
{darf(benutzer.rolle, "deploymentKeyVerwalten") && (
<Link
href="/deployment-key"
className="text-accent mt-1 inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
>
Deployment-Key rotieren
</Link>
)}
</>
) : (
<span className="text-accent">noch nicht erzeugt – läuft der Worker?</span>
)}
</Info>
<Info titel="Anmeldung (OIDC)">
{instanz.oidc.konfiguriert ? (
instanz.oidc.name
) : (
<span className="text-muted">nicht eingerichtet</span>
)}
</Info>
<Info titel="Notfall-Admin">
{!instanz.notfall.vorhanden ? (
<span className="text-muted">nicht angelegt</span>
) : instanz.notfall.totpEingerichtet ? (
<Badge variante="ok">TOTP eingerichtet</Badge>
) : (
<Badge variante="messing">TOTP noch nicht eingerichtet</Badge>
)}
</Info>
</dl>
</Karte>
</Abschnitt>
</div>
</div>
</div>
);
}