diff --git a/FEATURES.md b/FEATURES.md index 6cd2dd36..cf7a1bde 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -69,6 +69,7 @@ - Grid and list views with sorting by name, size, or date - Previews for images, text, audio, and video - Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files +- JMAP sharing (RFC 9670) for files and folders – share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you ## Security & Privacy diff --git a/app/(main)/[locale]/files/page.tsx b/app/(main)/[locale]/files/page.tsx index d1fe7ce8..4c3a5fad 100644 --- a/app/(main)/[locale]/files/page.tsx +++ b/app/(main)/[locale]/files/page.tsx @@ -22,6 +22,7 @@ import { useIsMobile } from "@/hooks/use-media-query"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { usePolicyStore } from "@/stores/policy-store"; import { FileBrowser } from "@/components/files/file-browser"; +import type { FileNodeRights } from "@/lib/jmap/types"; import { ImagePreviewModal } from "@/components/files/image-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { loadFilesSettings } from "@/components/files/files-settings-dialog"; @@ -88,6 +89,7 @@ export default function FilesPage() { cancelUpload, undoLastAction, lastAction, + shareResource, } = useFileStore(); const isMobile = useIsMobile(); @@ -401,6 +403,14 @@ export default function FilesPage() { const currentFilesAccountId = useFileStore((s) => s.currentAccountId); + // Sharing: the browsing client (store-attached) drives the principal picker + // and share mutations. supportsPrincipals() gates the whole Share affordance. + const sharingEnabled = !!storeClient?.supportsPrincipals(); + const filesAccountId = storeClient?.getFilesAccountId() ?? null; + const handleShare = useCallback(async (id: string, principalId: string, rights: FileNodeRights | null) => { + await shareResource(id, principalId, rights); + }, [shareResource]); + // Pro shell only: all connected accounts are equal top-level entries at // the root. The root path "/" itself is a cross-account picker - no // account's files are shown until the user enters one. @@ -540,6 +550,10 @@ export default function FilesPage() { onSelectAccount={handleSelectAccount} accountPickerMode={isAccountPicker} accountLabel={currentAccountLabel} + client={storeClient} + ownAccountId={filesAccountId} + sharingEnabled={sharingEnabled} + onShare={handleShare} /> )} diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 8399a612..b712c158 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -11,7 +11,7 @@ import { AlertCircle, Star, Clock, FolderUp, FileArchive, FileSpreadsheet, Presentation, FileCode, Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon, - Menu, + Menu, Users, Share2, } from "lucide-react"; import { useIsDesktop } from "@/hooks/use-media-query"; import { Button } from "@/components/ui/button"; @@ -26,6 +26,9 @@ import { ResizeHandle } from "@/components/layout/resize-handle"; import { Avatar } from "@/components/ui/avatar"; import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; import type { FileResource } from "@/stores/file-store"; +import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { FileNodeRights } from "@/lib/jmap/types"; type SortKey = "name" | "size" | "modified"; type SortDir = "asc" | "desc"; @@ -95,6 +98,14 @@ interface FileBrowserProps { accountPickerMode?: boolean; /** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */ accountLabel?: string | null; + /** JMAP client for the browsing account, used by the share dialog to list principals. */ + client?: IJMAPClient | null; + /** Files account id of the browsing account; used to exclude self from the share picker. */ + ownAccountId?: string | null; + /** True when the server supports JMAP Sharing (principals); gates the Share action. */ + sharingEnabled?: boolean; + /** Add/update/remove a principal's share on a node. Set null rights to revoke. */ + onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise; } const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]); @@ -230,6 +241,33 @@ function getGridIcon(resource: FileResource) { return getFileIconByName(resource.name, "lg"); } +// Small inline indicator: a node shared out by the user (shareWith has entries) +// or a node shared *with* the user by another principal (isShared). +function ShareBadge({ resource, t }: { resource: FileResource; t: (key: string) => string }) { + const sharedOut = !!resource.shareWith && Object.keys(resource.shareWith).length > 0; + if (resource.isShared) { + return ( + + {t("shared_with_me")} + + ); + } + if (sharedOut) { + return ( + + {t("shared")} + + ); + } + return null; +} + function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: { name: string; getImageUrl: (n: string) => Promise; @@ -342,11 +380,25 @@ export function FileBrowser({ onSelectAccount, accountPickerMode, accountLabel, + client, + ownAccountId, + sharingEnabled, + onShare, }: FileBrowserProps) { const t = useTranslations("files"); const [showNewFolder, setShowNewFolder] = useState(false); const [renameTarget, setRenameTarget] = useState(null); + const [shareTargetId, setShareTargetId] = useState(null); const [isDraggingOver, setIsDraggingOver] = useState(false); + // The share dialog is bound to a node id (not name) so its shareWith stays + // live after a share refresh re-derives the resource list. + const shareTarget = shareTargetId ? resources.find(r => r.id === shareTargetId) ?? null : null; + // A node is shareable when the server supports JMAP Sharing, the viewer owns + // it (not a shared-with-me node), and holds the mayShare right (owned nodes + // report full rights; treat missing myRights as allowed). + const canShare = useCallback((r: FileResource | null | undefined): boolean => + !!(sharingEnabled && onShare && client && r && !r.isShared && (r.myRights?.mayShare ?? true)), + [sharingEnabled, onShare, client]); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null); const [showNewTextFile, setShowNewTextFile] = useState(false); @@ -1440,8 +1492,9 @@ export function FileBrowser({ {showThumbnails && isImageFile(resource.name) ? : getGridIcon(resource)} - - {resource.name} + + {resource.name} + ))} @@ -1590,6 +1643,7 @@ export function FileBrowser({ ? : getFileIcon(resource)} {resource.name} + @@ -1697,6 +1751,19 @@ export function FileBrowser({ {t("duplicate")} )} + {canShare(resources.find(r => r.name === contextMenu.name)) && ( + + )}
); } diff --git a/components/files/folder-tree-sidebar.tsx b/components/files/folder-tree-sidebar.tsx index 48013f1c..1d7b0c94 100644 --- a/components/files/folder-tree-sidebar.tsx +++ b/components/files/folder-tree-sidebar.tsx @@ -8,6 +8,7 @@ import { ChevronRight, ChevronDown, Home, + Share2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useFileStore, type FileResource } from "@/stores/file-store"; @@ -29,6 +30,8 @@ interface FolderTreeSidebarProps { export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) { const t = useTranslations("files"); const client = useFileStore(s => s.client); + const sharedRoots = useFileStore(s => s.sharedRoots); + const loadSharedRoots = useFileStore(s => s.loadSharedRoots); const [rootChildren, setRootChildren] = useState(null); const [expandedIds, setExpandedIds] = useState>(new Set(["root"])); const [loadingIds, setLoadingIds] = useState>(new Set()); @@ -81,6 +84,8 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid useEffect(() => { if (client) { loadChildren(null, "/"); + // Discover folders shared with the user by other principals. + loadSharedRoots(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [client]); @@ -180,6 +185,40 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid /> )) )} + + {/* Shared with me: folders another principal has shared with the user */} + {sharedRoots.filter(r => r.isDirectory).length > 0 && ( +
+
+ + {t("shared_with_me")} +
+ {sharedRoots.filter(r => r.isDirectory).map(r => { + const path = `/${r.name}`; + const isSelected = currentPath === path; + return ( +
+ +
+ ); + })} +
+ )} ); diff --git a/components/settings/share-collection-dialog.tsx b/components/settings/share-collection-dialog.tsx index 7a3b9470..db64cd04 100644 --- a/components/settings/share-collection-dialog.tsx +++ b/components/settings/share-collection-dialog.tsx @@ -6,11 +6,11 @@ import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; -import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types"; +import type { Principal, CalendarRights, AddressBookRights, FileNodeRights } from "@/lib/jmap/types"; import { toast } from "@/stores/toast-store"; -type ShareKind = "calendar" | "addressBook"; -type AnyRights = CalendarRights | AddressBookRights; +type ShareKind = "calendar" | "addressBook" | "file"; +type AnyRights = CalendarRights | AddressBookRights | FileNodeRights; type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom"; @@ -39,6 +39,21 @@ const ADDRESS_BOOK_PRESETS: Record, A manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, }; +const FILE_PRESETS: Record, FileNodeRights> = { + read: { + mayRead: true, mayAddChildren: false, mayRename: false, + mayDelete: false, mayModifyContent: false, mayShare: false, + }, + readWrite: { + mayRead: true, mayAddChildren: true, mayRename: true, + mayDelete: true, mayModifyContent: true, mayShare: false, + }, + manager: { + mayRead: true, mayAddChildren: true, mayRename: true, + mayDelete: true, mayModifyContent: true, mayShare: true, + }, +}; + function detectCalendarPreset(r: CalendarRights): RolePreset { for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude, CalendarRights][]) { if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) { @@ -58,6 +73,29 @@ function detectAddressBookPreset(r: AddressBookRights): RolePreset { return "custom"; } +function detectFilePreset(r: FileNodeRights): RolePreset { + for (const [name, preset] of Object.entries(FILE_PRESETS) as [Exclude, FileNodeRights][]) { + const keys = Object.keys(preset) as (keyof FileNodeRights)[]; + if (keys.every((k) => preset[k] === (r[k] ?? false))) { + return name; + } + } + return "custom"; +} + +function presetRights(kind: ShareKind, preset: RolePreset): AnyRights | undefined { + if (preset === "custom") return undefined; + if (kind === "calendar") return CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]; + if (kind === "file") return FILE_PRESETS[preset as keyof typeof FILE_PRESETS]; + return ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS]; +} + +function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset { + if (kind === "calendar") return detectCalendarPreset(rights as CalendarRights); + if (kind === "file") return detectFilePreset(rights as FileNodeRights); + return detectAddressBookPreset(rights as AddressBookRights); +} + interface ShareCollectionDialogProps { client: IJMAPClient; kind: ShareKind; @@ -126,9 +164,7 @@ export function ShareCollectionDialog({ const handleSetRights = async (principalId: string, preset: RolePreset) => { if (preset === "custom") return; // custom is read-only here - const rights = kind === "calendar" - ? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS] - : ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS]; + const rights = presetRights(kind, preset); if (!rights) return; setSavingId(principalId); try { @@ -154,10 +190,8 @@ export function ShareCollectionDialog({ }; const handleAdd = async (principal: Principal) => { - const defaultPreset: RolePreset = "read"; - const rights = kind === "calendar" - ? CALENDAR_PRESETS[defaultPreset] - : ADDRESS_BOOK_PRESETS[defaultPreset]; + const rights = presetRights(kind, "read"); + if (!rights) return; setSavingId(principal.id); try { await onShare(principal.id, rights); @@ -226,9 +260,7 @@ export function ShareCollectionDialog({
    {sharedEntries.map(([principalId, rights]) => { const principal = allPrincipalsById.get(principalId); - const preset = kind === "calendar" - ? detectCalendarPreset(rights as CalendarRights) - : detectAddressBookPreset(rights as AddressBookRights); + const preset = detectPreset(kind, rights); return (
  • Promise.resolve(JSON.stringify(response)), + json: () => Promise.resolve(response), + } as Response); +} + +const READ: FileNodeRights = { + mayRead: true, mayAddChildren: false, mayRename: false, + mayDelete: false, mayModifyContent: false, mayShare: false, +}; + +function lastRequestBody(spy: ReturnType): { using: string[]; methodCalls: unknown[][] } { + const call = spy.mock.calls[spy.mock.calls.length - 1]; + return JSON.parse((call[1] as RequestInit).body as string); +} + +describe('JMAPClient.setFileNodeShare', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('sends a FileNode/set shareWith patch and resolves on success', async () => { + const spy = mockFetch({ + methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']], + }); + const client = createClient(); + + await client.setFileNodeShare('node-1', 'principal-9', READ); + + const body = lastRequestBody(spy); + expect(body.using).toContain('urn:ietf:params:jmap:filenode'); + expect(body.using).toContain('urn:ietf:params:jmap:principals:owner'); + const [method, args] = body.methodCalls[0] as [string, Record]; + expect(method).toBe('FileNode/set'); + expect(args.accountId).toBe('account-1'); + expect(args.update).toEqual({ + 'node-1': { 'shareWith/principal-9': READ }, + }); + }); + + it('sends null to revoke a principal\'s access', async () => { + const spy = mockFetch({ + methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']], + }); + const client = createClient(); + + await client.setFileNodeShare('node-1', 'principal-9', null); + + const body = lastRequestBody(spy); + const [, args] = body.methodCalls[0] as [string, Record]; + expect(args.update).toEqual({ 'node-1': { 'shareWith/principal-9': null } }); + }); + + it('throws with the server description when the update is rejected', async () => { + mockFetch({ + methodResponses: [['FileNode/set', { + notUpdated: { 'node-1': { type: 'forbidden', description: 'Not allowed' } }, + }, '0']], + }); + const client = createClient(); + + await expect(client.setFileNodeShare('node-1', 'principal-9', READ)) + .rejects.toThrow('Not allowed'); + }); + + it('throws when the server does not confirm the update', async () => { + mockFetch({ + methodResponses: [['FileNode/set', { updated: {} }, '0']], + }); + const client = createClient(); + + await expect(client.setFileNodeShare('node-1', 'principal-9', READ)) + .rejects.toThrow('did not confirm'); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 0d4a9456..edd0e93e 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -912,6 +912,12 @@ export class DemoJMAPClient implements IJMAPClient { return [...this.data.fileNodes]; } + async listAllFileNodesAcrossAccounts(): Promise { + return [...this.data.fileNodes]; + } + + async setFileNodeShare(): Promise { /* demo: no-op */ } + async getFileNodes(ids: string[] | null): Promise { if (ids === null) return [...this.data.fileNodes]; return this.data.fileNodes.filter(n => ids.includes(n.id)); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 06926548..c2715d1e 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types"; +import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; /** @@ -292,6 +292,7 @@ export interface IJMAPClient { getPrincipals(targetAccountId?: string): Promise; setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise; setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise; + setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise; // ── Sieve / Filters ────────────────────────────────────────── getSieveAccountId(): string; @@ -308,6 +309,7 @@ export interface IJMAPClient { probeFileNodeSupport(): Promise; listFileNodes(parentId: string | null): Promise; listAllFileNodes(): Promise; + listAllFileNodesAcrossAccounts(): Promise; getFileNodes(ids: string[] | null, properties?: string[]): Promise; createFileDirectory(name: string, parentId: string | null): Promise; createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 8aa1772c..a075d9f9 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types"; +import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; @@ -5073,10 +5073,37 @@ export class JMAPClient implements IJMAPClient { if (this.hasCapability("urn:ietf:params:jmap:filenode")) { using.push("urn:ietf:params:jmap:filenode"); } + // Required for shareWith/myRights on FileNode and for cross-account + // (shared-with-me) FileNode/get, mirroring calendarUsing(). + if (this.supportsPrincipals()) { + using.push("urn:ietf:params:jmap:principals:owner"); + } return using; } - private static FILE_NODE_PROPERTIES = ["id", "parentId", "name", "type", "blobId", "size", "created", "updated"]; + private static FILE_NODE_PROPERTIES = [ + "id", "parentId", "name", "type", "blobId", "size", "created", "updated", + // Stalwart omits shareWith/myRights from FileNode/get unless requested + // explicitly, so the share dialog and indicators can't see existing + // shares without naming them here (same as CALENDAR_PROPERTIES). + "shareWith", "myRights", + ]; + + // Accounts (primary + shared/group) that can hold FileNodes. Mirrors + // getCalendarCapableAccountIds(): includes any non-primary account that + // advertises the filenode capability or is a non-personal (shared/group) + // account, since Stalwart doesn't always advertise capabilities on those. + private getFilesCapableAccountIds(): string[] { + const primaryId = this.getFilesAccountId(); + const accountIds: string[] = []; + for (const [id, account] of Object.entries(this.accounts)) { + if (id === primaryId) continue; + if (account.accountCapabilities?.["urn:ietf:params:jmap:filenode"] || !account.isPersonal) { + accountIds.push(id); + } + } + return [primaryId, ...accountIds]; + } async getFileNodes(ids: string[] | null, properties?: string[]): Promise { const accountId = this.getFilesAccountId(); @@ -5143,6 +5170,79 @@ export class JMAPClient implements IJMAPClient { return (getResult[1].list || []) as FileNode[]; } + /** + * Fetch every FileNode the logged-in user can see across all connected and + * shared accounts. Nodes owned by another principal (shared with the user) + * are tagged with `isShared: true` and the owning `accountId`/`accountName`, + * and their ids are namespaced `accountId:nodeId` so they don't collide with + * the primary account's ids. Mirrors getAllCalendars(). + */ + async listAllFileNodesAcrossAccounts(): Promise { + const primaryId = this.getFilesAccountId(); + const accountIds = this.getFilesCapableAccountIds(); + const all: FileNode[] = []; + + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + const account = this.accounts[accountId]; + try { + const response = await this.request( + [["FileNode/get", { accountId, ids: null, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"]], + this.fileUsing(), + ); + const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get"); + if (!getResult || getResult[0] === "error") continue; + const nodes = (getResult[1].list || []) as FileNode[]; + for (const node of nodes) { + all.push({ + ...node, + id: isPrimary ? node.id : `${accountId}:${node.id}`, + parentId: node.parentId == null + ? null + : (isPrimary ? node.parentId : `${accountId}:${node.parentId}`), + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + }); + } + } catch (error) { + console.error(`[Files] Failed to fetch FileNodes for account ${accountId}:`, error); + } + } + + return all; + } + + /** + * Add, update, or remove a principal's rights on a FileNode (file or folder). + * Pass `rights: null` to revoke access. Mirrors setCalendarShare / + * setAddressBookShare; Stalwart applies it via a `shareWith/{principalId}` + * patch on FileNode/set. + */ + async setFileNodeShare( + fileNodeId: string, + principalId: string, + rights: FileNodeRights | null, + targetAccountId?: string, + ): Promise { + const accountId = targetAccountId || this.getFilesAccountId(); + const response = await this.request([ + ["FileNode/set", { + accountId, + update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } }, + }, "0"], + ], this.fileUsing()); + + const result = response.methodResponses?.[0]?.[1]; + if (result?.notUpdated?.[fileNodeId]) { + const err = result.notUpdated[fileNodeId]; + throw new Error(err.description || "Failed to update file share"); + } + if (!result?.updated || !(fileNodeId in result.updated)) { + throw new Error("Server did not confirm the share update"); + } + } + async createFileDirectory(name: string, parentId: string | null): Promise { const accountId = this.getFilesAccountId(); diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index ec65774b..c69d3be0 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -792,6 +792,32 @@ export interface FileNode { size: number; created: string; updated: string; + // JMAP Sharing (RFC 9670). Populated only when the server advertises the + // filenode capability and the properties are explicitly requested. A node is + // shared-out when `shareWith` has entries; `myRights` describes what the + // viewer may do (always full rights on owned nodes). + myRights?: FileNodeRights; + shareWith?: Record | null; + // True when this node was fetched from another principal's account that was + // shared with the logged-in user (mirrors Calendar.isShared / AddressBook.isShared). + isShared?: boolean; + // Owning account's JMAP id and display name, set when aggregating nodes + // across connected/shared accounts so mutations route to the right account. + accountId?: string; + accountName?: string; + // Local account-store id (per JMAP connection) in multi-account contexts. + // See Calendar.localAccountId. + localAccountId?: string; +} + +// FileNode rights as defined by Stalwart's JmapSharedObject implementation. +export interface FileNodeRights { + mayRead: boolean; + mayAddChildren: boolean; + mayRename: boolean; + mayDelete: boolean; + mayModifyContent: boolean; + mayShare: boolean; } export interface FileNodeFilter { diff --git a/locales/cs/common.json b/locales/cs/common.json index 4c8ffba1..45d9a8c7 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2921,6 +2921,10 @@ "duplicate": "Duplikovat", "duplicate_success": "Úspěšně duplikováno", "duplicate_error": "Duplikování selhalo", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Soubor byl vytvořen", "create_file_error": "Vytvoření souboru selhalo", "favorites": "Oblíbené", diff --git a/locales/da/common.json b/locales/da/common.json index d0a1e6f8..46d5230d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2944,6 +2944,10 @@ "duplicate": "Duplikér", "duplicate_success": "Duplikeret", "duplicate_error": "Kunne ikke duplikere", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Fil oprettet", "create_file_error": "Kunne ikke oprette fil", "favorites": "Favoritter", diff --git a/locales/de/common.json b/locales/de/common.json index 38bbf347..3634c04f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2921,6 +2921,10 @@ "duplicate": "Duplizieren", "duplicate_success": "Erfolgreich dupliziert", "duplicate_error": "Duplizieren fehlgeschlagen", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Datei erstellt", "create_file_error": "Datei konnte nicht erstellt werden", "favorites": "Favoriten", diff --git a/locales/en/common.json b/locales/en/common.json index fba1e9ff..aecc09ff 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2946,6 +2946,10 @@ "duplicate": "Duplicate", "duplicate_success": "Duplicated successfully", "duplicate_error": "Failed to duplicate", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "File created", "create_file_error": "Failed to create file", "favorites": "Favorites", diff --git a/locales/es/common.json b/locales/es/common.json index b117dc99..9fce4e64 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2921,6 +2921,10 @@ "duplicate": "Duplicar", "duplicate_success": "Duplicado correctamente", "duplicate_error": "Error al duplicar", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Archivo creado", "create_file_error": "Error al crear el archivo", "favorites": "Favoritos", diff --git a/locales/fr/common.json b/locales/fr/common.json index 55086fa8..4dce8ac6 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2921,6 +2921,10 @@ "duplicate": "Dupliquer", "duplicate_success": "Dupliqué avec succès", "duplicate_error": "Échec de la duplication", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Fichier créé", "create_file_error": "Échec de la création du fichier", "favorites": "Favoris", diff --git a/locales/hu/common.json b/locales/hu/common.json index 3abc697a..a7a0a08e 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2946,6 +2946,10 @@ "duplicate": "Duplikálás", "duplicate_success": "Sikeresen duplikálva", "duplicate_error": "Nem sikerült duplikálni", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Fájl létrehozva", "create_file_error": "Nem sikerült létrehozni a fájlt", "favorites": "Kedvencek", diff --git a/locales/it/common.json b/locales/it/common.json index 84398dc7..1aea1658 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2921,6 +2921,10 @@ "duplicate": "Duplica", "duplicate_success": "Duplicato con successo", "duplicate_error": "Duplicazione non riuscita", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "File creato", "create_file_error": "Creazione del file non riuscita", "favorites": "Preferiti", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3145ed6c..a1bae1ee 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2921,6 +2921,10 @@ "duplicate": "複製", "duplicate_success": "正常に複製しました", "duplicate_error": "複製に失敗しました", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "ファイルを作成しました", "create_file_error": "ファイルの作成に失敗しました", "favorites": "お気に入り", diff --git a/locales/ko/common.json b/locales/ko/common.json index 56cfb729..c8d35250 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2921,6 +2921,10 @@ "duplicate": "복제", "duplicate_success": "성공적으로 복제되었어요", "duplicate_error": "복제에 실패했어요", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "파일이 만들어졌어요", "create_file_error": "파일을 만들지 못했어요", "favorites": "즐겨찾기", diff --git a/locales/lv/common.json b/locales/lv/common.json index f69d9984..bafc37b8 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2921,6 +2921,10 @@ "duplicate": "Dublēt", "duplicate_success": "Dublēšana pabeigta", "duplicate_error": "Neizdevās dublēt", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Fails izveidots", "create_file_error": "Neizdevās izveidot failu", "favorites": "Izlase", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4412ccbd..ef9fb760 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2921,6 +2921,10 @@ "duplicate": "Dupliceren", "duplicate_success": "Succesvol gedupliceerd", "duplicate_error": "Dupliceren mislukt", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Bestand aangemaakt", "create_file_error": "Bestand aanmaken mislukt", "favorites": "Favorieten", diff --git a/locales/pl/common.json b/locales/pl/common.json index 17804a80..4ba6f76d 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2921,6 +2921,10 @@ "duplicate": "Duplikuj", "duplicate_success": "Zduplikowano pomyślnie", "duplicate_error": "Nie udało się zduplikować", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Plik utworzono", "create_file_error": "Nie udało się utworzyć pliku", "favorites": "Ulubione", diff --git a/locales/pt/common.json b/locales/pt/common.json index c78fd3d0..f07df2e2 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2921,6 +2921,10 @@ "duplicate": "Duplicar", "duplicate_success": "Duplicado com sucesso", "duplicate_error": "Falha ao duplicar", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Arquivo criado", "create_file_error": "Falha ao criar arquivo", "favorites": "Favoritos", diff --git a/locales/ru/common.json b/locales/ru/common.json index e62089fd..388a0480 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2921,6 +2921,10 @@ "duplicate": "Дублировать", "duplicate_success": "Дублирование выполнено успешно", "duplicate_error": "Не удалось дублировать", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Файл создан", "create_file_error": "Не удалось создать файл", "favorites": "Избранное", diff --git a/locales/tr/common.json b/locales/tr/common.json index 7d3fd075..e44217cc 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2944,6 +2944,10 @@ "duplicate": "Çoğalt", "duplicate_success": "Başarıyla çoğaltıldı", "duplicate_error": "Çoğaltılamadı", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Dosya oluşturuldu", "create_file_error": "Dosya oluşturulamadı", "favorites": "Favoriler", diff --git a/locales/uk/common.json b/locales/uk/common.json index 37d1d9a6..9b5443a5 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2921,6 +2921,10 @@ "duplicate": "дублікат", "duplicate_success": "Продубльовано успішно", "duplicate_error": "Не вдалося скопіювати", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "Файл створено", "create_file_error": "Не вдалося створити файл", "favorites": "Вибране", diff --git a/locales/zh/common.json b/locales/zh/common.json index feb684df..cf4bf5ea 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2921,6 +2921,10 @@ "duplicate": "创建副本", "duplicate_success": "复制成功", "duplicate_error": "复制失败", + "share": "Share", + "shared": "Shared", + "shared_with_me": "Shared with me", + "shared_by": "Shared by {name}", "create_file_success": "文件已创建", "create_file_error": "创建文件失败", "favorites": "收藏夹", diff --git a/stores/file-store.ts b/stores/file-store.ts index 35bee40b..471c7824 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import type { FileNode } from '@/lib/jmap/types'; +import type { FileNode, FileNodeRights } from '@/lib/jmap/types'; export interface FileResource { id: string; @@ -12,6 +12,14 @@ export interface FileResource { lastModified: string; blobId: string | null; parentId: string | null; + // Sharing (RFC 9670). `shareWith` has entries when this node is shared out by + // the current user; `myRights` describes what the viewer may do; `isShared` + // marks nodes owned by another principal and surfaced under "Shared with me". + myRights?: FileNodeRights; + shareWith?: Record | null; + isShared?: boolean; + ownerAccountId?: string; + ownerName?: string; } interface UploadProgress { @@ -57,6 +65,8 @@ interface FileState { favorites: string[]; recentFiles: { name: string; id: string; timestamp: number }[]; lastAction: UndoAction | null; + /** Top-level FileNodes shared with the user by other principals ("Shared with me"). */ + sharedRoots: FileResource[]; // Actions initClient: (client: IJMAPClient, accountId?: string | null) => void; @@ -103,6 +113,10 @@ interface FileState { toggleFavorite: (path: string) => void; addRecentFile: (name: string, id: string) => void; undoLastAction: () => Promise; + /** Add, update (rights), or remove (rights=null) a principal's share on a node. */ + shareResource: (id: string, principalId: string, rights: FileNodeRights | null) => Promise; + /** Load the top-level nodes shared with the user by other principals. */ + loadSharedRoots: () => Promise; } const DIRECTORY_TYPES = new Set(['d', 'application/x-directory', 'text/directory', 'httpd/unix-directory', 'inode/directory']); @@ -144,6 +158,13 @@ function childrenOf(nodes: FileNode[], parentId: string | null): FileNode[] { return nodes.filter(n => (n.parentId ?? null) === parentId); } +// Nodes from a non-primary (shared) account are namespaced "accountId:nodeId" +// by listAllFileNodesAcrossAccounts(). JMAP ids never contain ':', so the +// separator unambiguously marks a node we're browsing inside a shared account. +function isCrossAccountId(id: string | null): boolean { + return id != null && id.includes(':'); +} + function nodeToResource(node: FileNode): FileResource { const isDir = isFolder(node); return { @@ -156,6 +177,11 @@ function nodeToResource(node: FileNode): FileResource { lastModified: node.updated || node.created, blobId: node.blobId, parentId: node.parentId, + myRights: node.myRights, + shareWith: node.shareWith, + isShared: node.isShared, + ownerAccountId: node.accountId, + ownerName: node.accountName, }; } @@ -213,6 +239,7 @@ export const useFileStore = create((set, get) => ({ clipboard: null, uploadAbortController: null, lastAction: null, + sharedRoots: [], favorites: (() => { try { return JSON.parse(localStorage.getItem('files-favorites') || '[]'); } catch { return []; } })(), @@ -480,8 +507,12 @@ export const useFileStore = create((set, get) => ({ try { // Fetch the whole tree once and select the current parent's direct // children locally. Hierarchy is derived from parentId links, exactly as - // the JMAP FileNode spec intends (issue #379). - const allNodes = await client.listAllFileNodes(); + // the JMAP FileNode spec intends (issue #379). When browsing inside a + // folder shared by another principal (namespaced id), pull the tree + // across all accessible accounts so the shared subtree is visible. + const allNodes = isCrossAccountId(parentId) + ? await client.listAllFileNodesAcrossAccounts() + : await client.listAllFileNodes(); const resources = sortResources(childrenOf(allNodes, parentId).map(nodeToResource)); // Prune recent files whose backing node no longer exists on the server @@ -982,4 +1013,35 @@ export const useFileStore = create((set, get) => ({ set({ lastAction: null }); await refresh(); }, + + shareResource: async (id: string, principalId: string, rights: FileNodeRights | null) => { + const { client, currentAccountId, refresh } = get(); + if (!client) return; + // Owned nodes are browsed in the current account, so their ids are not + // namespaced; route the share to that account (defaults to the files account). + await client.setFileNodeShare(id, principalId, rights, currentAccountId ?? undefined); + await refresh(); + }, + + loadSharedRoots: async () => { + const { client } = get(); + if (!client) { + set({ sharedRoots: [] }); + return; + } + try { + const all = await client.listAllFileNodesAcrossAccounts(); + const idSet = new Set(all.map(n => n.id)); + // A shared node is a "root" of the shared-with-me tree when its parent is + // not among the nodes the user can see (either null, or owned by a + // principal whose ancestor folders weren't shared). + const roots = all.filter(n => + n.isShared && (n.parentId == null || !idSet.has(n.parentId)), + ); + set({ sharedRoots: sortResources(roots.map(nodeToResource)) }); + } catch (error) { + console.error('[Files] Failed to load shared roots:', error); + set({ sharedRoots: [] }); + } + }, }));