Add Administration, starting with accounts

An account whose Stalwart role manages accounts now finds Administration in
the account menu. It lists, searches, creates and edits accounts -- display
name, other addresses, role, storage limit -- sets a new password, and
deletes, each offered only when the role holds the matching permission.

The server keeps the permissions list from GET /api/account, which it
already called for the edition and threw the rest away. Everything else is
JMAP x:Account, x:Domain and x:Role calls through the existing /api/jmap
proxy, so nothing new is stored and Stalwart decides every call.

Stalwart checks a grant against the caller's permissions but not a password
change or a delete, so an account that outranks the viewer is shown
read-only. Your own password is changed in Settings, which re-seals the
session; changing it here would strand it.

The mock server gains a directory behind the same permission names, with
MOCK_ROLE choosing admin, tenant-admin, helpdesk or user.

68 new strings, translated in all nine catalogues; strings falling back to
English stay at 16.
This commit is contained in:
2026-09-13 15:11:55 -07:00
parent b0564679e6
commit 82e217155b
34 changed files with 2684 additions and 22 deletions
+8 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users, X } from "lucide-react";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
import { useSession } from "@/store/session";
import { withBase } from "@/lib/basePath";
import { DEFAULT_APP_NAME } from "@/lib/brand";
@@ -21,6 +21,8 @@ import { formatSize } from "@/lib/format";
import { collectShare } from "@/lib/shareTarget";
import { TranslateBoundary } from "@/ui/TranslateBoundary";
import { t } from "@/lib/i18n";
import { hasAdministration } from "@/lib/adminAccess";
import { usePermissions } from "./admin/usePermissions";
const PUSH_LABEL = {
connected: "Live updates connected",
@@ -42,6 +44,7 @@ export function AppShell({ children }: { children: ReactNode }) {
const logout = useSession((s) => s.logout);
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
const acctMenu = useMenu();
const administers = hasAdministration(usePermissions());
/*
* "Go to folder" (#233), hosted here rather than in the mail view because
* the `g` shortcuts are global: pressing it from the calendar should still
@@ -156,6 +159,9 @@ export function AppShell({ children }: { children: ReactNode }) {
app there was no way back to it. */}
<MenuItem icon={<Globe size={16} />} label={t("About ihasmail")} href="https://ihasmail.org" external />
<MenuItem icon={<Settings size={16} />} label={t("Settings")} onClick={() => navigate("/settings")} />
{/* Only for an account whose Stalwart role manages other accounts.
Nobody else is shown an entry that would open onto refusals. */}
{administers && <MenuItem icon={<ShieldCheck size={16} />} label={t("Administration")} active={section === "admin"} onClick={() => navigate("/admin")} />}
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
</Popover>
@@ -207,6 +213,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{section === "contacts" && <ContactsSidebar />}
{section === "files" && <FilesTree />}
{section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>}
{section === "admin" && <div className="nav-section"><span>{t("Administration")}</span></div>}
</div>
{(section === "mail" || section === "search") && <QuotaBar />}
<nav className="module-bar" aria-label={t("Go to")}>
+459
View File
@@ -0,0 +1,459 @@
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/adminAccess";
import {
aliasList,
createAccount,
describeDirectoryError,
destroyAccount,
hasPassword,
passwordPatch,
quotasWithDisk,
updateAccount,
DISK_QUOTA,
type DirectoryAccount,
type EmailAlias,
} from "@/lib/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:<ids>". */
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<EmailAlias[]>(() => Object.values(account?.aliases ?? {}));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!domainId && ctx.domains[0]) setDomainId(ctx.domains[0].id);
}, [ctx.domains, domainId]);
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 ?? "";
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<void>) => {
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) });
toast.success(t("Created {address}", { address }));
onCreated(id);
return;
}
const patch: Record<string, unknown> = {};
if ((account.description ?? "") !== description) patch.description = description.trim() || null;
if (roleKey(account.roles) !== role) patch.roles = rolesFromKey(role);
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 (
<aside className="admin-sheet" aria-label={creating ? t("New account") : address}>
<div className="admin-sheet-head">
{account && <Avatar who={{ name: account.description || account.name, email: account.emailAddress }} />}
<div className="grow">
<h2 className="truncate">{creating ? t("New account") : account.description || account.name}</h2>
{account && <div className="hint truncate notranslate" translate="no">{account.emailAddress}</div>}
</div>
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
<X size={20} />
</button>
</div>
<div className="admin-sheet-body">
{locked && (
<p className="admin-notice warn">
<Lock size={16} aria-hidden="true" />
<span>{t("This account has permissions yours doesn't, so you can view it but not change it.")}</span>
</p>
)}
{!creating && !locked && !can(perms, "Account", "Update") && (
<p className="admin-notice">{t("Your role lets you view accounts but not change them.")}</p>
)}
<h3>{t("Profile")}</h3>
<div className="field">
<label htmlFor="admin-description">{t("Display name")}</label>
<input id="admin-description" className="input" value={description} disabled={!editable} onChange={(e) => setDescription(e.target.value)} />
</div>
{creating && (
<div className="field">
<label htmlFor="admin-name">{t("Address")}</label>
<div className="row admin-address">
<input id="admin-name" className="input" value={name} autoComplete="off" spellCheck={false} onChange={(e) => setName(e.target.value.trim().toLowerCase())} />
<span className="muted">@</span>
<select className="input" aria-label={t("Domain")} value={domainId} onChange={(e) => setDomainId(e.target.value)}>
{ctx.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
{!ctx.domains.length && <span className="hint">{t("No domains are available to create an account on.")}</span>}
</div>
)}
<h3>{t("Sign-in")}</h3>
{creating ? (
<PasswordField value={password} onChange={setPassword} />
) : (
self ? (
// This session signs in with the password; changing it here would
// strand it. Settings re-seals the session as it changes, so that is
// the door for one's own.
<p className="hint" style={{ marginTop: 0 }}>
{tNode("Change your own password in {settings}.", { settings: <Link href="/settings/security">{t("Security & sessions")}</Link> })}
</p>
) : (
<PasswordReset account={account} disabled={!editable} onDone={onChanged} />
)
)}
{!creating && (
<>
<h3>{t("Other addresses")}</h3>
<Aliases aliases={aliases} setAliases={setAliases} editable={editable} domains={ctx.domains} defaultDomain={account.domainId} domainName={domainName} />
</>
)}
{!creating && (
<>
<h3>{t("Groups")}</h3>
<div className="row wrap gap-4">
{Object.keys(account.memberGroupIds ?? {}).length ? (
Object.keys(account.memberGroupIds ?? {}).map((id) => {
const g = ctx.groups.get(id);
return <span key={id} className="chip">{g ? g.description || g.name : id}</span>;
})
) : (
<span className="hint">{t("Not in any group")}</span>
)}
</div>
</>
)}
<h3>{t("Role")}</h3>
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable || self} onChange={(e) => setRole(e.target.value)}>
{roleOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<p className="hint">
{self ? t("You can't change your own role.") : t("Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.")}
</p>
<h3>{t("Storage")}</h3>
{!creating && (
<p className="hint" style={{ marginTop: 0 }}>
{limit ? t("{used} of {total}", { used: formatSize(used), total: formatSize(limit) }) : t("{used} · no limit", { used: formatSize(used) })}
</p>
)}
<div className="field">
<label htmlFor="admin-quota">{t("Limit in GB")}</label>
<input id="admin-quota" className="input admin-narrow" inputMode="decimal" value={quota} disabled={!editable} placeholder={t("No limit")} onChange={(e) => setQuota(e.target.value)} />
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{!creating && can(perms, "Account", "Destroy") && (
<DeleteAccount account={account} blocked={self ? t("You can't delete the account you're signed in with.") : locked ? t("This account has permissions yours doesn't.") : 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 || (creating && (!name || !domainId || !password))} onClick={() => void save()}>
{creating ? t("Create account") : t("Save changes")}
</button>
</div>
)}
</aside>
);
}
function PasswordField({ value, onChange, id = "admin-password" }: { value: string; onChange: (v: string) => void; id?: string }) {
return (
<div className="field">
<label htmlFor={id}>{t("Password")}</label>
<div className="row">
<input id={id} className="input grow mono" value={value} autoComplete="new-password" spellCheck={false} onChange={(e) => onChange(e.target.value)} />
<button type="button" className="icon-btn" aria-label={t("Generate a password")} title={t("Generate a password")} onClick={() => onChange(generatePassword())}>
<Dices size={18} />
</button>
<button
type="button"
className="icon-btn"
aria-label={t("Copy")}
title={t("Copy")}
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success(t("Copied")), () => toast.error(t("Could not copy")))}
>
<Copy size={18} />
</button>
</div>
<span className="hint">{t("Pass it on some way other than email to this address.")}</span>
</div>
);
}
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<string | null>(null);
const first = account.description?.split(" ")[0] || account.name;
if (!open) {
return (
<div>
{!hasPassword(account) && <p className="hint" style={{ marginTop: 0 }}>{t("This account has no password. It may sign in through a directory or single sign-on.")}</p>}
<button className="btn" disabled={disabled} onClick={() => { setValue(generatePassword()); setOpen(true); }}>
<KeyRound size={16} /> {t("Set a new password…")}
</button>
</div>
);
}
return (
<div>
<PasswordField id="admin-reset-password" value={value} onChange={setValue} />
<p className="hint">{t("{name} will be signed out of every app and device using the old password.", { name: first })}</p>
{error && <p className="admin-notice error" role="alert">{error}</p>}
<div className="row">
<button
className="btn btn-primary btn-sm"
disabled={busy || !value}
onClick={async () => {
setBusy(true);
setError(null);
try {
await updateAccount(account.id, passwordPatch(account, value));
toast.success(t("New password set for {address}", { address: account.emailAddress ?? account.name }));
setOpen(false);
onDone();
} catch (err) {
setError(describeDirectoryError(err));
} finally {
setBusy(false);
}
}}
>
{t("Set password")}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setOpen(false)}>{t("Cancel")}</button>
</div>
</div>
);
}
function Aliases({ aliases, setAliases, editable, domains, defaultDomain, domainName }: {
aliases: EmailAlias[];
setAliases: (a: EmailAlias[]) => void;
editable: boolean;
domains: { id: string; name: string }[];
defaultDomain: string;
domainName: (id: string) => 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 (
<div>
<div className="row wrap gap-4">
{aliases.length ? (
aliases.map((a, i) => (
<span key={`${a.name}@${a.domainId}`} className="chip notranslate" translate="no">
{a.name}@{domainName(a.domainId) || "…"}
{editable && (
<button className="chip-x" aria-label={t("Remove {address}", { address: `${a.name}@${domainName(a.domainId)}` })} onClick={() => setAliases(aliases.filter((_, j) => j !== i))}>
<X size={12} />
</button>
)}
</span>
))
) : (
<span className="hint">{t("None")}</span>
)}
</div>
{editable && (
<div className="row admin-address mt-8">
<input className="input" aria-label={t("New address")} placeholder={t("another name")} value={local} spellCheck={false} onChange={(e) => setLocal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} />
<span className="muted">@</span>
<select className="input" aria-label={t("Domain")} value={domain} onChange={(e) => setDomain(e.target.value)}>
{(domains.some((d) => d.id === defaultDomain) ? domains : [{ id: defaultDomain, name: domainName(defaultDomain) || "…" }, ...domains]).map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
<button className="btn btn-sm" onClick={add} disabled={!local.trim()}>
<Plus size={14} /> {t("Add")}
</button>
</div>
)}
{editable && <p className="hint">{t("Mail to these addresses is delivered to this account. Changes apply when you save.")}</p>}
</div>
);
}
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<string | null>(null);
const address = account.emailAddress ?? account.name;
return (
<>
<h3>{t("Delete")}</h3>
<div className="admin-danger">
<p>{blocked ?? t("Deletes the mailbox and everything in it.")}</p>
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
<Trash2 size={14} /> {t("Delete account…")}
</button>
</div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={t("Delete {address}?", { address })}
size="sm"
footer={
<>
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
<button
className="btn btn-danger"
disabled={busy || typed.trim().toLowerCase() !== address.toLowerCase()}
onClick={async () => {
setBusy(true);
setError(null);
try {
await destroyAccount(account.id);
toast.success(t("Deleted {address}", { address }));
setOpen(false);
onDeleted();
} catch (err) {
setError(describeDirectoryError(err));
} finally {
setBusy(false);
}
}}
>
{t("Delete account")}
</button>
</>
}
>
<p style={{ marginTop: 0 }}>{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.")}</p>
<div className="field">
<label htmlFor="admin-delete-confirm">{t("Type {address} to confirm", { address })}</label>
<input id="admin-delete-confirm" className="input notranslate" translate="no" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
</Dialog>
</>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, Search, UserPlus, Users } from "lucide-react";
import { useSession } from "@/store/session";
import { STALWART_CAP } from "@/jmap/client";
import { can, type RoleDef } from "@/lib/adminAccess";
import {
describeDirectoryError,
getAccounts,
listDomains,
listGroups,
listRoles,
queryAccounts,
DISK_QUOTA,
type DirectoryAccount,
type DirectoryDomain,
} from "@/lib/adminDirectory";
import { formatSize } from "@/lib/format";
import { plural, t } from "@/lib/i18n";
import { Avatar, Empty, Spinner } from "@/ui/misc";
import { usePermissions } from "./usePermissions";
import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
import { AccountSheet } from "./AccountSheet";
const PAGE_SIZE = 50;
export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
const [, navigate] = useLocation();
const perms = usePermissions();
const session = useSession((s) => s.session);
const [text, setText] = useState("");
const [query, setQuery] = useState("");
const [position, setPosition] = useState(0);
const [page, setPage] = useState<{ accounts: DirectoryAccount[]; total: number } | null>(null);
const [error, setError] = useState<string | null>(null);
const [reload, setReload] = useState(0);
const [serverDomains, setServerDomains] = useState<DirectoryDomain[] | null>(null);
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
const [groups, setGroups] = useState<Map<string, DirectoryAccount>>(new Map());
const [loose, setLoose] = useState<DirectoryAccount | null>(null);
// Typing is not a query per keystroke.
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 queryAccounts({ type: "User", text: query, position, limit: PAGE_SIZE });
const accounts = await getAccounts(q.ids);
if (!cancelled) setPage({ accounts, total: q.total });
} catch (err) {
if (!cancelled) {
setPage({ accounts: [], total: 0 });
setError(describeDirectoryError(err));
}
}
})();
return () => {
cancelled = true;
};
}, [query, position, reload]);
// The lists the account sheet picks from. Each is a nicety: without it the
// sheet falls back to what it can see, or offers less.
useEffect(() => {
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) void listDomains().then(setServerDomains, () => setServerDomains(null));
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) void listRoles().then((list) => setRoles(new Map(list.map((r) => [r.id, r]))), () => setRoles(null));
void listGroups().then((list) => setGroups(new Map(list.map((g) => [g.id, g]))), () => setGroups(new Map()));
}, [perms, reload]);
// An account opened by address that is not on the page being shown.
useEffect(() => {
if (!selectedId || selectedId === "new" || page?.accounts.some((a) => a.id === selectedId)) {
setLoose(null);
return;
}
let cancelled = false;
void getAccounts([selectedId]).then(
([a]) => { if (!cancelled) setLoose(a ?? null); },
() => { if (!cancelled) setLoose(null); },
);
return () => {
cancelled = true;
};
}, [selectedId, page]);
const ctx: DirectoryContext = useMemo(() => {
const seen = new Map<string, DirectoryDomain>();
for (const a of page?.accounts ?? []) {
const domain = a.emailAddress?.split("@")[1];
if (domain && !seen.has(a.domainId)) seen.set(a.domainId, { id: a.domainId, name: domain });
}
const ownId = session?.primaryAccounts?.[STALWART_CAP];
return {
domains: (serverDomains ?? [...seen.values()]).slice().sort((x, y) => x.name.localeCompare(y.name)),
roles,
groups,
self: { ids: new Set(ownId ? [ownId] : []), address: (session?.username ?? "").toLowerCase() },
};
}, [page, serverDomains, roles, groups, session]);
const selected = selectedId && selectedId !== "new" ? (page?.accounts.find((a) => a.id === selectedId) ?? loose) : null;
const close = () => navigate("/admin/accounts");
const changed = () => setReload((n) => n + 1);
return (
<div>
<div className="admin-head">
<div className="grow">
<h1>{t("Accounts")}</h1>
<p className="lead">{t("The people who sign in to mail on the domains you manage.")}</p>
</div>
{can(perms, "Account", "Create") && (
<button className="btn btn-primary" onClick={() => navigate("/admin/accounts/new")}>
<UserPlus size={16} /> {t("New account")}
</button>
)}
</div>
<div className="admin-toolbar">
<label className="admin-search">
<Search size={16} aria-hidden="true" />
<input
className="input"
type="search"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t("Search by name or address")}
aria-label={t("Search accounts")}
/>
</label>
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{page === null ? (
<Spinner />
) : page.accounts.length === 0 ? (
!error && (
<Empty icon={<Users size={32} />} title={query ? t("No accounts match") : t("No accounts yet")}>
{query ? t("Nothing on your domains matches “{query}”.", { query }) : undefined}
</Empty>
)
) : (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>{t("Account")}</th>
<th>{t("Role")}</th>
<th>{t("Storage")}</th>
<th className="hide-mobile">{t("Groups")}</th>
</tr>
</thead>
<tbody>
{page.accounts.map((a) => (
<tr
key={a.id}
className={a.id === selectedId ? "selected" : ""}
tabIndex={0}
onClick={() => navigate(`/admin/accounts/${a.id}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/admin/accounts/${a.id}`);
}
}}
aria-label={t("Open {address}", { address: a.emailAddress ?? a.name })}
>
<td>
<div className="admin-who">
<Avatar who={{ name: a.description || a.name, email: a.emailAddress }} size="sm" />
<div className="grow">
<div className="admin-who-name truncate">
{a.description || a.name}
{isSelf(a, ctx) && <span className="badge muted">{t("You")}</span>}
</div>
<div className="hint truncate notranslate" translate="no">{a.emailAddress}</div>
</div>
</div>
</td>
<td><RoleLabel account={a} roles={ctx.roles} /></td>
<td><StorageMeter account={a} /></td>
<td className="hide-mobile muted">
<span className="truncate admin-groups">{groupNames(a, ctx.groups) || "—"}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pager position={position} shown={page.accounts.length} total={page.total} onMove={setPosition} />
</>
)}
{(selectedId === "new" || selected) && (
<AccountSheet
key={selectedId}
account={selectedId === "new" ? null : selected}
ctx={ctx}
onClose={close}
onChanged={changed}
onCreated={(id) => {
changed();
navigate(`/admin/accounts/${id}`);
}}
onDeleted={() => {
changed();
close();
}}
/>
)}
</div>
);
}
function groupNames(a: DirectoryAccount, groups: Map<string, DirectoryAccount>): string {
return Object.keys(a.memberGroupIds ?? {})
.map((id) => groups.get(id))
.filter(Boolean)
.map((g) => g!.description || g!.name)
.join(", ");
}
function RoleLabel({ account, roles }: { account: DirectoryAccount; roles: Map<string, RoleDef> | null }) {
const kind = account.roles?.["@type"] ?? "User";
return <span className={`admin-role ${kind === "Admin" ? "admin" : kind === "Custom" ? "custom" : ""}`}>{roleName(account, roles)}</span>;
}
function StorageMeter({ account }: { account: DirectoryAccount }) {
const used = account.usedDiskQuota ?? 0;
const limit = account.quotas?.[DISK_QUOTA] ?? 0;
if (!limit) return <span className="muted small">{t("{used} · no limit", { used: formatSize(used) })}</span>;
const pct = Math.min(100, Math.round((used / limit) * 100));
return (
<div className="admin-meter" title={t("{used} of {total}", { used: formatSize(used), total: formatSize(limit) })}>
<div className="quota-bar"><span className={pct > 95 ? "danger" : pct > 80 ? "warn" : ""} style={{ width: `${pct}%` }} /></div>
<span className="small muted">{t("{used} of {total}", { used: formatSize(used), total: formatSize(limit) })}</span>
</div>
);
}
function Pager({ position, shown, total, onMove }: { position: number; shown: number; total: number; onMove: (p: number) => void }) {
if (total <= PAGE_SIZE && position === 0) {
return <p className="hint admin-count">{plural(total, { one: "{n} account", other: "{n} accounts" })}</p>;
}
return (
<div className="admin-pager">
<span className="hint">{t("{from}{to} of {total}", { from: position + 1, to: position + shown, total })}</span>
<button className="icon-btn sm" aria-label={t("Previous page")} disabled={position === 0} onClick={() => onMove(Math.max(0, position - PAGE_SIZE))}>
<ChevronLeft size={18} />
</button>
<button className="icon-btn sm" aria-label={t("Next page")} disabled={position + shown >= total} onClick={() => onMove(position + PAGE_SIZE)}>
<ChevronRight size={18} />
</button>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { Link, Redirect, useLocation } from "wouter";
import { ArrowLeft, User } from "lucide-react";
import { hasAdministration } from "@/lib/adminAccess";
import { t } from "@/lib/i18n";
import { AccountsAdmin } from "./AccountsAdmin";
import { usePermissions } from "./usePermissions";
/**
* Administration: what the signed-in account's Stalwart role lets it manage.
*
* Laid out like Settings, because it is the same kind of place -- a list of
* sections and the one that is open -- and on a phone it behaves the same way,
* the list first and a section on its own. Accounts is the only section so
* far; the nav is written as a list so the next one is an entry, not a rework.
*/
export function AdminView({ section, id }: { section?: string; id?: string }) {
const [, navigate] = useLocation();
const perms = usePermissions();
// Typed in by hand, or a role taken away since the menu was drawn. Stalwart
// would refuse every call anyway; this spares the page of refusals.
if (!hasAdministration(perms)) return <Redirect to="/mail" />;
return (
<div className={`settings-layout admin-layout ${section ? "section" : "root"}`}>
<nav className="settings-nav" aria-label={t("Administration")}>
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Directory")}</span></div>
<Link href="/admin/accounts" className={`nav-item ${!section || section === "accounts" ? "active" : ""}`}>
<User size={18} />
<span className="nav-label">{t("Accounts")}</span>
</Link>
</nav>
<div className="settings-content admin-content">
{section && (
<button className="btn btn-ghost btn-sm admin-back" style={{ marginBottom: 8, marginLeft: -8 }} onClick={() => navigate("/admin")}>
<ArrowLeft size={16} /> {t("Administration")}
</button>
)}
<AccountsAdmin selectedId={id} />
</div>
</div>
);
}
@@ -0,0 +1,94 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { useSession } from "@/store/session";
import type { JmapSession } from "@/jmap/types";
import type { DirectoryAccount } from "@/lib/adminDirectory";
import { AccountSheet } from "../AccountSheet";
import type { DirectoryContext } from "../directoryContext";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
function signIn(permissions: string[], username = "[email protected]") {
useSession.setState({
session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:stalwart:jmap": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession,
});
}
const account = (over: Partial<DirectoryAccount>): DirectoryAccount => ({
id: "u1",
"@type": "User",
name: "ada",
domainId: "d1",
emailAddress: "[email protected]",
description: "Ada Lovelace",
roles: { "@type": "User" },
credentials: { "0": { "@type": "Password", secret: "[********]" } },
...over,
});
const ctx: DirectoryContext = { domains: [{ id: "d1", name: "example.com" }], roles: null, groups: new Map(), self: { ids: new Set(["self"]), address: "[email protected]" } };
const button = (host: HTMLElement, text: string) => [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(text));
/**
* The guards that stand in for checks Stalwart does not make. A store test
* cannot see these: they are what the sheet renders, and what it leaves out.
*/
describe("the account sheet", () => {
let host: HTMLDivElement;
let root: Root;
const render = async (a: DirectoryAccount) => {
const { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` });
await act(async () => {
root.render(
<Router hook={hook}>
<AccountSheet account={a} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />
</Router>,
);
});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("shows an account that outranks the viewer read-only, password included", async () => {
signIn(HELPDESK);
await render(account({ roles: { "@type": "Admin" } }));
expect(host.textContent).toContain("permissions yours doesn't");
expect(button(host, "Set a new password")?.disabled).toBe(true);
expect((host.querySelector("#admin-description") as HTMLInputElement).disabled).toBe(true);
expect(host.textContent).not.toContain("Save changes");
});
it("lets the same viewer edit an ordinary account, but not delete it", async () => {
signIn(HELPDESK);
await render(account({}));
expect(button(host, "Set a new password")?.disabled).toBe(false);
expect(host.textContent).toContain("Save changes");
expect(host.textContent).not.toContain("Delete account");
});
it("sends your own password to Settings, and keeps your role and account out of reach", async () => {
signIn([...HELPDESK, "sysAccountDestroy"]);
await render(account({ id: "self", emailAddress: "[email protected]" }));
expect(host.textContent).toContain("Change your own password in");
expect(host.querySelector('a[href="/settings/security"]')).not.toBeNull();
expect(button(host, "Set a new password")).toBeUndefined();
expect((host.querySelector('select[aria-label="Role"]') as HTMLSelectElement).disabled).toBe(true);
expect(button(host, "Delete account")?.disabled).toBe(true);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { RoleDef } from "@/lib/adminAccess";
import type { DirectoryAccount, DirectoryDomain } from "@/lib/adminDirectory";
import { t } from "@/lib/i18n";
export interface DirectoryContext {
/** Domains to offer. Read from the server when allowed, else seen on accounts. */
domains: DirectoryDomain[];
/** Null when the viewer cannot read roles, which `outranks` treats as unknown. */
roles: Map<string, RoleDef> | null;
groups: Map<string, DirectoryAccount>;
/** Registry ids and addresses that are the signed-in account itself. */
self: { ids: Set<string>; address: string };
}
export function isSelf(a: Pick<DirectoryAccount, "id" | "emailAddress">, ctx: DirectoryContext): boolean {
return ctx.self.ids.has(a.id) || (!!a.emailAddress && a.emailAddress.toLowerCase() === ctx.self.address);
}
export function roleName(a: Pick<DirectoryAccount, "roles">, roles: Map<string, RoleDef> | null): string {
const r = a.roles;
if (!r || r["@type"] === "User") return t("User");
if (r["@type"] === "Admin") return t("Administrator");
const names = Object.keys(r.roleIds ?? {}).map((id) => roles?.get(id)?.description).filter(Boolean);
return names.length ? names.join(", ") : t("Custom role");
}
+16
View File
@@ -0,0 +1,16 @@
import { useMemo } from "react";
import { useSession } from "@/store/session";
import { permissionSet, type Permissions } from "@/lib/adminAccess";
/**
* The signed-in account's permissions, as a set, stable between renders.
*
* Keyed on the contents, not the array. The session is fetched again whenever
* a response carries a different session state, and each fetch brings a new
* array with the same names in it; a set rebuilt from identity would re-run
* everything that depends on it, whose requests could bring another refresh.
*/
export function usePermissions(): Permissions {
const key = useSession((s) => (s.session?.ihasmail?.permissions ?? []).join(","));
return useMemo(() => permissionSet(key ? key.split(",") : []), [key]);
}