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:
Nick SzaunigandClaude Opus 5 committed 2026-09-22 02:07:48 +02:00
1 parent 42ceb33844
commit 9a147ee123
18 files changed
+604 -26

No files matched your search

@@ -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"