Open Administration on a dashboard of what the role can read
Administration used to open on its first section. It opens on a grid of cards now: users, domains, messages waiting in the delivery queue, server memory, and the last 24 hours' received and sent. Each card is there only when the role holds what its number needs -- a count is a query, the metric history a query and a get -- so a helpdesk role that reads accounts and domains sees those two cards and nothing about the server. What the cards count is whatever Stalwart answers for the signed-in account, which scopes a tenant administrator's accounts, domains and queue to the tenancy. The metric history has no tenant in it, and Stalwart's Tenant Administrator role does not hold it, so a tenant's dashboard is users, domains and pending. The history is Enterprise-only and switched off by default. A server that refuses it leaves those cards off; one that records nothing says so rather than showing zeroes. Received and sent add up the queue counters Stalwart's own dashboard uses, filtered with the comparison names the live server accepts (a bare timestamp is unsupportedFilter). The column count follows the number of cards so rows stay even, and falls back by the grid's own width rather than the window's. The server's test for whether an account is offered Administration matches the client's again, now that a count is enough. The mock answers the queue and an hourly history ending in the current hour; MOCK_METRICS=off refuses the history as Community does, a tenant administrator gets the queue, and helpdesk reads domains, as the demo's does. ROADMAP and FEATURES said reporting and queues were out of scope; they say the dashboard reads a handful of numbers and that managing queues, logs and settings stays out. KNOWN-ISSUES records what was settled on the live server and what was only read from source. Fourteen new strings, in all nine catalogues.
This commit is contained in:
@@ -57,7 +57,12 @@ test("administration needs both the installation and a device marked as the pers
|
||||
test("an account counts as an administrator by the same test the menu makes", () => {
|
||||
assert.equal(grantsAdministration(["sysAccountQuery", "sysAccountGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysDomainQuery", "sysDomainGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysAccountQuery", "sysDomainGet"]), false);
|
||||
// The dashboard opens on less than a list: a count is only a query.
|
||||
assert.equal(grantsAdministration(["sysAccountQuery"]), true);
|
||||
assert.equal(grantsAdministration(["sysQueuedMessageQuery"]), true);
|
||||
assert.equal(grantsAdministration(["sysMetricQuery", "sysMetricGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysMetricQuery"]), false);
|
||||
assert.equal(grantsAdministration(["sysAccountGet", "sysDomainGet"]), false);
|
||||
assert.equal(grantsAdministration(["jmapEmailGet", "sysAccountSettingsGet"]), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -41,10 +41,15 @@ export function administrationAllowed(enabled: boolean, remember: boolean): bool
|
||||
* Whether an account's permissions would put Administration in its menu --
|
||||
* the same test the client makes, so the server can say why it is missing
|
||||
* without handing over the permissions themselves.
|
||||
*
|
||||
* The client's test is whether any section opens, and the dashboard opens on
|
||||
* less than a list does: a count needs only the query, the metric history its
|
||||
* query and get. The account and domain lists need more than their counts, so
|
||||
* they add nothing here.
|
||||
*/
|
||||
export function grantsAdministration(permissions: readonly string[]): boolean {
|
||||
const has = new Set(permissions);
|
||||
return (has.has("sysAccountQuery") && has.has("sysAccountGet")) || (has.has("sysDomainQuery") && has.has("sysDomainGet"));
|
||||
return has.has("sysAccountQuery") || has.has("sysDomainQuery") || has.has("sysQueuedMessageQuery") || (has.has("sysMetricQuery") && has.has("sysMetricGet"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ class Refused extends Error {
|
||||
constructor(readonly type: string, description?: string) { super(description ?? type); }
|
||||
}
|
||||
|
||||
const make = (role: MockRole) => createDirectory({ accountId: "a1", user: "[email protected]", locale: "en_US", role, fail: (t, d) => new Refused(t, d) });
|
||||
const make = (role: MockRole, extra: { metricsOff?: boolean; now?: Date } = {}) => createDirectory({ accountId: "a1", user: "[email protected]", locale: "en_US", role, fail: (t, d) => new Refused(t, d), ...extra });
|
||||
|
||||
/**
|
||||
* The mock stands in for a server that decides what each account may do, so
|
||||
@@ -109,3 +109,41 @@ test("the domain validators refuse what the live server refused, in its words",
|
||||
const updated = set({ update: { d2: { catchAllAddress: "postmaster" } } }) as { notUpdated?: Record<string, { type: string; description: string }> };
|
||||
assert.deepEqual([updated.notUpdated?.d2?.type, updated.notUpdated?.d2?.description], ["invalidPatch", "Invalid email address"]);
|
||||
});
|
||||
|
||||
/** The dashboard's feeds: counts, the queue, and the metric history. */
|
||||
test("counts come back with no ids when the client asks for a total and no page", () => {
|
||||
const dir = make("admin");
|
||||
const r = dir.handlers["x:QueuedMessage/query"]!({ limit: 0, calculateTotal: true }) as { ids: string[]; total: number };
|
||||
assert.deepEqual(r.ids, []);
|
||||
assert.equal(r.total, 9);
|
||||
});
|
||||
|
||||
test("the metric history answers the filter the dashboard sends, newest first", () => {
|
||||
const dir = make("admin", { now: new Date("2026-09-15T14:25:00Z") });
|
||||
const q = dir.handlers["x:Metric/query"]!({
|
||||
filter: { timestampIsGreaterThanOrEqual: "2026-09-14T14:25:00Z", metric: ["server.memory"] },
|
||||
sort: [{ property: "timestamp", isAscending: false }],
|
||||
}) as { ids: string[] };
|
||||
const { list } = dir.handlers["x:Metric/get"]!({ ids: q.ids }) as { list: Array<{ metric: string; timestamp: string }> };
|
||||
assert.equal(list.length, 24);
|
||||
assert.ok(list.every((m) => m.metric === "server.memory"));
|
||||
const newest = (dir.handlers["x:Metric/get"]!({ ids: [q.ids[0]] }) as { list: Array<{ timestamp: string }> }).list[0]!;
|
||||
const next = (dir.handlers["x:Metric/get"]!({ ids: [q.ids[1]] }) as { list: Array<{ timestamp: string }> }).list[0]!;
|
||||
assert.equal(newest.timestamp, "2026-09-15T14:00:00Z");
|
||||
assert.ok(newest.timestamp > next.timestamp);
|
||||
// A bare timestamp is what a live server refuses.
|
||||
assert.throws(() => dir.handlers["x:Metric/query"]!({ filter: { timestamp: "2026-09-15T00:00:00Z" } }), (e: Refused) => e.type === "unsupportedFilter");
|
||||
});
|
||||
|
||||
test("a tenant administrator gets the queue but not the history, and Community refuses the history", () => {
|
||||
const tenant = make("tenant-admin");
|
||||
assert.equal((tenant.handlers["x:QueuedMessage/query"]!({ calculateTotal: true }) as { total: number }).total, 9);
|
||||
assert.throws(() => tenant.handlers["x:Metric/query"]!({}), (e: Refused) => e.type === "forbidden");
|
||||
const community = make("admin", { metricsOff: true });
|
||||
assert.throws(() => community.handlers["x:Metric/query"]!({}), (e: Refused) => e.type === "forbidden" && /Enterprise/.test(e.message));
|
||||
});
|
||||
|
||||
test("helpdesk may count domains, which is what the demo's helpdesk may do", () => {
|
||||
assert.ok(permissionsFor("helpdesk").includes("sysDomainQuery"));
|
||||
assert.ok(!permissionsFor("helpdesk").includes("sysMetricQuery"));
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
* credential's own pointer, `credentials/<index>/secret`;
|
||||
* - `x:Account/query` understands AND and nothing else.
|
||||
*
|
||||
* It also answers the two feeds Administration's dashboard reads: a short
|
||||
* outbound queue (`x:QueuedMessage`) and a day and a bit of hourly metric
|
||||
* history (`x:Metric`), dated from when the mock started. MOCK_METRICS=off
|
||||
* refuses the history the way a Community server does.
|
||||
*
|
||||
* What it does not reproduce is tenancy: every caller sees every record. The
|
||||
* real server scopes a tenant administrator's queries, and nothing in the client
|
||||
* relies on seeing more or less than it is given.
|
||||
@@ -29,17 +34,22 @@ export type MockRole = "admin" | "tenant-admin" | "helpdesk" | "user";
|
||||
const OPS = ["Get", "Query", "Create", "Update", "Destroy"] as const;
|
||||
const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sys${o}${op}`));
|
||||
|
||||
/** What the dashboard reads beyond the directory. */
|
||||
const READ_SERVER = ["sysQueuedMessageGet", "sysQueuedMessageQuery", "sysMetricGet", "sysMetricQuery"];
|
||||
|
||||
/** A few of the ordinary ones, so the list looks like what a server sends. */
|
||||
const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailSet", "jmapMailboxGet", "sysAccountSettingsGet"];
|
||||
|
||||
export function permissionsFor(role: MockRole): string[] {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), "impersonate"];
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), ...READ_SERVER, "impersonate"];
|
||||
case "tenant-admin":
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer")];
|
||||
// The queue but not the metric history: Stalwart scopes the one to a
|
||||
// tenant's domains, and the other has no tenant to scope it by.
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer"), "sysQueuedMessageGet", "sysQueuedMessageQuery"];
|
||||
case "helpdesk":
|
||||
return [...USER_PERMISSIONS, "sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
|
||||
return [...USER_PERMISSIONS, "sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "sysDomainGet", "sysDomainQuery"];
|
||||
default:
|
||||
return USER_PERMISSIONS;
|
||||
}
|
||||
@@ -61,6 +71,10 @@ interface Options {
|
||||
role: MockRole;
|
||||
/** Build the error a method fails with; the mock server owns the type. */
|
||||
fail: (type: string, description?: string) => Error;
|
||||
/** Refuse the metric history, as a Community server does. */
|
||||
metricsOff?: boolean;
|
||||
/** When the history ends; the newest hour is the one this falls in. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export function createDirectory(opts: Options) {
|
||||
@@ -168,6 +182,38 @@ export function createDirectory(opts: Options) {
|
||||
user({ name, domain: i % 3 === 0 ? "d2" : "d1", description, used: (i % 7) * 0.6, quota: i % 4 === 0 ? 0 : 5 });
|
||||
});
|
||||
|
||||
// Nine messages waiting, which is what a small live server had queued on the
|
||||
// day this was written: a few retries and the odd report.
|
||||
const queue: Obj[] = Array.from({ length: 9 }, (_, i) => ({ id: `q${i + 1}`, createdAt: new Date(Date.UTC(2026, 8, 15, 6 + i)).toISOString(), size: 2400 + i * 310, priority: 0, flags: {} }));
|
||||
|
||||
/**
|
||||
* Thirty hours of history ending in the current hour: a Counter per hour for
|
||||
* what was queued, and a memory Gauge. Counters that would be zero are left
|
||||
* out, as Stalwart leaves them out.
|
||||
*/
|
||||
const metrics: Obj[] = [];
|
||||
{
|
||||
const hour = 3600_000;
|
||||
const end = Math.floor((opts.now ?? new Date()).getTime() / hour) * hour;
|
||||
for (let h = 29; h >= 0; h--) {
|
||||
const at = end - h * hour;
|
||||
const timestamp = new Date(at).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const seq = (29 - h) * 10;
|
||||
const push = (n: number, type: string, metric: string, count: number) => {
|
||||
if (type === "Counter" && !count) return;
|
||||
metrics.push({ id: `m${String(seq + n).padStart(4, "0")}`, "@type": type, metric, count, timestamp });
|
||||
};
|
||||
push(0, "Gauge", "server.memory", 360_000_000 + ((h * 7_919_000) % 40_000_000));
|
||||
push(1, "Counter", "queue.message-queued", (h * 5 + 3) % 9);
|
||||
push(2, "Counter", "queue.authenticated-message-queued", h % 3);
|
||||
push(3, "Counter", "queue.dsn-queued", h % 11 === 0 ? 1 : 0);
|
||||
push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0);
|
||||
}
|
||||
}
|
||||
const refuseMetrics = () => {
|
||||
if (opts.metricsOff) throw opts.fail("forbidden", "This feature is only available in the Enterprise edition of Stalwart.");
|
||||
};
|
||||
|
||||
const demand = (perm: string) => {
|
||||
if (!permissions.has(perm)) throw opts.fail("forbidden", `You do not have the ${perm} permission.`);
|
||||
};
|
||||
@@ -394,6 +440,21 @@ export function createDirectory(opts: Options) {
|
||||
demand("sysDnsServerGet");
|
||||
return { accountId: opts.accountId, state: "1", list: ((a.ids as string[]) ?? ["ns1"]).filter((id) => id === "ns1").map((id) => ({ id, "@type": "Cloudflare", description: "Cloudflare (main zone)" })), notFound: [] };
|
||||
},
|
||||
"x:QueuedMessage/get": get(queue, "sysQueuedMessageGet"),
|
||||
"x:QueuedMessage/query": query(() => queue, "sysQueuedMessageQuery", [], () => true),
|
||||
"x:Metric/get": (a) => {
|
||||
refuseMetrics();
|
||||
return get(metrics, "sysMetricGet")(a);
|
||||
},
|
||||
// Ids sort the way timestamps do, so the helper's newest-first order is the
|
||||
// `timestamp` descending the dashboard asks for.
|
||||
"x:Metric/query": (a) => {
|
||||
refuseMetrics();
|
||||
return query(() => metrics, "sysMetricQuery", ["timestampIsGreaterThanOrEqual", "timestampIsLessThanOrEqual", "metric"], (o, f) =>
|
||||
(f.timestampIsGreaterThanOrEqual === undefined || String(o.timestamp) >= String(f.timestampIsGreaterThanOrEqual)) &&
|
||||
(f.timestampIsLessThanOrEqual === undefined || String(o.timestamp) <= String(f.timestampIsLessThanOrEqual)) &&
|
||||
(!Array.isArray(f.metric) || (f.metric as string[]).includes(o.metric as string)))(a);
|
||||
},
|
||||
"x:Role/get": get(roles, "sysRoleGet"),
|
||||
"x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => matchText(o, f.description)),
|
||||
};
|
||||
|
||||
@@ -892,6 +892,7 @@ const directory = createDirectory({
|
||||
user: USER,
|
||||
locale: MOCK_LOCALE,
|
||||
role: mockRole(process.env.MOCK_ROLE),
|
||||
metricsOff: process.env.MOCK_METRICS === "off",
|
||||
fail: (type, description) => new MethodError(type, description),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user