From ad94efb65b1ce4c973b2f9f88d633f90fddb4c86 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 27 Aug 2026 10:13:58 -0700 Subject: [PATCH] Reach shared folders from Files, not the profile menu A folder somebody shared was reachable only by switching the whole app to their account from the profile menu -- which nobody would think to look in for files, and which pointed mail, calendar and contacts at them as well. The server refused all three, so nothing leaked; it was simply the app claiming to be somewhere it could not go. Files now lists shared accounts itself, under "Shared with me", and opens them in place. Only Files moves: `accountId` in its store is the account being browsed, `ownAccountId` is the reader's, and nothing else in the app notices. Which accounts hold shared files cannot be worked out from capabilities. Stalwart advertises the whole set on a shared account -- mail, calendars, contacts, sieve, the lot, identical to a personal one, whatever was actually shared (checked live on 0.16.19, 2026-08-27). That is why routing alone could never have fixed this, and why the list offers every account that is not the reader's own and lets its folders answer for themselves. The mock's shared account now advertises the same full set, because a mock that quietly advertised only what it shared would agree with a fix that cannot work. Shares also went unseen until the next sign-in. They arrive in the JMAP session, which is fetched once and refreshed only when a session-state change is pushed to that tab -- so a share granted while the tab was open stayed invisible, and one removed stayed on offer. That is the two browsers disagreeing about whether an account still existed. Opening Files now re-reads the session, throttled, and the section header carries a refresh for when someone is waiting on a share they have just been promised. The sidebar's button on Files was Compose, which wrote mail from the file manager. It uploads. Verified against the mock, which grew a second account to make any of this testable: "Shared with me" lists it, opening it shows its folders and not the reader's, the header says whose they are, "Back to my files" returns, and the profile menu is not involved at any point. --- server/src/mock/index.ts | 36 +++++++++++-- web/src/store/files.ts | 51 ++++++++++++++++-- web/src/styles/app.css | 6 +++ web/src/views/AppShell.tsx | 9 ++-- web/src/views/files/FilesTree.tsx | 89 +++++++++++++++++++++++++++++-- web/src/views/files/FilesView.tsx | 13 ++++- 6 files changed, 189 insertions(+), 15 deletions(-) diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 125c55a..0f67a6e 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -26,6 +26,13 @@ const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1"; /** What the session advertises, matching Stalwart's own 30 days. */ const MAX_DELAYED_SEND = 86400 * 30; const ACCOUNT = "a1"; +/** An account somebody has shared with the demo user. See the session below. */ +const SHARED_ACCOUNT = "a2"; +const SHARED_CAPS: Obj = { + "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, + "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, +}; const USER = process.env.MOCK_USER ?? "demo@example.com"; /** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */ const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US"; @@ -197,6 +204,17 @@ const fileNodes: Obj[] = [ { 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: {} }, ]; + +/* What the shared account holds. Its own nodes, so opening the share in Files + shows something different from the reader's own folders rather than the same + list under another name. */ +const sharedFileNodes: Obj[] = [ + { id: "s1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Team plans", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, + { id: "s2", parentId: "s1", nodeType: "file", blobId: putBlob("shared notes", "text/plain"), size: 12, name: "roadmap.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, +]; +/** The node list an account owns. */ +const nodesFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedFileNodes : fileNodes); + function fr() { return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true }; } @@ -721,6 +739,7 @@ const handlers: Record = { "ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, "FileNode/query": (a) => { const f = (a.filter as Obj) ?? {}; + const fileNodes = nodesFor(a.accountId); // `nodeType` is a filter 0.16.19 really applies -- checked live on // 2026-08-27, where it returned the two directories out of seven nodes. The // mock ignoring it was worse than not having it: the sidebar tree asks for @@ -732,9 +751,9 @@ const handlers: Record = { }); return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length }; }, - "FileNode/get": genericGet(fileNodes), + "FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a), "FileNode/set": (a) => { - return genericSet(fileNodes, "f", (o) => { + return genericSet(nodesFor(a.accountId), "f", (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. @@ -779,7 +798,18 @@ const session = () => ({ capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY" }, "urn:ietf:params:jmap:emailpush": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} }, - accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } }, + /* + * Two accounts: the demo user's own, and one somebody has shared. + * + * The shared one carries the *same* capability list, because that is what + * Stalwart does -- checked on 0.16.19 (2026-08-27), where a shared account + * advertised mail, calendars, contacts and the rest, identical to a personal + * one, whatever had actually been shared. Giving the mock a truthful shared + * account is the only way to exercise the Files "Shared with me" list, and + * the only way this stays honest about what can be inferred from a + * capability, which is nothing. + */ + accounts: { [SHARED_ACCOUNT]: { name: "grace@example.org", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } }, primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) }, username: USER, apiUrl: `http://127.0.0.1:${PORT}/jmap/`, diff --git a/web/src/store/files.ts b/web/src/store/files.ts index 5c262cd..7df9a10 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -6,8 +6,26 @@ import { isAppFolder } from "@/lib/appFolder"; import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types"; import { useSession } from "./session"; +interface SharedAccount { + id: Id; + name: string; +} + interface FilesState { + /** + * The account being browsed, which is not always the reader's own. + * + * Files is the one module that opens somebody else's account in place: a + * folder shared with you is reached from "Shared with me" in the tree, not by + * switching the whole app over. So this moves and `ownAccountId` does not, + * and anything belonging to the reader -- their settings, their signatures -- + * goes through `ownAccountFor` rather than either of them. + */ accountId: Id | null; + /** The reader's own file account, wherever they happen to be looking. */ + ownAccountId: Id | null; + /** Accounts someone else has shared, from the session. */ + sharedAccounts: SharedAccount[]; available: boolean; nodes: Record; children: Record; // parentId ("root" for null) → ids @@ -32,6 +50,8 @@ interface FilesState { draggingId: Id | null; init(): Promise; + /** Browse an account: the reader's own, or one shared with them. */ + openAccount(accountId: Id | null): void; loadChildren(parentId: Id | null): Promise; mkdir(parentId: Id | null, name: string): Promise; upload(parentId: Id | null, files: File[]): Promise; @@ -93,6 +113,8 @@ export function emptyForAccount(accountId: Id | null) { export const useFiles = create((set, get) => ({ accountId: null, + ownAccountId: null, + sharedAccounts: [], available: false, nodes: {}, children: {}, @@ -104,10 +126,31 @@ export const useFiles = create((set, get) => ({ draggingId: null, async init() { - const accountId = useSession.getState().accountFor(CAP.filenode); - const available = Boolean(accountId && client.hasCapability(CAP.filenode)); - if (accountId !== get().accountId) set(emptyForAccount(accountId)); - set({ available }); + const session = useSession.getState(); + const ownAccountId = session.ownAccountFor(CAP.filenode); + const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode)); + /* + * Which accounts hold shared files cannot be worked out from capabilities: + * Stalwart advertises the whole set on a shared account -- mail, calendars, + * contacts and the rest -- identical to a personal one, whatever was + * actually shared (checked on 0.16.19, 2026-08-27). So every account that + * is not the reader's own is offered, and what it really holds is settled + * by asking it for its folders and showing what comes back. + */ + const s = session.session; + const sharedAccounts = Object.entries(s?.accounts ?? {}) + .filter(([, a]) => a.isPersonal === false) + .map(([id, a]) => ({ id, name: a.name })); + // Stay where the reader is if they are reading a share that still exists. + const browsing = get().accountId; + const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing)); + if (!keep) set(emptyForAccount(ownAccountId)); + set({ available, ownAccountId, sharedAccounts }); + }, + + openAccount(accountId) { + if (accountId === get().accountId) return; + set(emptyForAccount(accountId)); }, /* diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 1a6d4bf..add8b79 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1024,3 +1024,9 @@ button.dp-open:disabled { cursor: default; opacity: .5; } .files-table tbody tr[draggable="true"]:active { cursor: grabbing; } .sidebar .nav-item[draggable="true"] { cursor: pointer; } .f-name .faint { flex: none; } + +/* The "Shared with me" header carries a refresh control, so it is a row rather + than the plain label the other sections use. */ +.sidebar .nav-section { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.spin { animation: spin 1s linear infinite; } +@media (prefers-reduced-motion: reduce) { .spin { animation: none; } } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 4cbaab2..d3513d9 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; -import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react"; +import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users } from "lucide-react"; import { useSession } from "@/store/session"; import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings"; import { useMail } from "@/store/mail"; @@ -114,16 +114,19 @@ export function AppShell({ children }: { children: ReactNode }) {
setDrawer(false)} />