feat: make webhooks and documents editable instead of delete-and-recreate
Both could only be created and deleted. For a webhook that is actively harmful: recreating it issues a new signing secret, so the receiver that verifies the X-ApiCore-Signature header against the old one starts rejecting every delivery without anyone changing it on purpose. Backend: - New PATCH /webhooks/:id behind a new webhooks:update permission, covering the target URL, the event selection and the active flag - the last of which had no way to be set after creation at all, so a misbehaving receiver could only be deleted. The signing secret is deliberately left alone and stays out of the audit log. - A changed webhook URL runs through the same assertPublicWebhookUrl SSRF check as a new one; without that the edit would have been a way around the guard create() applies. Covered by a test. - New PATCH /documents/:id behind a new documents:update permission, for re-linking a document filed against the wrong record and correcting its display name. Re-linking validates the new target with the existing assertEntityExists; the stored file is untouched. Frontend: - New webhook edit dialog. The event labels and the toggle helper moved out of the create dialog into lib/webhooks/labels.ts so both use one copy. - Documents can be renamed from the attachment card. Re-linking a document to a different record has no UI yet: it needs a two-step picker (colony, then inspection) that is not worth building unprompted. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
42ceb33844
commit
9a147ee123
18 files changed
+604
-26
No files matched your search
@@ -6,6 +6,10 @@ 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 {
|
||||
WEBHOOK_EVENT_LABELS,
|
||||
toggleWebhookEvent,
|
||||
} from "@/lib/webhooks/labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -17,23 +21,6 @@ import {
|
||||
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);
|
||||
@@ -154,10 +141,12 @@ export function CreateWebhookDialog() {
|
||||
className="size-4.5"
|
||||
checked={events.includes(webhookEvent)}
|
||||
onChange={(inputEvent) =>
|
||||
setEvents((current) => toggleEvent(current, webhookEvent, inputEvent.target.checked))
|
||||
setEvents((current) =>
|
||||
toggleWebhookEvent(current, webhookEvent, inputEvent.target.checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{EVENT_LABELS[webhookEvent]}
|
||||
{WEBHOOK_EVENT_LABELS[webhookEvent]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateWebhook } from "@/lib/webhooks/api";
|
||||
import {
|
||||
WEBHOOK_EVENTS,
|
||||
WebhookEvent,
|
||||
WebhookSubscription,
|
||||
} from "@/lib/webhooks/types";
|
||||
import {
|
||||
WEBHOOK_EVENT_LABELS,
|
||||
toggleWebhookEvent,
|
||||
} from "@/lib/webhooks/labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
/**
|
||||
* Corrects an existing subscription. Editing beats delete-and-recreate here:
|
||||
* a new subscription would get a new signing secret, so the receiver that
|
||||
* already verifies against the old one would silently start rejecting every
|
||||
* delivery.
|
||||
*
|
||||
* The secret itself is never shown again after creation and is not touched by
|
||||
* an edit.
|
||||
*/
|
||||
function EditWebhookForm({
|
||||
webhook,
|
||||
onSaved,
|
||||
}: {
|
||||
webhook: WebhookSubscription;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [url, setUrl] = useState(webhook.url);
|
||||
const [events, setEvents] = useState<WebhookEvent[]>(webhook.events);
|
||||
const [active, setActive] = useState(webhook.active);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateWebhook(webhook.id, { url, events, active }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["webhooks"] });
|
||||
onSaved();
|
||||
},
|
||||
onError: (mutationError: Error) =>
|
||||
setError(
|
||||
mutationError.message || "Webhook konnte nicht gespeichert werden.",
|
||||
),
|
||||
});
|
||||
|
||||
function handleSubmit(formEvent: FormEvent<HTMLFormElement>) {
|
||||
formEvent.preventDefault();
|
||||
setError(null);
|
||||
if (events.length === 0) {
|
||||
setError("Bitte mindestens ein Ereignis auswählen.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="webhook-edit-url">Ziel-URL</Label>
|
||||
<Input
|
||||
id="webhook-edit-url"
|
||||
type="url"
|
||||
required
|
||||
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-edit-event-${webhookEvent}`}
|
||||
>
|
||||
<input
|
||||
id={`webhook-edit-event-${webhookEvent}`}
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={events.includes(webhookEvent)}
|
||||
onChange={(inputEvent) =>
|
||||
setEvents((current) =>
|
||||
toggleWebhookEvent(
|
||||
current,
|
||||
webhookEvent,
|
||||
inputEvent.target.checked,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{WEBHOOK_EVENT_LABELS[webhookEvent]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
className="flex h-9 items-center gap-3 text-sm"
|
||||
htmlFor="webhook-edit-active"
|
||||
>
|
||||
<input
|
||||
id="webhook-edit-active"
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={active}
|
||||
onChange={(inputEvent) => setActive(inputEvent.target.checked)}
|
||||
/>
|
||||
Aktiv – bei deaktiviertem Webhook werden keine Ereignisse zugestellt
|
||||
</label>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditWebhookDialog({
|
||||
webhook,
|
||||
}: {
|
||||
webhook: WebhookSubscription;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Webhook bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
{open ? (
|
||||
<EditWebhookForm webhook={webhook} onSaved={() => setOpen(false)} />
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { CreateWebhookDialog } from "./create-webhook-dialog";
|
||||
import { EditWebhookDialog } from "./edit-webhook-dialog";
|
||||
|
||||
export function WebhooksTab() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -78,6 +79,8 @@ export function WebhooksTab() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<EditWebhookDialog webhook={webhook} />
|
||||
<ConfirmDialog
|
||||
triggerLabel="Löschen"
|
||||
triggerSize="sm"
|
||||
@@ -88,6 +91,7 @@ export function WebhooksTab() {
|
||||
isPending={deleteMutation.isPending}
|
||||
onConfirm={() => deleteMutation.mutate(webhook.id)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user