Add Roles to Administration, with Stalwart's permissions in every language

A role is a named set of permissions given to accounts, groups and
tenants. It gets its own section under a new Access heading: every role
listed with the permissions it grants once its bases are followed, and a
panel to create, edit and delete one.

A role builds on others and has everything they grant; a denial anywhere in
the tree wins, which is how Stalwart resolves it (permissions.rs unions
enabled and disabled across the tree, then subtracts). The picker is
Stalwart's own list of permissions, under its headings, searchable and
filterable to what is granted or set here. Each permission is not set,
allowed or denied, and one that is inherited says which role it comes from.
Only permissions the viewer holds can be allowed, because Stalwart refuses
the rest, and a role carrying anything the viewer lacks opens read-only with
no delete, because Stalwart checks a grant but not a delete. Saving sends a
pointer for each permission and base role that changed.

The roles Stalwart hands out by default, read from x:Authentication, say so
before they are changed and cannot be deleted here; a role still in use is
kept by the server, and the refusal names what uses it.

The permission list is Stalwart's schema. A new route, GET
/api/admin/permissions, fetches /api/schema as the signed-in account and
returns only names and labels, behind the same two gates as the registry
methods and held in memory for an hour. Its labels are English only, so
every one of the 661 has a translation in each of the eight other
languages, in its own file keyed by permission name and loaded only when
Roles opens. A permission a later Stalwart adds shows its English label. A
test holds every language to the 0.16.22 snapshot: nothing missing, nothing
stale.

The mock answers x:Role/set with the grant check, loops and in-use
refusals, reads the defaults from x:Authentication, and serves the schema
gzipped as the real one is.

