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