feat: P2.3 Folder Sharing + P2.5 Email Import + P2.6 Contact Import + P2.7 Free/Busy
- 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
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user