Fifty-two new strings and two plurals in all nine catalogues, and 661
permission labels with 59 headings in each of the eight translations.
This commit is contained in:
2026-09-15 09:13:05 -07:00
parent 627422d794
commit a00d07b430
42 changed files with 11021 additions and 12 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { Globe, LayoutDashboard, List, User, UsersRound } from "lucide-react";
import { Globe, LayoutDashboard, List, ShieldCheck, User, UsersRound } from "lucide-react";
import { adminSections, type AdminSection } from "@/lib/adminAccess";
import { t } from "@/lib/i18n";
import { usePermissions } from "./usePermissions";
@@ -10,6 +10,7 @@ export const ADMIN_SECTIONS: Record<AdminSection, { group: string; label: string
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
groups: { group: "Directory", label: "Groups", icon: <UsersRound size={20} /> },
lists: { group: "Directory", label: "Mailing lists", icon: <List size={20} /> },
roles: { group: "Access", label: "Roles", icon: <ShieldCheck size={20} /> },
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
};
+2
View File
@@ -6,6 +6,7 @@ import { AdminDashboard } from "./AdminDashboard";
import { DomainsAdmin } from "./DomainsAdmin";
import { GroupsAdmin } from "./GroupsAdmin";
import { ListsAdmin } from "./ListsAdmin";
import { RolesAdmin } from "./RolesAdmin";
import { currentAdminSection } from "./AdminNav";
import { usePermissions } from "./usePermissions";
@@ -14,6 +15,7 @@ const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
accounts: (id) => <AccountsAdmin selectedId={id} />,
groups: (id) => <GroupsAdmin selectedId={id} />,
lists: (id) => <ListsAdmin selectedId={id} />,
roles: (id) => <RolesAdmin selectedId={id} />,
domains: (id) => <DomainsAdmin selectedId={id} />,
};
+418
View File
@@ -0,0 +1,418 @@
import { useEffect, useMemo, useState } from "react";
import { Lock, Search, Trash2, X } from "lucide-react";
import { can, canGrantRole } from "@/lib/adminAccess";
import { describeDirectoryError } from "@/lib/adminDirectory";
import { describeLinked, DomainError } from "@/lib/adminDomains";
import {
canBuildOn,
createRole,
destroyRole,
effectivePermissions,
inherited,
roleOutranks,
setPatch,
updateRole,
type DirectoryRole,
type PermissionState,
type RoleDefaults,
} from "@/lib/adminRoles";
import type { PermissionEntry } from "@/lib/permissionLabels";
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";
interface Props {
/** Null to create one. */
role: DirectoryRole | null;
roles: ReadonlyMap<string, DirectoryRole>;
defaults: RoleDefaults | null;
/** Stalwart's permissions in the reader's language; null while loading, and on failure with `permissionsError`. */
entries: PermissionEntry[] | null;
permissionsError: string | null;
onClose: () => void;
onChanged: () => void;
onCreated: (id: string) => void;
onDeleted: () => void;
}
/** The kinds of account a role can be the default for, in the order they are named. */
export function defaultKinds(id: string, defaults: RoleDefaults | null): string[] {
if (!defaults) return [];
const out: string[] = [];
if (defaults.user.includes(id)) out.push(t("users"));
if (defaults.group.includes(id)) out.push(t("groups"));
if (defaults.tenant.includes(id)) out.push(t("tenant administrators"));
if (defaults.admin.includes(id)) out.push(t("administrators"));
return out;
}
type Show = "all" | "granted" | "set";
/**
* One role, opened beside the list.
*
* Description, the roles it builds on and its own permissions save together.
* What Save sends for the sets is one pointer per permission or role added or
* taken away, so nothing it did not touch moves.
*/
export function RoleSheet({ role, roles, defaults, entries, permissionsError, onClose, onChanged, onCreated, onDeleted }: Props) {
const perms = usePermissions();
const creating = role === null;
const locked = role ? roleOutranks(perms, role, roles) : false;
const editable = creating ? can(perms, "Role", "Create") : can(perms, "Role", "Update") && !locked;
const kinds = role ? defaultKinds(role.id, defaults) : [];
const [description, setDescription] = useState(role?.description ?? "");
const [bases, setBases] = useState<string[]>(() => Object.keys(role?.roleIds ?? {}));
const [enabled, setEnabled] = useState<Set<string>>(() => new Set(Object.keys(role?.enabledPermissions ?? {})));
const [disabled, setDisabled] = useState<Set<string>>(() => new Set(Object.keys(role?.disabledPermissions ?? {})));
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]);
const base = useMemo(() => inherited(bases, roles, role?.id), [bases, roles, role]);
const effective = useMemo(() => effectivePermissions({ roleIds: Object.fromEntries(bases.map((b) => [b, true])), enabledPermissions: Object.fromEntries([...enabled].map((p) => [p, true])), disabledPermissions: Object.fromEntries([...disabled].map((p) => [p, true])) }, roles, role?.id), [bases, enabled, disabled, roles, role]);
const nameOf = (id: string) => roles.get(id)?.description || id;
const setState = (name: string, state: PermissionState) => {
const nextEnabled = new Set(enabled);
const nextDisabled = new Set(disabled);
nextEnabled.delete(name);
nextDisabled.delete(name);
if (state === "allow") nextEnabled.add(name);
if (state === "deny") nextDisabled.add(name);
setEnabled(nextEnabled);
setDisabled(nextDisabled);
};
const save = async () => {
setBusy(true);
setError(null);
try {
if (!role) {
if (!description.trim()) {
setError(t("A role needs a name."));
return;
}
const id = await createRole({ description, roleIds: bases, enabled: [...enabled], disabled: [...disabled] });
toast.success(t("Created {name}", { name: description.trim() }));
onCreated(id);
return;
}
const patch: Record<string, unknown> = {
...setPatch("roleIds", Object.keys(role.roleIds ?? {}), bases),
...setPatch("enabledPermissions", Object.keys(role.enabledPermissions ?? {}), enabled),
...setPatch("disabledPermissions", Object.keys(role.disabledPermissions ?? {}), disabled),
};
if ((role.description ?? "") !== description.trim()) patch.description = description.trim();
if (!Object.keys(patch).length) {
onClose();
return;
}
await updateRole(role.id, patch);
toast.success(t("Saved {name}", { name: description.trim() }));
onChanged();
} catch (err) {
setError(describeDirectoryError(err, "role"));
} finally {
setBusy(false);
}
};
const title = creating ? t("New role") : role.description || role.id;
return (
<aside className="admin-sheet admin-sheet-wide" aria-label={title}>
<div className="admin-sheet-head">
<div className="grow">
<h2 className="truncate">{title}</h2>
<div className="hint">{plural(effective.size, { one: "Grants {n} permission", other: "Grants {n} permissions" })}</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 role carries permissions yours doesn't, so you can view it but not change it.")}</span>
</p>
)}
{!creating && !locked && !can(perms, "Role", "Update") && <p className="admin-notice">{t("Your role lets you view roles but not change them.")}</p>}
{kinds.length > 0 && (
<p className="admin-notice warn">
<span>{t("Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.", { kinds: kinds.join(", ") })}</span>
</p>
)}
<h3>{t("Name")}</h3>
<input className="input admin-wide" aria-label={t("Name")} value={description} disabled={!editable} onChange={(e) => setDescription(e.target.value)} />
<h3>{t("Builds on")}</h3>
<BasePicker role={role} roles={roles} bases={bases} setBases={setBases} editable={editable} />
<h3>{t("Permissions")}</h3>
{permissionsError ? (
<p className="admin-notice error" role="alert">{permissionsError}</p>
) : entries === null ? (
<Spinner />
) : (
<PermissionPicker
entries={entries}
enabled={enabled}
disabled={disabled}
base={base}
effective={effective}
nameOf={nameOf}
editable={editable}
onChange={setState}
/>
)}
{error && <p className="admin-notice error" role="alert">{error}</p>}
{!creating && can(perms, "Role", "Destroy") && (
<DeleteRole
role={role}
blocked={kinds.length ? t("Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.") : locked ? t("This role carries 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 || !description.trim()} onClick={() => void save()}>
{creating ? t("Create role") : t("Save changes")}
</button>
</div>
)}
</aside>
);
}
function BasePicker({ role, roles, bases, setBases, editable }: {
role: DirectoryRole | null;
roles: ReadonlyMap<string, DirectoryRole>;
bases: string[];
setBases: (b: string[]) => void;
editable: boolean;
}) {
const perms = usePermissions();
const selfId = role?.id ?? null;
const options = [...roles.values()].filter((r) => r.id !== selfId);
return (
<div>
<div className="admin-checks">
{options.map((r) => {
const on = bases.includes(r.id);
// Building on a role that already builds on this one would be a loop.
const loop = !canBuildOn(selfId, r.id, roles);
const grantable = canGrantRole(perms, r.id, roles as Map<string, DirectoryRole>);
return (
<label key={r.id} className={`admin-check ${!editable || loop || (!on && !grantable) ? "disabled" : ""}`}>
<input
type="checkbox"
checked={on}
disabled={!editable || loop || (!on && !grantable)}
onChange={(e) => setBases(e.target.checked ? [...bases, r.id] : bases.filter((b) => b !== r.id))}
/>
<span>{r.description || r.id}</span>
{loop && <span className="hint">{t("builds on this one")}</span>}
{!loop && !on && !grantable && <span className="hint">{t("has permissions yours doesn't")}</span>}
</label>
);
})}
{!options.length && <span className="hint">{t("No other roles")}</span>}
</div>
<p className="hint">{t("A role has every permission of the roles it builds on, apart from any it or they deny.")}</p>
</div>
);
}
function PermissionPicker({ entries, enabled, disabled, base, effective, nameOf, editable, onChange }: {
entries: PermissionEntry[];
enabled: Set<string>;
disabled: Set<string>;
base: { granted: Map<string, string>; denied: Map<string, string> };
effective: Set<string>;
nameOf: (id: string) => string;
editable: boolean;
onChange: (name: string, state: PermissionState) => void;
}) {
const perms = usePermissions();
const [text, setText] = useState("");
const [show, setShow] = useState<Show>("all");
const [open, setOpen] = useState<Set<string>>(new Set());
const needle = text.trim().toLowerCase();
const visible = entries.filter((e) => {
if (show === "granted" && !effective.has(e.name)) return false;
if (show === "set" && !enabled.has(e.name) && !disabled.has(e.name)) return false;
return !needle || e.name.toLowerCase().includes(needle) || e.action.toLowerCase().includes(needle) || e.category.toLowerCase().includes(needle);
});
const groups = useMemo(() => {
const out = new Map<string, { label: string; items: PermissionEntry[]; total: number; granted: number }>();
for (const e of entries) {
const g = out.get(e.categoryKey) ?? { label: e.category, items: [], total: 0, granted: 0 };
g.total += 1;
if (effective.has(e.name)) g.granted += 1;
out.set(e.categoryKey, g);
}
for (const e of visible) out.get(e.categoryKey)!.items.push(e);
return [...out.entries()].filter(([, g]) => g.items.length);
}, [entries, visible, effective]);
const expandAll = Boolean(needle) || show !== "all";
return (
<div className="admin-perms">
<div className="admin-perms-tools">
<label className="admin-search">
<Search size={16} aria-hidden="true" />
<input className="input" type="search" value={text} placeholder={t("Search permissions")} aria-label={t("Search permissions")} onChange={(e) => setText(e.target.value)} />
</label>
<select className="input" aria-label={t("Show")} value={show} onChange={(e) => setShow(e.target.value as Show)}>
<option value="all">{t("All permissions")}</option>
<option value="granted">{t("Granted")}</option>
<option value="set">{t("Set on this role")}</option>
</select>
</div>
{groups.length === 0 && <p className="hint">{t("No permissions match")}</p>}
{groups.map(([key, g]) => {
const isOpen = expandAll || open.has(key);
return (
<section key={key} className="admin-perm-group">
<button
type="button"
className="admin-perm-head"
aria-expanded={isOpen}
onClick={() => {
const next = new Set(open);
if (next.has(key)) next.delete(key);
else next.add(key);
setOpen(next);
}}
>
<span className="grow">{g.label}</span>
<span className="hint">{t("{granted} of {total}", { granted: g.granted, total: g.total })}</span>
</button>
{isOpen && (
<ul className="admin-perm-rows">
{g.items.map((e) => {
const own: PermissionState = enabled.has(e.name) ? "allow" : disabled.has(e.name) ? "deny" : "none";
const from = base.denied.get(e.name) ?? base.granted.get(e.name);
const note = base.denied.has(e.name)
? t("Denied by {role}", { role: nameOf(base.denied.get(e.name)!) })
: from
? t("Granted by {role}", { role: nameOf(from) })
: null;
// Stalwart refuses a grant the caller does not hold; a denial takes nothing it needs to check.
const cannotAllow = !perms.has(e.name) && own !== "allow";
return (
<li key={e.name} className={effective.has(e.name) ? "granted" : ""}>
<div className="grow" style={{ minWidth: 0 }}>
<div>{e.action}</div>
<div className="hint mono notranslate" translate="no">
{e.name}
{note && own === "none" ? ` · ${note}` : ""}
</div>
</div>
<select
className="input admin-perm-state"
aria-label={e.action}
value={own}
disabled={!editable}
onChange={(ev) => onChange(e.name, ev.target.value as PermissionState)}
>
<option value="none">{from ? t("Inherit") : t("Not set")}</option>
<option value="allow" disabled={cannotAllow}>{t("Allow")}</option>
<option value="deny">{t("Deny")}</option>
</select>
</li>
);
})}
</ul>
)}
</section>
);
})}
<p className="hint">{t("A denial wins over anything allowed, here or on a role this one builds on. You can only allow permissions you hold yourself.")}</p>
</div>
);
}
function DeleteRole({ role, blocked, onDeleted }: { role: DirectoryRole; 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 name = role.description || role.id;
return (
<>
<h3>{t("Delete")}</h3>
<div className="admin-danger">
<p>{blocked ?? t("Accounts, groups and other roles that use it must be moved off it first.")}</p>
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
<Trash2 size={14} /> {t("Delete role…")}
</button>
</div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={t("Delete {name}?", { name })}
size="sm"
footer={
<>
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
<button
className="btn btn-danger"
disabled={busy || typed.trim() !== name}
onClick={async () => {
setBusy(true);
setError(null);
try {
await destroyRole(role.id);
toast.success(t("Deleted {name}", { name }));
setOpen(false);
onDeleted();
} catch (err) {
setError(
err instanceof DomainError && err.type === "objectIsLinked" && err.linked.length
? t("Still used by {things}. Move them to another role first.", { things: describeLinked(err.linked) })
: describeDirectoryError(err, "role"),
);
} finally {
setBusy(false);
}
}}
>
{t("Delete role")}
</button>
</>
}
>
<p style={{ marginTop: 0 }}>{t("It can't be undone.")}</p>
<div className="field">
<label htmlFor="admin-role-delete-confirm">{t("Type {name} to confirm", { name })}</label>
<input id="admin-role-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>
</>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "wouter";
import { Plus, Search, ShieldCheck } from "lucide-react";
import { can } from "@/lib/adminAccess";
import { describeDirectoryError } from "@/lib/adminDirectory";
import { effectivePermissions, listAllRoles, loadPermissionList, loadRoleDefaults, type DirectoryRole, type RoleDefaults } from "@/lib/adminRoles";
import { describePermissions, loadPermissionCatalog, type PermissionEntry } from "@/lib/permissionLabels";
import { plural, t } from "@/lib/i18n";
import { Empty, Spinner } from "@/ui/misc";
import { usePermissions } from "./usePermissions";
import { defaultKinds, RoleSheet } from "./RoleSheet";
/**
* Roles: named sets of permissions that accounts are given.
*
* All of them at once rather than paged -- a server has a handful, not
* thousands -- with the permission list loaded alongside, so a panel opens
* ready to edit.
*/
export function RolesAdmin({ selectedId }: { selectedId?: string }) {
const [, navigate] = useLocation();
const perms = usePermissions();
const [text, setText] = useState("");
const [roles, setRoles] = useState<DirectoryRole[] | null>(null);
const [defaults, setDefaults] = useState<RoleDefaults | null>(null);
const [entries, setEntries] = useState<PermissionEntry[] | null>(null);
const [permissionsError, setPermissionsError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [reload, setReload] = useState(0);
useEffect(() => {
let cancelled = false;
setError(null);
listAllRoles().then(
(list) => !cancelled && setRoles(list),
(err) => {
if (cancelled) return;
setRoles([]);
setError(describeDirectoryError(err, "role"));
},
);
void loadRoleDefaults().then((d) => !cancelled && setDefaults(d));
return () => {
cancelled = true;
};
}, [reload]);
// The permission list changes only when Stalwart is upgraded; once per visit is plenty.
useEffect(() => {
let cancelled = false;
Promise.all([loadPermissionList(), loadPermissionCatalog()]).then(
([list, catalog]) => !cancelled && setEntries(describePermissions(list, catalog, t("General"))),
(err) => !cancelled && setPermissionsError(t("Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})", { reason: describeDirectoryError(err, "role") })),
);
return () => {
cancelled = true;
};
}, []);
const byId = useMemo(() => new Map((roles ?? []).map((r) => [r.id, r])), [roles]);
const needle = text.trim().toLowerCase();
const shown = (roles ?? []).filter((r) => !needle || (r.description ?? r.id).toLowerCase().includes(needle));
const selected = selectedId && selectedId !== "new" ? byId.get(selectedId) : null;
const close = () => navigate("/admin/roles");
const changed = () => setReload((n) => n + 1);
return (
<div>
<div className="admin-head">
<div className="grow">
<h1>{t("Roles")}</h1>
<p className="lead">{t("Named sets of permissions, given to accounts, groups and tenants.")}</p>
</div>
{can(perms, "Role", "Create") && (
<button className="btn btn-primary" onClick={() => navigate("/admin/roles/new")}>
<Plus size={16} /> {t("New role")}
</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 roles")} aria-label={t("Search roles")} />
</label>
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{roles === null ? (
<Spinner />
) : shown.length === 0 ? (
!error && <Empty icon={<ShieldCheck size={32} />} title={needle ? t("No roles match") : t("No roles yet")} />
) : (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>{t("Role")}</th>
<th>{t("Permissions")}</th>
<th className="hide-mobile">{t("Builds on")}</th>
</tr>
</thead>
<tbody>
{shown.map((r) => {
const kinds = defaultKinds(r.id, defaults);
return (
<tr
key={r.id}
className={r.id === selectedId ? "selected" : ""}
tabIndex={0}
onClick={() => navigate(`/admin/roles/${r.id}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/admin/roles/${r.id}`);
}
}}
aria-label={t("Open {name}", { name: r.description || r.id })}
>
<td>
<div className="admin-who-name truncate">{r.description || r.id}</div>
{kinds.length > 0 && <div className="hint truncate">{t("Default for {kinds}", { kinds: kinds.join(", ") })}</div>}
</td>
<td className="muted" style={{ fontVariantNumeric: "tabular-nums" }}>{effectivePermissions(r, byId, r.id).size}</td>
<td className="hide-mobile muted">
<span className="truncate admin-groups">{Object.keys(r.roleIds ?? {}).map((id) => byId.get(id)?.description || id).join(", ") || "—"}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="hint admin-count">{plural(roles.length, { one: "{n} role", other: "{n} roles" })}</p>
</>
)}
{(selectedId === "new" || selected) && roles && (
<RoleSheet
key={selectedId}
role={selectedId === "new" ? null : selected!}
roles={byId}
defaults={defaults}
entries={entries}
permissionsError={permissionsError}
onClose={close}
onChanged={changed}
onCreated={(id) => {
changed();
navigate(`/admin/roles/${id}`);
}}
onDeleted={() => {
changed();
close();
}}
/>
)}
</div>
);
}
@@ -0,0 +1,92 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSession } from "@/store/session";
import type { JmapSession } from "@/jmap/types";
import type { DirectoryRole } from "@/lib/adminRoles";
import type { PermissionEntry } from "@/lib/permissionLabels";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const api = vi.hoisted(() => ({ updateRole: vi.fn(async () => {}) }));
vi.mock("@/lib/adminRoles", async (original) => ({ ...(await original<typeof import("@/lib/adminRoles")>()), updateRole: api.updateRole }));
const { RoleSheet } = await import("../RoleSheet");
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
const roles = new Map<string, DirectoryRole>([
["user", { id: "user", description: "User", enabledPermissions: flags("jmapEmailGet") }],
["help", { id: "help", description: "Helpdesk", enabledPermissions: flags("sysAccountGet"), roleIds: flags("user") }],
["admin", { id: "admin", description: "Admin", enabledPermissions: flags("sysTenantCreate") }],
]);
const entries: PermissionEntry[] = [
{ name: "jmapEmailGet", categoryKey: "JMAP", category: "JMAP", action: "Get emails" },
{ name: "sysAccountGet", categoryKey: "Accounts Management", category: "Accounts Management", action: "Get accounts" },
{ name: "sysAccountUpdate", categoryKey: "Accounts Management", category: "Accounts Management", action: "Update accounts" },
{ name: "sysTenantCreate", categoryKey: "Tenants Management", category: "Tenants Management", action: "Create tenants" },
];
const signIn = (permissions: string[]) =>
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
const button = (host: HTMLElement, label: string) => [...host.querySelectorAll("button")].find((b) => b.textContent?.trim() === label || b.textContent?.includes(label));
const change = async (el: HTMLSelectElement, value: string) => {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!.call(el, value);
el.dispatchEvent(new Event("change", { bubbles: true }));
});
};
describe("the role sheet", () => {
let host: HTMLDivElement;
let root: Root;
const render = async (role: DirectoryRole, defaults = null as { user: string[]; group: string[]; tenant: string[]; admin: string[] } | null) => {
await act(async () => {
root.render(<RoleSheet role={role} roles={roles} defaults={defaults} entries={entries} permissionsError={null} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
});
};
const VIEWER = ["sysRoleGet", "sysRoleQuery", "sysRoleUpdate", "sysRoleDestroy", "jmapEmailGet", "sysAccountGet", "sysAccountUpdate"];
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
api.updateRole.mockClear();
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("shows where an inherited permission comes from, and saves only the change as a pointer", async () => {
signIn(VIEWER);
await render(roles.get("help")!);
await act(async () => button(host, "Accounts Management")!.click());
await act(async () => button(host, "JMAP")!.click());
expect(host.textContent).toContain("Granted by User");
await change(host.querySelector<HTMLSelectElement>('select[aria-label="Update accounts"]')!, "allow");
await change(host.querySelector<HTMLSelectElement>('select[aria-label="Get emails"]')!, "deny");
await act(async () => button(host, "Save changes")!.click());
expect(api.updateRole).toHaveBeenCalledWith("help", { "enabledPermissions/sysAccountUpdate": true, "disabledPermissions/jmapEmailGet": true });
});
it("will not let a viewer allow what they do not hold", async () => {
signIn(VIEWER);
await render(roles.get("help")!);
await act(async () => button(host, "Tenants Management")!.click());
const allow = host.querySelector<HTMLSelectElement>('select[aria-label="Create tenants"]')!.querySelector<HTMLOptionElement>('option[value="allow"]')!;
expect(allow.disabled).toBe(true);
});
it("opens a role that outranks the viewer read-only, with no delete", async () => {
signIn(VIEWER);
await render(roles.get("admin")!);
expect(host.textContent).toContain("This role carries permissions yours doesn't");
expect(button(host, "Save changes")).toBeUndefined();
expect(button(host, "Delete role…")?.disabled).toBe(true);
});
it("warns about a default role and will not delete it", async () => {
signIn(VIEWER);
await render(roles.get("user")!, { user: ["user"], group: [], tenant: [], admin: [] });
expect(host.textContent).toContain("Stalwart gives this role by default to users");
expect(button(host, "Delete role…")?.disabled).toBe(true);
});
});