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:
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { permissionSet } from "@/lib/adminAccess";
|
||||
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/adminRoles";
|
||||
|
||||
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", "jmapEmailSet") }],
|
||||
["help", { id: "help", description: "Helpdesk", enabledPermissions: flags("sysAccountGet"), disabledPermissions: flags("jmapEmailSet"), roleIds: flags("user") }],
|
||||
["lead", { id: "lead", description: "Lead", enabledPermissions: flags("sysAccountUpdate"), roleIds: flags("help") }],
|
||||
]);
|
||||
|
||||
describe("what a role holds", () => {
|
||||
it("follows every base, and a denial anywhere in the tree wins", () => {
|
||||
// Stalwart unions enabled with enabled and disabled with disabled across
|
||||
// the tree, then takes the disabled away (permissions.rs).
|
||||
expect([...effectivePermissions(roles.get("lead")!, roles, "lead")].sort()).toEqual(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
|
||||
const { granted, denied } = inherited(["help"], roles, "lead");
|
||||
expect(granted.get("jmapEmailGet")).toBe("help");
|
||||
expect(denied.get("jmapEmailSet")).toBe("help");
|
||||
});
|
||||
|
||||
it("changes a set one pointer at a time", () => {
|
||||
expect(setPatch("enabledPermissions", ["a", "b"], new Set(["b", "c"]))).toEqual({ "enabledPermissions/a": null, "enabledPermissions/c": true });
|
||||
expect(setPatch("roleIds", [], [])).toEqual({});
|
||||
});
|
||||
|
||||
it("will not build on itself, or on a role already built on it", () => {
|
||||
expect(canBuildOn("help", "help", roles)).toBe(false);
|
||||
expect(canBuildOn("help", "lead", roles)).toBe(false);
|
||||
expect(canBuildOn("lead", "user", roles)).toBe(true);
|
||||
expect(canBuildOn(null, "lead", roles)).toBe(true);
|
||||
});
|
||||
|
||||
it("is read-only to a viewer missing anything enabled in its tree, denied or not", () => {
|
||||
const viewer = permissionSet(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
|
||||
// jmapEmailSet is denied on Helpdesk but enabled on User beneath it: a
|
||||
// grant Stalwart would check, and a delete it would not.
|
||||
expect(roleOutranks(viewer, roles.get("lead")!, roles)).toBe(true);
|
||||
expect(roleOutranks(permissionSet([...viewer, "jmapEmailSet"]), roles.get("lead")!, roles)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import source from "../../locales/permissions/source.json";
|
||||
import { describePermissions, splitLabel, type PermissionCatalog } from "@/lib/permissionLabels";
|
||||
import { UI_LANGUAGES } from "@/lib/languages";
|
||||
|
||||
|
||||
describe("permission labels", () => {
|
||||
it("split Stalwart's label into its heading and action", () => {
|
||||
expect(splitLabel("Accounts Management: Create accounts")).toEqual({ categoryKey: "Accounts Management", action: "Create accounts" });
|
||||
expect(splitLabel("Action: Reload: TLS certificates")).toEqual({ categoryKey: "Action", action: "Reload: TLS certificates" });
|
||||
expect(splitLabel("Act on behalf of another user")).toEqual({ categoryKey: "General", action: "Act on behalf of another user" });
|
||||
});
|
||||
|
||||
it("fall back to Stalwart's English for a permission a language does not have yet", () => {
|
||||
const catalog: PermissionCatalog = { categories: { "Accounts Management": "Kontenverwaltung" }, labels: { sysAccountGet: "Konten abrufen" } };
|
||||
expect(describePermissions([
|
||||
{ name: "sysAccountGet", label: "Accounts Management: Get accounts" },
|
||||
{ name: "sysBrandNewThing", label: "Novelties: Do something new" },
|
||||
{ name: "impersonate", label: "Act on behalf of another user" },
|
||||
], catalog, "Allgemein")).toEqual([
|
||||
{ name: "sysAccountGet", categoryKey: "Accounts Management", category: "Kontenverwaltung", action: "Konten abrufen" },
|
||||
{ name: "sysBrandNewThing", categoryKey: "Novelties", category: "Novelties", action: "Do something new" },
|
||||
{ name: "impersonate", categoryKey: "General", category: "Allgemein", action: "Act on behalf of another user" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every language covers every permission the snapshot has, and nothing it
|
||||
* does not: a missing one would show English in the middle of a translated
|
||||
* picker, and a stale one would never be looked up.
|
||||
*/
|
||||
describe("the permission catalogues", () => {
|
||||
const modules = import.meta.glob<{ permissionCatalog: PermissionCatalog }>("../../locales/permissions/*.ts");
|
||||
const tagOf = (path: string) => path.split("/").pop()!.replace(/\.ts$/, "");
|
||||
const languages = UI_LANGUAGES.map((l) => l.tag).filter((tag) => tag !== "en");
|
||||
const names = new Set(source.permissions.map((p) => p.name));
|
||||
const categories = new Set(source.permissions.map((p) => splitLabel(p.label).categoryKey).filter((c) => c !== "General"));
|
||||
|
||||
it("exist for every language the interface ships", () => {
|
||||
expect(Object.keys(modules).map(tagOf).sort()).toEqual([...languages].sort());
|
||||
});
|
||||
|
||||
for (const tag of languages) {
|
||||
it(`${tag} names every permission and heading, and nothing else`, async () => {
|
||||
const load = Object.entries(modules).find(([path]) => tagOf(path) === tag)?.[1];
|
||||
expect(load, `locales/permissions/${tag}.ts`).toBeTruthy();
|
||||
const { permissionCatalog } = await load!();
|
||||
expect(Object.keys(permissionCatalog.labels).filter((n) => !names.has(n))).toEqual([]);
|
||||
expect([...names].filter((n) => !permissionCatalog.labels[n]?.trim())).toEqual([]);
|
||||
expect(Object.keys(permissionCatalog.categories).sort()).toEqual([...categories].sort());
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export function can(perms: Permissions, object: AdminObject, op: AdminOp): boole
|
||||
return perms.has(`sys${object}${op}`);
|
||||
}
|
||||
|
||||
export type AdminSection = "dashboard" | "accounts" | "groups" | "lists" | "domains";
|
||||
export type AdminSection = "dashboard" | "accounts" | "groups" | "lists" | "roles" | "domains";
|
||||
|
||||
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
|
||||
|
||||
@@ -63,6 +63,7 @@ export function adminSections(perms: Permissions): AdminSection[] {
|
||||
// Groups are accounts to the server, behind the same two permissions.
|
||||
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts", "groups");
|
||||
if (can(perms, "MailingList", "Query") && can(perms, "MailingList", "Get")) out.push("lists");
|
||||
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) out.push("roles");
|
||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ const VALIDATOR_MESSAGES: Record<string, () => string> = {
|
||||
};
|
||||
|
||||
/** What kind of thing a refusal was about, where the wording has to differ. */
|
||||
export type DirectoryObject = "account" | "domain" | "group" | "list";
|
||||
export type DirectoryObject = "account" | "domain" | "group" | "list" | "role";
|
||||
|
||||
/**
|
||||
* Say what went wrong in terms of the person's own action, in their language.
|
||||
@@ -261,7 +261,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
||||
const description = err.description ?? "";
|
||||
switch (err.type) {
|
||||
case "forbidden":
|
||||
if (/not authorized to grant/i.test(description)) return t("You can't give an account permissions your own role doesn't have.");
|
||||
if (/not authorized to grant/i.test(description)) {
|
||||
return object === "role" ? t("You can't give a role permissions your own role doesn't have.") : t("You can't give an account permissions your own role doesn't have.");
|
||||
}
|
||||
if (/external directory/i.test(description)) return t("This account signs in through an external directory, so its password can't be set here.");
|
||||
if (/licen[cs]ed account limit/i.test(description)) return t("The server's licence allows no more accounts.");
|
||||
return t("The mail server refused this. Your role may not allow it.");
|
||||
@@ -278,7 +280,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
||||
? t("Your organisation has reached the number of groups it is allowed.")
|
||||
: object === "list"
|
||||
? t("Your organisation has reached the number of mailing lists it is allowed.")
|
||||
: t("Your organisation has reached the number of accounts it is allowed.");
|
||||
: object === "role"
|
||||
? t("Your organisation has reached the number of roles it is allowed.")
|
||||
: t("Your organisation has reached the number of accounts it is allowed.");
|
||||
case "objectIsLinked":
|
||||
return t("Something still depends on this, so the server kept it.");
|
||||
case "notFound":
|
||||
@@ -288,7 +292,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
||||
? t("This group no longer exists. Someone may have deleted it.")
|
||||
: object === "list"
|
||||
? t("This mailing list no longer exists. Someone may have deleted it.")
|
||||
: t("This account no longer exists. Someone may have deleted it.");
|
||||
: object === "role"
|
||||
? t("This role no longer exists. Someone may have deleted it.")
|
||||
: t("This account no longer exists. Someone may have deleted it.");
|
||||
case "rateLimit":
|
||||
return t("Too many attempts. Please wait a few minutes and try again.");
|
||||
case "tooLarge":
|
||||
|
||||
@@ -227,6 +227,8 @@ export function describeLinked(linked: string[]): string {
|
||||
if (kind === "Account") parts.push(plural(n, { one: "{n} account", other: "{n} accounts" }));
|
||||
else if (kind === "MailingList") parts.push(plural(n, { one: "{n} mailing list", other: "{n} mailing lists" }));
|
||||
else if (kind === "DkimSignature") parts.push(plural(n, { one: "{n} DKIM key", other: "{n} DKIM keys" }));
|
||||
else if (kind === "Role") parts.push(plural(n, { one: "{n} role", other: "{n} roles" }));
|
||||
else if (kind === "Authentication") parts.push(t("the default roles"));
|
||||
else parts.push(plural(n, { one: "{n} other item", other: "{n} other items" }));
|
||||
}
|
||||
return parts.join(", ");
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { apiFetch, client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { Permissions, RoleDef } from "@/lib/adminAccess";
|
||||
import { DirectoryError } from "@/lib/adminDirectory";
|
||||
import { DomainError } from "@/lib/adminDomains";
|
||||
import type { PermissionInfo } from "@/lib/permissionLabels";
|
||||
|
||||
/**
|
||||
* Roles, from Stalwart 0.16's directory.
|
||||
*
|
||||
* `x:Role` behind `sysRole*`. A role has a `description` -- which is its name;
|
||||
* there is no other -- the roles it builds on (`roleIds`, followed all the way
|
||||
* down), and two sets of permissions: `enabledPermissions` it adds and
|
||||
* `disabledPermissions` it takes away, which wins over anything enabled or
|
||||
* inherited. The four a new server starts with (User, Group, Tenant
|
||||
* Administrator, System Administrator) are ordinary rows, editable like any
|
||||
* other, written once at first boot.
|
||||
*
|
||||
* Stalwart refuses to create or change a role that would carry a permission
|
||||
* the caller does not hold, which is what the picker's locked rows show ahead
|
||||
* of time. It does not check a delete.
|
||||
*/
|
||||
|
||||
export interface DirectoryRole extends RoleDef {
|
||||
description?: string | null;
|
||||
enabledPermissions?: Record<string, boolean>;
|
||||
disabledPermissions?: Record<string, boolean>;
|
||||
roleIds?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/** Which roles Stalwart gives an account that has been given none of its own. */
|
||||
export interface RoleDefaults {
|
||||
user: string[];
|
||||
group: string[];
|
||||
tenant: string[];
|
||||
admin: string[];
|
||||
}
|
||||
|
||||
const ROLE_PROPERTIES = ["description", "enabledPermissions", "disabledPermissions", "roleIds"];
|
||||
|
||||
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined> & {
|
||||
created?: Record<string, { id: string }>;
|
||||
};
|
||||
|
||||
/** A refusal, carrying what the server says still uses the role -- a delete's usual answer. */
|
||||
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||
const first = Object.values(res[key] ?? {})[0] as ({ type: string; description?: string; properties?: string[]; linkedObjects?: Array<{ object?: string; id?: string }> } | null | undefined);
|
||||
if (first) throw new DomainError(first);
|
||||
}
|
||||
|
||||
/** Every role, sorted by name. There are few enough to hold at once; the server caps a get anyway. */
|
||||
export async function listAllRoles(): Promise<DirectoryRole[]> {
|
||||
const q = await client.call<{ ids?: string[] }>("x:Role/query", { limit: client.maxObjectsInGet });
|
||||
if (!q.ids?.length) return [];
|
||||
const res = await client.call<{ list: DirectoryRole[] }>("x:Role/get", { ids: q.ids, properties: ROLE_PROPERTIES });
|
||||
return res.list.sort((a, b) => (a.description ?? a.id).localeCompare(b.description ?? b.id));
|
||||
}
|
||||
|
||||
/** The default roles, or null when the viewer may not read the authentication settings. */
|
||||
export async function loadRoleDefaults(): Promise<RoleDefaults | null> {
|
||||
try {
|
||||
const res = await client.call<{ list: Array<Record<string, Record<string, boolean> | undefined>> }>("x:Authentication/get", {
|
||||
ids: ["singleton"],
|
||||
properties: ["defaultUserRoleIds", "defaultGroupRoleIds", "defaultTenantRoleIds", "defaultAdminRoleIds"],
|
||||
});
|
||||
const s = res.list[0];
|
||||
if (!s) return null;
|
||||
const ids = (k: string) => Object.keys(s[k] ?? {});
|
||||
return { user: ids("defaultUserRoleIds"), group: ids("defaultGroupRoleIds"), tenant: ids("defaultTenantRoleIds"), admin: ids("defaultAdminRoleIds") };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stalwart's labelled permission list, through ihasmail's server. */
|
||||
export async function loadPermissionList(): Promise<PermissionInfo[]> {
|
||||
const res = await apiFetch<{ permissions: PermissionInfo[] }>("/api/admin/permissions");
|
||||
return res.permissions;
|
||||
}
|
||||
|
||||
export interface NewRole {
|
||||
description: string;
|
||||
roleIds: string[];
|
||||
enabled: string[];
|
||||
disabled: string[];
|
||||
}
|
||||
|
||||
const set = (names: readonly string[]) => Object.fromEntries(names.map((n) => [n, true]));
|
||||
|
||||
export async function createRole(input: NewRole): Promise<string> {
|
||||
const res = await client.call<SetResponse>("x:Role/set", {
|
||||
create: { n: { description: input.description.trim(), roleIds: set(input.roleIds), enabledPermissions: set(input.enabled), disabledPermissions: set(input.disabled) } },
|
||||
});
|
||||
throwIfRefused(res, "notCreated");
|
||||
const id = res.created?.n?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the role was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function updateRole(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||
if (!Object.keys(patch).length) return;
|
||||
const res = await client.call<SetResponse>("x:Role/set", { update: { [id]: patch } });
|
||||
throwIfRefused(res, "notUpdated");
|
||||
}
|
||||
|
||||
export async function destroyRole(id: string): Promise<void> {
|
||||
const res = await client.call<SetResponse>("x:Role/set", { destroy: [id] });
|
||||
throwIfRefused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
/** A set property's changes as one pointer per name, so nothing else in the set is touched. */
|
||||
export function setPatch(property: string, before: Iterable<string>, after: Iterable<string>): Record<string, true | null> {
|
||||
const was = new Set(before);
|
||||
const now = new Set(after);
|
||||
const patch: Record<string, true | null> = {};
|
||||
for (const n of was) if (!now.has(n)) patch[`${property}/${n}`] = null;
|
||||
for (const n of now) if (!was.has(n)) patch[`${property}/${n}`] = true;
|
||||
return patch;
|
||||
}
|
||||
|
||||
/** What a permission is, on the role being edited. */
|
||||
export type PermissionState = "allow" | "deny" | "none";
|
||||
|
||||
/**
|
||||
* What a role's bases grant and take away, and which base each came through.
|
||||
*
|
||||
* Stalwart unions every role in the tree -- enabled with enabled, disabled with
|
||||
* disabled -- and then takes the disabled set away (`permissions.rs`), so a
|
||||
* denial on a base role holds on every role built on it.
|
||||
*/
|
||||
export function inherited(roleIds: readonly string[], roles: ReadonlyMap<string, DirectoryRole>, exclude?: string): { granted: Map<string, string>; denied: Map<string, string> } {
|
||||
const granted = new Map<string, string>();
|
||||
const denied = new Map<string, string>();
|
||||
const seen = new Set<string>(exclude ? [exclude] : []);
|
||||
const walk = (id: string, via: string) => {
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
const role = roles.get(id);
|
||||
if (!role) return;
|
||||
for (const p of Object.keys(role.enabledPermissions ?? {})) if (!granted.has(p)) granted.set(p, via);
|
||||
for (const p of Object.keys(role.disabledPermissions ?? {})) if (!denied.has(p)) denied.set(p, via);
|
||||
for (const child of Object.keys(role.roleIds ?? {})) walk(child, via);
|
||||
};
|
||||
for (const id of roleIds) walk(id, id);
|
||||
return { granted, denied };
|
||||
}
|
||||
|
||||
/** The roles a role may build on: not itself, and none that already builds on it. */
|
||||
export function canBuildOn(roleId: string | null, candidate: string, roles: ReadonlyMap<string, DirectoryRole>): boolean {
|
||||
if (!roleId) return roles.has(candidate);
|
||||
if (candidate === roleId) return false;
|
||||
const seen = new Set<string>();
|
||||
const reaches = (id: string): boolean => {
|
||||
if (id === roleId) return true;
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return Object.keys(roles.get(id)?.roleIds ?? {}).some(reaches);
|
||||
};
|
||||
return !reaches(candidate);
|
||||
}
|
||||
|
||||
/** Everything a role grants once its bases are followed and every denial in the tree taken away. */
|
||||
export function effectivePermissions(role: Pick<DirectoryRole, "enabledPermissions" | "disabledPermissions" | "roleIds">, roles: ReadonlyMap<string, DirectoryRole>, self?: string): Set<string> {
|
||||
const base = inherited(Object.keys(role.roleIds ?? {}), roles, self);
|
||||
const out = new Set<string>([...base.granted.keys(), ...Object.keys(role.enabledPermissions ?? {})]);
|
||||
for (const p of [...base.denied.keys(), ...Object.keys(role.disabledPermissions ?? {})]) out.delete(p);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a role carries a permission the viewer does not hold, which makes it
|
||||
* read-only to them. Everything enabled anywhere in its tree counts, denied or
|
||||
* not: that is what Stalwart checks a grant against, and what a delete -- which
|
||||
* it does not check -- would otherwise let someone take away.
|
||||
*/
|
||||
export function roleOutranks(viewer: Permissions, role: DirectoryRole, roles: ReadonlyMap<string, DirectoryRole>): boolean {
|
||||
const granted = new Set<string>([...inherited(Object.keys(role.roleIds ?? {}), roles, role.id).granted.keys(), ...Object.keys(role.enabledPermissions ?? {})]);
|
||||
for (const p of granted) if (!viewer.has(p)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { currentLanguage } from "@/lib/i18n";
|
||||
import { DEFAULT_UI_LANGUAGE } from "@/lib/languages";
|
||||
|
||||
/**
|
||||
* Stalwart's permission labels, in the reader's language.
|
||||
*
|
||||
* The server publishes one English label per permission -- "Accounts
|
||||
* Management: Create accounts" -- and nothing else. Each language has its own
|
||||
* file under `locales/permissions`, loaded only when the Roles screen asks, so
|
||||
* six hundred labels do not ride along with every page of mail.
|
||||
*
|
||||
* A file is keyed by permission name, not by the English label, so a change
|
||||
* to Stalwart's wording does not orphan its translation. A permission a later
|
||||
* Stalwart adds has no entry yet, and shows the server's English label until
|
||||
* one is written.
|
||||
*/
|
||||
|
||||
export interface PermissionCatalog {
|
||||
/** The English heading before the colon, e.g. "Accounts Management", to its translation. */
|
||||
categories: Record<string, string>;
|
||||
/** Permission name to the translated action, the part after the colon. */
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PermissionInfo {
|
||||
name: string;
|
||||
/** Stalwart's English label, as the server sent it. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PermissionEntry {
|
||||
name: string;
|
||||
/** The heading this permission is listed under, in the reader's language. */
|
||||
category: string;
|
||||
/** The English heading, which is what groups are keyed by. */
|
||||
categoryKey: string;
|
||||
/** What it allows, in the reader's language. */
|
||||
action: string;
|
||||
}
|
||||
|
||||
/** The heading a label without one is listed under. */
|
||||
export const GENERAL_CATEGORY = "General";
|
||||
|
||||
/** Split Stalwart's "Heading: action" label at its first colon. */
|
||||
export function splitLabel(label: string): { categoryKey: string; action: string } {
|
||||
const at = label.indexOf(":");
|
||||
if (at < 0) return { categoryKey: GENERAL_CATEGORY, action: label };
|
||||
return { categoryKey: label.slice(0, at).trim(), action: label.slice(at + 1).trim() };
|
||||
}
|
||||
|
||||
const loaded = new Map<string, Promise<PermissionCatalog | null>>();
|
||||
|
||||
/** The catalogue for a language, or null for English and for a language without a file. */
|
||||
export function loadPermissionCatalog(tag: string = currentLanguage()): Promise<PermissionCatalog | null> {
|
||||
if (tag === DEFAULT_UI_LANGUAGE) return Promise.resolve(null);
|
||||
let pending = loaded.get(tag);
|
||||
if (!pending) {
|
||||
pending = import(`../locales/permissions/${tag}.ts`).then(
|
||||
(m: { permissionCatalog: PermissionCatalog }) => m.permissionCatalog,
|
||||
() => null,
|
||||
);
|
||||
loaded.set(tag, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Each permission with its heading and action in the reader's language, falling back to Stalwart's English. */
|
||||
export function describePermissions(list: readonly PermissionInfo[], catalog: PermissionCatalog | null, generalLabel: string): PermissionEntry[] {
|
||||
return list.map(({ name, label }) => {
|
||||
const { categoryKey, action } = splitLabel(label);
|
||||
const category = categoryKey === GENERAL_CATEGORY ? generalLabel : (catalog?.categories[categoryKey] ?? categoryKey);
|
||||
return { name, categoryKey, category, action: catalog?.labels[name] ?? action };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user