feat(frontend): Webhook-Verwaltung fuer Administratoren (Modul 28)

/settings/webhooks: Liste, Anlage-Dialog mit Ereignis-Auswahl und
einmaliger Secret-Anzeige (Kopieren), Loeschen. Sidebar-Eintrag unter
Verwaltung. Live in Chrome verifiziert (Anlegen/Kopieren/Loeschen,
SSRF-Ablehnung, Mobile-Breakpoint); dabei einen Reset-Bug im
Anlage-Dialog gefunden und behoben (Fertig-Button loeschte den
Dialogzustand nicht, da onOpenChange bei programmatischem Schliessen
nicht feuert).
This commit is contained in:
Nick Szaunig committed 2026-08-23 11:15:48 +02:00
1 parent 79010a926b
commit e4d5af5fa7
6 files changed
+334 -2

No files matched your search

@@ -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<WebhookEvent, string> = {
"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<WebhookEvent[]>([]);
const [error, setError] = useState<string | null>(null);
const [createdSecret, setCreatedSecret] = useState<string | null>(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<HTMLFormElement>) {
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 (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<Button onClick={() => setOpen(true)}>+ Neuer Webhook</Button>
<DialogContent>
{createdSecret ? (
<>
<DialogHeader>
<DialogTitle>Webhook angelegt</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Dieses Secret wird nur einmal angezeigt. Speichere es jetzt – es dient zur Prüfung der{" "}
<code className="rounded bg-muted px-1 py-0.5">X-ApiCore-Signature</code>-Kopfzeile
(HMAC-SHA256) jeder Auslieferung.
</p>
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-3">
<code className="flex-1 overflow-x-auto text-xs">{createdSecret}</code>
<Button type="button" variant="outline" size="sm" onClick={() => void copySecret()}>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
</Button>
</div>
<DialogFooter>
<Button
type="button"
onClick={() => {
setOpen(false);
reset();
}}
>
Fertig
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Neuen Webhook anlegen</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="webhook-url">Ziel-URL</Label>
<Input
id="webhook-url"
type="url"
required
placeholder="https://example.com/hook"
value={url}
onChange={(inputEvent) => setUrl(inputEvent.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Ereignisse</Label>
<div className="flex flex-col gap-2">
{WEBHOOK_EVENTS.map((webhookEvent) => (
<label
key={webhookEvent}
className="flex h-9 items-center gap-3 text-sm"
htmlFor={`webhook-event-${webhookEvent}`}
>
<input
id={`webhook-event-${webhookEvent}`}
type="checkbox"
className="size-4.5"
checked={events.includes(webhookEvent)}
onChange={(inputEvent) =>
setEvents((current) => toggleEvent(current, webhookEvent, inputEvent.target.checked))
}
/>
{EVENT_LABELS[webhookEvent]}
</label>
))}
</div>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
</Button>
</DialogFooter>
</form>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -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 (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Webhooks</h1>
<p className="text-sm text-muted-foreground">
Externe Systeme über Ereignisse in ApiCore benachrichtigen
</p>
</div>
<CreateWebhookDialog />
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Webhooks konnten nicht geladen werden.
</p>
) : null}
{webhooks && webhooks.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Webhooks angelegt.</p>
) : null}
{webhooks && webhooks.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>URL</TableHead>
<TableHead>Ereignisse</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell className="max-w-64 truncate font-mono text-xs">{webhook.url}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{webhook.events.map((event) => (
<Badge key={event} variant="secondary">
{event}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<Badge variant={webhook.active ? "default" : "outline"}>
{webhook.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(webhook.id)}
>
Löschen
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : null}
</main>
);
}
@@ -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 },
],
};
+17
View File
@@ -0,0 +1,17 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateWebhookInput, WebhookSubscription, WebhookSubscriptionWithSecret } from "./types";
export function fetchWebhooks(): Promise<WebhookSubscription[]> {
return apiFetch<WebhookSubscription[]>("/webhooks");
}
export function createWebhook(input: CreateWebhookInput): Promise<WebhookSubscriptionWithSecret> {
return apiFetch<WebhookSubscriptionWithSecret>("/webhooks", {
method: "POST",
body: JSON.stringify(input),
});
}
export function deleteWebhook(id: string): Promise<void> {
return apiFetch<void>(`/webhooks/${id}`, { method: "DELETE" });
}
@@ -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[];
}
+2 -2
View File
@@ -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