Merge branch 'phase/028-benachrichtigungen'

# Conflicts:
#	docker-compose.yml
#	scripts/screenshots.ts
This commit is contained in:
2026-10-12 16:55:57 +02:00
31 changed files with 3397 additions and 16 deletions
+188 -10
View File
@@ -1,18 +1,196 @@
import { Inbox } from "lucide-react";
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
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" };
export default async function Einstellungen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
function Info({ titel, children }: { titel: string; children: ReactNode }) {
return (
<Platzhalterseite
titel="Einstellungen"
untertitel="Deployment-Key, eigene Vorlagen, Benachrichtigungen"
phase={3}
issue="#019"
/>
<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
/>
) : (
<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>
);
}