Files
ihasmail/web/src/views/admin/TenantSheet.tsx
T
jcoffey-dev bd6a605d61 Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.

  lib/mailbox/  archiveDate, emptyFolder, folderMove, labelTree,
                mailboxName, mailboxRoute
  lib/sieve/    sieve, sieveApply, sieveFolders
  lib/input/    keyboard, swipe, touch, listSelection, dropUpload
  lib/notify/   notify, webpush, webpushEnable
  lib/sw/       swCache, swFacts, staleBuild
  lib/text/     html, markdown, text, emlName

FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:

  - appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
    *Files*, where the client keeps signature images and synced settings.
    It stays flat.
  - format holds no formatting of text. It re-exports the date and clock
    formatters, so it belongs with dates/datetime, not with text/.
  - preview is the file viewer deciding what it can show without
    downloading, and source is where to point someone asking for this
    instance's AGPL source. Neither is about text.
  - notify is not Web Push. It is the tab title, the favicon badge and
    the new-mail sound -- in-app notification, which is why it sits with
    webpush rather than under sw/ with the service worker's own concerns.

threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.

No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
2026-09-15 23:17:50 -07:00

401 lines
17 KiB
TypeScript

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";
const GIB = 1024 ** 3;
interface Props {
/** Null to create one. */
tenant: DirectoryTenant | null;
roles: ReadonlyMap<string, RoleDef> | 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<Record<TenantQuota, string>>(() => Object.fromEntries(TENANT_QUOTAS.map((q) => [q, fieldOf(q, tenant?.quotas?.[q])])) as Record<TenantQuota, string>);
const [counts, setCounts] = useState<Partial<Record<TenantMemberKind, number>> | null>(null);
const [revision, setRevision] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<TenantQuota, number | null>;
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<string, number>;
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<string, unknown> = { ...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 (
<aside className="admin-sheet" aria-label={creating ? t("New tenant") : tenant.name}>
<div className="admin-sheet-head">
{logoSrc ? <img className="admin-tenant-logo" src={logoSrc} alt="" /> : null}
<div className="grow">
<h2 className="truncate">{creating ? t("New tenant") : tenant.name}</h2>
{tenant && <div className="hint">{t("{used} used", { used: formatSize(tenant.usedDiskQuota ?? 0) })}</div>}
</div>
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
<X size={20} />
</button>
</div>
<div className="admin-sheet-body">
{!creating && !editable && <p className="admin-notice">{t("Your role lets you view tenants but not change them.")}</p>}
<h3>{t("Profile")}</h3>
<div className="field">
<label htmlFor="admin-tenant-name">{t("Name")}</label>
<input id="admin-tenant-name" className="input" value={name} disabled={!editable} onChange={(e) => setName(e.target.value)} />
</div>
<div className="field">
<label htmlFor="admin-tenant-logo">{t("Logo")}</label>
<input id="admin-tenant-logo" className="input" value={logo} disabled={!editable} placeholder="https://…" spellCheck={false} onChange={(e) => setLogo(e.target.value)} />
<span className="hint">{t("An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.")}</span>
</div>
{!creating && (
<>
<h3>{t("What it holds")}</h3>
{counts === null ? (
<Spinner />
) : (
<dl className="admin-kv">
{TENANT_MEMBERS.map((m) => {
const limit = tenant.quotas?.[m.quota];
const n = counts[m.key];
return (
<div key={m.key} style={{ display: "contents" }}>
<dt>{quotaLabel(m.quota)}</dt>
<dd>{n == null ? "—" : limit != null ? t("{n} of {limit}", { n, limit }) : n}</dd>
</div>
);
})}
<dt>{t("Storage")}</dt>
<dd>{tenant.quotas?.maxDiskQuota ? t("{used} of {total}", { used: formatSize(tenant.usedDiskQuota ?? 0), total: formatSize(tenant.quotas.maxDiskQuota) }) : formatSize(tenant.usedDiskQuota ?? 0)}</dd>
</dl>
)}
<h3>{t("Domains")}</h3>
<TenantDomains tenant={tenant} canChange={can(perms, "Domain", "Update")} onChanged={() => { setRevision((n) => n + 1); onChanged(); }} />
</>
)}
<h3>{t("Limits")}</h3>
<div className="admin-quota-grid">
{TENANT_QUOTAS.map((q) => (
<div key={q} className="field">
<label htmlFor={`admin-tenant-${q}`}>{quotaLabel(q)}</label>
<input id={`admin-tenant-${q}`} className="input" inputMode="decimal" value={quotas[q]} disabled={!editable} placeholder={t("No limit")} onChange={(e) => setQuotas({ ...quotas, [q]: e.target.value })} />
</div>
))}
</div>
<p className="hint">{t("Stalwart refuses to create more than a limit allows. An empty field is no limit.")}</p>
<h3>{t("Role")}</h3>
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable} onChange={(e) => setRole(e.target.value)}>
{roleOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<p className="hint">{t("The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.")}</p>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{!creating && can(perms, "Tenant", "Destroy") && (
<DeleteTenant
tenant={tenant}
blocked={
!countsComplete
? t("Checking what is still in this tenant…")
: held
? t("It still holds accounts, domains or other things. Move them out first.")
: null
}
onDeleted={onDeleted}
/>
)}
</div>
{editable && (
<div className="admin-sheet-foot">
<button className="btn btn-ghost" onClick={onClose}>{t("Cancel")}</button>
<button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>
{creating ? t("Create tenant") : t("Save changes")}
</button>
</div>
)}
</aside>
);
}
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<string | null>(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 <Spinner />;
return (
<div>
{state.inTenant.length ? (
<ul className="admin-members">
{state.inTenant.map((d) => (
<li key={d.id}>
<Globe size={16} aria-hidden="true" />
<span className="grow truncate notranslate" translate="no">{d.name}</span>
{canChange && (
<button className="icon-btn sm" aria-label={t("Take {domain} out of the tenant", { domain: d.name })} disabled={busy} onClick={() => void move(d, false)}>
<X size={16} />
</button>
)}
</li>
))}
</ul>
) : (
<p className="hint" style={{ marginTop: 0 }}>{t("No domains in this tenant yet")}</p>
)}
{canChange && state.unassigned.length > 0 && (
<div className="row mt-8">
<select className="input grow" aria-label={t("Domain to add")} value={pick} onChange={(e) => setPick(e.target.value)}>
{state.unassigned.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
<button className="btn btn-sm" disabled={busy || !pick} onClick={() => { const d = state.unassigned.find((x) => x.id === pick); if (d) void move(d, true); }}>
<Plus size={14} /> {t("Add")}
</button>
</div>
)}
{error && <p className="admin-notice error" role="alert">{error}</p>}
<p className="hint">{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.")}</p>
</div>
);
}
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<string | null>(null);
return (
<>
<h3>{t("Delete")}</h3>
<div className="admin-danger">
<p>{blocked ?? t("An empty tenant can be deleted.")}</p>
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
<Trash2 size={14} /> {t("Delete tenant…")}
</button>
</div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={t("Delete {name}?", { name: tenant.name })}
size="sm"
footer={
<>
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
<button
className="btn btn-danger"
disabled={busy || typed.trim() !== tenant.name}
onClick={async () => {
setBusy(true);
setError(null);
try {
await destroyTenant(tenant.id);
toast.success(t("Deleted {name}", { name: tenant.name }));
setOpen(false);
onDeleted();
} catch (err) {
setError(
err instanceof DomainError && err.type === "objectIsLinked" && err.linked.length
? t("Still holds {things}. Move them out first.", { things: describeLinked(err.linked) })
: describeDirectoryError(err, "tenant"),
);
} finally {
setBusy(false);
}
}}
>
{t("Delete tenant")}
</button>
</>
}
>
<p style={{ marginTop: 0 }}>{t("It can't be undone.")}</p>
<div className="field">
<label htmlFor="admin-tenant-delete-confirm">{t("Type {name} to confirm", { name: tenant.name })}</label>
<input id="admin-tenant-delete-confirm" className="input" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
</Dialog>
</>
);
}