import { useEffect, useState } from "react"; import { useLocation } from "wouter"; import { Building2, ChevronLeft, ChevronRight, Plus, Search } from "lucide-react"; import { can, type RoleDef } from "@/lib/adminAccess"; import { describeDirectoryError, listRoles } from "@/lib/adminDirectory"; import { drawableLogo, getTenants, queryTenants, type DirectoryTenant } from "@/lib/adminTenants"; import { formatSize } from "@/lib/format"; import { proxiedImageUrl } from "@/lib/html"; import { plural, t } from "@/lib/i18n"; import { useSession } from "@/store/session"; import { Empty, Spinner } from "@/ui/misc"; import { usePermissions } from "./usePermissions"; import { TenantSheet } from "./TenantSheet"; const PAGE_SIZE = 50; /** * Tenants: separate organisations on one server, each with its own people, * domains and limits. * * The section is offered to whoever may read tenants, but on a server that does * not report Enterprise the page is only the notice: tenants there hold nobody * to anything beyond an ordinary user's permissions, so there is nothing worth * creating or listing. A server that reports no edition at all counts as not * Enterprise. */ export function TenantsAdmin({ selectedId }: { selectedId?: string }) { const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null); if (edition !== "enterprise") { return (

{t("Tenants")}

{t("Separate organisations on one server, each with its own people, domains and limits.")}

{t("Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.")}

); } return ; } function EnterpriseTenants({ selectedId }: { selectedId?: string }) { const [, navigate] = useLocation(); const perms = usePermissions(); const [text, setText] = useState(""); const [query, setQuery] = useState(""); const [position, setPosition] = useState(0); const [page, setPage] = useState<{ tenants: DirectoryTenant[]; total: number } | null>(null); const [error, setError] = useState(null); const [reload, setReload] = useState(0); const [roles, setRoles] = useState | null>(null); const [loose, setLoose] = useState(null); useEffect(() => { const id = window.setTimeout(() => { setQuery(text); setPosition(0); }, 250); return () => window.clearTimeout(id); }, [text]); useEffect(() => { let cancelled = false; setError(null); void (async () => { try { const q = await queryTenants({ text: query, position, limit: PAGE_SIZE }); const tenants = await getTenants(q.ids); if (!cancelled) setPage({ tenants, total: q.total }); } catch (err) { if (!cancelled) { setPage({ tenants: [], total: 0 }); setError(describeDirectoryError(err, "tenant")); } } })(); return () => { cancelled = true; }; }, [query, position, reload]); useEffect(() => { if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) void listRoles().then((list) => setRoles(new Map(list.map((r) => [r.id, r]))), () => setRoles(null)); }, [perms]); useEffect(() => { if (!selectedId || selectedId === "new" || page?.tenants.some((x) => x.id === selectedId)) { setLoose(null); return; } let cancelled = false; void getTenants([selectedId]).then( ([x]) => { if (!cancelled) setLoose(x ?? null); }, () => { if (!cancelled) setLoose(null); }, ); return () => { cancelled = true; }; }, [selectedId, page]); const selected = selectedId && selectedId !== "new" ? (page?.tenants.find((x) => x.id === selectedId) ?? loose) : null; const close = () => navigate("/admin/tenants"); const changed = () => setReload((n) => n + 1); return (

{t("Tenants")}

{t("Separate organisations on one server, each with its own people, domains and limits.")}

{can(perms, "Tenant", "Create") && ( )}
{error &&

{error}

} {page === null ? ( ) : page.tenants.length === 0 ? ( !error && } title={query ? t("No tenants match") : t("No tenants yet")} /> ) : ( <>
{page.tenants.map((x) => { const logo = drawableLogo(x.logo); const src = logo?.startsWith("data:") ? logo : logo ? proxiedImageUrl(logo) : null; return ( navigate(`/admin/tenants/${x.id}`)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); navigate(`/admin/tenants/${x.id}`); } }} aria-label={t("Open {name}", { name: x.name })} > ); })}
{t("Tenant")} {t("Storage")} {t("Account limit")}
{src ? :
{x.quotas?.maxDiskQuota ? t("{used} of {total}", { used: formatSize(x.usedDiskQuota ?? 0), total: formatSize(x.quotas.maxDiskQuota) }) : t("{used} · no limit", { used: formatSize(x.usedDiskQuota ?? 0) })} {x.quotas?.maxAccounts ?? "—"}
{page.total <= PAGE_SIZE && position === 0 ? (

{plural(page.total, { one: "{n} tenant", other: "{n} tenants" })}

) : (
{t("{from}–{to} of {total}", { from: position + 1, to: position + page.tenants.length, total: page.total })}
)} )} {(selectedId === "new" || selected) && ( { changed(); navigate(`/admin/tenants/${id}`); }} onDeleted={() => { changed(); close(); }} /> )}
); }