feat: document delete permissions + three-dots menu portal fix

- Add can_delete column to document_shares (migration 0005)
- Inject x-user-is-admin header from backend proxy to doc-service
- Add get_user_is_admin() dep in doc-service
- Delete endpoint now allows: owner, admin, or group member with can_delete=true
- Watch documents (user_id='watch') deletable by admins only
- DocumentOut gains viewer_can_delete (computed per-request)
- Share UI: 'Allow group members to delete' checkbox + trash badge on shares
- RowActionsMenu dropdown portaled to document.body — fixes overflow-hidden clipping
- Delete mutation onError handler — no more silent failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-18 21:39:01 +02:00
parent 05d79d3d21
commit 6e5e5c08bf
13 changed files with 239 additions and 55 deletions
+4 -2
View File
@@ -220,6 +220,7 @@ export interface DocumentOut {
suggested_folder: string | null;
suggested_filename: string | null;
share_count: number;
viewer_can_delete: boolean;
}
export interface SharedDocumentOut extends DocumentOut {
@@ -232,6 +233,7 @@ export interface DocumentShareOut {
document_id: string;
group_id: string;
shared_by_user_id: string;
can_delete: boolean;
created_at: string;
}
@@ -270,8 +272,8 @@ export const listSharedWithMe = (params: DocumentListParams = {}) =>
export const getDocumentShares = (docId: string) =>
api.get<DocumentShareOut[]>(`/documents/${docId}/shares`);
export const addDocumentShare = (docId: string, groupId: string) =>
api.post<DocumentShareOut>(`/documents/${docId}/shares`, { group_id: groupId });
export const addDocumentShare = (docId: string, groupId: string, canDelete = false) =>
api.post<DocumentShareOut>(`/documents/${docId}/shares`, { group_id: groupId, can_delete: canDelete });
export const removeDocumentShare = (docId: string, groupId: string) =>
api.delete(`/documents/${docId}/shares/${groupId}`);
+52 -29
View File
@@ -186,6 +186,7 @@ export default function DocumentSlideOver({ doc, isOwner, onClose, onDeleted }:
const [titleValue, setTitleValue] = useState("");
const [editingType, setEditingType] = useState(false);
const [tagInput, setTagInput] = useState("");
const [canDeleteNew, setCanDeleteNew] = useState(false);
const titleInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -279,8 +280,12 @@ export default function DocumentSlideOver({ doc, isOwner, onClose, onDeleted }:
});
const addShareMut = useMutation({
mutationFn: (groupId: string) => addDocumentShare(doc!.id, groupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["document-shares", doc!.id] }),
mutationFn: ({ groupId, canDelete }: { groupId: string; canDelete: boolean }) =>
addDocumentShare(doc!.id, groupId, canDelete),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["document-shares", doc!.id] });
setCanDeleteNew(false);
},
});
const removeShareMut = useMutation({
@@ -694,6 +699,11 @@ export default function DocumentSlideOver({ doc, isOwner, onClose, onDeleted }:
<div key={share.id} className="flex items-center gap-2 text-sm">
<Users className="h-3.5 w-3.5 text-muted shrink-0" />
<span className="flex-1 text-sm">{group?.name ?? share.group_id}</span>
{share.can_delete && (
<span title="Group members can delete this document">
<Trash2 className="h-3 w-3 text-muted shrink-0" />
</span>
)}
<button
onClick={() => removeShareMut.mutate(share.group_id)}
disabled={removeShareMut.isPending}
@@ -706,41 +716,54 @@ export default function DocumentSlideOver({ doc, isOwner, onClose, onDeleted }:
);
})}
</div>
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer mt-1 mb-0.5 select-none">
<input
type="checkbox"
checked={canDeleteNew}
onChange={(e) => setCanDeleteNew(e.target.checked)}
className="h-3 w-3 accent-primary"
/>
Allow group members to delete
</label>
<GroupCombobox
groups={myGroups}
sharedGroupIds={sharedGroupIds}
onShare={(id) => addShareMut.mutate(id)}
onShare={(id) => addShareMut.mutate({ groupId: id, canDelete: canDeleteNew })}
/>
</div>
)}
{/* Owner actions */}
{isOwner && (
{/* Owner/permitted actions */}
{(isOwner || doc.viewer_can_delete) && (
<div className="flex gap-2 pt-1">
<Button
variant="outline"
size="sm"
onClick={() => reprocessMut.mutate()}
disabled={reprocessMut.isPending || doc.status === "pending" || doc.status === "processing"}
className="gap-1.5"
>
<RefreshCw className={cn("h-3.5 w-3.5", reprocessMut.isPending && "animate-spin")} />
Re-analyse
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
if (confirm(`Delete "${doc.title ?? doc.filename}"? This cannot be undone.`)) {
deleteMut.mutate();
}
}}
disabled={deleteMut.isPending}
className="gap-1.5 text-red-500 border-red-200 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
{isOwner && (
<Button
variant="outline"
size="sm"
onClick={() => reprocessMut.mutate()}
disabled={reprocessMut.isPending || doc.status === "pending" || doc.status === "processing"}
className="gap-1.5"
>
<RefreshCw className={cn("h-3.5 w-3.5", reprocessMut.isPending && "animate-spin")} />
Re-analyse
</Button>
)}
{doc.viewer_can_delete && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (confirm(`Delete "${doc.title ?? doc.filename}"? This cannot be undone.`)) {
deleteMut.mutate();
}
}}
disabled={deleteMut.isPending}
className="gap-1.5 text-red-500 border-red-200 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
)}
+43 -14
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -47,43 +48,70 @@ function formatDate(iso: string) {
// ── Row actions dropdown ──────────────────────────────────────────────────────
function RowActionsMenu({
doc, isOwner, onSelect,
}: { doc: DocumentOut; isOwner: boolean; onSelect: () => void }) {
doc, onSelect,
}: { doc: DocumentOut; onSelect: () => void }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [menuPos, setMenuPos] = useState({ top: 0, right: 0 });
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
const close = (e: MouseEvent) => {
if (
triggerRef.current && !triggerRef.current.contains(e.target as Node) &&
menuRef.current && !menuRef.current.contains(e.target as Node)
) setOpen(false);
};
const closeOnScroll = () => setOpen(false);
document.addEventListener("mousedown", close);
window.addEventListener("scroll", closeOnScroll, true);
window.addEventListener("resize", closeOnScroll);
return () => {
document.removeEventListener("mousedown", close);
window.removeEventListener("scroll", closeOnScroll, true);
window.removeEventListener("resize", closeOnScroll);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const deleteMut = useMutation({
mutationFn: () => deleteDocument(doc.id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["documents"] }),
onError: () => alert("Failed to delete document. Please try again."),
});
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (!open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
}
setOpen((o) => !o);
};
return (
<div className="relative" ref={ref} onClick={(e) => e.stopPropagation()}>
<div onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setOpen((o) => !o)}
ref={triggerRef}
onClick={handleToggle}
className="p-1 rounded text-muted hover:text-foreground hover:bg-muted/20 transition-colors opacity-0 group-hover:opacity-100"
title="Actions"
>
<MoreHorizontal className="h-4 w-4" />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-20 bg-surface border border-border rounded-lg shadow-lg w-36 py-1">
{open && createPortal(
<div
ref={menuRef}
style={{ position: "fixed", top: menuPos.top, right: menuPos.right, zIndex: 9999 }}
className="bg-surface border border-border rounded-lg shadow-lg w-36 py-1"
>
<button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted/20 transition-colors"
onClick={() => { onSelect(); setOpen(false); }}
>
Open details
</button>
{isOwner && (
{doc.viewer_can_delete && (
<button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted/20 transition-colors text-red-500"
onClick={() => {
@@ -94,7 +122,8 @@ function RowActionsMenu({
Delete
</button>
)}
</div>
</div>,
document.body
)}
</div>
);
@@ -470,7 +499,7 @@ function DocumentRow({
</td>
<td className="px-3 py-2.5 w-8 text-right">
<RowActionsMenu doc={doc} isOwner={isOwner} onSelect={onOpen} />
<RowActionsMenu doc={doc} onSelect={onOpen} />
</td>
</tr>
);