"use client"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { Info, Loader2, X } from "@/components/icons"; import { OrgLockedNote } from "@/components/settings/org-locked-note"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { Principal, PrincipalListing } from "@/lib/jmap/types"; import type { ShareChange, ShareChangeResult, ShareTarget } from "@/lib/jmap/share-batch"; import type { AccessLevel } from "@/lib/jmap/share-rights"; import { buildGrantChanges, DEFAULT_DELEGATE_SCOPE, failedChanges, scopeIsEmpty, type CollectionScope, type DelegateScope, type LevelChoice, type MailScope, } from "@/lib/delegation/delegation"; import { principalDisplay } from "./principal-display"; import { cn } from "@/lib/utils"; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; type Lookup = | { state: "idle" } | { state: "busy" } | { state: "notFound"; address: string } | { state: "directoryOff" } | { state: "self" } | { state: "failed" }; export interface DelegateDialogProps { client: IJMAPClient; targets: ShareTarget[]; directory: PrincipalListing | null; /** The user's own principal ids, never offered. */ ownIds: string[]; /** The user's own addresses: typing one says "That's you". */ ownEmails: string[]; /** Ids that already have access to something. */ existingIds: Set; /** Set for "Change": the person, and what they have now. */ initial?: { principal: Principal; scope: DelegateScope; level: LevelChoice }; levels: AccessLevel[]; /** The admin limited the rights (sharingRights). */ levelsRestricted: boolean; /** * The server keeps "mark as read" and flags as one right on folders * (Stalwart 1.0), so read delegates can flag too; the read hint says so. */ seenSetsKeywords?: boolean; onApplied: (principal: Principal, results: ShareChangeResult[]) => void; onClose: () => void; } /** * "Give access" and "Change": one person, what they get, and with which * rights, applied to every folder, calendar and address book in one batch. * Full screen on a phone. */ export function DelegateDialog({ client, targets, directory, ownIds, ownEmails, existingIds, initial, levels, levelsRestricted, seenSetsKeywords, onApplied, onClose, }: DelegateDialogProps) { const t = useTranslations("delegates"); const tCommon = useTranslations("common"); const titleId = useId(); const changing = !!initial; const [principal, setPrincipal] = useState(initial?.principal ?? null); const [query, setQuery] = useState(""); const [lookup, setLookup] = useState({ state: "idle" }); const [scope, setScope] = useState(initial?.scope ?? DEFAULT_DELEGATE_SCOPE); const [level, setLevel] = useState(() => { const wanted = initial?.level ?? "read"; return wanted === "keep" || levels.includes(wanted) ? wanted : levels[0]; }); // "Keep as it is" is offered where Change started with it, and stays offered. const keep = { mail: initial?.scope.mail === "keep", calendars: initial?.scope.calendars === "keep", addressBooks: initial?.scope.addressBooks === "keep", level: initial?.level === "keep", }; const [saving, setSaving] = useState(false); const [failures, setFailures] = useState(null); const dialogRef = useFocusTrap({ isActive: true, onEscape: () => { if (!saving) onClose(); }, restoreFocus: true }); // The trap focuses the first control, the close button; picking a person // starts in the search field. Declared after the trap, so it runs after it. const searchRef = useRef(null); useEffect(() => { searchRef.current?.focus(); }, []); const has = (kind: ShareTarget["kind"]) => targets.some((target) => target.kind === kind); const directoryOff = directory?.status === "restricted" && !!directory.directoryQueriesOff; const candidates = useMemo(() => { const own = new Set(ownIds); const q = query.trim().toLowerCase(); return (directory?.principals ?? []) .filter((p) => !own.has(p.id)) .filter((p) => !q || p.name.toLowerCase().includes(q) || p.email?.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q)); }, [directory, ownIds, query]); const typedAddress = query.trim(); const canLookUp = EMAIL_RE.test(typedAddress) && !candidates.some((p) => p.email?.toLowerCase() === typedAddress.toLowerCase()); const lookUp = async () => { if (!canLookUp || lookup.state === "busy") return; if (ownEmails.some((email) => email.toLowerCase() === typedAddress.toLowerCase())) { setLookup({ state: "self" }); return; } if (!client.findPrincipalByEmail) { setLookup({ state: "failed" }); return; } setLookup({ state: "busy" }); const found = await client.findPrincipalByEmail(typedAddress); if (found.principal && ownIds.includes(found.principal.id)) { setLookup({ state: "self" }); } else if (found.principal) { setPrincipal(found.principal); setLookup({ state: "idle" }); } else if (found.status === "forbidden") { setLookup({ state: "directoryOff" }); } else if (found.status === "ok") { setLookup({ state: "notFound", address: typedAddress }); } else { setLookup({ state: "failed" }); } }; const apply = async (changes: ShareChange[]) => { if (!principal || !client.applyShareChanges) return; setSaving(true); try { const results = await client.applyShareChanges(changes); const failed = failedChanges(results); onApplied(principal, results); if (failed.length === 0) onClose(); else setFailures(failed); } finally { setSaving(false); } }; const changes = useMemo( () => (principal ? buildGrantChanges(targets, principal.id, scope, level, { replace: changing }) : []), [targets, principal, scope, level, changing], ); const nothingToDo = changing ? changes.length === 0 : scopeIsEmpty(scope); const submit = () => { if (!principal || nothingToDo) return; void apply(changes); }; const retry = () => { if (failures) void apply(failures.map((f) => f.change)); }; if (typeof document === "undefined") return null; return createPortal(
{ if (e.target === e.currentTarget && !saving) onClose(); }} // Keys typed here are the dialog's: the mail shortcuts behind it must not see them. onKeyDown={(e) => e.stopPropagation()} data-testid="delegate-dialog" >

