Files
ihasmail-inbuxa/web/src/views/admin/RolesAdmin.tsx
T
jcoffey-dev a00d07b430 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.
2026-09-15 09:13:05 -07:00

163 lines
6.5 KiB
TypeScript

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>
);
}