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,
|
ShoppingBag,
|
||||||
Users,
|
Users,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
|
Webhook,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -80,6 +81,7 @@ const ADMIN_GROUP: NavGroup = {
|
|||||||
items: [
|
items: [
|
||||||
{ label: "Benutzer", href: "/settings/users", icon: Users },
|
{ label: "Benutzer", href: "/settings/users", icon: Users },
|
||||||
{ label: "Audit-Log", href: "/settings/audit-log", icon: History },
|
{ 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
|
- 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`
|
- Endpunkte: `GET/POST /api/v1/webhooks`, `DELETE /api/v1/webhooks/:id`
|
||||||
- Frontend: `/settings/webhooks` (nur Administrator)
|
- Frontend: `/settings/webhooks` (nur Administrator)
|
||||||
- [ ] Backend-Modul fertig, getestet, grün, committet
|
- [x] Backend-Modul fertig, getestet, grün, committet
|
||||||
- [ ] Frontend-Views fertig, getestet, grün, committet
|
- [x] Frontend-Views fertig, getestet, grün, committet
|
||||||
|
|
||||||
### 29. Dokumente und Bilder
|
### 29. Dokumente und Bilder
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user