diff --git a/ApiCore-Frontend/src/app/(app)/settings/webhooks/create-webhook-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/webhooks/create-webhook-dialog.tsx new file mode 100644 index 0000000..3cef3b2 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/webhooks/create-webhook-dialog.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Check, Copy } from "lucide-react"; +import { toast } from "sonner"; +import { createWebhook } from "@/lib/webhooks/api"; +import { WEBHOOK_EVENTS, WebhookEvent } from "@/lib/webhooks/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +const EVENT_LABELS: Record = { + "colony.created": "Volk angelegt", + "inspection.created": "Durchsicht erfasst", + "queen.created": "Königin angelegt", + "queen.replaced": "Königin zugewiesen/ersetzt", + "harvest.created": "Ernte erfasst", + "batch.created": "Honigcharge angelegt", + "batch.updated": "Honigcharge geändert", + "product.stock_changed": "Lagerbestand geändert", + "task.created": "Aufgabe angelegt", + "task.completed": "Aufgabe erledigt", +}; + +function toggleEvent(events: WebhookEvent[], event: WebhookEvent, checked: boolean): WebhookEvent[] { + return checked ? [...events, event] : events.filter((entry) => entry !== event); +} + +export function CreateWebhookDialog() { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(""); + const [events, setEvents] = useState([]); + const [error, setError] = useState(null); + const [createdSecret, setCreatedSecret] = useState(null); + const [copied, setCopied] = useState(false); + + function reset() { + setUrl(""); + setEvents([]); + setError(null); + setCreatedSecret(null); + setCopied(false); + } + + const mutation = useMutation({ + mutationFn: createWebhook, + onSuccess: (subscription) => { + void queryClient.invalidateQueries({ queryKey: ["webhooks"] }); + setCreatedSecret(subscription.secret); + setError(null); + }, + onError: () => { + setError("Webhook konnte nicht angelegt werden."); + }, + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + if (events.length === 0) { + setError("Bitte mindestens ein Ereignis auswählen."); + return; + } + mutation.mutate({ url, events }); + } + + async function copySecret() { + if (!createdSecret) return; + await navigator.clipboard.writeText(createdSecret); + setCopied(true); + toast.success("Secret kopiert"); + } + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + + + {createdSecret ? ( + <> + + Webhook angelegt + +

+ Dieses Secret wird nur einmal angezeigt. Speichere es jetzt – es dient zur Prüfung der{" "} + X-ApiCore-Signature-Kopfzeile + (HMAC-SHA256) jeder Auslieferung. +

+
+ {createdSecret} + +
+ + + + + ) : ( + <> + + Neuen Webhook anlegen + +
+
+ + setUrl(inputEvent.target.value)} + /> +
+
+ +
+ {WEBHOOK_EVENTS.map((webhookEvent) => ( + + ))} +
+
+ {error ? ( +

+ {error} +

+ ) : null} + + + +
+ + )} +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/webhooks/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/webhooks/page.tsx new file mode 100644 index 0000000..7f5c7ba --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/webhooks/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { deleteWebhook, fetchWebhooks } from "@/lib/webhooks/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { CreateWebhookDialog } from "./create-webhook-dialog"; + +export default function WebhooksPage() { + const queryClient = useQueryClient(); + const { data: webhooks, isLoading, isError } = useQuery({ + queryKey: ["webhooks"], + queryFn: fetchWebhooks, + }); + + const deleteMutation = useMutation({ + mutationFn: deleteWebhook, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["webhooks"] }); + toast.success("Webhook gelöscht"); + }, + }); + + return ( +
+
+
+

Webhooks

+

+ Externe Systeme über Ereignisse in ApiCore benachrichtigen +

+
+ +
+ + {isLoading ?

Lädt…

: null} + {isError ? ( +

+ Webhooks konnten nicht geladen werden. +

+ ) : null} + + {webhooks && webhooks.length === 0 ? ( +

Noch keine Webhooks angelegt.

+ ) : null} + + {webhooks && webhooks.length > 0 ? ( +
+ + + + URL + Ereignisse + Status + Aktion + + + + {webhooks.map((webhook) => ( + + {webhook.url} + +
+ {webhook.events.map((event) => ( + + {event} + + ))} +
+
+ + + {webhook.active ? "Aktiv" : "Inaktiv"} + + + + + +
+ ))} +
+
+
+ ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/sidebar.tsx b/ApiCore-Frontend/src/app/(app)/sidebar.tsx index 49f933d..13b2ea6 100644 --- a/ApiCore-Frontend/src/app/(app)/sidebar.tsx +++ b/ApiCore-Frontend/src/app/(app)/sidebar.tsx @@ -20,6 +20,7 @@ import { ShoppingBag, Users, Warehouse, + Webhook, Wrench, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -80,6 +81,7 @@ const ADMIN_GROUP: NavGroup = { items: [ { label: "Benutzer", href: "/settings/users", icon: Users }, { label: "Audit-Log", href: "/settings/audit-log", icon: History }, + { label: "Webhooks", href: "/settings/webhooks", icon: Webhook }, ], }; diff --git a/ApiCore-Frontend/src/lib/webhooks/api.ts b/ApiCore-Frontend/src/lib/webhooks/api.ts new file mode 100644 index 0000000..fc233b2 --- /dev/null +++ b/ApiCore-Frontend/src/lib/webhooks/api.ts @@ -0,0 +1,17 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { CreateWebhookInput, WebhookSubscription, WebhookSubscriptionWithSecret } from "./types"; + +export function fetchWebhooks(): Promise { + return apiFetch("/webhooks"); +} + +export function createWebhook(input: CreateWebhookInput): Promise { + return apiFetch("/webhooks", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function deleteWebhook(id: string): Promise { + return apiFetch(`/webhooks/${id}`, { method: "DELETE" }); +} diff --git a/ApiCore-Frontend/src/lib/webhooks/types.ts b/ApiCore-Frontend/src/lib/webhooks/types.ts new file mode 100644 index 0000000..bbf00c3 --- /dev/null +++ b/ApiCore-Frontend/src/lib/webhooks/types.ts @@ -0,0 +1,33 @@ +export const WEBHOOK_EVENTS = [ + "colony.created", + "inspection.created", + "queen.created", + "queen.replaced", + "harvest.created", + "batch.created", + "batch.updated", + "product.stock_changed", + "task.created", + "task.completed", +] as const; + +export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]; + +export interface WebhookSubscription { + id: string; + url: string; + events: WebhookEvent[]; + active: boolean; + createdByUserId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface WebhookSubscriptionWithSecret extends WebhookSubscription { + secret: string; +} + +export interface CreateWebhookInput { + url: string; + events: WebhookEvent[]; +} diff --git a/TASKS.md b/TASKS.md index d70b2bc..a6c875c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -227,8 +227,8 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li - Backend: `WebhooksModule` (`WebhookSubscription`: URL, Events, Secret, aktiv); Event-Dispatcher an bestehende Services angehängt (`colony.created`, `inspection.created`, `queen.created`, `queen.replaced`, `harvest.created`, `batch.created`, `batch.updated`, `product.stock_changed`, `task.created`, `task.completed`), HMAC-signierter POST - Endpunkte: `GET/POST /api/v1/webhooks`, `DELETE /api/v1/webhooks/:id` - Frontend: `/settings/webhooks` (nur Administrator) -- [ ] Backend-Modul fertig, getestet, grün, committet -- [ ] Frontend-Views fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet +- [x] Frontend-Views fertig, getestet, grün, committet ### 29. Dokumente und Bilder