feat(benachrichtigung): E-Mail und Webhook bei Drift und Ablauf (#028)
Drift, ablaufende und abgelaufene Schluessel, unerreichbare Hosts und geaenderte Host-Keys sollen auffallen, ohne dass jemand taeglich in die Oberflaeche schaut. Versand per SMTP (Microsoft 365, STARTTLS) und per Webhook mit HMAC-Signatur, eingestellt unter /einstellungen. Die Anlaesse liest der Worker aus der Datenbank, statt sie im Abgleich auszuloesen: der Abgleich-Ablauf bleibt unberuehrt, und ein verpasster Lauf holt sich der naechste. Ein Versandprotokoll (Zustellung) mit Anlass-Fingerabdruck verhindert Doppelmeldungen und haelt Fehlschlaege fuer begrenzte Wiederholungen fest. Taeglich eine Zusammenfassung, geaenderte Host-Keys im Fuenf-Minuten-Takt sofort. Versand und Testnachricht laufen im Worker, damit der Webprozess keinen Weg zu Mailserver oder Webhook-Ziel braucht. Passwort und Geheimnis liegen nur verschluesselt in der Datenbank; die Seite zeigt "gesetzt". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user