From 6566f4c2d3832672b19e7ab2853dc20de3c628ad Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 27 Aug 2026 09:07:13 -0700 Subject: [PATCH] Share files and folders with other people Calendars and address books have been shareable since JMAP Sharing went in; Files never was, though Stalwart treats file storage as a first-class thing to share and ihasmail has carried the types for it all along. `FilesRights` and `FileNode.shareWith` were already declared -- what was missing was asking for the property, offering the dialog, and saying so in the list. Checked against the live 0.16.19 first, read-only, because building a picker against a mock that agrees with you proves nothing: - `FileNode/get` returns `shareWith`, and `myRights` carries all six rights, `mayShare` among them and true on one's own nodes. So the menu entry has a real right to gate on -- unlike folder sharing, which is offered ungated because `MailboxRights` has no such right - `Principal/query` answers now that `allowDirectoryQueries` is on: six individuals, no groups - `ShareNotification/get` is implemented, which is worth knowing for later; nothing here reads it yet The editor preset grants read, add files and edit contents, and stops there. Rename and delete stay with whoever shared the folder: someone given a folder to work in should not be able to rename the thing they were given, or delete it out from under the person who shared it. Both are still there to tick by hand. One finding is worth a test of its own, and has one. Stalwart answers `shareWith` as `{}` for a node shared with nobody, not `null` -- every unshared node in a live account came back that way. A truthiness test on the property is therefore true for every node the server has ever returned, and the badge driven by it would report the whole account as shared while being, technically, about the right property. `isShared` counts keys, and the test says why. Verified against the mock end to end: sharing Documents with a principal as Editor persists `mayRead`, `mayAddChildren` and `mayModifyContent` and nothing else, the badge appears on that folder and not on the file beside it, and re-opening the dialog shows the saved rights rather than an empty form -- which is what proves `fileNodeProps` is really asking for the property. --- server/src/mock/index.ts | 8 ++--- web/src/lib/__tests__/filenodeShared.test.ts | 34 ++++++++++++++++++++ web/src/lib/filenode.ts | 16 +++++++-- web/src/store/files.ts | 15 +++++++++ web/src/views/files/FilesView.tsx | 9 ++++-- web/src/views/settings/ShareDialog.tsx | 25 ++++++++++++-- 6 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 web/src/lib/__tests__/filenodeShared.test.ts diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 58ebfeb..acbe0f1 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -193,9 +193,9 @@ const cards: Obj[] = people.slice(0, 6).map((p, i) => { }); const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" })); const fileNodes: Obj[] = [ - { id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), role: "documents" }, - { id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() }, - { id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() }, + { id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, role: "documents" }, + { id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, + { id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, ]; function fr() { return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true }; @@ -727,7 +727,7 @@ const handlers: Record = { "FileNode/get": genericGet(fileNodes), "FileNode/set": (a) => { return genericSet(fileNodes, "f", (o) => { - Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o }); + Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o }); // Without nodeType, a node is a directory precisely when it carries no // file properties. Keep it internally so query and get stay consistent. if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory"; diff --git a/web/src/lib/__tests__/filenodeShared.test.ts b/web/src/lib/__tests__/filenodeShared.test.ts new file mode 100644 index 0000000..8d20c44 --- /dev/null +++ b/web/src/lib/__tests__/filenodeShared.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { isShared } from "@/lib/filenode"; + +/** + * The one thing about file sharing that a mock would never have told us. + * + * Stalwart 0.16.19 answers `shareWith` as `{}` for a node shared with nobody, + * not `null` — every unshared node in a live account came back that way on + * 2026-08-27. A truthiness test on the property is therefore true for every + * node the server has ever returned, and a badge driven by one would report + * the entire account as shared while being, technically, about the right + * property. + */ + +describe("whether a node is shared", () => { + it("treats the empty object Stalwart sends as not shared", () => { + expect(isShared({ shareWith: {} })).toBe(false); + }); + + it("treats a missing or null shareWith as not shared", () => { + expect(isShared({ shareWith: null })).toBe(false); + expect(isShared({})).toBe(false); + }); + + it("is shared once a principal is on it", () => { + expect(isShared({ shareWith: { p1: { mayRead: true } } as never })).toBe(true); + }); + + it("stays shared when the rights granted are all false", () => { + // An entry with nothing enabled is still an entry: the principal is on the + // list, and the owner should see that rather than an empty-looking folder. + expect(isShared({ shareWith: { p1: { mayRead: false } } as never })).toBe(true); + }); +}); diff --git a/web/src/lib/filenode.ts b/web/src/lib/filenode.ts index 8436207..64a5832 100644 --- a/web/src/lib/filenode.ts +++ b/web/src/lib/filenode.ts @@ -8,11 +8,11 @@ * separate ones. ihasmail requires 0.16 now — sign-in refuses anything older — * so a node has one shape and there is nothing left to detect. */ -import type { Id } from "@/jmap/types"; +import type { FileNode, Id } from "@/jmap/types"; /** Properties to request for a node. */ export function fileNodeProps(): string[] { - return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable", "nodeType"]; + return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "shareWith", "role", "executable", "nodeType"]; } /** Create-arguments for a directory. */ @@ -24,3 +24,15 @@ export function directoryCreate(parentId: Id | null, name: string): Record { return { parentId, name, blobId, type, nodeType: "file" }; } + +/** + * Whether a node is shared with anyone. + * + * Stalwart answers `shareWith` as `{}` for "nobody", not `null` — confirmed + * against 0.16.19 on 2026-08-27, where every unshared node in the account came + * back that way. So a truthiness test passes for every node ever returned, and + * a badge driven by one would say the whole account is shared. Count the keys. + */ +export function isShared(node: Pick): boolean { + return Object.keys(node.shareWith ?? {}).length > 0; +} diff --git a/web/src/store/files.ts b/web/src/store/files.ts index 8a92188..a8fd773 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -21,6 +21,7 @@ interface FilesState { rename(id: Id, name: string): Promise; move(id: Id, parentId: Id | null): Promise; destroy(ids: Id[]): Promise; + refresh(ids: Id[]): Promise; pathTo(id: Id | null): FileNode[]; applyChanges(types: Set): void; } @@ -129,6 +130,20 @@ export const useFiles = create((set, get) => ({ await get().loadChildren(parentId); }, + /* Re-read named nodes in place. Sharing changes one property of one node and + nothing about which folder it sits in, so reloading the level around it + would be a bigger round trip to land in the same place. */ + async refresh(ids) { + const accountId = get().accountId; + if (!accountId || !ids.length) return; + const res = await client.call>("FileNode/get", { accountId, ids, properties: fileNodeProps() }); + set((s) => { + const nodes = { ...s.nodes }; + for (const n of res.list) nodes[n.id] = n; + return { nodes }; + }); + }, + async rename(id, name) { const accountId = get().accountId!; const res = await client.call("FileNode/set", { accountId, update: { [id]: { name } } }); diff --git a/web/src/views/files/FilesView.tsx b/web/src/views/files/FilesView.tsx index 6000a3b..49c9ce8 100644 --- a/web/src/views/files/FilesView.tsx +++ b/web/src/views/files/FilesView.tsx @@ -1,10 +1,12 @@ import { useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; -import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Trash2, Upload, FolderInput } from "lucide-react"; +import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput } from "lucide-react"; import { useFiles } from "@/store/files"; import { client } from "@/jmap/client"; import type { FileNode } from "@/jmap/types"; import { formatSize, formatListDate } from "@/lib/format"; +import { isShared } from "@/lib/filenode"; +import { ShareDialog } from "../settings/ShareDialog"; import { Empty, Spinner } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog"; @@ -19,6 +21,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) { const menu = useMenu(); const [menuNode, setMenuNode] = useState(null); const [moveNode, setMoveNode] = useState(null); + const [shareNode, setShareNode] = useState(null); const inputRef = useRef(null); useEffect(() => { @@ -94,7 +97,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) { {nodes.map((n) => ( setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}> -
{n.nodeType === "directory" ? : } { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}
+
{n.nodeType === "directory" ? : } { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}{isShared(n) && }
{n.nodeType === "directory" ? "—" : formatSize(n.size)} {formatListDate(n.modified ?? n.created)} @@ -110,12 +113,14 @@ export function FilesView({ nodeId }: { nodeId?: string }) { {menuNode.nodeType === "directory" ? } label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : } label="Download" onClick={() => download(menuNode)} />} } label="Rename" disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} /> } label="Move to…" onClick={() => setMoveNode(menuNode)} /> + } label="Share…" disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} /> } label="Delete" disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} /> )} {moveNode && setMoveNode(null)} />} + {shareNode && setShareNode(null)} />} ); } diff --git a/web/src/views/settings/ShareDialog.tsx b/web/src/views/settings/ShareDialog.tsx index 9408079..036f0c6 100644 --- a/web/src/views/settings/ShareDialog.tsx +++ b/web/src/views/settings/ShareDialog.tsx @@ -4,11 +4,13 @@ import { Dialog } from "@/ui/dialog"; import { useContacts } from "@/store/contacts"; import { useMail } from "@/store/mail"; import { useCalendar } from "@/store/calendar"; +import { useFiles } from "@/store/files"; import { client, setErrorMessage } from "@/jmap/client"; import { toast } from "@/ui/toast"; import type { Id, Principal } from "@/jmap/types"; -type Kind = "Mailbox" | "Calendar" | "AddressBook"; +/* The JMAP type name, used verbatim as the `/set` method prefix. */ +type Kind = "Mailbox" | "Calendar" | "AddressBook" | "FileNode"; const RIGHTS: Record> = { Mailbox: [ @@ -38,15 +40,27 @@ const RIGHTS: Record> = { { key: "mayShare", label: "Share" }, { key: "mayDelete", label: "Delete" }, ], + // Stalwart 0.16.19 returns all six on a node of your own (2026-08-27). + FileNode: [ + { key: "mayRead", label: "Read" }, + { key: "mayAddChildren", label: "Add files" }, + { key: "mayModifyContent", label: "Edit contents" }, + { key: "mayRename", label: "Rename" }, + { key: "mayDelete", label: "Delete" }, + { key: "mayShare", label: "Share" }, + ], }; const PRESETS: Record = { Mailbox: { reader: ["mayReadItems"], editor: ["mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild"] }, Calendar: { reader: ["mayReadFreeBusy", "mayReadItems"], editor: ["mayReadFreeBusy", "mayReadItems", "mayWriteAll", "mayRSVP"] }, AddressBook: { reader: ["mayRead"], editor: ["mayRead", "mayWrite"] }, + // An editor can fill a folder and change what is in it, but not rename or + // delete the folder they were given -- those stay with whoever shared it. + FileNode: { reader: ["mayRead"], editor: ["mayRead", "mayAddChildren", "mayModifyContent"] }, }; -/** Share a mailbox / calendar / address book with other principals (JMAP Sharing, RFC 9670). */ +/** Share a mailbox / calendar / address book / file node with other principals (JMAP Sharing, RFC 9670). */ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record | null; onClose: () => void }) { const principals = useContacts((s) => s.principals); const loadPrincipals = useContacts((s) => s.loadPrincipals); @@ -67,7 +81,11 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind const save = async () => { setBusy(true); try { - const accountId = kind === "Mailbox" ? useMail.getState().accountId : kind === "Calendar" ? useCalendar.getState().accountId : useContacts.getState().accountId; + const accountId = + kind === "Mailbox" ? useMail.getState().accountId + : kind === "Calendar" ? useCalendar.getState().accountId + : kind === "FileNode" ? useFiles.getState().accountId + : useContacts.getState().accountId; const res = await client.call<{ notUpdated?: Record }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } }); const err = res.notUpdated?.[id]; if (err) throw new Error(setErrorMessage(err)); @@ -75,6 +93,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind if (kind === "Mailbox") void useMail.getState().loadMailboxes(); if (kind === "Calendar") void useCalendar.getState().loadCalendars(); if (kind === "AddressBook") void useContacts.getState().loadBooks(); + if (kind === "FileNode") void useFiles.getState().refresh([id]); onClose(); } catch (err) { toast.error((err as Error).message);