Merge branch 'phase/028-benachrichtigungen'
# Conflicts: # docker-compose.yml # scripts/screenshots.ts
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user