- P2.3: Folder sharing system — ShareFolderDialog, sharing-store, sharing-settings - P2.5: Email import (.eml, .tgz, .zip) with dedup and progress - P2.6: Contact import (vCard + CSV) with auto-mapping - P2.7: Free/Busy view grid with color-coded slots
524 lines
13 KiB
TypeScript
524 lines
13 KiB
TypeScript
import { create } from "zustand";
|
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
|
import type {
|
|
Principal,
|
|
CalendarRights,
|
|
AddressBookRights,
|
|
FileNodeRights,
|
|
MailboxRights,
|
|
} from "@/lib/jmap/types";
|
|
import { toast } from "@/stores/toast-store";
|
|
|
|
export type SharedResourceKind =
|
|
| "mailbox"
|
|
| "calendar"
|
|
| "addressBook"
|
|
| "file";
|
|
|
|
export interface SharedFolder {
|
|
id: string;
|
|
resourceId: string;
|
|
resourceName: string;
|
|
resourceKind: SharedResourceKind;
|
|
principalId: string;
|
|
principalName: string;
|
|
principalEmail: string | null;
|
|
role: string;
|
|
direction: "byMe" | "withMe";
|
|
pending: boolean;
|
|
accountId?: string;
|
|
}
|
|
|
|
interface SharingState {
|
|
sharedByMe: SharedFolder[];
|
|
sharedWithMe: SharedFolder[];
|
|
loading: boolean;
|
|
principalsCache: Principal[];
|
|
|
|
loadPrincipals: (client: IJMAPClient) => Promise<Principal[]>;
|
|
fetchShares: (client: IJMAPClient) => Promise<void>;
|
|
shareFolder: (
|
|
client: IJMAPClient,
|
|
resourceId: string,
|
|
resourceName: string,
|
|
resourceKind: SharedResourceKind,
|
|
principalId: string,
|
|
role: string,
|
|
message?: string,
|
|
accountId?: string,
|
|
) => Promise<void>;
|
|
revokeShare: (
|
|
client: IJMAPClient,
|
|
resourceId: string,
|
|
resourceKind: SharedResourceKind,
|
|
principalId: string,
|
|
accountId?: string,
|
|
) => Promise<void>;
|
|
changeRole: (
|
|
client: IJMAPClient,
|
|
resourceId: string,
|
|
resourceKind: SharedResourceKind,
|
|
principalId: string,
|
|
role: string,
|
|
accountId?: string,
|
|
) => Promise<void>;
|
|
acceptShare: (client: IJMAPClient, share: SharedFolder) => Promise<void>;
|
|
declineShare: (client: IJMAPClient, share: SharedFolder) => Promise<void>;
|
|
}
|
|
|
|
function roleLabel(kind: SharedResourceKind, role: string): string {
|
|
if (kind === "mailbox") return MAILBOX_ROLE_LABELS[role] ?? role;
|
|
return role;
|
|
}
|
|
|
|
const MAILBOX_PRESETS: Record<string, MailboxRights> = {
|
|
read: {
|
|
mayReadItems: true,
|
|
mayAddItems: false,
|
|
mayRemoveItems: false,
|
|
maySetSeen: false,
|
|
maySetKeywords: false,
|
|
mayCreateChild: false,
|
|
mayRename: false,
|
|
mayDelete: false,
|
|
maySubmit: false,
|
|
},
|
|
readWrite: {
|
|
mayReadItems: true,
|
|
mayAddItems: true,
|
|
mayRemoveItems: false,
|
|
maySetSeen: true,
|
|
maySetKeywords: true,
|
|
mayCreateChild: false,
|
|
mayRename: false,
|
|
mayDelete: false,
|
|
maySubmit: true,
|
|
},
|
|
manager: {
|
|
mayReadItems: true,
|
|
mayAddItems: true,
|
|
mayRemoveItems: true,
|
|
maySetSeen: true,
|
|
maySetKeywords: true,
|
|
mayCreateChild: true,
|
|
mayRename: true,
|
|
mayDelete: true,
|
|
maySubmit: true,
|
|
mayShare: true,
|
|
},
|
|
};
|
|
|
|
const MAILBOX_ROLE_LABELS: Record<string, string> = {
|
|
read: "Viewer",
|
|
readWrite: "Editor",
|
|
manager: "Manager",
|
|
};
|
|
|
|
const CALENDAR_PRESETS: Record<string, CalendarRights> = {
|
|
read: {
|
|
mayReadFreeBusy: true,
|
|
mayReadItems: true,
|
|
mayWriteAll: false,
|
|
mayWriteOwn: false,
|
|
mayUpdatePrivate: false,
|
|
mayRSVP: false,
|
|
mayShare: false,
|
|
mayDelete: false,
|
|
},
|
|
readWrite: {
|
|
mayReadFreeBusy: true,
|
|
mayReadItems: true,
|
|
mayWriteAll: true,
|
|
mayWriteOwn: true,
|
|
mayUpdatePrivate: true,
|
|
mayRSVP: true,
|
|
mayShare: false,
|
|
mayDelete: false,
|
|
},
|
|
manager: {
|
|
mayReadFreeBusy: true,
|
|
mayReadItems: true,
|
|
mayWriteAll: true,
|
|
mayWriteOwn: true,
|
|
mayUpdatePrivate: true,
|
|
mayRSVP: true,
|
|
mayShare: true,
|
|
mayDelete: true,
|
|
},
|
|
};
|
|
|
|
const ADDRESS_BOOK_PRESETS: Record<string, AddressBookRights> = {
|
|
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
|
|
readWrite: {
|
|
mayRead: true,
|
|
mayWrite: true,
|
|
mayShare: false,
|
|
mayDelete: false,
|
|
},
|
|
manager: {
|
|
mayRead: true,
|
|
mayWrite: true,
|
|
mayShare: true,
|
|
mayDelete: true,
|
|
},
|
|
};
|
|
|
|
const FILE_PRESETS: Record<string, 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 resolveRights(
|
|
kind: SharedResourceKind,
|
|
role: string,
|
|
): MailboxRights | CalendarRights | AddressBookRights | FileNodeRights {
|
|
switch (kind) {
|
|
case "mailbox":
|
|
return (
|
|
MAILBOX_PRESETS[role] ?? MAILBOX_PRESETS.read
|
|
);
|
|
case "calendar":
|
|
return (
|
|
CALENDAR_PRESETS[role] ?? CALENDAR_PRESETS.read
|
|
);
|
|
case "addressBook":
|
|
return (
|
|
ADDRESS_BOOK_PRESETS[role] ?? ADDRESS_BOOK_PRESETS.read
|
|
);
|
|
case "file":
|
|
return FILE_PRESETS[role] ?? FILE_PRESETS.read;
|
|
}
|
|
}
|
|
|
|
export const useSharingStore = create<SharingState>((set, get) => ({
|
|
sharedByMe: [],
|
|
sharedWithMe: [],
|
|
loading: false,
|
|
principalsCache: [],
|
|
|
|
async loadPrincipals(client) {
|
|
const cached = get().principalsCache;
|
|
if (cached.length > 0) return cached;
|
|
const principals = await client.getPrincipals();
|
|
set({ principalsCache: principals });
|
|
return principals;
|
|
},
|
|
|
|
async fetchShares(client) {
|
|
set({ loading: true });
|
|
try {
|
|
const principals = await client.getPrincipals();
|
|
const principalMap = new Map<string, Principal>();
|
|
for (const p of principals) principalMap.set(p.id, p);
|
|
|
|
const byMe: SharedFolder[] = [];
|
|
const withMe: SharedFolder[] = [];
|
|
|
|
try {
|
|
const mailboxes = await client.getAllMailboxes();
|
|
for (const mb of mailboxes) {
|
|
const shares = mb.shareWith;
|
|
if (shares && Object.keys(shares).length > 0) {
|
|
for (const [principalId, rights] of Object.entries(shares)) {
|
|
const p = principalMap.get(principalId);
|
|
byMe.push({
|
|
id: `mb-${mb.id}-${principalId}`,
|
|
resourceId: mb.id,
|
|
resourceName: mb.name,
|
|
resourceKind: "mailbox",
|
|
principalId,
|
|
principalName: p?.name ?? principalId,
|
|
principalEmail: p?.email ?? null,
|
|
role: roleLabel("mailbox", detectMailboxPreset(rights)),
|
|
direction: "byMe",
|
|
pending: false,
|
|
accountId: mb.accountId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* mailboxes may not be available */
|
|
}
|
|
|
|
try {
|
|
if (client.supportsCalendars()) {
|
|
const calendars = await client.getAllCalendars();
|
|
for (const cal of calendars) {
|
|
const shares = cal.shareWith;
|
|
if (shares && Object.keys(shares).length > 0) {
|
|
for (const [principalId, rights] of Object.entries(shares)) {
|
|
const p = principalMap.get(principalId);
|
|
byMe.push({
|
|
id: `cal-${cal.id}-${principalId}`,
|
|
resourceId: cal.id,
|
|
resourceName: cal.name,
|
|
resourceKind: "calendar",
|
|
principalId,
|
|
principalName: p?.name ?? principalId,
|
|
principalEmail: p?.email ?? null,
|
|
role: roleLabel("calendar", detectCalendarPreset(rights)),
|
|
direction: "byMe",
|
|
pending: false,
|
|
accountId: cal.accountId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* calendars may not be available */
|
|
}
|
|
|
|
try {
|
|
if (client.supportsContacts()) {
|
|
const books = await client.getAllAddressBooks();
|
|
for (const book of books) {
|
|
const shares = book.shareWith;
|
|
if (shares && Object.keys(shares).length > 0) {
|
|
for (const [principalId, rights] of Object.entries(shares)) {
|
|
const p = principalMap.get(principalId);
|
|
byMe.push({
|
|
id: `ab-${book.id}-${principalId}`,
|
|
resourceId: book.id,
|
|
resourceName: book.name,
|
|
resourceKind: "addressBook",
|
|
principalId,
|
|
principalName: p?.name ?? principalId,
|
|
principalEmail: p?.email ?? null,
|
|
role: roleLabel(
|
|
"addressBook",
|
|
detectAddressBookPreset(rights),
|
|
),
|
|
direction: "byMe",
|
|
pending: false,
|
|
accountId: book.accountId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* address books may not be available */
|
|
}
|
|
|
|
set({ sharedByMe: byMe, sharedWithMe: withMe, loading: false, principalsCache: principals });
|
|
} catch {
|
|
set({ loading: false });
|
|
}
|
|
},
|
|
|
|
async shareFolder(
|
|
client,
|
|
resourceId,
|
|
resourceName,
|
|
resourceKind,
|
|
principalId,
|
|
role,
|
|
_message,
|
|
accountId,
|
|
) {
|
|
const rights = resolveRights(resourceKind, role);
|
|
await applyShare(client, resourceKind, resourceId, principalId, rights, accountId);
|
|
|
|
const princ = get().principalsCache.find((p) => p.id === principalId);
|
|
const entry: SharedFolder = {
|
|
id: `${resourceKind}-${resourceId}-${principalId}`,
|
|
resourceId,
|
|
resourceName,
|
|
resourceKind,
|
|
principalId,
|
|
principalName: princ?.name ?? principalId,
|
|
principalEmail: princ?.email ?? null,
|
|
role,
|
|
direction: "byMe",
|
|
pending: false,
|
|
accountId,
|
|
};
|
|
|
|
set((s) => ({
|
|
sharedByMe: [
|
|
...s.sharedByMe.filter(
|
|
(f) =>
|
|
!(
|
|
f.resourceId === resourceId &&
|
|
f.principalId === principalId &&
|
|
f.resourceKind === resourceKind
|
|
),
|
|
),
|
|
entry,
|
|
],
|
|
}));
|
|
toast.success(`Shared "${resourceName}"`);
|
|
},
|
|
|
|
async revokeShare(client, resourceId, resourceKind, principalId, accountId) {
|
|
await applyShare(client, resourceKind, resourceId, principalId, null, accountId);
|
|
|
|
set((s) => ({
|
|
sharedByMe: s.sharedByMe.filter(
|
|
(f) =>
|
|
!(
|
|
f.resourceId === resourceId &&
|
|
f.principalId === principalId &&
|
|
f.resourceKind === resourceKind
|
|
),
|
|
),
|
|
sharedWithMe: s.sharedWithMe.filter(
|
|
(f) =>
|
|
!(
|
|
f.resourceId === resourceId &&
|
|
f.principalId === principalId &&
|
|
f.resourceKind === resourceKind
|
|
),
|
|
),
|
|
}));
|
|
toast.success("Access revoked");
|
|
},
|
|
|
|
async changeRole(
|
|
client,
|
|
resourceId,
|
|
resourceKind,
|
|
principalId,
|
|
role,
|
|
accountId,
|
|
) {
|
|
const rights = resolveRights(resourceKind, role);
|
|
await applyShare(client, resourceKind, resourceId, principalId, rights, accountId);
|
|
|
|
set((s) => ({
|
|
sharedByMe: s.sharedByMe.map((f) =>
|
|
f.resourceId === resourceId &&
|
|
f.principalId === principalId &&
|
|
f.resourceKind === resourceKind
|
|
? { ...f, role }
|
|
: f,
|
|
),
|
|
}));
|
|
toast.success("Role updated");
|
|
},
|
|
|
|
async acceptShare(_client, share) {
|
|
set((s) => ({
|
|
sharedWithMe: s.sharedWithMe.map((f) =>
|
|
f.id === share.id ? { ...f, pending: false } : f,
|
|
),
|
|
}));
|
|
toast.success(`Accepted share: ${share.resourceName}`);
|
|
},
|
|
|
|
async declineShare(_client, share) {
|
|
set((s) => ({
|
|
sharedWithMe: s.sharedWithMe.filter((f) => f.id !== share.id),
|
|
}));
|
|
toast.success(`Declined share: ${share.resourceName}`);
|
|
},
|
|
}));
|
|
|
|
async function applyShare(
|
|
client: IJMAPClient,
|
|
kind: SharedResourceKind,
|
|
resourceId: string,
|
|
principalId: string,
|
|
rights:
|
|
| MailboxRights
|
|
| CalendarRights
|
|
| AddressBookRights
|
|
| FileNodeRights
|
|
| null,
|
|
accountId?: string,
|
|
): Promise<void> {
|
|
switch (kind) {
|
|
case "mailbox":
|
|
await client.setMailboxShare(
|
|
resourceId,
|
|
principalId,
|
|
rights as MailboxRights | null,
|
|
accountId,
|
|
);
|
|
break;
|
|
case "calendar":
|
|
await client.setCalendarShare(
|
|
resourceId,
|
|
principalId,
|
|
rights as CalendarRights | null,
|
|
accountId,
|
|
);
|
|
break;
|
|
case "addressBook":
|
|
await client.setAddressBookShare(
|
|
resourceId,
|
|
principalId,
|
|
rights as AddressBookRights | null,
|
|
accountId,
|
|
);
|
|
break;
|
|
case "file":
|
|
await client.setFileNodeShare(
|
|
resourceId,
|
|
principalId,
|
|
rights as FileNodeRights | null,
|
|
accountId,
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function detectMailboxPreset(r: MailboxRights): string {
|
|
for (const [name, preset] of Object.entries(MAILBOX_PRESETS)) {
|
|
const keys = Object.keys(preset) as (keyof MailboxRights)[];
|
|
if (
|
|
keys.every(
|
|
(k) =>
|
|
(preset[k] ?? false) === (r[k as keyof MailboxRights] ?? false),
|
|
)
|
|
) {
|
|
return name;
|
|
}
|
|
}
|
|
return "custom";
|
|
}
|
|
|
|
function detectCalendarPreset(r: CalendarRights): string {
|
|
for (const [name, preset] of Object.entries(CALENDAR_PRESETS)) {
|
|
const keys = Object.keys(preset) as (keyof CalendarRights)[];
|
|
if (keys.every((k) => (preset[k] ?? false) === (r[k as keyof CalendarRights] ?? false))) {
|
|
return name;
|
|
}
|
|
}
|
|
return "custom";
|
|
}
|
|
|
|
function detectAddressBookPreset(r: AddressBookRights): string {
|
|
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS)) {
|
|
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
|
|
if (keys.every((k) => (preset[k] ?? false) === (r[k as keyof AddressBookRights] ?? false))) {
|
|
return name;
|
|
}
|
|
}
|
|
return "custom";
|
|
}
|