{changing && principal ? t("dialog_title_change", { name: principalDisplay(principal).name }) : t("dialog_title_add")}

{failures ? (

{t("partial_title")}

    {failures.map((f) => (
  • {f.change.name} ยท {t(`kind.${f.change.kind}`)} {f.error &&
    {f.error}
    }
  • ))}
) : (
{principal ? (
{!changing && ( )}
) : (
{ setQuery(e.target.value); setLookup({ state: "idle" }); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void lookUp(); } }} placeholder={t("search_placeholder")} aria-label={t("person")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden" ref={searchRef} /> {directory === null ? (

{t("loading")}

) : (
    {candidates.length === 0 && !canLookUp && (
  • {t("no_match")}
  • )} {candidates.map((p) => (
  • ))} {canLookUp && (
  • )}
)} {lookup.state === "notFound" &&

{t("not_found", { address: lookup.address })}

} {lookup.state === "failed" &&

{t("lookup_failed")}

} {lookup.state === "self" &&

{t("is_you")}

} {(directoryOff || lookup.state === "directoryOff") && (

{directoryOff ? `${t("known_only")} ` : ""}{t("directory_off_note")}

)} {directory?.status === "error" &&

{t("directory_failed")}

}
)}
{t("what")} {has("mailbox") && ( label={t("mail")} value={scope.mail} onChange={(mail) => setScope({ ...scope, mail })} options={[...keepOption(keep.mail, t("keep")), ["all", t("mail_all")], ["inbox", t("mail_inbox")], ["none", t("none")]]} /> )} {has("calendar") && ( label={t("calendars")} value={scope.calendars} onChange={(calendars) => setScope({ ...scope, calendars })} options={[...keepOption(keep.calendars, t("keep")), ["default", t("calendars_default")], ["all", t("calendars_all")], ["none", t("none")]]} /> )} {has("addressBook") && ( label={t("address_books")} value={scope.addressBooks} onChange={(addressBooks) => setScope({ ...scope, addressBooks })} options={[...keepOption(keep.addressBooks, t("keep")), ["default", t("address_books_default")], ["all", t("address_books_all")], ["none", t("none")]]} /> )} {!changing && scopeIsEmpty(scope) &&

{t("empty_scope")}

} {changing &&

{t("change_note")}

}
{t("rights")} {[...(keep.level ? ["keep" as const] : []), ...levels].map((option) => ( ))} {levelsRestricted && }
{changing && nothingToDo &&

{t("nothing_to_change")}

}

)}
{failures ? ( <> ) : ( <> )}
, document.body, ); } function Field({ label, children }: { label: string; children: ReactNode }) { return (
{label}
{children}
); } function PrincipalText({ principal }: { principal: Principal }) { const t = useTranslations("delegates"); const { name, detail } = principalDisplay(principal); return (
{name} {principal.type === "group" && ( {t("group")} )}
{detail &&
{detail}
}
); } function keepOption(offered: boolean, label: string): [T, string][] { return offered ? [["keep" as T, label]] : []; } function Choice({ label, value, onChange, options }: { label: string; value: T; onChange: (value: T) => void; options: [T, string][]; }) { const name = useId(); return (
{label}
{options.map(([option, text]) => ( ))}
); }