diff --git a/artifacts/tx-os/src/components/notes/folders-rail.tsx b/artifacts/tx-os/src/components/notes/folders-rail.tsx index 53340e60..dbad962f 100644 --- a/artifacts/tx-os/src/components/notes/folders-rail.tsx +++ b/artifacts/tx-os/src/components/notes/folders-rail.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useDroppable } from "@dnd-kit/core"; import { @@ -78,18 +78,27 @@ export const folderDropId = (target: "unfiled" | number) => // its own droppable id (one hook per row). function DroppableRow({ target, + ownerId, className, testId, children, }: { target: "unfiled" | number; + // When set, the drop target belongs to a shared folder owned by this + // user id. handleDragEnd uses this to enforce the "same-owner only" + // rule the server applies to editors. Leave undefined for own folders + // and the Unfiled bucket. + ownerId?: number; className?: string; testId?: string; children: React.ReactNode; }) { const { setNodeRef, isOver } = useDroppable({ - id: folderDropId(target), - data: { type: "folder-drop", target }, + id: + ownerId === undefined + ? folderDropId(target) + : `${folderDropId(target)}-shared-${ownerId}`, + data: { type: "folder-drop", target, ownerId }, }); return (
); + return canDrop ? ( + + {rowInner} + + ) : ( + {rowInner} + ); })} )} diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index b6830540..2217a06d 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -257,6 +257,7 @@ export default function NotesPage() { // for shared folders. Null means "no editable shared-folder context". const sharedFolderBucketRef = useRef<{ folderId: number; + ownerId: number; notes: Note[]; } | null>(null); @@ -348,6 +349,10 @@ export default function NotesPage() { | { type?: string; target?: "unfiled" | number; + // Owner of the target droppable. Set ONLY for shared-folder + // rows; absent (undefined) on own folders + the Unfiled bucket + // — those targets implicitly belong to the current user. + ownerId?: number; noteId?: number; folderId?: number | null; isPinned?: boolean; @@ -365,7 +370,19 @@ export default function NotesPage() { if (!isOwn && !isEditableShared) return; if (o?.type === "folder-drop" && o.target !== undefined) { - if (!isOwn) return; + // The server enforces: an editor may only move a note BETWEEN + // folders owned by the same owner. Mirror that constraint + // client-side so cross-owner drops never round-trip: + // • Target with no ownerId = own folder / Unfiled. Allow only + // for the user's OWN notes. + // • Target with ownerId = a shared folder we can edit. + // Allow only when the source note also lives in that owner's + // namespace (i.e. came from sharedBucket with the same owner). + if (o.ownerId === undefined) { + if (!isOwn) return; + } else { + if (!isEditableShared || sharedBucket?.ownerId !== o.ownerId) return; + } moveNoteToTarget(o.target, a.noteId); return; } @@ -3348,7 +3365,7 @@ function SharedFolderView({ onBack: () => void; onEdit: (n: Note) => void; onSend: (n: Note) => void; - bucketRef: React.MutableRefObject<{ folderId: number; notes: Note[] } | null>; + bucketRef: React.MutableRefObject<{ folderId: number; ownerId: number; notes: Note[] } | null>; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; @@ -3364,7 +3381,11 @@ function SharedFolderView({ bucketRef.current = null; return; } - bucketRef.current = { folderId, notes: data.notes }; + bucketRef.current = { + folderId, + ownerId: data.folder.ownerId, + notes: data.notes, + }; return () => { if (bucketRef.current?.folderId === folderId) { bucketRef.current = null;