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>
|
||||
))}
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, useRef } from "react";
|
||||
import { FileText, Image as ImageIcon, Paperclip } from "lucide-react";
|
||||
import { ChangeEvent, FormEvent, useRef, useState } from "react";
|
||||
import { FileText, Image as ImageIcon, Paperclip, Pencil } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { deleteDocument, downloadDocument, fetchDocuments, uploadDocument } from "@/lib/documents/api";
|
||||
import { DocumentEntityType } from "@/lib/documents/types";
|
||||
import {
|
||||
deleteDocument,
|
||||
downloadDocument,
|
||||
fetchDocuments,
|
||||
updateDocument,
|
||||
uploadDocument,
|
||||
} from "@/lib/documents/api";
|
||||
import { DocumentEntityType, DocumentSummary } from "@/lib/documents/types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
@@ -20,6 +36,68 @@ function formatFileSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects the display name of an uploaded file. The stored content is not
|
||||
* touched - replacing it means uploading a new file - but a wrong name is the
|
||||
* part someone actually reads when looking for the document later.
|
||||
*/
|
||||
function RenameDocumentDialog({ document }: { document: DocumentSummary }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [fileName, setFileName] = useState(document.fileName);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateDocument(document.id, { fileName }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["documents", document.entityType, document.entityId],
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast.error(error.message || "Datei konnte nicht umbenannt werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="sm" variant="outline">
|
||||
<Pencil className="size-3.5" />
|
||||
Umbenennen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Datei umbenennen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`document-rename-${document.id}`}>Dateiname</Label>
|
||||
<Input
|
||||
id={`document-rename-${document.id}`}
|
||||
required
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentTypeIcon({ mimeType }: { mimeType: string }) {
|
||||
return mimeType.startsWith("image/") ? (
|
||||
<ImageIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
@@ -137,6 +215,7 @@ export function DocumentUploadCard({
|
||||
>
|
||||
Herunterladen
|
||||
</Button>
|
||||
<RenameDocumentDialog document={document} />
|
||||
<ConfirmDialog
|
||||
triggerLabel="Löschen"
|
||||
triggerVariant="outline"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { apiFetch, apiFetchBlob, apiFetchFormData } from "@/lib/auth/api-client";
|
||||
import { downloadBlob } from "@/lib/export/api";
|
||||
import { DocumentEntityType, DocumentSummary } from "./types";
|
||||
import {
|
||||
DocumentEntityType,
|
||||
DocumentSummary,
|
||||
UpdateDocumentInput,
|
||||
} from "./types";
|
||||
|
||||
export function fetchDocuments(
|
||||
entityType: DocumentEntityType,
|
||||
@@ -22,6 +26,16 @@ export function uploadDocument(
|
||||
return apiFetchFormData<DocumentSummary>("/documents", formData);
|
||||
}
|
||||
|
||||
export function updateDocument(
|
||||
id: string,
|
||||
input: UpdateDocumentInput,
|
||||
): Promise<DocumentSummary> {
|
||||
return apiFetch<DocumentSummary>(`/documents/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDocument(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/documents/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -10,3 +10,9 @@ export interface DocumentSummary {
|
||||
uploadedByUserId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateDocumentInput {
|
||||
entityType?: DocumentEntityType;
|
||||
entityId?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateWebhookInput, WebhookSubscription, WebhookSubscriptionWithSecret } from "./types";
|
||||
import {
|
||||
CreateWebhookInput,
|
||||
UpdateWebhookInput,
|
||||
WebhookSubscription,
|
||||
WebhookSubscriptionWithSecret,
|
||||
} from "./types";
|
||||
|
||||
export function fetchWebhooks(): Promise<WebhookSubscription[]> {
|
||||
return apiFetch<WebhookSubscription[]>("/webhooks");
|
||||
@@ -12,6 +17,16 @@ export function createWebhook(input: CreateWebhookInput): Promise<WebhookSubscri
|
||||
});
|
||||
}
|
||||
|
||||
export function updateWebhook(
|
||||
id: string,
|
||||
input: UpdateWebhookInput,
|
||||
): Promise<WebhookSubscription> {
|
||||
return apiFetch<WebhookSubscription>(`/webhooks/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteWebhook(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/webhooks/${id}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { WebhookEvent } from "./types";
|
||||
|
||||
export const WEBHOOK_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",
|
||||
};
|
||||
|
||||
export function toggleWebhookEvent(
|
||||
events: WebhookEvent[],
|
||||
event: WebhookEvent,
|
||||
checked: boolean,
|
||||
): WebhookEvent[] {
|
||||
return checked
|
||||
? [...events, event]
|
||||
: events.filter((entry) => entry !== event);
|
||||
}
|
||||
@@ -31,3 +31,9 @@ export interface CreateWebhookInput {
|
||||
url: string;
|
||||
events: WebhookEvent[];
|
||||
}
|
||||
|
||||
export interface UpdateWebhookInput {
|
||||
url?: string;
|
||||
events?: WebhookEvent[];
|
||||
active?: boolean;
|
||||
}
|
||||
Reference in new issue
Block a user