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:
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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user