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:
2026-10-12 16:54:43 +02:00
co-authored by Claude Opus 5.5
parent 83bd9e0922
commit 8f5b31c0cb
26 changed files with 3243 additions and 12 deletions
@@ -0,0 +1,366 @@
"use client";
import { Mail, Send, Webhook } from "lucide-react";
import { startTransition, useActionState, useState, type FormEvent, type ReactNode } from "react";
import {
Ankreuzfeld,
Badge,
Button,
Feld,
Feldgruppe,
Formularfehler,
Karte,
Textbereich,
} from "@/components/ui";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import type { BenachrichtigungAnsicht } from "@/lib/benachrichtigung/einstellungen";
import { ANLAESSE, ANLASS_TEXT } from "@/lib/benachrichtigung/regeln";
import { benachrichtigungSpeichernAktion, testSendenAktion } from "@/server/actions/einstellungen";
import type { FormularZustand } from "@/server/actions/formular";
type Zustand = FormularZustand & { gespeichert?: boolean };
/** Fester Anfangszustand: ein neues `{}` je Render liesse den Vergleich unten nie zur Ruhe kommen. */
const ANFANG: Zustand = {};
/**
* Benachrichtigungskanaele und Anlaesse (#028). Geheimnisse kommen nie zurueck in den Browser:
* die Felder starten leer, „gesetzt“ zeigt, dass ein Wert hinterlegt ist, und leer speichern
* laesst ihn unveraendert.
*/
export function BenachrichtigungFormular({
ansicht,
darfAendern,
}: {
ansicht: BenachrichtigungAnsicht;
darfAendern: boolean;
}) {
const [zustand, ausfuehren, laeuft] = useActionState<Zustand, FormData>(
benachrichtigungSpeichernAktion,
ANFANG,
);
// Nach dem Speichern neu aufbauen: Passwortfelder leeren sich, die Vorgaben kommen frisch
// vom Server (revalidatePath). Ueber `key` statt Formular-Reset, damit Fehlerfaelle die
// Eingaben behalten (Muster aus useFormularAktion, #008).
const [fassung, setFassung] = useState(0);
const [gemerkt, setGemerkt] = useState<Zustand>(zustand);
if (zustand !== gemerkt) {
setGemerkt(zustand);
if (zustand.gespeichert) setFassung((f) => f + 1);
}
function absenden(ereignis: FormEvent<HTMLFormElement>) {
ereignis.preventDefault();
const daten = new FormData(ereignis.currentTarget);
startTransition(() => ausfuehren(daten));
}
return (
<form
key={fassung}
onSubmit={absenden}
noValidate
className="flex flex-col gap-6"
aria-label="Benachrichtigungen"
>
<Formularfehler text={zustand.fehler} />
{zustand.gespeichert && !laeuft && (
<p role="status" className="text-ok text-[14px]">
Einstellungen gespeichert.
</p>
)}
<Inhalt ansicht={ansicht} darfAendern={darfAendern} f={zustand.feldfehler ?? {}} />
{darfAendern && (
<div className="flex flex-wrap gap-2.5">
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Speichere …" : "Benachrichtigungen speichern"}
</Button>
</div>
)}
</form>
);
}
function Inhalt({
ansicht,
darfAendern,
f,
}: {
ansicht: BenachrichtigungAnsicht;
darfAendern: boolean;
f: Record<string, string>;
}) {
const [sicherheit, setSicherheit] = useState(ansicht.email.sicherheit);
const [httpErlaubt, setHttpErlaubt] = useState(ansicht.webhook.httpErlaubt);
const { email, webhook } = ansicht;
return (
// Leser sehen dieselben Felder, aber gesperrt und ohne Knoepfe.
<fieldset disabled={!darfAendern} className="flex min-w-0 flex-col gap-6">
<Karte
titel={
<span className="inline-flex items-center gap-2">
<Mail className="text-muted size-4" aria-hidden /> E-Mail (SMTP)
</span>
}
untertitel="Vorbelegt für Microsoft 365: smtp.office365.com, Port 587, STARTTLS."
aktionen={<KanalStatus aktiv={email.aktiv} />}
>
<div className="flex flex-col gap-5">
<Ankreuzfeld
name="emailAktiv"
value="ja"
defaultChecked={email.aktiv}
beschriftung="Per E-Mail benachrichtigen"
/>
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_6.5rem] gap-3">
<Feld
beschriftung="Server"
name="emailServer"
defaultValue={email.server}
mono
autoComplete="off"
spellCheck={false}
fehler={f["emailServer"]}
/>
<Feld
beschriftung="Port"
name="emailPort"
defaultValue={email.port}
mono
inputMode="numeric"
fehler={f["emailPort"]}
/>
</div>
<Feldgruppe legende="Verschlüsselung" fehler={f["emailSicherheit"]}>
<div className="grid gap-x-4 sm:grid-cols-3">
{(
[
["STARTTLS", "STARTTLS", "Port 587 – Microsoft 365"],
["TLS", "TLS (SMTPS)", "Port 465"],
["KEINE", "Unverschlüsselt", "nur internes Relay"],
] as const
).map(([wert, titel, erklaerung]) => (
<Ankreuzfeld
key={wert}
type="radio"
name="emailSicherheit"
value={wert}
defaultChecked={email.sicherheit === wert}
onChange={() => setSicherheit(wert)}
beschriftung={titel}
erklaerung={erklaerung}
/>
))}
</div>
{sicherheit === "KEINE" && (
<Warnung>
Ohne Verschlüsselung gehen Zugangsdaten und Inhalt im Klartext über das Netz. Nur für ein
Relay im selben, vertrauenswürdigen Netz verwenden.
</Warnung>
)}
</Feldgruppe>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Absender"
name="emailAbsender"
type="email"
defaultValue={email.absender}
autoComplete="off"
placeholder="sshkv@example.de"
fehler={f["emailAbsender"]}
/>
<Feld
beschriftung="Benutzer (optional)"
name="emailBenutzer"
defaultValue={email.benutzer}
autoComplete="off"
spellCheck={false}
hinweis="Bei Microsoft 365 meist die Absenderadresse."
fehler={f["emailBenutzer"]}
/>
</div>
<GeheimFeld
beschriftung="Passwort"
name="emailPasswort"
gesetzt={email.passwortGesetzt}
darfAendern={darfAendern}
fehler={f["emailPasswort"]}
/>
<Textbereich
beschriftung="Empfänger"
name="emailEmpfaenger"
defaultValue={email.empfaenger.join("\n")}
rows={3}
spellCheck={false}
placeholder="admin@example.de"
hinweis="Eine Adresse je Zeile oder durch Komma getrennt."
fehler={f["emailEmpfaenger"]}
/>
{darfAendern && <TestKnopf kanal="EMAIL" />}
</div>
</Karte>
<Karte
titel={
<span className="inline-flex items-center gap-2">
<Webhook className="text-muted size-4" aria-hidden /> Webhook
</span>
}
untertitel="JSON per POST, optional signiert mit X-Sshkv-Signatur: sha256=<HMAC des Bodys>."
aktionen={<KanalStatus aktiv={webhook.aktiv} />}
>
<div className="flex flex-col gap-5">
<Ankreuzfeld
name="webhookAktiv"
value="ja"
defaultChecked={webhook.aktiv}
beschriftung="Per Webhook benachrichtigen"
/>
<Feld
beschriftung="Adresse"
name="webhookUrl"
type="url"
defaultValue={webhook.url}
mono
autoComplete="off"
spellCheck={false}
placeholder="https://alarm.example.de/hooks/sshkv"
fehler={f["webhookUrl"]}
/>
<div>
<Ankreuzfeld
name="webhookHttpErlaubt"
value="ja"
defaultChecked={webhook.httpErlaubt}
onChange={(e) => setHttpErlaubt(e.currentTarget.checked)}
beschriftung="http:// für ein internes Ziel erlauben"
erklaerung="Sonst nimmt sshkv nur https://-Adressen an."
/>
{httpErlaubt && (
<Warnung>
Über http:// sind Nutzlast und Signatur unterwegs lesbar und veränderbar. Nur für Ziele im
selben, vertrauenswürdigen Netz.
</Warnung>
)}
</div>
<GeheimFeld
beschriftung="Geheimnis für die Signatur (optional)"
name="webhookGeheimnis"
gesetzt={webhook.geheimnisGesetzt}
darfAendern={darfAendern}
fehler={f["webhookGeheimnis"]}
/>
{darfAendern && <TestKnopf kanal="WEBHOOK" />}
</div>
</Karte>
<Karte
titel="Anlässe"
untertitel="Drift, Ablauf und Erreichbarkeit kommen gesammelt in der täglichen Zusammenfassung; ein geänderter Host-Key sofort."
>
<Feldgruppe legende="Benachrichtigen bei" fehler={f["anlaesse"]}>
<div className="grid gap-x-4 sm:grid-cols-2">
{ANLAESSE.map((anlass) => (
<Ankreuzfeld
key={anlass}
name="anlaesse"
value={anlass}
defaultChecked={ansicht.anlaesse[anlass]}
beschriftung={ANLASS_TEXT[anlass].titel}
erklaerung={ANLASS_TEXT[anlass].erklaerung}
/>
))}
</div>
</Feldgruppe>
</Karte>
</fieldset>
);
}
function KanalStatus({ aktiv }: { aktiv: boolean }) {
return aktiv ? <Badge variante="ok">aktiv</Badge> : <Badge>aus</Badge>;
}
function Warnung({ children }: { children: ReactNode }) {
return (
<p
role="note"
className="border-accent/40 bg-accent-soft text-accent rounded-box mt-2 border px-3 py-2 text-[13.5px]"
>
▲ {children}
</p>
);
}
/**
* Passwort bzw. Geheimnis: nie vorbelegt. Ist ein Wert gesetzt, zeigt das Feld nur „gesetzt“;
* leer lassen behaelt ihn, das Ankreuzfeld entfernt ihn.
*/
function GeheimFeld({
beschriftung,
name,
gesetzt,
darfAendern,
fehler,
}: {
beschriftung: string;
name: string;
gesetzt: boolean;
darfAendern: boolean;
fehler?: string | undefined;
}) {
if (!darfAendern) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<span className="text-muted text-[13px] font-medium">{beschriftung}</span>
<span>{gesetzt ? <Badge variante="ok">gesetzt</Badge> : <Badge>nicht gesetzt</Badge>}</span>
</div>
);
}
return (
<div className="flex min-w-0 flex-col gap-1">
<Feld
beschriftung={beschriftung}
name={name}
type="password"
autoComplete="new-password"
defaultValue=""
placeholder={gesetzt ? "gesetzt – leer lassen, um es zu behalten" : "nicht gesetzt"}
hinweis={
gesetzt ? (
<span className="inline-flex items-center gap-2">
<Badge variante="ok">gesetzt</Badge> Wird verschlüsselt gespeichert und nie wieder angezeigt.
</span>
) : (
"Wird verschlüsselt gespeichert und nie wieder angezeigt."
)
}
fehler={fehler}
/>
{gesetzt && (
<Ankreuzfeld
name={`${name}Entfernen`}
value="ja"
beschriftung={`${beschriftung.replace(/ \(optional\)$/, "")} entfernen`}
/>
)}
</div>
);
}
function TestKnopf({ kanal }: { kanal: "EMAIL" | "WEBHOOK" }) {
return (
<div className="border-border flex flex-col gap-2 border-t pt-4">
<p className="text-muted text-[13px]">
Die Testnachricht nutzt den <strong className="text-text font-medium">gespeicherten</strong> Stand –
erst speichern, dann testen. Der Versand läuft im Worker.
</p>
<VorgangsKnopf
aktion={() => testSendenAktion(kanal)}
beschriftung="Testnachricht senden"
icon={<Send className="size-4" />}
/>
</div>
);
}