Merge pull request 'feat: document delete permissions + three-dots menu portal fix' (#2) from feat/document-delete-permissions into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-04-18 22:27:17 +02:00
13 changed files with 239 additions and 55 deletions
+1 -1
View File
@@ -215,7 +215,7 @@ Auth: is_superuser OR member of group listed in manifest `required_groups`. Retu
### Documents and Categories — proxied ### Documents and Categories — proxied
`/api/documents/*` and `/api/documents/categories/*` are transparently proxied to `doc-service:8001`. The backend injects `x-user-id` and `x-user-groups` headers. See `features/doc-service/CLAUDE.md` for the internal endpoint list. `/api/documents/*` and `/api/documents/categories/*` are transparently proxied to `doc-service:8001`. The backend injects `x-user-id`, `x-user-groups`, and `x-user-is-admin` headers. See `features/doc-service/CLAUDE.md` for the internal endpoint list.
--- ---
+5 -2
View File
@@ -50,13 +50,16 @@ _HOP_BY_HOP = frozenset([
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"]) _STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"])
async def _forward_headers(request: Request, user_id: str, db: AsyncSession) -> dict: async def _forward_headers(
request: Request, user_id: str, is_admin: bool, db: AsyncSession
) -> dict:
headers = { headers = {
k: v k: v
for k, v in request.headers.items() for k, v in request.headers.items()
if k.lower() not in _HOP_BY_HOP if k.lower() not in _HOP_BY_HOP
} }
headers["x-user-id"] = user_id headers["x-user-id"] = user_id
headers["x-user-is-admin"] = "true" if is_admin else "false"
# Inject the user's group memberships so the doc-service can evaluate # Inject the user's group memberships so the doc-service can evaluate
# group-shared document access without querying the backend DB. # group-shared document access without querying the backend DB.
@@ -78,7 +81,7 @@ async def proxy_documents(
path: str = "", path: str = "",
) -> Response: ) -> Response:
url = f"/documents/{path}" if path else "/documents" url = f"/documents/{path}" if path else "/documents"
headers = await _forward_headers(request, str(current_user.id), db) headers = await _forward_headers(request, str(current_user.id), current_user.is_superuser, db)
body = await request.body() body = await request.body()
try: try:
@@ -0,0 +1,25 @@
# 2026-04-18 — Document delete permissions + three-dots menu fix
**Timestamp:** 2026-04-18T00:00:00Z
## Summary
Added a proper permission model for document deletion: owners and admins can always delete; group members can delete only when the share was explicitly granted `can_delete=true`. Fixed silent delete failures (watch docs returning 404 with no user feedback) and fixed the three-dots context menu being clipped by `overflow-hidden` on the table container.
## Files Added / Modified / Deleted
### Added
- `features/doc-service/alembic/versions/0005_add_share_can_delete.py` — migration: adds `can_delete BOOLEAN NOT NULL DEFAULT false` to `document_shares`
### Modified
- `features/doc-service/app/models/document_share.py` — added `can_delete: Mapped[bool]` column
- `features/doc-service/app/schemas/share.py` — added `can_delete` to `DocumentShareOut` and `DocumentShareCreate`; added `viewer_can_delete` to `SharedDocumentOut`
- `features/doc-service/app/schemas/document.py` — added `viewer_can_delete: bool = False` to `DocumentOut`
- `features/doc-service/app/deps.py` — added `get_user_is_admin()` dep reading `x-user-is-admin` header
- `features/doc-service/app/routers/documents.py` — added `_get_deletable_doc_ids()` helper; updated list/get/delete endpoints with permission logic; updated `add_share` to store `can_delete`; updated shared-with-me to include `viewer_can_delete`
- `backend/app/routers/documents_proxy.py``_forward_headers()` now injects `x-user-is-admin` header
- `frontend/src/api/client.ts``DocumentOut`: added `viewer_can_delete`; `DocumentShareOut`: added `can_delete`; `addDocumentShare`: accepts `canDelete` param
- `frontend/src/pages/DocumentsPage.tsx``RowActionsMenu`: replaced absolute dropdown with `createPortal` to fix clipping; delete button now uses `doc.viewer_can_delete`; added `onError` handler for silent failures
- `frontend/src/components/DocumentSlideOver.tsx` — sharing section: shows trash icon badge on shares with `can_delete=true`; added "Allow group members to delete" checkbox before group picker; delete button uses `doc.viewer_can_delete`
- `features/doc-service/CLAUDE.md` — updated `document_shares` table docs + migration chain
- `backend/CLAUDE.md` — noted `x-user-is-admin` header injection
+2
View File
@@ -100,6 +100,7 @@ features/doc-service/
| `document_id` | String | indexed, NOT NULL | not FK — trusts proxy | | `document_id` | String | indexed, NOT NULL | not FK — trusts proxy |
| `group_id` | String | indexed, NOT NULL | group from backend | | `group_id` | String | indexed, NOT NULL | group from backend |
| `shared_by_user_id` | String | NOT NULL | owner who shared | | `shared_by_user_id` | String | NOT NULL | owner who shared |
| `can_delete` | Boolean | NOT NULL, default=false | whether group members may delete the doc |
| `created_at` | DateTime(tz) | server_default=now() | | | `created_at` | DateTime(tz) | server_default=now() | |
Unique constraint: `(document_id, group_id)` Unique constraint: `(document_id, group_id)`
@@ -112,6 +113,7 @@ Unique constraint: `(document_id, group_id)`
| `0002` | `add_document_title` | | `0002` | `add_document_title` |
| `0003` | `add_watch_columns` | | `0003` | `add_watch_columns` |
| `0004` | `add_document_shares` | | `0004` | `add_document_shares` |
| `0005` | `add_share_can_delete` |
--- ---
@@ -0,0 +1,32 @@
"""add can_delete to document_shares
Revision ID: 0005
Revises: 0004
Create Date: 2026-04-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0005"
down_revision: Union[str, None] = "0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"document_shares",
sa.Column(
"can_delete",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
def downgrade() -> None:
op.drop_column("document_shares", "can_delete")
+8
View File
@@ -21,3 +21,11 @@ async def get_user_groups(x_user_groups: str = Header(default="")) -> list[str]:
if not x_user_groups: if not x_user_groups:
return [] return []
return [g.strip() for g in x_user_groups.split(",") if g.strip()] return [g.strip() for g in x_user_groups.split(",") if g.strip()]
async def get_user_is_admin(x_user_is_admin: str = Header(default="false")) -> bool:
"""
Extract the admin flag injected by the main backend proxy.
Returns True only if the header value is exactly "true" (lowercase).
"""
return x_user_is_admin.lower() == "true"
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, String, UniqueConstraint, func from sqlalchemy import Boolean, DateTime, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base from app.database import Base
@@ -14,6 +14,7 @@ class DocumentShare(Base):
document_id: Mapped[str] = mapped_column(String, nullable=False, index=True) document_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
group_id: Mapped[str] = mapped_column(String, nullable=False, index=True) group_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
shared_by_user_id: Mapped[str] = mapped_column(String, nullable=False) shared_by_user_id: Mapped[str] = mapped_column(String, nullable=False)
can_delete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
+61 -6
View File
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.database import AsyncSessionLocal, get_db from app.database import AsyncSessionLocal, get_db
from app.deps import get_user_groups, get_user_id from app.deps import get_user_groups, get_user_id, get_user_is_admin
from app.models.category import DocumentCategory from app.models.category import DocumentCategory
from app.models.category_assignment import CategoryAssignment from app.models.category_assignment import CategoryAssignment
from app.models.document import Document from app.models.document import Document
@@ -73,7 +73,26 @@ async def _get_share_counts(doc_ids: list[str], db: AsyncSession) -> dict[str, i
return {row[0]: row[1] for row in rows.all()} return {row[0]: row[1] for row in rows.all()}
def _doc_with_categories(doc: Document, share_count: int = 0) -> DocumentOut: async def _get_deletable_doc_ids(
doc_ids: list[str], user_groups: list[str], db: AsyncSession
) -> set[str]:
"""Return doc IDs for which the user has delete permission via a group share."""
if not doc_ids or not user_groups:
return set()
rows = await db.execute(
select(DocumentShare.document_id)
.where(
DocumentShare.document_id.in_(doc_ids),
DocumentShare.group_id.in_(user_groups),
DocumentShare.can_delete.is_(True),
)
)
return {row[0] for row in rows.all()}
def _doc_with_categories(
doc: Document, share_count: int = 0, viewer_can_delete: bool = False
) -> DocumentOut:
from app.schemas.document import CategoryOut from app.schemas.document import CategoryOut
cats = [CategoryOut(id=a.category.id, name=a.category.name) for a in doc.category_assignments] cats = [CategoryOut(id=a.category.id, name=a.category.name) for a in doc.category_assignments]
return DocumentOut( return DocumentOut(
@@ -95,6 +114,7 @@ def _doc_with_categories(doc: Document, share_count: int = 0) -> DocumentOut:
suggested_folder=doc.suggested_folder, suggested_folder=doc.suggested_folder,
suggested_filename=doc.suggested_filename, suggested_filename=doc.suggested_filename,
share_count=share_count, share_count=share_count,
viewer_can_delete=viewer_can_delete,
) )
@@ -209,6 +229,8 @@ async def list_documents(
search: str | None = Query(default=None), search: str | None = Query(default=None),
category_id: str | None = Query(default=None), category_id: str | None = Query(default=None),
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
is_admin: bool = Depends(get_user_is_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> DocumentPage: ) -> DocumentPage:
sort_col = _SORT_COLUMNS.get(sort, Document.created_at) sort_col = _SORT_COLUMNS.get(sort, Document.created_at)
@@ -254,8 +276,19 @@ async def list_documents(
) )
docs = items_result.scalars().all() docs = items_result.scalars().all()
share_counts = await _get_share_counts([d.id for d in docs], db) doc_ids = [d.id for d in docs]
items = [_doc_with_categories(d, share_counts.get(d.id, 0)) for d in docs] share_counts = await _get_share_counts(doc_ids, db)
if is_admin:
deletable_ids = set(doc_ids)
else:
deletable_ids = {d.id for d in docs if d.user_id == user_id}
deletable_ids |= await _get_deletable_doc_ids(doc_ids, user_groups, db)
items = [
_doc_with_categories(d, share_counts.get(d.id, 0), viewer_can_delete=d.id in deletable_ids)
for d in docs
]
return DocumentPage( return DocumentPage(
items=items, items=items,
@@ -367,6 +400,7 @@ async def list_shared_with_me(
source=doc.source, source=doc.source,
shared_by_user_id=share.shared_by_user_id if share else "", shared_by_user_id=share.shared_by_user_id if share else "",
shared_via_group_id=share.group_id if share else "", shared_via_group_id=share.group_id if share else "",
viewer_can_delete=bool(share and share.can_delete),
) )
) )
@@ -382,11 +416,19 @@ async def list_shared_with_me(
async def get_document( async def get_document(
doc_id: str, doc_id: str,
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
is_admin: bool = Depends(get_user_is_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> DocumentOut: ) -> DocumentOut:
doc = await _get_user_doc(doc_id, user_id, db) doc = await _get_user_doc(doc_id, user_id, db)
counts = await _get_share_counts([doc.id], db) counts = await _get_share_counts([doc.id], db)
return _doc_with_categories(doc, counts.get(doc.id, 0)) if is_admin:
viewer_can_delete = True
elif doc.user_id == user_id:
viewer_can_delete = True
else:
viewer_can_delete = bool(await _get_deletable_doc_ids([doc.id], user_groups, db))
return _doc_with_categories(doc, counts.get(doc.id, 0), viewer_can_delete=viewer_can_delete)
@router.get("/{doc_id}/status", response_model=DocumentStatusOut) @router.get("/{doc_id}/status", response_model=DocumentStatusOut)
@@ -479,14 +521,26 @@ async def reprocess_document(
async def delete_document( async def delete_document(
doc_id: str, doc_id: str,
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
is_admin: bool = Depends(get_user_is_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> None: ) -> None:
result = await db.execute( result = await db.execute(
select(Document).where(Document.id == doc_id, Document.user_id == user_id) select(Document).where(
Document.id == doc_id,
or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID),
)
) )
doc = result.scalar_one_or_none() doc = result.scalar_one_or_none()
if doc is None: if doc is None:
raise HTTPException(status_code=404, detail="Document not found") raise HTTPException(status_code=404, detail="Document not found")
is_owner = doc.user_id == user_id
if not is_owner and not is_admin:
can_delete_via_group = bool(await _get_deletable_doc_ids([doc_id], user_groups, db))
if not can_delete_via_group:
raise HTTPException(status_code=403, detail="Delete not permitted")
delete_file(doc.file_path) delete_file(doc.file_path)
await db.delete(doc) await db.delete(doc)
await db.commit() await db.commit()
@@ -718,6 +772,7 @@ async def add_document_share(
document_id=doc_id, document_id=doc_id,
group_id=body.group_id, group_id=body.group_id,
shared_by_user_id=user_id, shared_by_user_id=user_id,
can_delete=body.can_delete,
) )
db.add(share) db.add(share)
await db.commit() await db.commit()
@@ -28,6 +28,7 @@ class DocumentOut(BaseModel):
suggested_folder: str | None = None suggested_folder: str | None = None
suggested_filename: str | None = None suggested_filename: str | None = None
share_count: int = 0 share_count: int = 0
viewer_can_delete: bool = False
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -8,6 +8,7 @@ class DocumentShareOut(BaseModel):
document_id: str document_id: str
group_id: str group_id: str
shared_by_user_id: str shared_by_user_id: str
can_delete: bool
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -15,6 +16,7 @@ class DocumentShareOut(BaseModel):
class DocumentShareCreate(BaseModel): class DocumentShareCreate(BaseModel):
group_id: str group_id: str
can_delete: bool = False
class SharedDocumentOut(BaseModel): class SharedDocumentOut(BaseModel):
@@ -34,6 +36,7 @@ class SharedDocumentOut(BaseModel):
categories: list = [] categories: list = []
source: str = "upload" source: str = "upload"
share_count: int = 0 share_count: int = 0
viewer_can_delete: bool = False
# Sharing context # Sharing context
shared_by_user_id: str shared_by_user_id: str
shared_via_group_id: str shared_via_group_id: str
+4 -2
View File
@@ -220,6 +220,7 @@ export interface DocumentOut {
suggested_folder: string | null; suggested_folder: string | null;
suggested_filename: string | null; suggested_filename: string | null;
share_count: number; share_count: number;
viewer_can_delete: boolean;
} }
export interface SharedDocumentOut extends DocumentOut { export interface SharedDocumentOut extends DocumentOut {
@@ -232,6 +233,7 @@ export interface DocumentShareOut {
document_id: string; document_id: string;
group_id: string; group_id: string;
shared_by_user_id: string; shared_by_user_id: string;
can_delete: boolean;
created_at: string; created_at: string;
} }
@@ -270,8 +272,8 @@ export const listSharedWithMe = (params: DocumentListParams = {}) =>
export const getDocumentShares = (docId: string) => export const getDocumentShares = (docId: string) =>
api.get<DocumentShareOut[]>(`/documents/${docId}/shares`); api.get<DocumentShareOut[]>(`/documents/${docId}/shares`);
export const addDocumentShare = (docId: string, groupId: string) => export const addDocumentShare = (docId: string, groupId: string, canDelete = false) =>
api.post<DocumentShareOut>(`/documents/${docId}/shares`, { group_id: groupId }); api.post<DocumentShareOut>(`/documents/${docId}/shares`, { group_id: groupId, can_delete: canDelete });
export const removeDocumentShare = (docId: string, groupId: string) => export const removeDocumentShare = (docId: string, groupId: string) =>
api.delete(`/documents/${docId}/shares/${groupId}`); api.delete(`/documents/${docId}/shares/${groupId}`);
+28 -5
View File
@@ -186,6 +186,7 @@ export default function DocumentSlideOver({ doc, isOwner, onClose, onDeleted }:
const [titleValue, setTitleValue] = useState(""); const [titleValue, setTitleValue] = useState("");
const [editingType, setEditingType] = useState(false); const [editingType, setEditingType] = useState(false);
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [canDeleteNew, setCanDeleteNew] = useState(false);
const titleInputRef = useRef<HTMLInputElement>(null); const titleInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
@@ -279,8 +280,12 @@ export default function DocumentSlideOver({ doc, isOwner, onClose, onDeleted }:
}); });
const addShareMut = useMutation({ const addShareMut = useMutation({
mutationFn: (groupId: string) => addDocumentShare(doc!.id, groupId), mutationFn: ({ groupId, canDelete }: { groupId: string; canDelete: boolean }) =>
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["document-shares", doc!.id] }), addDocumentShare(doc!.id, groupId, canDelete),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["document-shares", doc!.id] });
setCanDeleteNew(false);
},
}); });
const removeShareMut = useMutation({ 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"> <div key={share.id} className="flex items-center gap-2 text-sm">
<Users className="h-3.5 w-3.5 text-muted shrink-0" /> <Users className="h-3.5 w-3.5 text-muted shrink-0" />
<span className="flex-1 text-sm">{group?.name ?? share.group_id}</span> <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 <button
onClick={() => removeShareMut.mutate(share.group_id)} onClick={() => removeShareMut.mutate(share.group_id)}
disabled={removeShareMut.isPending} disabled={removeShareMut.isPending}
@@ -706,17 +716,27 @@ export default function DocumentSlideOver({ doc, isOwner, onClose, onDeleted }:
); );
})} })}
</div> </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 <GroupCombobox
groups={myGroups} groups={myGroups}
sharedGroupIds={sharedGroupIds} sharedGroupIds={sharedGroupIds}
onShare={(id) => addShareMut.mutate(id)} onShare={(id) => addShareMut.mutate({ groupId: id, canDelete: canDeleteNew })}
/> />
</div> </div>
)} )}
{/* Owner actions */} {/* Owner/permitted actions */}
{isOwner && ( {(isOwner || doc.viewer_can_delete) && (
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
{isOwner && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -727,6 +747,8 @@ export default function DocumentSlideOver({ doc, isOwner, onClose, onDeleted }:
<RefreshCw className={cn("h-3.5 w-3.5", reprocessMut.isPending && "animate-spin")} /> <RefreshCw className={cn("h-3.5 w-3.5", reprocessMut.isPending && "animate-spin")} />
Re-analyse Re-analyse
</Button> </Button>
)}
{doc.viewer_can_delete && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -741,6 +763,7 @@ export default function DocumentSlideOver({ doc, isOwner, onClose, onDeleted }:
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Delete Delete
</Button> </Button>
)}
</div> </div>
)} )}
+43 -14
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useCallback, useEffect } from "react"; import { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
@@ -47,43 +48,70 @@ function formatDate(iso: string) {
// ── Row actions dropdown ────────────────────────────────────────────────────── // ── Row actions dropdown ──────────────────────────────────────────────────────
function RowActionsMenu({ function RowActionsMenu({
doc, isOwner, onSelect, doc, onSelect,
}: { doc: DocumentOut; isOwner: boolean; onSelect: () => void }) { }: { doc: DocumentOut; onSelect: () => void }) {
const [open, setOpen] = useState(false); 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(); const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); 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({ const deleteMut = useMutation({
mutationFn: () => deleteDocument(doc.id), mutationFn: () => deleteDocument(doc.id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["documents"] }), 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 ( return (
<div className="relative" ref={ref} onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<button <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" className="p-1 rounded text-muted hover:text-foreground hover:bg-muted/20 transition-colors opacity-0 group-hover:opacity-100"
title="Actions" title="Actions"
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</button> </button>
{open && ( {open && createPortal(
<div className="absolute right-0 top-full mt-1 z-20 bg-surface border border-border rounded-lg shadow-lg w-36 py-1"> <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 <button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted/20 transition-colors" className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted/20 transition-colors"
onClick={() => { onSelect(); setOpen(false); }} onClick={() => { onSelect(); setOpen(false); }}
> >
Open details Open details
</button> </button>
{isOwner && ( {doc.viewer_can_delete && (
<button <button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted/20 transition-colors text-red-500" className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted/20 transition-colors text-red-500"
onClick={() => { onClick={() => {
@@ -94,7 +122,8 @@ function RowActionsMenu({
Delete Delete
</button> </button>
)} )}
</div> </div>,
document.body
)} )}
</div> </div>
); );
@@ -470,7 +499,7 @@ function DocumentRow({
</td> </td>
<td className="px-3 py-2.5 w-8 text-right"> <td className="px-3 py-2.5 w-8 text-right">
<RowActionsMenu doc={doc} isOwner={isOwner} onSelect={onOpen} /> <RowActionsMenu doc={doc} onSelect={onOpen} />
</td> </td>
</tr> </tr>
); );