import { useEffect, useMemo, useState } from "react"; import { Copy, Dices, KeyRound, Lock, Plus, Trash2, X } from "lucide-react"; import { ADMIN_BASELINE, can, canGrantRole, generatePassword, outranks, type UserRoles, } from "@/lib/admin/adminAccess"; import { aliasList, createAccount, describeDirectoryError, destroyAccount, hasPassword, passwordPatch, quotasWithDisk, updateAccount, DISK_QUOTA, type DirectoryAccount, type EmailAlias, } from "@/lib/admin/adminDirectory"; import { formatSize } from "@/lib/format"; import { t, tNode } from "@/lib/i18n"; import { Link } from "wouter"; import { Avatar } from "@/ui/misc"; import { Dialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { isSelf, roleName, type DirectoryContext } from "./directoryContext"; import { usePermissions } from "./usePermissions"; const GIB = 1024 ** 3; interface Props { /** Null to create one. */ account: DirectoryAccount | null; ctx: DirectoryContext; onClose: () => void; onChanged: () => void; onCreated: (id: string) => void; onDeleted: () => void; } /** A role as one select value: "User", "Admin", or "custom:". */ function roleKey(roles: UserRoles | undefined): string { if (!roles || roles["@type"] === "User") return "User"; if (roles["@type"] === "Admin") return "Admin"; return `custom:${Object.keys(roles.roleIds ?? {}).sort().join(",")}`; } function rolesFromKey(key: string): UserRoles { if (key === "Admin") return { "@type": "Admin" }; if (key.startsWith("custom:")) { return { "@type": "Custom", roleIds: Object.fromEntries(key.slice(7).split(",").filter(Boolean).map((id) => [id, true])) }; } return { "@type": "User" }; } const gibOf = (bytes: number | undefined) => (bytes ? String(Math.round((bytes / GIB) * 10) / 10) : ""); const bytesOf = (gib: string) => { const n = Number(gib.replace(",", ".")); return Number.isFinite(n) && n > 0 ? Math.round(n * GIB) : null; }; /** * One account, opened beside the list. * * A panel rather than a dialog, so the list stays visible and the next account * is one click away. Saving sends one `x:Account/set` with only what changed; * a password and a delete are their own calls, because each is a decision of * its own and should never ride along with a renamed display name. */ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDeleted }: Props) { const perms = usePermissions(); const creating = account === null; const self = account ? isSelf(account, ctx) : false; const locked = account ? outranks(perms, account, ctx.roles) : false; const editable = creating ? can(perms, "Account", "Create") : can(perms, "Account", "Update") && !locked; const [description, setDescription] = useState(account?.description ?? ""); const [name, setName] = useState(""); const [domainId, setDomainId] = useState(ctx.domains[0]?.id ?? ""); const [password, setPassword] = useState(() => (creating ? generatePassword() : "")); const [role, setRole] = useState(roleKey(account?.roles)); const [quota, setQuota] = useState(gibOf(account?.quotas?.[DISK_QUOTA])); const [aliases, setAliases] = useState(() => Object.values(account?.aliases ?? {})); const [tenantId, setTenantId] = useState(account?.memberTenantId ?? ""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!domainId && ctx.domains[0]) setDomainId(ctx.domains[0].id); }, [ctx.domains, domainId]); // A new account starts in the tenant of the domain it is being made on. useEffect(() => { if (creating) setTenantId(ctx.domains.find((d) => d.id === domainId)?.memberTenantId ?? ""); }, [creating, domainId, ctx.domains]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const domainName = (id: string) => ctx.domains.find((d) => d.id === id)?.name ?? ""; /* * Stalwart refuses an account in a tenant on a domain outside it (live, * 2026-09-15: invalidForeignKey naming the domain), and allows one in no * tenant on a tenant's domain. So the only tenant to offer is the domain's. */ const domainTenant = ctx.domains.find((d) => d.id === (account?.domainId ?? domainId))?.memberTenantId ?? null; const tenantName = (id: string) => ctx.tenants?.find((x) => x.id === id)?.name ?? id; const address = account?.emailAddress ?? `${name}@${domainName(domainId)}`; const roleOptions = useMemo(() => { const options: { value: string; label: string }[] = [{ value: "User", label: t("User") }]; if (ADMIN_BASELINE.every((p) => perms.has(p)) || role === "Admin") options.push({ value: "Admin", label: t("Administrator") }); for (const r of ctx.roles?.values() ?? []) { if (canGrantRole(perms, r.id, ctx.roles)) options.push({ value: `custom:${r.id}`, label: r.description || r.id }); } if (!options.some((o) => o.value === role)) options.push({ value: role, label: account ? roleName(account, ctx.roles) : role }); return options; }, [perms, ctx.roles, role, account]); const run = async (work: () => Promise) => { setBusy(true); setError(null); try { await work(); } catch (err) { setError(describeDirectoryError(err)); } finally { setBusy(false); } }; const save = () => run(async () => { if (!account) { if (!name.trim() || !domainId) { setError(t("An account needs an address.")); return; } const id = await createAccount({ name, domainId, description, password, roles: rolesFromKey(role), diskQuotaBytes: bytesOf(quota), memberTenantId: tenantId || null }); toast.success(t("Created {address}", { address })); onCreated(id); return; } const patch: Record = {}; if ((account.description ?? "") !== description) patch.description = description.trim() || null; if (roleKey(account.roles) !== role) patch.roles = rolesFromKey(role); if ((account.memberTenantId ?? "") !== tenantId) patch.memberTenantId = tenantId || null; if ((account.quotas?.[DISK_QUOTA] ?? null) !== bytesOf(quota)) patch.quotas = quotasWithDisk(account.quotas, bytesOf(quota)); const before = JSON.stringify(aliasList(Object.values(account.aliases ?? {}))); if (before !== JSON.stringify(aliasList(aliases))) patch.aliases = aliasList(aliases); if (!Object.keys(patch).length) { onClose(); return; } await updateAccount(account.id, patch); toast.success(t("Saved {address}", { address })); onChanged(); }); const used = account?.usedDiskQuota ?? 0; const limit = account?.quotas?.[DISK_QUOTA]; return ( ); } function PasswordField({ value, onChange, id = "admin-password" }: { value: string; onChange: (v: string) => void; id?: string }) { return (
onChange(e.target.value)} />
{t("Pass it on some way other than email to this address.")}
); } function PasswordReset({ account, disabled, onDone }: { account: DirectoryAccount; disabled: boolean; onDone: () => void }) { const [open, setOpen] = useState(false); const [value, setValue] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const first = account.description?.split(" ")[0] || account.name; if (!open) { return (
{!hasPassword(account) &&

{t("This account has no password. It may sign in through a directory or single sign-on.")}

}
); } return (

{t("{name} will be signed out of every app and device using the old password.", { name: first })}

{error &&

{error}

}
); } export function Aliases({ aliases, setAliases, editable, domains, defaultDomain, domainName, hint }: { aliases: EmailAlias[]; setAliases: (a: EmailAlias[]) => void; editable: boolean; domains: { id: string; name: string }[]; defaultDomain: string; domainName: (id: string) => string; /** What mail to these addresses does, when it is not reaching this account. */ hint?: string; }) { const [local, setLocal] = useState(""); const [domain, setDomain] = useState(defaultDomain); const add = () => { const name = local.trim().toLowerCase(); if (!name || aliases.some((a) => a.name === name && a.domainId === domain)) return; setAliases([...aliases, { enabled: true, name, domainId: domain }]); setLocal(""); }; return (
{aliases.length ? ( aliases.map((a, i) => ( {a.name}@{domainName(a.domainId) || "…"} {editable && ( )} )) ) : ( {t("None")} )}
{editable && (
setLocal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} /> @
)} {editable &&

{hint ?? t("Mail to these addresses is delivered to this account. Changes apply when you save.")}

}
); } function DeleteAccount({ account, blocked, onDeleted }: { account: DirectoryAccount; blocked: string | null; onDeleted: () => void }) { const [open, setOpen] = useState(false); const [typed, setTyped] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const address = account.emailAddress ?? account.name; return ( <>

{t("Delete")}

{blocked ?? t("Deletes the mailbox and everything in it.")}

setOpen(false)} title={t("Delete {address}?", { address })} size="sm" footer={ <> } >

{t("This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.")}

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

{error}

}
); }