"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types"; import { toast } from "@/stores/toast-store"; type ShareKind = "calendar" | "addressBook"; type AnyRights = CalendarRights | AddressBookRights; type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom"; const CALENDAR_PRESETS: Record, CalendarRights> = { freeBusy: { mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false, }, 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, 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 }, }; function detectCalendarPreset(r: CalendarRights): RolePreset { for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude, CalendarRights][]) { if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) { return name; } } return "custom"; } function detectAddressBookPreset(r: AddressBookRights): RolePreset { for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS) as [Exclude, AddressBookRights][]) { const keys = Object.keys(preset) as (keyof AddressBookRights)[]; if (keys.every((k) => preset[k] === (r[k] ?? false))) { return name; } } return "custom"; } interface ShareCollectionDialogProps { client: IJMAPClient; kind: ShareKind; collectionName: string; shareWith: Record | null | undefined; ownAccountId: string; onShare: (principalId: string, rights: AnyRights | null) => Promise; onClose: () => void; } export function ShareCollectionDialog({ client, kind, collectionName, shareWith, ownAccountId, onShare, onClose, }: ShareCollectionDialogProps) { const t = useTranslations("sharing"); const tCommon = useTranslations("common"); const modalRef = useRef(null); const [allPrincipals, setAllPrincipals] = useState([]); const [loadingPrincipals, setLoadingPrincipals] = useState(true); const [search, setSearch] = useState(""); const [savingId, setSavingId] = useState(null); const [showAdd, setShowAdd] = useState(false); // Load principals on mount useEffect(() => { let cancelled = false; setLoadingPrincipals(true); client.getPrincipals().then((list) => { if (cancelled) return; setAllPrincipals(list); setLoadingPrincipals(false); }).catch(() => { if (!cancelled) setLoadingPrincipals(false); }); return () => { cancelled = true; }; }, [client]); // Map of every fetched principal by id, used for name/description lookups in // the shared list. Must include principals that already have a share so the // list shows their name rather than the raw id. const allPrincipalsById = useMemo(() => { const map = new Map(); for (const p of allPrincipals) map.set(p.id, p); return map; }, [allPrincipals]); // Principals available to add: exclude self and anyone already shared with. const principals = useMemo(() => { const existing = new Set(Object.keys(shareWith || {})); return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id)); }, [allPrincipals, ownAccountId, shareWith]); // Close on Escape, focus trap, click outside useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [onClose]); const handleSetRights = async (principalId: string, preset: RolePreset) => { if (preset === "custom") return; // custom is read-only here const rights = kind === "calendar" ? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS] : ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS]; if (!rights) return; setSavingId(principalId); try { await onShare(principalId, rights); toast.success(t("share_updated")); } catch (err) { toast.error(err instanceof Error ? err.message : t("share_failed")); } finally { setSavingId(null); } }; const handleRemove = async (principalId: string) => { setSavingId(principalId); try { await onShare(principalId, null); toast.success(t("share_removed")); } catch (err) { toast.error(err instanceof Error ? err.message : t("share_failed")); } finally { setSavingId(null); } }; const handleAdd = async (principal: Principal) => { const defaultPreset: RolePreset = "read"; const rights = kind === "calendar" ? CALENDAR_PRESETS[defaultPreset] : ADDRESS_BOOK_PRESETS[defaultPreset]; setSavingId(principal.id); try { await onShare(principal.id, rights); setShowAdd(false); setSearch(""); toast.success(t("share_added")); } catch (err) { toast.error(err instanceof Error ? err.message : t("share_failed")); } finally { setSavingId(null); } }; const filteredPrincipals = useMemo(() => { const q = search.trim().toLowerCase(); if (!q) return principals; return principals.filter((p) => p.name.toLowerCase().includes(q) || p.email?.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q) ); }, [principals, search]); const sharedEntries = useMemo(() => { return Object.entries(shareWith || {}) as [string, AnyRights][]; }, [shareWith]); const presetOptions = kind === "calendar" ? ["freeBusy", "read", "readWrite", "manager"] as const : ["read", "readWrite", "manager"] as const; return (
); }