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; fetchShares: (client: IJMAPClient) => Promise; shareFolder: ( client: IJMAPClient, resourceId: string, resourceName: string, resourceKind: SharedResourceKind, principalId: string, role: string, message?: string, accountId?: string, ) => Promise; revokeShare: ( client: IJMAPClient, resourceId: string, resourceKind: SharedResourceKind, principalId: string, accountId?: string, ) => Promise; changeRole: ( client: IJMAPClient, resourceId: string, resourceKind: SharedResourceKind, principalId: string, role: string, accountId?: string, ) => Promise; acceptShare: (client: IJMAPClient, share: SharedFolder) => Promise; declineShare: (client: IJMAPClient, share: SharedFolder) => Promise; } function roleLabel(kind: SharedResourceKind, role: string): string { if (kind === "mailbox") return MAILBOX_ROLE_LABELS[role] ?? role; return role; } const MAILBOX_PRESETS: Record = { 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 = { read: "Viewer", readWrite: "Editor", manager: "Manager", }; const CALENDAR_PRESETS: Record = { 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 = { 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 = { 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((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(); 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, }); } } if (mb.isShared && mb.myRights) { withMe.push({ id: `mb-withme-${mb.id}`, resourceId: mb.id, resourceName: mb.name, resourceKind: "mailbox", principalId: mb.accountId || "unknown", principalName: mb.accountName || "Unknown", principalEmail: null, role: roleLabel("mailbox", detectMailboxPreset(mb.myRights)), direction: "withMe", 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, }); } } if (cal.isShared && cal.myRights) { withMe.push({ id: `cal-withme-${cal.id}`, resourceId: cal.id, resourceName: cal.name, resourceKind: "calendar", principalId: cal.accountId || "unknown", principalName: cal.accountName || "Unknown", principalEmail: null, role: roleLabel("calendar", detectCalendarPreset(cal.myRights)), direction: "withMe", 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, }); } } if (book.isShared && book.myRights) { withMe.push({ id: `ab-withme-${book.id}`, resourceId: book.id, resourceName: book.name, resourceKind: "addressBook", principalId: book.accountId || "unknown", principalName: book.accountName || "Unknown", principalEmail: null, role: roleLabel("addressBook", detectAddressBookPreset(book.myRights)), direction: "withMe", 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 { 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"; }