Files
sshkeyvault/src/components/einstellungen/BenachrichtigungFormular.tsx
T
florian.wildeandClaude Opus 5.5 8f5b31c0cb 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>
2026-10-12 16:54:43 +02:00

367 lines
12 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.
"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>
);
}