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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user