diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index d15252b..a5d6f0e 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -1,5 +1,10 @@ import type { Metadata } from "next"; import { AccountManager, type AccountData } from "@/components/account/AccountManager"; +import { + NotificationSection, + type NotificationTargetEntry, +} from "@/components/account/NotificationSection"; +import { emailAvailable } from "@/lib/notify"; import { requireUserPage } from "@/lib/auth/guard"; import { WEBAUTHN_AVAILABLE } from "@/lib/config"; import { prisma } from "@/lib/db"; @@ -13,7 +18,7 @@ export async function generateMetadata(): Promise { export default async function AccountPage() { const user = await requireUserPage(); - const [passkeys, sessions] = await Promise.all([ + const [passkeys, sessions, targets] = await Promise.all([ prisma.credential.findMany({ where: { userId: user.id }, select: { @@ -37,6 +42,22 @@ export default async function AccountPage() { }, orderBy: { lastSeenAt: "desc" }, }), + prisma.notificationTarget.findMany({ + where: { userId: user.id }, + select: { + id: true, + name: true, + channel: true, + target: true, + secretHint: true, + events: true, + isActive: true, + lastSentAt: true, + lastOk: true, + lastMessage: true, + }, + orderBy: { name: "asc" }, + }), ]); const data: AccountData = { @@ -60,6 +81,11 @@ export default async function AccountPage() { + + ); } diff --git a/src/app/api/notifications/[id]/route.ts b/src/app/api/notifications/[id]/route.ts new file mode 100644 index 0000000..85c83ed --- /dev/null +++ b/src/app/api/notifications/[id]/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { encryptSecret, tokenHint } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; +import { jsonError, readJsonBody } from "@/lib/security/request"; +import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf"; +import { targetSafeSelect } from "@/lib/notify/types"; +import { formatZodError, notificationTargetUpdateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function PATCH(request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + const existing = await prisma.notificationTarget.findFirst({ + where: { id, userId: auth.user.id }, + }); + if (!existing) return jsonError("Ziel nicht gefunden", 404); + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = notificationTargetUpdateSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const data = parsed.data; + const update: Record = {}; + + if (data.name !== undefined) update.name = data.name; + if (data.events !== undefined) update.events = data.events; + if (data.isActive !== undefined) update.isActive = data.isActive; + + if (data.target !== undefined) { + if (existing.channel !== "EMAIL") { + try { + await assertAllowedUrl(data.target); + } catch (error) { + if (error instanceof BlockedHostError) { + return NextResponse.json( + { error: error.message, fields: { target: error.message } }, + { status: 422 }, + ); + } + throw error; + } + } + update.target = data.target; + } + + // Leeres Feld heisst "unveraendert", null heisst "entfernen". + if (data.secret !== undefined) { + if (data.secret) { + update.secretCipher = encryptSecret(data.secret); + update.secretHint = tokenHint(data.secret); + } else { + update.secretCipher = null; + update.secretHint = null; + } + } + + try { + const target = await prisma.notificationTarget.update({ + where: { id }, + data: update, + select: targetSafeSelect, + }); + return NextResponse.json({ target }); + } catch { + return jsonError("Name bereits vergeben.", 409); + } +} + +export async function DELETE(_request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + const result = await prisma.notificationTarget.deleteMany({ + where: { id, userId: auth.user.id }, + }); + if (result.count === 0) return jsonError("Ziel nicht gefunden", 404); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/notifications/[id]/test/route.ts b/src/app/api/notifications/[id]/test/route.ts new file mode 100644 index 0000000..d245cf4 --- /dev/null +++ b/src/app/api/notifications/[id]/test/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { deliver } from "@/lib/notify/channels"; +import { testMessage } from "@/lib/notify"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +/** Schickt eine Testnachricht an genau dieses Ziel. */ +export async function POST(request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + // Loest einen ausgehenden Aufruf aus – entsprechend begrenzt. + const limited = rateLimit( + `notify-test:${auth.user.id}:${clientIp(request)}`, + LIMITS.outbound.limit, + LIMITS.outbound.windowMs, + ); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + + const { id } = await params; + const target = await prisma.notificationTarget.findFirst({ + where: { id, userId: auth.user.id }, + }); + if (!target) return jsonError("Ziel nicht gefunden", 404); + + const result = await deliver(target, testMessage()); + + await prisma.notificationTarget.update({ + where: { id }, + data: { lastSentAt: new Date(), lastOk: result.ok, lastMessage: result.message }, + }); + + return NextResponse.json(result, { status: result.ok ? 200 : 502 }); +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..8413493 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { encryptSecret, tokenHint } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; +import { readJsonBody } from "@/lib/security/request"; +import { targetSafeSelect } from "@/lib/notify/types"; +import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf"; +import { formatZodError, notificationTargetCreateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const targets = await prisma.notificationTarget.findMany({ + where: { userId: auth.user.id }, + select: targetSafeSelect, + orderBy: { name: "asc" }, + }); + return NextResponse.json({ targets }); +} + +export async function POST(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = notificationTargetCreateSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const { name, channel, target, secret, events, isActive } = parsed.data; + + // Webhook und ntfy ruft der Server spaeter selbst auf – gleiche Pruefung + // wie bei den Git-Verbindungen. + if (channel !== "EMAIL") { + try { + await assertAllowedUrl(target); + } catch (error) { + if (error instanceof BlockedHostError) { + return NextResponse.json( + { error: error.message, fields: { target: error.message } }, + { status: 422 }, + ); + } + throw error; + } + } + + const duplicate = await prisma.notificationTarget.findFirst({ + where: { userId: auth.user.id, name }, + select: { id: true }, + }); + if (duplicate) { + return NextResponse.json( + { error: "Name bereits vergeben.", fields: { name: "Name bereits vergeben" } }, + { status: 409 }, + ); + } + + const created = await prisma.notificationTarget.create({ + data: { + userId: auth.user.id, + name, + channel, + target, + secretCipher: secret ? encryptSecret(secret) : null, + secretHint: secret ? tokenHint(secret) : null, + events, + isActive, + }, + select: targetSafeSelect, + }); + + return NextResponse.json({ target: created }, { status: 201 }); +} diff --git a/src/components/account/NotificationSection.tsx b/src/components/account/NotificationSection.tsx new file mode 100644 index 0000000..1f15953 --- /dev/null +++ b/src/components/account/NotificationSection.tsx @@ -0,0 +1,515 @@ +"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} + +
+ + +
+
+
+ ); +}