feat: JMAP file/folder sharing in Files app #408
This commit is contained in:
@@ -69,6 +69,7 @@
|
|||||||
- Grid and list views with sorting by name, size, or date
|
- Grid and list views with sorting by name, size, or date
|
||||||
- Previews for images, text, audio, and video
|
- Previews for images, text, audio, and video
|
||||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
- 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
|
## Security & Privacy
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useIsMobile } from "@/hooks/use-media-query";
|
|||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { FileBrowser } from "@/components/files/file-browser";
|
import { FileBrowser } from "@/components/files/file-browser";
|
||||||
|
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||||
@@ -88,6 +89,7 @@ export default function FilesPage() {
|
|||||||
cancelUpload,
|
cancelUpload,
|
||||||
undoLastAction,
|
undoLastAction,
|
||||||
lastAction,
|
lastAction,
|
||||||
|
shareResource,
|
||||||
} = useFileStore();
|
} = useFileStore();
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -401,6 +403,14 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
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
|
// Pro shell only: all connected accounts are equal top-level entries at
|
||||||
// the root. The root path "/" itself is a cross-account picker - no
|
// the root. The root path "/" itself is a cross-account picker - no
|
||||||
// account's files are shown until the user enters one.
|
// account's files are shown until the user enters one.
|
||||||
@@ -540,6 +550,10 @@ export default function FilesPage() {
|
|||||||
onSelectAccount={handleSelectAccount}
|
onSelectAccount={handleSelectAccount}
|
||||||
accountPickerMode={isAccountPicker}
|
accountPickerMode={isAccountPicker}
|
||||||
accountLabel={currentAccountLabel}
|
accountLabel={currentAccountLabel}
|
||||||
|
client={storeClient}
|
||||||
|
ownAccountId={filesAccountId}
|
||||||
|
sharingEnabled={sharingEnabled}
|
||||||
|
onShare={handleShare}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
AlertCircle, Star, Clock, FolderUp,
|
AlertCircle, Star, Clock, FolderUp,
|
||||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||||
Menu,
|
Menu, Users, Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -26,6 +26,9 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
|
|||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||||
import type { FileResource } from "@/stores/file-store";
|
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 SortKey = "name" | "size" | "modified";
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
@@ -95,6 +98,14 @@ interface FileBrowserProps {
|
|||||||
accountPickerMode?: boolean;
|
accountPickerMode?: boolean;
|
||||||
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
|
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
|
||||||
accountLabel?: string | null;
|
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<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
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");
|
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 (
|
||||||
|
<Share2
|
||||||
|
className="w-3.5 h-3.5 text-primary shrink-0"
|
||||||
|
aria-label={t("shared_with_me")}
|
||||||
|
>
|
||||||
|
<title>{t("shared_with_me")}</title>
|
||||||
|
</Share2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sharedOut) {
|
||||||
|
return (
|
||||||
|
<Users
|
||||||
|
className="w-3.5 h-3.5 text-primary shrink-0"
|
||||||
|
aria-label={t("shared")}
|
||||||
|
>
|
||||||
|
<title>{t("shared")}</title>
|
||||||
|
</Users>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: {
|
function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: {
|
||||||
name: string;
|
name: string;
|
||||||
getImageUrl: (n: string) => Promise<string>;
|
getImageUrl: (n: string) => Promise<string>;
|
||||||
@@ -342,11 +380,25 @@ export function FileBrowser({
|
|||||||
onSelectAccount,
|
onSelectAccount,
|
||||||
accountPickerMode,
|
accountPickerMode,
|
||||||
accountLabel,
|
accountLabel,
|
||||||
|
client,
|
||||||
|
ownAccountId,
|
||||||
|
sharingEnabled,
|
||||||
|
onShare,
|
||||||
}: FileBrowserProps) {
|
}: FileBrowserProps) {
|
||||||
const t = useTranslations("files");
|
const t = useTranslations("files");
|
||||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||||
|
const [shareTargetId, setShareTargetId] = useState<string | null>(null);
|
||||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
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 [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||||
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
|
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [showNewTextFile, setShowNewTextFile] = useState(false);
|
const [showNewTextFile, setShowNewTextFile] = useState(false);
|
||||||
@@ -1440,8 +1492,9 @@ export function FileBrowser({
|
|||||||
{showThumbnails && isImageFile(resource.name)
|
{showThumbnails && isImageFile(resource.name)
|
||||||
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
|
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
|
||||||
: getGridIcon(resource)}
|
: getGridIcon(resource)}
|
||||||
<span className="text-xs truncate w-full text-center" title={resource.name}>
|
<span className="text-xs truncate w-full text-center flex items-center justify-center gap-1" title={resource.name}>
|
||||||
{resource.name}
|
<span className="truncate">{resource.name}</span>
|
||||||
|
<ShareBadge resource={resource} t={t} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1590,6 +1643,7 @@ export function FileBrowser({
|
|||||||
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
|
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
|
||||||
: getFileIcon(resource)}
|
: getFileIcon(resource)}
|
||||||
<span className="truncate">{resource.name}</span>
|
<span className="truncate">{resource.name}</span>
|
||||||
|
<ShareBadge resource={resource} t={t} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
||||||
@@ -1697,6 +1751,19 @@ export function FileBrowser({
|
|||||||
{t("duplicate")}
|
{t("duplicate")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canShare(resources.find(r => r.name === contextMenu.name)) && (
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||||
|
onClick={() => {
|
||||||
|
const r = resources.find(res => res.name === contextMenu.name);
|
||||||
|
if (r) setShareTargetId(r.id);
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
{t("share")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||||
@@ -1936,6 +2003,20 @@ export function FileBrowser({
|
|||||||
onCancel={() => setRenameTarget(null)}
|
onCancel={() => setRenameTarget(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Share dialog */}
|
||||||
|
{shareTarget && client && onShare && (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="file"
|
||||||
|
collectionName={shareTarget.name}
|
||||||
|
shareWith={shareTarget.shareWith}
|
||||||
|
ownAccountId={ownAccountId || ""}
|
||||||
|
onShare={(principalId, rights) =>
|
||||||
|
onShare(shareTarget.id, principalId, rights as FileNodeRights | null)}
|
||||||
|
onClose={() => setShareTargetId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Home,
|
Home,
|
||||||
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFileStore, type FileResource } from "@/stores/file-store";
|
import { useFileStore, type FileResource } from "@/stores/file-store";
|
||||||
@@ -29,6 +30,8 @@ interface FolderTreeSidebarProps {
|
|||||||
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) {
|
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) {
|
||||||
const t = useTranslations("files");
|
const t = useTranslations("files");
|
||||||
const client = useFileStore(s => s.client);
|
const client = useFileStore(s => s.client);
|
||||||
|
const sharedRoots = useFileStore(s => s.sharedRoots);
|
||||||
|
const loadSharedRoots = useFileStore(s => s.loadSharedRoots);
|
||||||
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
|
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
|
||||||
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
|
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
|
||||||
@@ -81,6 +84,8 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client) {
|
if (client) {
|
||||||
loadChildren(null, "/");
|
loadChildren(null, "/");
|
||||||
|
// Discover folders shared with the user by other principals.
|
||||||
|
loadSharedRoots();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [client]);
|
}, [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 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-border/60">
|
||||||
|
<div className="px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Share2 className="w-3 h-3" />
|
||||||
|
<span className="truncate">{t("shared_with_me")}</span>
|
||||||
|
</div>
|
||||||
|
{sharedRoots.filter(r => r.isDirectory).map(r => {
|
||||||
|
const path = `/${r.name}`;
|
||||||
|
const isSelected = currentPath === path;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
style={{ paddingBlock: "var(--density-sidebar-py)" }}
|
||||||
|
className={cn(
|
||||||
|
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
|
||||||
|
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleFolderClick(path, r.id)}
|
||||||
|
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left min-w-0"
|
||||||
|
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
||||||
|
title={r.ownerName ? t("shared_by", { name: r.ownerName }) : r.name}
|
||||||
|
>
|
||||||
|
<Folder className="w-4 h-4 flex-shrink-0 mr-2 text-primary" />
|
||||||
|
<span className="truncate">{r.name}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
|
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
|
||||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
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";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
type ShareKind = "calendar" | "addressBook";
|
type ShareKind = "calendar" | "addressBook" | "file";
|
||||||
type AnyRights = CalendarRights | AddressBookRights;
|
type AnyRights = CalendarRights | AddressBookRights | FileNodeRights;
|
||||||
|
|
||||||
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
|
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
|
||||||
|
|
||||||
@@ -39,6 +39,21 @@ const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, A
|
|||||||
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
|
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FILE_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, 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 {
|
function detectCalendarPreset(r: CalendarRights): RolePreset {
|
||||||
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
|
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
|
||||||
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
|
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
|
||||||
@@ -58,6 +73,29 @@ function detectAddressBookPreset(r: AddressBookRights): RolePreset {
|
|||||||
return "custom";
|
return "custom";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectFilePreset(r: FileNodeRights): RolePreset {
|
||||||
|
for (const [name, preset] of Object.entries(FILE_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, 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 {
|
interface ShareCollectionDialogProps {
|
||||||
client: IJMAPClient;
|
client: IJMAPClient;
|
||||||
kind: ShareKind;
|
kind: ShareKind;
|
||||||
@@ -126,9 +164,7 @@ export function ShareCollectionDialog({
|
|||||||
|
|
||||||
const handleSetRights = async (principalId: string, preset: RolePreset) => {
|
const handleSetRights = async (principalId: string, preset: RolePreset) => {
|
||||||
if (preset === "custom") return; // custom is read-only here
|
if (preset === "custom") return; // custom is read-only here
|
||||||
const rights = kind === "calendar"
|
const rights = presetRights(kind, preset);
|
||||||
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
|
|
||||||
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
|
|
||||||
if (!rights) return;
|
if (!rights) return;
|
||||||
setSavingId(principalId);
|
setSavingId(principalId);
|
||||||
try {
|
try {
|
||||||
@@ -154,10 +190,8 @@ export function ShareCollectionDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = async (principal: Principal) => {
|
const handleAdd = async (principal: Principal) => {
|
||||||
const defaultPreset: RolePreset = "read";
|
const rights = presetRights(kind, "read");
|
||||||
const rights = kind === "calendar"
|
if (!rights) return;
|
||||||
? CALENDAR_PRESETS[defaultPreset]
|
|
||||||
: ADDRESS_BOOK_PRESETS[defaultPreset];
|
|
||||||
setSavingId(principal.id);
|
setSavingId(principal.id);
|
||||||
try {
|
try {
|
||||||
await onShare(principal.id, rights);
|
await onShare(principal.id, rights);
|
||||||
@@ -226,9 +260,7 @@ export function ShareCollectionDialog({
|
|||||||
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
|
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
|
||||||
{sharedEntries.map(([principalId, rights]) => {
|
{sharedEntries.map(([principalId, rights]) => {
|
||||||
const principal = allPrincipalsById.get(principalId);
|
const principal = allPrincipalsById.get(principalId);
|
||||||
const preset = kind === "calendar"
|
const preset = detectPreset(kind, rights);
|
||||||
? detectCalendarPreset(rights as CalendarRights)
|
|
||||||
: detectAddressBookPreset(rights as AddressBookRights);
|
|
||||||
return (
|
return (
|
||||||
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
|
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { JMAPClient } from '../jmap/client';
|
||||||
|
import type { FileNodeRights } from '../jmap/types';
|
||||||
|
|
||||||
|
function createClient(): JMAPClient {
|
||||||
|
const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
|
||||||
|
Object.assign(client, {
|
||||||
|
apiUrl: 'https://jmap.example.com/api',
|
||||||
|
accountId: 'account-1',
|
||||||
|
capabilities: { 'urn:ietf:params:jmap:filenode': {}, 'urn:ietf:params:jmap:principals': {} },
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetch(response: object, ok = true, status = 200) {
|
||||||
|
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
text: () => 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<typeof vi.spyOn>): { 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<string, unknown>];
|
||||||
|
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<string, unknown>];
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -912,6 +912,12 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
return [...this.data.fileNodes];
|
return [...this.data.fileNodes];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
|
||||||
|
return [...this.data.fileNodes];
|
||||||
|
}
|
||||||
|
|
||||||
|
async setFileNodeShare(): Promise<void> { /* demo: no-op */ }
|
||||||
|
|
||||||
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
||||||
if (ids === null) return [...this.data.fileNodes];
|
if (ids === null) return [...this.data.fileNodes];
|
||||||
return this.data.fileNodes.filter(n => ids.includes(n.id));
|
return this.data.fileNodes.filter(n => ids.includes(n.id));
|
||||||
|
|||||||
@@ -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";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -292,6 +292,7 @@ export interface IJMAPClient {
|
|||||||
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
|
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
|
||||||
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
||||||
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||||
|
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
|
||||||
|
|
||||||
// ── Sieve / Filters ──────────────────────────────────────────
|
// ── Sieve / Filters ──────────────────────────────────────────
|
||||||
getSieveAccountId(): string;
|
getSieveAccountId(): string;
|
||||||
@@ -308,6 +309,7 @@ export interface IJMAPClient {
|
|||||||
probeFileNodeSupport(): Promise<boolean>;
|
probeFileNodeSupport(): Promise<boolean>;
|
||||||
listFileNodes(parentId: string | null): Promise<FileNode[]>;
|
listFileNodes(parentId: string | null): Promise<FileNode[]>;
|
||||||
listAllFileNodes(): Promise<FileNode[]>;
|
listAllFileNodes(): Promise<FileNode[]>;
|
||||||
|
listAllFileNodesAcrossAccounts(): Promise<FileNode[]>;
|
||||||
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
|
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
|
||||||
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
|
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
|
||||||
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
|
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
|
||||||
|
|||||||
+102
-2
@@ -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 { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
@@ -5073,10 +5073,37 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (this.hasCapability("urn:ietf:params:jmap:filenode")) {
|
if (this.hasCapability("urn:ietf:params:jmap:filenode")) {
|
||||||
using.push("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;
|
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<FileNode[]> {
|
async getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]> {
|
||||||
const accountId = this.getFilesAccountId();
|
const accountId = this.getFilesAccountId();
|
||||||
@@ -5143,6 +5170,79 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return (getResult[1].list || []) as FileNode[];
|
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<FileNode[]> {
|
||||||
|
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<void> {
|
||||||
|
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<FileNode> {
|
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
||||||
const accountId = this.getFilesAccountId();
|
const accountId = this.getFilesAccountId();
|
||||||
|
|
||||||
|
|||||||
@@ -792,6 +792,32 @@ export interface FileNode {
|
|||||||
size: number;
|
size: number;
|
||||||
created: string;
|
created: string;
|
||||||
updated: 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<string, FileNodeRights> | 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 {
|
export interface FileNodeFilter {
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "Duplikovat",
|
"duplicate": "Duplikovat",
|
||||||
"duplicate_success": "Úspěšně duplikováno",
|
"duplicate_success": "Úspěšně duplikováno",
|
||||||
"duplicate_error": "Duplikování selhalo",
|
"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_success": "Soubor byl vytvořen",
|
||||||
"create_file_error": "Vytvoření souboru selhalo",
|
"create_file_error": "Vytvoření souboru selhalo",
|
||||||
"favorites": "Oblíbené",
|
"favorites": "Oblíbené",
|
||||||
|
|||||||
@@ -2944,6 +2944,10 @@
|
|||||||
"duplicate": "Duplikér",
|
"duplicate": "Duplikér",
|
||||||
"duplicate_success": "Duplikeret",
|
"duplicate_success": "Duplikeret",
|
||||||
"duplicate_error": "Kunne ikke duplikere",
|
"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_success": "Fil oprettet",
|
||||||
"create_file_error": "Kunne ikke oprette fil",
|
"create_file_error": "Kunne ikke oprette fil",
|
||||||
"favorites": "Favoritter",
|
"favorites": "Favoritter",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "Duplizieren",
|
"duplicate": "Duplizieren",
|
||||||
"duplicate_success": "Erfolgreich dupliziert",
|
"duplicate_success": "Erfolgreich dupliziert",
|
||||||
"duplicate_error": "Duplizieren fehlgeschlagen",
|
"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_success": "Datei erstellt",
|
||||||
"create_file_error": "Datei konnte nicht erstellt werden",
|
"create_file_error": "Datei konnte nicht erstellt werden",
|
||||||
"favorites": "Favoriten",
|
"favorites": "Favoriten",
|
||||||
|
|||||||
@@ -2946,6 +2946,10 @@
|
|||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
"duplicate_success": "Duplicated successfully",
|
"duplicate_success": "Duplicated successfully",
|
||||||
"duplicate_error": "Failed to duplicate",
|
"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_success": "File created",
|
||||||
"create_file_error": "Failed to create file",
|
"create_file_error": "Failed to create file",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "Duplicar",
|
"duplicate": "Duplicar",
|
||||||
"duplicate_success": "Duplicado correctamente",
|
"duplicate_success": "Duplicado correctamente",
|
||||||
"duplicate_error": "Error al duplicar",
|
"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_success": "Archivo creado",
|
||||||
"create_file_error": "Error al crear el archivo",
|
"create_file_error": "Error al crear el archivo",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "Dupliquer",
|
"duplicate": "Dupliquer",
|
||||||
"duplicate_success": "Dupliqué avec succès",
|
"duplicate_success": "Dupliqué avec succès",
|
||||||
"duplicate_error": "Échec de la duplication",
|
"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_success": "Fichier créé",
|
||||||
"create_file_error": "Échec de la création du fichier",
|
"create_file_error": "Échec de la création du fichier",
|
||||||
"favorites": "Favoris",
|
"favorites": "Favoris",
|
||||||
|
|||||||
@@ -2946,6 +2946,10 @@
|
|||||||
"duplicate": "Duplikálás",
|
"duplicate": "Duplikálás",
|
||||||
"duplicate_success": "Sikeresen duplikálva",
|
"duplicate_success": "Sikeresen duplikálva",
|
||||||
"duplicate_error": "Nem sikerült duplikálni",
|
"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_success": "Fájl létrehozva",
|
||||||
"create_file_error": "Nem sikerült létrehozni a fájlt",
|
"create_file_error": "Nem sikerült létrehozni a fájlt",
|
||||||
"favorites": "Kedvencek",
|
"favorites": "Kedvencek",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "Duplica",
|
"duplicate": "Duplica",
|
||||||
"duplicate_success": "Duplicato con successo",
|
"duplicate_success": "Duplicato con successo",
|
||||||
"duplicate_error": "Duplicazione non riuscita",
|
"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_success": "File creato",
|
||||||
"create_file_error": "Creazione del file non riuscita",
|
"create_file_error": "Creazione del file non riuscita",
|
||||||
"favorites": "Preferiti",
|
"favorites": "Preferiti",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "複製",
|
"duplicate": "複製",
|
||||||
"duplicate_success": "正常に複製しました",
|
"duplicate_success": "正常に複製しました",
|
||||||
"duplicate_error": "複製に失敗しました",
|
"duplicate_error": "複製に失敗しました",
|
||||||
|
"share": "Share",
|
||||||
|
"shared": "Shared",
|
||||||
|
"shared_with_me": "Shared with me",
|
||||||
|
"shared_by": "Shared by {name}",
|
||||||
"create_file_success": "ファイルを作成しました",
|
"create_file_success": "ファイルを作成しました",
|
||||||
"create_file_error": "ファイルの作成に失敗しました",
|
"create_file_error": "ファイルの作成に失敗しました",
|
||||||
"favorites": "お気に入り",
|
"favorites": "お気に入り",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "복제",
|
"duplicate": "복제",
|
||||||
"duplicate_success": "성공적으로 복제되었어요",
|
"duplicate_success": "성공적으로 복제되었어요",
|
||||||
"duplicate_error": "복제에 실패했어요",
|
"duplicate_error": "복제에 실패했어요",
|
||||||
|
"share": "Share",
|
||||||
|
"shared": "Shared",
|
||||||
|
"shared_with_me": "Shared with me",
|
||||||
|
"shared_by": "Shared by {name}",
|
||||||
"create_file_success": "파일이 만들어졌어요",
|
"create_file_success": "파일이 만들어졌어요",
|
||||||
"create_file_error": "파일을 만들지 못했어요",
|
"create_file_error": "파일을 만들지 못했어요",
|
||||||
"favorites": "즐겨찾기",
|
"favorites": "즐겨찾기",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "Dublēt",
|
"duplicate": "Dublēt",
|
||||||
"duplicate_success": "Dublēšana pabeigta",
|
"duplicate_success": "Dublēšana pabeigta",
|
||||||
"duplicate_error": "Neizdevās dublēt",
|
"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_success": "Fails izveidots",
|
||||||
"create_file_error": "Neizdevās izveidot failu",
|
"create_file_error": "Neizdevās izveidot failu",
|
||||||
"favorites": "Izlase",
|
"favorites": "Izlase",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "Dupliceren",
|
"duplicate": "Dupliceren",
|
||||||
"duplicate_success": "Succesvol gedupliceerd",
|
"duplicate_success": "Succesvol gedupliceerd",
|
||||||
"duplicate_error": "Dupliceren mislukt",
|
"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_success": "Bestand aangemaakt",
|
||||||
"create_file_error": "Bestand aanmaken mislukt",
|
"create_file_error": "Bestand aanmaken mislukt",
|
||||||
"favorites": "Favorieten",
|
"favorites": "Favorieten",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "Duplikuj",
|
"duplicate": "Duplikuj",
|
||||||
"duplicate_success": "Zduplikowano pomyślnie",
|
"duplicate_success": "Zduplikowano pomyślnie",
|
||||||
"duplicate_error": "Nie udało się zduplikować",
|
"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_success": "Plik utworzono",
|
||||||
"create_file_error": "Nie udało się utworzyć pliku",
|
"create_file_error": "Nie udało się utworzyć pliku",
|
||||||
"favorites": "Ulubione",
|
"favorites": "Ulubione",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "Duplicar",
|
"duplicate": "Duplicar",
|
||||||
"duplicate_success": "Duplicado com sucesso",
|
"duplicate_success": "Duplicado com sucesso",
|
||||||
"duplicate_error": "Falha ao duplicar",
|
"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_success": "Arquivo criado",
|
||||||
"create_file_error": "Falha ao criar arquivo",
|
"create_file_error": "Falha ao criar arquivo",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "Дублировать",
|
"duplicate": "Дублировать",
|
||||||
"duplicate_success": "Дублирование выполнено успешно",
|
"duplicate_success": "Дублирование выполнено успешно",
|
||||||
"duplicate_error": "Не удалось дублировать",
|
"duplicate_error": "Не удалось дублировать",
|
||||||
|
"share": "Share",
|
||||||
|
"shared": "Shared",
|
||||||
|
"shared_with_me": "Shared with me",
|
||||||
|
"shared_by": "Shared by {name}",
|
||||||
"create_file_success": "Файл создан",
|
"create_file_success": "Файл создан",
|
||||||
"create_file_error": "Не удалось создать файл",
|
"create_file_error": "Не удалось создать файл",
|
||||||
"favorites": "Избранное",
|
"favorites": "Избранное",
|
||||||
|
|||||||
@@ -2944,6 +2944,10 @@
|
|||||||
"duplicate": "Çoğalt",
|
"duplicate": "Çoğalt",
|
||||||
"duplicate_success": "Başarıyla çoğaltıldı",
|
"duplicate_success": "Başarıyla çoğaltıldı",
|
||||||
"duplicate_error": "Çoğaltılamadı",
|
"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_success": "Dosya oluşturuldu",
|
||||||
"create_file_error": "Dosya oluşturulamadı",
|
"create_file_error": "Dosya oluşturulamadı",
|
||||||
"favorites": "Favoriler",
|
"favorites": "Favoriler",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "дублікат",
|
"duplicate": "дублікат",
|
||||||
"duplicate_success": "Продубльовано успішно",
|
"duplicate_success": "Продубльовано успішно",
|
||||||
"duplicate_error": "Не вдалося скопіювати",
|
"duplicate_error": "Не вдалося скопіювати",
|
||||||
|
"share": "Share",
|
||||||
|
"shared": "Shared",
|
||||||
|
"shared_with_me": "Shared with me",
|
||||||
|
"shared_by": "Shared by {name}",
|
||||||
"create_file_success": "Файл створено",
|
"create_file_success": "Файл створено",
|
||||||
"create_file_error": "Не вдалося створити файл",
|
"create_file_error": "Не вдалося створити файл",
|
||||||
"favorites": "Вибране",
|
"favorites": "Вибране",
|
||||||
|
|||||||
@@ -2921,6 +2921,10 @@
|
|||||||
"duplicate": "创建副本",
|
"duplicate": "创建副本",
|
||||||
"duplicate_success": "复制成功",
|
"duplicate_success": "复制成功",
|
||||||
"duplicate_error": "复制失败",
|
"duplicate_error": "复制失败",
|
||||||
|
"share": "Share",
|
||||||
|
"shared": "Shared",
|
||||||
|
"shared_with_me": "Shared with me",
|
||||||
|
"shared_by": "Shared by {name}",
|
||||||
"create_file_success": "文件已创建",
|
"create_file_success": "文件已创建",
|
||||||
"create_file_error": "创建文件失败",
|
"create_file_error": "创建文件失败",
|
||||||
"favorites": "收藏夹",
|
"favorites": "收藏夹",
|
||||||
|
|||||||
+65
-3
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
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 {
|
export interface FileResource {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,6 +12,14 @@ export interface FileResource {
|
|||||||
lastModified: string;
|
lastModified: string;
|
||||||
blobId: string | null;
|
blobId: string | null;
|
||||||
parentId: 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<string, FileNodeRights> | null;
|
||||||
|
isShared?: boolean;
|
||||||
|
ownerAccountId?: string;
|
||||||
|
ownerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadProgress {
|
interface UploadProgress {
|
||||||
@@ -57,6 +65,8 @@ interface FileState {
|
|||||||
favorites: string[];
|
favorites: string[];
|
||||||
recentFiles: { name: string; id: string; timestamp: number }[];
|
recentFiles: { name: string; id: string; timestamp: number }[];
|
||||||
lastAction: UndoAction | null;
|
lastAction: UndoAction | null;
|
||||||
|
/** Top-level FileNodes shared with the user by other principals ("Shared with me"). */
|
||||||
|
sharedRoots: FileResource[];
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initClient: (client: IJMAPClient, accountId?: string | null) => void;
|
initClient: (client: IJMAPClient, accountId?: string | null) => void;
|
||||||
@@ -103,6 +113,10 @@ interface FileState {
|
|||||||
toggleFavorite: (path: string) => void;
|
toggleFavorite: (path: string) => void;
|
||||||
addRecentFile: (name: string, id: string) => void;
|
addRecentFile: (name: string, id: string) => void;
|
||||||
undoLastAction: () => Promise<void>;
|
undoLastAction: () => Promise<void>;
|
||||||
|
/** Add, update (rights), or remove (rights=null) a principal's share on a node. */
|
||||||
|
shareResource: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
|
||||||
|
/** Load the top-level nodes shared with the user by other principals. */
|
||||||
|
loadSharedRoots: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIRECTORY_TYPES = new Set(['d', 'application/x-directory', 'text/directory', 'httpd/unix-directory', 'inode/directory']);
|
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);
|
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 {
|
function nodeToResource(node: FileNode): FileResource {
|
||||||
const isDir = isFolder(node);
|
const isDir = isFolder(node);
|
||||||
return {
|
return {
|
||||||
@@ -156,6 +177,11 @@ function nodeToResource(node: FileNode): FileResource {
|
|||||||
lastModified: node.updated || node.created,
|
lastModified: node.updated || node.created,
|
||||||
blobId: node.blobId,
|
blobId: node.blobId,
|
||||||
parentId: node.parentId,
|
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<FileState>((set, get) => ({
|
|||||||
clipboard: null,
|
clipboard: null,
|
||||||
uploadAbortController: null,
|
uploadAbortController: null,
|
||||||
lastAction: null,
|
lastAction: null,
|
||||||
|
sharedRoots: [],
|
||||||
favorites: (() => {
|
favorites: (() => {
|
||||||
try { return JSON.parse(localStorage.getItem('files-favorites') || '[]'); } catch { return []; }
|
try { return JSON.parse(localStorage.getItem('files-favorites') || '[]'); } catch { return []; }
|
||||||
})(),
|
})(),
|
||||||
@@ -480,8 +507,12 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
// Fetch the whole tree once and select the current parent's direct
|
// Fetch the whole tree once and select the current parent's direct
|
||||||
// children locally. Hierarchy is derived from parentId links, exactly as
|
// children locally. Hierarchy is derived from parentId links, exactly as
|
||||||
// the JMAP FileNode spec intends (issue #379).
|
// the JMAP FileNode spec intends (issue #379). When browsing inside a
|
||||||
const allNodes = await client.listAllFileNodes();
|
// 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));
|
const resources = sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
|
||||||
|
|
||||||
// Prune recent files whose backing node no longer exists on the server
|
// Prune recent files whose backing node no longer exists on the server
|
||||||
@@ -982,4 +1013,35 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
set({ lastAction: null });
|
set({ lastAction: null });
|
||||||
await refresh();
|
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: [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user