"use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Bell, BellRing, CheckCircle2, KeyRound, Loader2, Mail, Pencil, Plus, Trash2, Webhook, XCircle, } from "lucide-react"; import type { NotificationChannel, NotificationEvent } from "@prisma/client"; import { Modal } from "@/components/ui/Modal"; import { EVENT_META, NOTIFICATION_EVENTS } from "@/lib/notify/types"; import { cn, formatRelative } from "@/lib/utils"; export interface NotificationTargetEntry { id: string; name: string; channel: NotificationChannel; target: string; secretHint: string | null; events: NotificationEvent[]; isActive: boolean; lastSentAt: string | Date | null; lastOk: boolean | null; lastMessage: string | null; } const CHANNEL_META: Record< NotificationChannel, { label: string; icon: typeof Webhook; hint: string; placeholder: string } > = { NTFY: { label: "ntfy", icon: BellRing, hint: "Vollstaendige Themen-URL, z. B. https://ntfy.sh/mein-geheimes-thema", placeholder: "https://ntfy.sh/mein-thema", }, WEBHOOK: { label: "Webhook", icon: Webhook, hint: "Bekommt JSON per POST. Mit Geheimnis wird der Rumpf signiert (X-Nebula-Signature).", placeholder: "https://mein-dienst.de/haken", }, EMAIL: { label: "E-Mail", icon: Mail, hint: "Braucht SMTP-Zugangsdaten in der .env der Instanz.", placeholder: "ich@example.com", }, }; export function NotificationSection({ targets, emailAvailable, }: { targets: NotificationTargetEntry[]; emailAvailable: boolean; }) { const router = useRouter(); const [dialogOpen, setDialogOpen] = useState(false); const [editing, setEditing] = useState(null); const [busyId, setBusyId] = useState(null); const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null); async function test(entry: NotificationTargetEntry) { setBusyId(entry.id); setNotice(null); try { const response = await fetch(`/api/notifications/${entry.id}/test`, { method: "POST" }); const body = await response.json().catch(() => ({})); setNotice({ ok: response.ok, text: body.message ?? body.error ?? "Unbekannte Antwort." }); router.refresh(); } finally { setBusyId(null); } } async function remove(entry: NotificationTargetEntry) { if (!window.confirm(`Ziel „${entry.name}“ entfernen?`)) return; setBusyId(entry.id); await fetch(`/api/notifications/${entry.id}`, { method: "DELETE" }).catch(() => undefined); setBusyId(null); router.refresh(); } async function toggle(entry: NotificationTargetEntry) { setBusyId(entry.id); await fetch(`/api/notifications/${entry.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isActive: !entry.isActive }), }).catch(() => undefined); setBusyId(null); router.refresh(); } return (

Benachrichtigungen

Meldung bei fehlgeschlagenem Abgleich, roter CI oder neuem Release. Gemeldet wird nur bei einer Aenderung – nicht bei jedem Durchlauf.

{targets.length === 0 ? (

Noch kein Ziel eingerichtet.

) : (
{targets.map((entry) => { const meta = CHANNEL_META[entry.channel]; const Icon = meta.icon; return (

{entry.name} {meta.label} {!entry.isActive ? ( pausiert ) : null} {entry.secretHint ? ( {entry.secretHint} ) : null}

{entry.target}

{entry.events.map((event) => ( {EVENT_META[event].emoji} {EVENT_META[event].label} ))}

{entry.lastSentAt ? (

{entry.lastOk ? ( ) : ( )} {formatRelative(entry.lastSentAt)} · {entry.lastMessage}

) : null}
{busyId === entry.id ? ( ) : ( <> )}
); })}
)} {notice ? (

{notice.text}

) : null} {!emailAvailable ? (

E-Mail steht nicht bereit: In der .env der Instanz fehlt SMTP_HOST. ntfy und Webhook funktionieren ohne weitere Einrichtung.

) : null} { setDialogOpen(false); setEditing(null); }} />
); } function TargetDialog({ open, entry, emailAvailable, onClose, }: { open: boolean; entry: NotificationTargetEntry | null; emailAvailable: boolean; onClose: () => void; }) { const router = useRouter(); const [name, setName] = useState(""); const [channel, setChannel] = useState("NTFY"); const [target, setTarget] = useState(""); const [secret, setSecret] = useState(""); const [events, setEvents] = useState(["SYNC_FAILED", "CI_FAILED"]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); const isEdit = Boolean(entry); useEffect(() => { if (!open) return; setName(entry?.name ?? ""); setChannel(entry?.channel ?? "NTFY"); setTarget(entry?.target ?? ""); setSecret(""); setEvents(entry?.events ?? ["SYNC_FAILED", "CI_FAILED"]); setError(null); setFieldErrors({}); }, [open, entry]); async function submit(event: React.FormEvent) { event.preventDefault(); setBusy(true); setError(null); setFieldErrors({}); const body: Record = { name, target, events }; if (!isEdit) body.channel = channel; if (!isEdit || secret.trim()) body.secret = secret; try { const response = await fetch( isEdit ? `/api/notifications/${entry?.id}` : "/api/notifications", { method: isEdit ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, ); const payload = await response.json().catch(() => ({})); if (!response.ok) { setFieldErrors(payload.fields ?? {}); throw new Error(payload.error ?? "Speichern fehlgeschlagen."); } onClose(); router.refresh(); } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)); } finally { setBusy(false); } } const meta = CHANNEL_META[channel]; return (
{!isEdit ? (
Weg
{(Object.keys(CHANNEL_META) as NotificationChannel[]).map((value) => { const option = CHANNEL_META[value]; const Icon = option.icon; const disabled = value === "EMAIL" && !emailAvailable; return ( ); })}
) : null}
setName(e.target.value)} placeholder="Handy" required maxLength={60} /> {fieldErrors.name ? (

{fieldErrors.name}

) : null}
setTarget(e.target.value)} placeholder={meta.placeholder} required />

{meta.hint}

{fieldErrors.target ? (

{fieldErrors.target}

) : null}
{channel !== "EMAIL" ? (
setSecret(e.target.value)} autoComplete="off" placeholder={isEdit ? "••••••••" : ""} />

{channel === "NTFY" ? "Wird als Bearer-Token gesendet – fuer geschuetzte Themen." : "Signiert den Rumpf per HMAC-SHA256 im Kopf X-Nebula-Signature."}

) : null}
Anlaesse *
{NOTIFICATION_EVENTS.map((event) => { const option = EVENT_META[event]; const checked = events.includes(event); return ( ); })}
{fieldErrors.events ? (

{fieldErrors.events}

) : null}
{error ? (

{error}

) : null}
); }