notes(#454): per-recipient view/edit folder sharing
- Schema: noteFolderShares.permission ('view'|'edit', default 'view').
Project uses drizzle-kit push (no migration files); existing rows
pick up the default automatically on next push.
- Server: resolveFolderEditAccess + emitFolderChanged helpers.
- POST/PATCH/DELETE/checklist now allow folder editors.
- Editors stamp notes with folder owner's userId; labels validated
against owner; PATCH editors blocked from unfile (folderId=null)
and cross-folder moves to non-matching owners.
- emitFolderChanged fires for owner AND editor mutations across
create / patch (old + new folder) / delete / checklist toggle.
- PUT /shares accepts both legacy recipientUserIds and new
recipients:[{userId,permission}]; diffs add/update/remove/perm-flip.
- GET /shares, /shared-with-me, /shared-notes return permission /
myPermission / readOnly.
- Distinct socket events: note-folder-shared (added),
note-folder-share-updated (permission flipped),
note-folder-unshared (removed). Permission events carry the new
permission so clients can react.
- Client:
- notes-api types: FolderSharePermission, myPermission on
SharedFolder/SharedFolderView, permission on FolderShareRecipient,
readOnly:boolean on SharedFolderNote.
- useUpdateFolderShares takes recipients[].
- useCreate/Update/DeleteNote also invalidate ['note-folders'] so
actor's shared-folder rail badge stays fresh.
- FolderShareDialog: per-row checkbox + segmented View/Edit toggle.
- SharedFolderView: editor mode mounts Composer + NoteCard with
full edit/delete/archive affordances; Send hidden in editor
context (Composer + NoteCard hideSend prop) since /send is
owner-only.
- folders-rail: per-folder permission badge.
- socket: handlers for shared / share-updated / unshared all
invalidate the right query keys for live UI flips.
- Locales: shareDescriptionPerm, permissionView/Edit, canEdit
(en + ar).
- Tests: new permission roundtrip test in notes-share.test.mjs covering
view-rejects-write, edit-can-write, editor-cannot-unfile,
editor-create-stamped-to-owner, downgrade/upgrade flips.
- Pre-existing executive-meetings.ts type errors are out of scope.
This commit is contained in:
@@ -1702,15 +1702,22 @@ router.put(
|
||||
}
|
||||
|
||||
// Emit live socket events so recipients' rails refresh without polling.
|
||||
// Permission flips reuse the `note-folder-shared` event — recipients
|
||||
// simply refetch shared-with-me / shared-notes and pick up the new
|
||||
// myPermission, which is enough to flip their UI between view / edit
|
||||
// affordances without a dedicated event type.
|
||||
// Three distinct events so the client can tell apart "you got access",
|
||||
// "your permission level changed", and "your access was revoked":
|
||||
// - note-folder-shared → newly added recipient
|
||||
// - note-folder-share-updated → existing recipient's permission flipped
|
||||
// - note-folder-unshared → recipient removed
|
||||
for (const a of toAdd) {
|
||||
void emitToUser(a.userId, "note-folder-shared", { folderId: id.id });
|
||||
void emitToUser(a.userId, "note-folder-shared", {
|
||||
folderId: id.id,
|
||||
permission: a.permission,
|
||||
});
|
||||
}
|
||||
for (const u of toUpdate) {
|
||||
void emitToUser(u.userId, "note-folder-shared", { folderId: id.id });
|
||||
void emitToUser(u.userId, "note-folder-share-updated", {
|
||||
folderId: id.id,
|
||||
permission: u.permission,
|
||||
});
|
||||
}
|
||||
for (const rid of toRemove) {
|
||||
void emitToUser(rid, "note-folder-unshared", { folderId: id.id });
|
||||
|
||||
@@ -88,6 +88,8 @@ async function createNote(cookie, body) {
|
||||
return json;
|
||||
}
|
||||
|
||||
const createdFolderIds = [];
|
||||
|
||||
after(async () => {
|
||||
if (createdNoteIds.length > 0) {
|
||||
await pool.query(
|
||||
@@ -102,6 +104,15 @@ after(async () => {
|
||||
createdNoteIds,
|
||||
]);
|
||||
}
|
||||
if (createdFolderIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM note_folder_shares WHERE folder_id = ANY($1::int[])`,
|
||||
[createdFolderIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM note_folders WHERE id = ANY($1::int[])`, [
|
||||
createdFolderIds,
|
||||
]);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM notifications WHERE user_id = ANY($1::int[])`,
|
||||
@@ -496,6 +507,116 @@ test("GET /notes/my returns the same payload as GET /notes (alias)", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
// Task #454: per-recipient folder permission (view vs edit).
|
||||
test("folder share permissions: view-only is rejected on writes; edit can mutate; permission roundtrip works", async () => {
|
||||
const owner = await createUser("notes_perm_owner");
|
||||
const viewer = await createUser("notes_perm_viewer");
|
||||
const editor = await createUser("notes_perm_editor");
|
||||
const oCookie = await login(owner.username);
|
||||
const vCookie = await login(viewer.username);
|
||||
const eCookie = await login(editor.username);
|
||||
|
||||
// Owner creates a folder + a note inside it.
|
||||
const fRes = await api(`/note-folders`, oCookie, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "perm-folder", color: "blue" }),
|
||||
});
|
||||
assert.ok(fRes.status === 200 || fRes.status === 201, `folder create expected 2xx, got ${fRes.status}`);
|
||||
const folder = await fRes.json();
|
||||
createdFolderIds.push(folder.id);
|
||||
|
||||
const note = await createNote(oCookie, { folderId: folder.id, title: "T" });
|
||||
|
||||
// Owner shares: viewer = view, editor = edit (new recipients[] payload).
|
||||
const shareRes = await api(`/note-folders/${folder.id}/shares`, oCookie, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
recipients: [
|
||||
{ userId: viewer.id, permission: "view" },
|
||||
{ userId: editor.id, permission: "edit" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.equal(shareRes.status, 200);
|
||||
const shareList = await shareRes.json();
|
||||
const viewerRow = shareList.find((r) => r.userId === viewer.id);
|
||||
const editorRow = shareList.find((r) => r.userId === editor.id);
|
||||
assert.equal(viewerRow.permission, "view");
|
||||
assert.equal(editorRow.permission, "edit");
|
||||
|
||||
// Recipients see correct myPermission via shared-with-me.
|
||||
const vSwm = await (await api(`/note-folders/shared-with-me`, vCookie)).json();
|
||||
assert.equal(vSwm.find((f) => f.id === folder.id).myPermission, "view");
|
||||
const eSwm = await (await api(`/note-folders/shared-with-me`, eCookie)).json();
|
||||
assert.equal(eSwm.find((f) => f.id === folder.id).myPermission, "edit");
|
||||
|
||||
// Viewer cannot patch a note in the shared folder.
|
||||
const vPatch = await api(`/notes/${note.id}`, vCookie, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title: "hacked" }),
|
||||
});
|
||||
assert.equal(vPatch.status, 403, "view-only must not patch");
|
||||
|
||||
// Editor can patch the note.
|
||||
const ePatch = await api(`/notes/${note.id}`, eCookie, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title: "edited by editor" }),
|
||||
});
|
||||
assert.equal(ePatch.status, 200, "editor must patch");
|
||||
|
||||
// Editor cannot unfile (folderId=null) — would steal the note from owner.
|
||||
const eUnfile = await api(`/notes/${note.id}`, eCookie, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ folderId: null }),
|
||||
});
|
||||
assert.equal(eUnfile.status, 403, "editor must not unfile");
|
||||
|
||||
// Editor can create a new note in the folder; it is stamped to owner.
|
||||
const eCreate = await api(`/notes`, eCookie, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: "by editor",
|
||||
content: "x",
|
||||
color: "blue",
|
||||
folderId: folder.id,
|
||||
}),
|
||||
});
|
||||
assert.ok(eCreate.status === 200 || eCreate.status === 201, `editor create expected 2xx, got ${eCreate.status}`);
|
||||
const eCreated = await eCreate.json();
|
||||
createdNoteIds.push(eCreated.id);
|
||||
const { rows: ownerRows } = await pool.query(
|
||||
`SELECT user_id FROM notes WHERE id = $1`,
|
||||
[eCreated.id],
|
||||
);
|
||||
assert.equal(ownerRows[0].user_id, owner.id, "editor-created note belongs to owner");
|
||||
|
||||
// Permission flip: downgrade editor to view, upgrade viewer to edit.
|
||||
const flipRes = await api(`/note-folders/${folder.id}/shares`, oCookie, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
recipients: [
|
||||
{ userId: viewer.id, permission: "edit" },
|
||||
{ userId: editor.id, permission: "view" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.equal(flipRes.status, 200);
|
||||
|
||||
// Former editor (now view) can no longer patch.
|
||||
const ePatch2 = await api(`/notes/${note.id}`, eCookie, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title: "after-downgrade" }),
|
||||
});
|
||||
assert.equal(ePatch2.status, 403, "downgraded editor must lose write access");
|
||||
|
||||
// Former viewer (now edit) can patch.
|
||||
const vPatch2 = await api(`/notes/${note.id}`, vCookie, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title: "after-upgrade" }),
|
||||
});
|
||||
assert.equal(vPatch2.status, 200, "upgraded viewer must gain write access");
|
||||
});
|
||||
|
||||
test("re-sending to the same recipient does not duplicate the recipient row", async () => {
|
||||
const sender = await createUser("notes_dup_sender");
|
||||
const recipient = await createUser("notes_dup_recipient");
|
||||
|
||||
@@ -343,6 +343,19 @@ export function useNotificationsSocket() {
|
||||
invalidate(["note-folders"]);
|
||||
},
|
||||
);
|
||||
// Task #454: dedicated event for permission-only flips on an
|
||||
// existing share (view ↔ edit). Same invalidation as `shared`,
|
||||
// but kept distinct so the contract is explicit and so future UI
|
||||
// can react with a toast like "Your access changed to Edit".
|
||||
socket.on(
|
||||
"note-folder-share-updated",
|
||||
(payload?: { folderId?: number; permission?: "view" | "edit" }) => {
|
||||
invalidate(["note-folders", "shared-with-me"]);
|
||||
if (typeof payload?.folderId === "number") {
|
||||
invalidate(["notes", "shared-folder", payload.folderId]);
|
||||
}
|
||||
},
|
||||
);
|
||||
socket.on(
|
||||
"note-folder-unshared",
|
||||
(payload?: { folderId?: number }) => {
|
||||
|
||||
Reference in New Issue
Block a user