import { useEffect, useMemo, useState } from "react"; import { Globe, Plus, Trash2, X } from "lucide-react"; import { can, canGrantRole, type RoleDef } from "@/lib/admin/adminAccess"; import { describeDirectoryError } from "@/lib/admin/adminDirectory"; import { describeLinked, DomainError } from "@/lib/admin/adminDomains"; import { countTenantMembers, createTenant, destroyTenant, drawableLogo, quotasPatch, setDomainTenant, tenantAccountsOnDomain, tenantDomains, updateTenant, TENANT_MEMBERS, TENANT_QUOTAS, type DirectoryTenant, type TenantMemberKind, type TenantQuota, type TenantRoles, } from "@/lib/admin/adminTenants"; import { formatSize } from "@/lib/format"; import { proxiedImageUrl } from "@/lib/text/html"; import { plural, t } from "@/lib/i18n"; import { Dialog } from "@/ui/dialog"; import { Spinner } from "@/ui/misc"; import { toast } from "@/ui/toast"; import { usePermissions } from "./usePermissions"; import { TenantLegacyProtocols } from "./TenantLegacyProtocols"; const GIB = 1024 ** 3; interface Props { /** Null to create one. */ tenant: DirectoryTenant | null; roles: ReadonlyMap | null; onClose: () => void; onChanged: () => void; onCreated: (id: string) => void; onDeleted: () => void; } /** The label for each quota, and for each kind of thing a tenant holds. */ function quotaLabel(q: TenantQuota): string { switch (q) { case "maxAccounts": return t("Accounts"); case "maxGroups": return t("Groups"); case "maxMailingLists": return t("Mailing lists"); case "maxDomains": return t("Domains"); case "maxRoles": return t("Roles"); case "maxDkimKeys": return t("DKIM keys"); case "maxDiskQuota": return t("Storage in GB"); } } const roleKey = (roles: TenantRoles | undefined) => (!roles || roles["@type"] === "Default" ? "Default" : `custom:${Object.keys(roles.roleIds ?? {}).sort().join(",")}`); const rolesFromKey = (key: string): TenantRoles => key.startsWith("custom:") ? { "@type": "Custom", roleIds: Object.fromEntries(key.slice(7).split(",").filter(Boolean).map((id) => [id, true])) } : { "@type": "Default" }; /** A quota as the field shows it: GB for disk space, a whole number for the rest, empty for no limit. */ const fieldOf = (q: TenantQuota, v: number | undefined) => (v == null ? "" : q === "maxDiskQuota" ? String(Math.round((v / GIB) * 10) / 10) : String(v)); const valueOf = (q: TenantQuota, s: string): number | null => { const n = Number(s.replace(",", ".")); if (!s.trim() || !Number.isFinite(n) || n < 0) return null; return q === "maxDiskQuota" ? Math.round(n * GIB) : Math.floor(n); }; /** * One tenant, opened beside the list. * * Name, logo, role and quotas save together. What is in the tenant is shown * rather than stored on it: counts of each kind, read with a `memberTenantId` * filter, and its domains, which are added and taken out on the spot because * each is a change to the domain. */ export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDeleted }: Props) { const perms = usePermissions(); const creating = tenant === null; const editable = creating ? can(perms, "Tenant", "Create") : can(perms, "Tenant", "Update"); const [name, setName] = useState(tenant?.name ?? ""); const [logo, setLogo] = useState(tenant?.logo ?? ""); const [role, setRole] = useState(roleKey(tenant?.roles)); const [quotas, setQuotas] = useState>(() => Object.fromEntries(TENANT_QUOTAS.map((q) => [q, fieldOf(q, tenant?.quotas?.[q])])) as Record); const [counts, setCounts] = useState> | null>(null); const [revision, setRevision] = useState(0); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); useEffect(() => { if (!tenant) return; let canceled = false; void countTenantMembers(tenant.id).then((c) => !canceled && setCounts(c)); return () => { canceled = true; }; }, [tenant, revision]); const roleOptions = useMemo(() => { const options = [{ value: "Default", label: t("Default tenant roles") }]; for (const r of roles?.values() ?? []) { if (canGrantRole(perms, r.id, roles)) options.push({ value: `custom:${r.id}`, label: r.description || r.id }); } if (!options.some((o) => o.value === role)) options.push({ value: role, label: t("Custom role") }); return options; }, [perms, roles, role]); const save = async () => { setBusy(true); setError(null); try { const values = Object.fromEntries(TENANT_QUOTAS.map((q) => [q, valueOf(q, quotas[q])])) as Record; if (!tenant) { if (!name.trim()) { setError(t("A tenant needs a name.")); return; } const set = Object.fromEntries(Object.entries(values).filter(([, v]) => v != null)) as Record; const id = await createTenant({ name, logo: logo.trim() || null, roles: rolesFromKey(role), quotas: set }); toast.success(t("Created {name}", { name: name.trim() })); onCreated(id); return; } const patch: Record = { ...quotasPatch(tenant.quotas, values) }; if (tenant.name !== name.trim()) patch.name = name.trim(); if ((tenant.logo ?? "") !== logo.trim()) patch.logo = logo.trim() || null; if (roleKey(tenant.roles) !== role) patch.roles = rolesFromKey(role); if (!Object.keys(patch).length) { onClose(); return; } await updateTenant(tenant.id, patch); toast.success(t("Saved {name}", { name: name.trim() })); onChanged(); } catch (err) { setError(describeDirectoryError(err, "tenant")); } finally { setBusy(false); } }; const drawable = drawableLogo(logo.trim()); const logoSrc = drawable?.startsWith("data:") ? drawable : drawable ? proxiedImageUrl(drawable) : null; const held = counts ? Object.values(counts).reduce((a, b) => a + (b ?? 0), 0) : null; const countsComplete = counts !== null && TENANT_MEMBERS.every((m) => typeof counts[m.key] === "number"); return ( ); } function TenantDomains({ tenant, canChange, onChanged }: { tenant: DirectoryTenant; canChange: boolean; onChanged: () => void }) { const [state, setState] = useState<{ inTenant: Array<{ id: string; name: string }>; unassigned: Array<{ id: string; name: string }> } | null>(null); const [pick, setPick] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [revision, setRevision] = useState(0); useEffect(() => { let canceled = false; tenantDomains(tenant.id).then( (s) => { if (canceled) return; setState(s); setPick(s.unassigned[0]?.id ?? ""); }, (err) => { if (canceled) return; setState({ inTenant: [], unassigned: [] }); setError(describeDirectoryError(err, "domain")); }, ); return () => { canceled = true; }; }, [tenant, revision]); const move = async (domain: { id: string; name: string }, into: boolean) => { setBusy(true); setError(null); try { if (!into) { const stranded = await tenantAccountsOnDomain(tenant.id, domain.id); if (stranded > 0) { setError(plural(stranded, { one: "{n} account in this tenant is still on {domain}. Move it or delete it before taking the domain out.", other: "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.", }, { domain: domain.name })); return; } } await setDomainTenant(domain.id, into ? tenant.id : null); toast.success(into ? t("Added {domain} to {tenant}", { domain: domain.name, tenant: tenant.name }) : t("Took {domain} out of {tenant}", { domain: domain.name, tenant: tenant.name })); setRevision((n) => n + 1); onChanged(); } catch (err) { setError(describeDirectoryError(err, "domain")); } finally { setBusy(false); } }; if (!state) return ; return (
{state.inTenant.length ? (
    {state.inTenant.map((d) => (
  • ))}
) : (

{t("No domains in this tenant yet")}

)} {canChange && state.unassigned.length > 0 && (
)} {error &&

{error}

}

{t("Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.")}

); } function DeleteTenant({ tenant, blocked, onDeleted }: { tenant: DirectoryTenant; blocked: string | null; onDeleted: () => void }) { const [open, setOpen] = useState(false); const [typed, setTyped] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); return ( <>

{t("Delete")}

{blocked ?? t("An empty tenant can be deleted.")}

setOpen(false)} title={t("Delete {name}?", { name: tenant.name })} size="sm" footer={ <> } >

{t("It can't be undone.")}

setTyped(e.target.value)} />
{error &&

{error}

}
); }