Merge: resolve conflicts between feat/document-delete-permissions and feat/category-scopes-group-admin
- Keep HEAD's get_user_admin_groups dep and richer delete permission logic (can_delete via share OR group admin path)
- Use sa.text("false") for migration server_default (correct SQLAlchemy form)
- Preserve 0006/0007 migration entries in doc-service CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -222,6 +222,7 @@ export interface DocumentOut {
|
||||
suggested_folder: string | null;
|
||||
suggested_filename: string | null;
|
||||
share_count: number;
|
||||
viewer_can_delete: boolean;
|
||||
}
|
||||
|
||||
export interface SharedDocumentOut extends DocumentOut {
|
||||
@@ -234,6 +235,7 @@ export interface DocumentShareOut {
|
||||
document_id: string;
|
||||
group_id: string;
|
||||
shared_by_user_id: string;
|
||||
can_delete: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -272,8 +274,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}`);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user