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);