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:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ADMIN_BASELINE, adminSections, can, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||
import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||
|
||||
const set = (...p: string[]) => permissionSet(p);
|
||||
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
||||
@@ -12,17 +12,27 @@ const roles = new Map<string, RoleDef>([
|
||||
]);
|
||||
|
||||
describe("who is offered administration", () => {
|
||||
it("needs both halves of reading the account list", () => {
|
||||
expect(hasAdministration(set("sysAccountQuery", "sysAccountGet"))).toBe(true);
|
||||
expect(hasAdministration(set("sysAccountQuery"))).toBe(false);
|
||||
it("needs both halves of reading the account list to list accounts", () => {
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet"))).toEqual(["dashboard", "accounts"]);
|
||||
// A query alone is a count on the dashboard, not a list.
|
||||
expect(adminSections(set("sysAccountQuery"))).toEqual(["dashboard"]);
|
||||
expect(hasAdministration(set("sysAccountGet"))).toBe(false);
|
||||
expect(hasAdministration(permissionSet(undefined))).toBe(false);
|
||||
});
|
||||
|
||||
it("offers each section only with both halves of reading it", () => {
|
||||
expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["domains"]);
|
||||
expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["dashboard", "domains"]);
|
||||
expect(hasAdministration(set("sysDomainQuery", "sysDomainGet"))).toBe(true);
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["accounts"]);
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["dashboard", "accounts"]);
|
||||
});
|
||||
|
||||
it("gives the dashboard a card for each number the role can read", () => {
|
||||
expect(dashboardCards(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"))).toEqual(["users", "domains"]);
|
||||
expect(dashboardCards(set("sysQueuedMessageQuery"))).toEqual(["pending"]);
|
||||
// The history takes its get as well: the query only finds the records.
|
||||
expect(dashboardCards(set("sysMetricQuery"))).toEqual([]);
|
||||
expect(dashboardCards(set("sysMetricQuery", "sysMetricGet"))).toEqual(["memory", "received", "sent"]);
|
||||
expect(adminSections(set("jmapEmailGet"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads one permission per object and operation", () => {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client, JmapMethodError } from "@/jmap/client";
|
||||
import { balancedColumns, countObjects, isRefused, loadMetrics, summariseMetrics, type MetricRecord } from "@/lib/adminDashboard";
|
||||
|
||||
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
|
||||
|
||||
describe("the dashboard's message numbers", () => {
|
||||
it("adds received and sent up over the metric names Stalwart's own dashboard uses", () => {
|
||||
const stats = summariseMetrics([
|
||||
counter("queue.message-queued", 6),
|
||||
counter("queue.message-queued", 4, "2026-09-15T13:00:00Z"),
|
||||
counter("queue.authenticated-message-queued", 2),
|
||||
counter("queue.dsn-queued", 1),
|
||||
counter("queue.report-queued", 3),
|
||||
// Recorded, but not either number.
|
||||
counter("message-ingest.ham", 50),
|
||||
]);
|
||||
expect(stats.received).toBe(10);
|
||||
expect(stats.sent).toBe(6);
|
||||
});
|
||||
|
||||
it("reads memory from the newest gauge, not the first one listed", () => {
|
||||
const stats = summariseMetrics([
|
||||
{ "@type": "Gauge", metric: "server.memory", count: 100, timestamp: "2026-09-15T12:00:00Z" },
|
||||
{ "@type": "Gauge", metric: "server.memory", count: 300, timestamp: "2026-09-15T14:00:00Z" },
|
||||
{ "@type": "Gauge", metric: "queue.count", count: 7, timestamp: "2026-09-15T15:00:00Z" },
|
||||
]);
|
||||
expect(stats.memory).toEqual({ bytes: 300, at: "2026-09-15T14:00:00Z" });
|
||||
});
|
||||
|
||||
it("tells a history that records nothing from a quiet day", () => {
|
||||
expect(summariseMetrics([]).recorded).toBe(false);
|
||||
const quiet = summariseMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
|
||||
expect(quiet).toMatchObject({ recorded: true, received: 0, sent: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the dashboard's queries", () => {
|
||||
it("counts users rather than accounts, and asks for no ids", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 5 });
|
||||
expect(await countObjects("Account")).toBe(5);
|
||||
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User" }, limit: 0, calculateTotal: true });
|
||||
await countObjects("QueuedMessage");
|
||||
expect(call).toHaveBeenLastCalledWith("x:QueuedMessage/query", { limit: 0, calculateTotal: true });
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("filters the history with Stalwart's comparison names, and pages the gets", async () => {
|
||||
// A bare `timestamp` or `after` is unsupportedFilter on a live server.
|
||||
vi.spyOn(client, "maxObjectsInGet", "get").mockReturnValue(2);
|
||||
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
|
||||
if (method === "x:Metric/query") return (args as { position: number }).position === 0 ? { ids: ["a", "b"] } : { ids: ["c"] };
|
||||
return { list: ((args as { ids: string[] }).ids).map((id) => counter("queue.message-queued", 1, id)) };
|
||||
});
|
||||
const records = await loadMetrics(new Date("2026-09-14T15:30:00.123Z"));
|
||||
expect(records).toHaveLength(3);
|
||||
expect(call.mock.calls[0]).toEqual([
|
||||
"x:Metric/query",
|
||||
{
|
||||
filter: { timestampIsGreaterThanOrEqual: "2026-09-14T15:30:00Z", metric: ["queue.message-queued", "queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued", "server.memory"] },
|
||||
sort: [{ property: "timestamp", isAscending: false }],
|
||||
position: 0,
|
||||
limit: 2,
|
||||
},
|
||||
]);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("treats only a forbidden answer as the server refusing", () => {
|
||||
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "forbidden" }))).toBe(true);
|
||||
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "serverFail" }))).toBe(false);
|
||||
expect(isRefused(new Error("offline"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the card grid", () => {
|
||||
it("never leaves a row short when the cards can be divided evenly", () => {
|
||||
for (const n of [1, 2, 3, 4, 6]) {
|
||||
const { wide, mid } = balancedColumns(n);
|
||||
expect(n % wide, `${n} cards across ${wide}`).toBe(0);
|
||||
expect(n % mid, `${n} cards across ${mid}`).toBe(0);
|
||||
expect(wide).toBeLessThanOrEqual(4);
|
||||
}
|
||||
expect(balancedColumns(6)).toEqual({ wide: 3, mid: 2 });
|
||||
expect(balancedColumns(3)).toEqual({ wide: 3, mid: 1 });
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
* for a check Stalwart does not make. See there.
|
||||
*/
|
||||
|
||||
export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant";
|
||||
export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant" | "QueuedMessage" | "Metric";
|
||||
export type AdminOp = "Get" | "Query" | "Create" | "Update" | "Destroy";
|
||||
|
||||
export type Permissions = ReadonlySet<string>;
|
||||
@@ -27,16 +27,39 @@ export function can(perms: Permissions, object: AdminObject, op: AdminOp): boole
|
||||
return perms.has(`sys${object}${op}`);
|
||||
}
|
||||
|
||||
export type AdminSection = "accounts" | "domains";
|
||||
export type AdminSection = "dashboard" | "accounts" | "domains";
|
||||
|
||||
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
|
||||
|
||||
/**
|
||||
* The dashboard's cards an account may see.
|
||||
*
|
||||
* A count is a query with `calculateTotal`, so a query alone earns one. The
|
||||
* three read from the metric history need the get as well, since the query
|
||||
* only finds the records. Stalwart scopes the first three to a tenant
|
||||
* administrator's own tenancy; the metric history has no tenant in it at all,
|
||||
* and the Tenant Administrator role Stalwart creates does not hold it -- which
|
||||
* is how a tenant's dashboard comes to show only what is theirs.
|
||||
*/
|
||||
export function dashboardCards(perms: Permissions): DashboardCard[] {
|
||||
const out: DashboardCard[] = [];
|
||||
if (can(perms, "Account", "Query")) out.push("users");
|
||||
if (can(perms, "Domain", "Query")) out.push("domains");
|
||||
if (can(perms, "QueuedMessage", "Query")) out.push("pending");
|
||||
if (can(perms, "Metric", "Query") && can(perms, "Metric", "Get")) out.push("memory", "received", "sent");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections an account may open, in the order they are listed.
|
||||
*
|
||||
* A list that cannot be read is not worth an entry, so each takes both halves
|
||||
* of reading one: the query that finds the objects and the get that shows them.
|
||||
* The dashboard comes first, and is there whenever it has a card to show.
|
||||
*/
|
||||
export function adminSections(perms: Permissions): AdminSection[] {
|
||||
const out: AdminSection[] = [];
|
||||
if (dashboardCards(perms).length) out.push("dashboard");
|
||||
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts");
|
||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
|
||||
return out;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { client, JmapMethodError } from "@/jmap/client";
|
||||
|
||||
/**
|
||||
* The numbers on Administration's dashboard, over the ordinary JMAP proxy.
|
||||
*
|
||||
* Counts are queries with `calculateTotal` and `limit: 0`: Stalwart lifts its
|
||||
* own limit when asked for a total, so the number is the whole count, and no
|
||||
* ids come back to be thrown away. For a tenant administrator the server scopes
|
||||
* all three to the tenancy -- accounts and domains to its members, the queue to
|
||||
* messages touching its domains.
|
||||
*
|
||||
* The rest is read from `x:Metric`, the history Stalwart records once per
|
||||
* collection interval (hourly by default): a Counter holds what happened in
|
||||
* that interval, a Gauge the reading at its end. Received and sent are the sums
|
||||
* Stalwart's own dashboard shows, over the same metric names. The history is
|
||||
* Enterprise-only and has to be switched on (`x:MetricsStore`); a Community
|
||||
* server refuses the query as `forbidden`, and one that records nothing
|
||||
* answers with nothing -- the two cases the dashboard tells apart.
|
||||
*/
|
||||
|
||||
export const RECEIVED_METRICS = ["queue.message-queued"] as const;
|
||||
export const SENT_METRICS = ["queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued"] as const;
|
||||
export const MEMORY_METRIC = "server.memory";
|
||||
|
||||
/** The window received and sent cover. */
|
||||
export const DASHBOARD_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface MetricRecord {
|
||||
"@type": "Counter" | "Gauge" | "Histogram";
|
||||
metric: string;
|
||||
count: number;
|
||||
timestamp: string;
|
||||
sum?: number;
|
||||
}
|
||||
|
||||
export interface MessageStats {
|
||||
received: number;
|
||||
sent: number;
|
||||
/** The latest memory reading, or null when none was recorded in the window. */
|
||||
memory: { bytes: number; at: string } | null;
|
||||
/**
|
||||
* Whether the server recorded anything in the window. Memory is written every
|
||||
* interval, so a window with no records at all is a history that is switched
|
||||
* off -- not a quiet day, which would still say zero.
|
||||
*/
|
||||
recorded: boolean;
|
||||
}
|
||||
|
||||
export function summariseMetrics(records: readonly MetricRecord[]): MessageStats {
|
||||
let received = 0;
|
||||
let sent = 0;
|
||||
let memory: MessageStats["memory"] = null;
|
||||
const receivedNames = new Set<string>(RECEIVED_METRICS);
|
||||
const sentNames = new Set<string>(SENT_METRICS);
|
||||
for (const r of records) {
|
||||
if (r["@type"] === "Counter") {
|
||||
if (receivedNames.has(r.metric)) received += r.count;
|
||||
else if (sentNames.has(r.metric)) sent += r.count;
|
||||
} else if (r["@type"] === "Gauge" && r.metric === MEMORY_METRIC) {
|
||||
if (!memory || r.timestamp > memory.at) memory = { bytes: r.count, at: r.timestamp };
|
||||
}
|
||||
}
|
||||
return { received, sent, memory, recorded: records.length > 0 };
|
||||
}
|
||||
|
||||
type CountedObject = "Account" | "Domain" | "QueuedMessage";
|
||||
|
||||
/** How many there are. Accounts are counted as users: groups are accounts too. */
|
||||
export async function countObjects(object: CountedObject): Promise<number> {
|
||||
const res = await client.call<{ total?: number; ids?: string[] }>(`x:${object}/query`, {
|
||||
...(object === "Account" ? { filter: { "@type": "User" } } : {}),
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
});
|
||||
return res.total ?? res.ids?.length ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every record of the dashboard's metrics since `since`, newest first.
|
||||
*
|
||||
* The filter keys are Stalwart's comparison names for the property -- a bare
|
||||
* `timestamp` is `unsupportedFilter`. A day at the default hourly interval is
|
||||
* well under one page; the paging is for a server that collects far more often.
|
||||
*/
|
||||
export async function loadMetrics(since: Date): Promise<MetricRecord[]> {
|
||||
const filter = {
|
||||
timestampIsGreaterThanOrEqual: since.toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||
metric: [...RECEIVED_METRICS, ...SENT_METRICS, MEMORY_METRIC],
|
||||
};
|
||||
const step = client.maxObjectsInGet;
|
||||
const out: MetricRecord[] = [];
|
||||
for (let position = 0; ; position += step) {
|
||||
const q = await client.call<{ ids?: string[] }>("x:Metric/query", { filter, sort: [{ property: "timestamp", isAscending: false }], position, limit: step });
|
||||
const ids = q.ids ?? [];
|
||||
if (ids.length) out.push(...(await client.call<{ list: MetricRecord[] }>("x:Metric/get", { ids })).list);
|
||||
if (ids.length < step) return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many columns the cards take, so no row is left short.
|
||||
*
|
||||
* `wide` is the most that divides the cards evenly without going past four;
|
||||
* `mid` is what they drop to when that no longer fits, again only a count the
|
||||
* cards divide into -- three cards go to one column rather than two and one.
|
||||
* Five is the one count nothing divides, and takes three over two. A phone
|
||||
* always gets one column, which the stylesheet decides.
|
||||
*/
|
||||
export function balancedColumns(cards: number): { wide: number; mid: number } {
|
||||
switch (cards) {
|
||||
case 0:
|
||||
case 1:
|
||||
return { wide: 1, mid: 1 };
|
||||
case 2:
|
||||
return { wide: 2, mid: 2 };
|
||||
case 3:
|
||||
return { wide: 3, mid: 1 };
|
||||
case 4:
|
||||
return { wide: 4, mid: 2 };
|
||||
default:
|
||||
return { wide: 3, mid: 2 };
|
||||
}
|
||||
}
|
||||
|
||||
/** A refusal from the server itself, as opposed to a failure to reach it. */
|
||||
export function isRefused(err: unknown): boolean {
|
||||
return err instanceof JmapMethodError && err.error.type === "forbidden";
|
||||
}
|
||||
@@ -133,6 +133,20 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Ihr eigenes Passwort ändern Sie unter {settings}.",
|
||||
"Administration": "Verwaltung",
|
||||
"Directory": "Verzeichnis",
|
||||
"Overview": "Übersicht",
|
||||
"Dashboard": "Dashboard",
|
||||
"Users": "Benutzer",
|
||||
"User accounts": "Benutzerkonten",
|
||||
"Mail domains": "E-Mail-Domains",
|
||||
"Pending": "Ausstehend",
|
||||
"Waiting in the delivery queue": "In der Zustellwarteschlange",
|
||||
"Server memory": "Arbeitsspeicher des Servers",
|
||||
"As of {time}": "Stand: {time}",
|
||||
"Not recorded on this server": "Auf diesem Server nicht erfasst",
|
||||
"Last 24 hours": "Letzte 24 Stunden",
|
||||
"The numbers your role can see, as the server reports them.": "Die Zahlen, die Ihre Rolle sehen darf, so wie der Server sie meldet.",
|
||||
"Nothing to show": "Nichts anzuzeigen",
|
||||
"Could not be loaded": "Konnte nicht geladen werden",
|
||||
"User": "Benutzer",
|
||||
"Administrator": "Administrator",
|
||||
"Custom role": "Eigene Rolle",
|
||||
|
||||
@@ -125,6 +125,20 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Cambie su propia contraseña en {settings}.",
|
||||
"Administration": "Administración",
|
||||
"Directory": "Directorio",
|
||||
"Overview": "Resumen",
|
||||
"Dashboard": "Panel",
|
||||
"Users": "Usuarios",
|
||||
"User accounts": "Cuentas de usuario",
|
||||
"Mail domains": "Dominios de correo",
|
||||
"Pending": "Pendientes",
|
||||
"Waiting in the delivery queue": "En la cola de entrega",
|
||||
"Server memory": "Memoria del servidor",
|
||||
"As of {time}": "A las {time}",
|
||||
"Not recorded on this server": "No se registra en este servidor",
|
||||
"Last 24 hours": "Últimas 24 horas",
|
||||
"The numbers your role can see, as the server reports them.": "Las cifras que su rol puede ver, tal como las informa el servidor.",
|
||||
"Nothing to show": "Nada que mostrar",
|
||||
"Could not be loaded": "No se pudo cargar",
|
||||
"User": "Usuario",
|
||||
"Administrator": "Administrador",
|
||||
"Custom role": "Rol personalizado",
|
||||
|
||||
@@ -130,6 +130,20 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Modifiez votre propre mot de passe dans {settings}.",
|
||||
"Administration": "Administration",
|
||||
"Directory": "Annuaire",
|
||||
"Overview": "Vue d’ensemble",
|
||||
"Dashboard": "Tableau de bord",
|
||||
"Users": "Utilisateurs",
|
||||
"User accounts": "Comptes utilisateurs",
|
||||
"Mail domains": "Domaines de messagerie",
|
||||
"Pending": "En attente",
|
||||
"Waiting in the delivery queue": "Dans la file de distribution",
|
||||
"Server memory": "Mémoire du serveur",
|
||||
"As of {time}": "Au {time}",
|
||||
"Not recorded on this server": "Non enregistré sur ce serveur",
|
||||
"Last 24 hours": "Dernières 24 heures",
|
||||
"The numbers your role can see, as the server reports them.": "Les chiffres que votre rôle permet de voir, tels que le serveur les indique.",
|
||||
"Nothing to show": "Rien à afficher",
|
||||
"Could not be loaded": "Chargement impossible",
|
||||
"User": "Utilisateur",
|
||||
"Administrator": "Administrateur",
|
||||
"Custom role": "Rôle personnalisé",
|
||||
|
||||
@@ -124,6 +124,20 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "ご自身のパスワードは{settings}で変更してください。",
|
||||
"Administration": "管理",
|
||||
"Directory": "ディレクトリ",
|
||||
"Overview": "概要",
|
||||
"Dashboard": "ダッシュボード",
|
||||
"Users": "ユーザー",
|
||||
"User accounts": "ユーザーアカウント",
|
||||
"Mail domains": "メールドメイン",
|
||||
"Pending": "保留中",
|
||||
"Waiting in the delivery queue": "配送キューで待機中",
|
||||
"Server memory": "サーバーのメモリ",
|
||||
"As of {time}": "{time} 時点",
|
||||
"Not recorded on this server": "このサーバーでは記録されていません",
|
||||
"Last 24 hours": "過去 24 時間",
|
||||
"The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。",
|
||||
"Nothing to show": "表示するものはありません",
|
||||
"Could not be loaded": "読み込めませんでした",
|
||||
"User": "ユーザー",
|
||||
"Administrator": "管理者",
|
||||
"Custom role": "カスタムロール",
|
||||
|
||||
@@ -121,6 +121,20 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Wijzig uw eigen wachtwoord bij {settings}.",
|
||||
"Administration": "Beheer",
|
||||
"Directory": "Adreslijst",
|
||||
"Overview": "Overzicht",
|
||||
"Dashboard": "Dashboard",
|
||||
"Users": "Gebruikers",
|
||||
"User accounts": "Gebruikersaccounts",
|
||||
"Mail domains": "E-maildomeinen",
|
||||
"Pending": "In behandeling",
|
||||
"Waiting in the delivery queue": "In de bezorgwachtrij",
|
||||
"Server memory": "Servergeheugen",
|
||||
"As of {time}": "Stand van {time}",
|
||||
"Not recorded on this server": "Niet vastgelegd op deze server",
|
||||
"Last 24 hours": "Afgelopen 24 uur",
|
||||
"The numbers your role can see, as the server reports them.": "De cijfers die uw rol mag zien, zoals de server ze meldt.",
|
||||
"Nothing to show": "Niets om te tonen",
|
||||
"Could not be loaded": "Kon niet worden geladen",
|
||||
"User": "Gebruiker",
|
||||
"Administrator": "Beheerder",
|
||||
"Custom role": "Aangepaste rol",
|
||||
|
||||
@@ -128,6 +128,20 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Altere sua própria senha em {settings}.",
|
||||
"Administration": "Administração",
|
||||
"Directory": "Diretório",
|
||||
"Overview": "Visão geral",
|
||||
"Dashboard": "Painel",
|
||||
"Users": "Usuários",
|
||||
"User accounts": "Contas de usuário",
|
||||
"Mail domains": "Domínios de e-mail",
|
||||
"Pending": "Pendentes",
|
||||
"Waiting in the delivery queue": "Na fila de entrega",
|
||||
"Server memory": "Memória do servidor",
|
||||
"As of {time}": "Em {time}",
|
||||
"Not recorded on this server": "Não registrado neste servidor",
|
||||
"Last 24 hours": "Últimas 24 horas",
|
||||
"The numbers your role can see, as the server reports them.": "Os números que sua função pode ver, como o servidor os informa.",
|
||||
"Nothing to show": "Nada para mostrar",
|
||||
"Could not be loaded": "Não foi possível carregar",
|
||||
"User": "Usuário",
|
||||
"Administrator": "Administrador",
|
||||
"Custom role": "Função personalizada",
|
||||
|
||||
@@ -127,6 +127,20 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Свой пароль можно изменить в разделе {settings}.",
|
||||
"Administration": "Администрирование",
|
||||
"Directory": "Каталог",
|
||||
"Overview": "Обзор",
|
||||
"Dashboard": "Панель",
|
||||
"Users": "Пользователи",
|
||||
"User accounts": "Учётные записи пользователей",
|
||||
"Mail domains": "Почтовые домены",
|
||||
"Pending": "В очереди",
|
||||
"Waiting in the delivery queue": "Ожидают в очереди доставки",
|
||||
"Server memory": "Память сервера",
|
||||
"As of {time}": "На {time}",
|
||||
"Not recorded on this server": "На этом сервере не записывается",
|
||||
"Last 24 hours": "За последние 24 часа",
|
||||
"The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.",
|
||||
"Nothing to show": "Нечего показать",
|
||||
"Could not be loaded": "Не удалось загрузить",
|
||||
"User": "Пользователь",
|
||||
"Administrator": "Администратор",
|
||||
"Custom role": "Особая роль",
|
||||
|
||||
@@ -121,6 +121,20 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Власний пароль можна змінити в розділі {settings}.",
|
||||
"Administration": "Адміністрування",
|
||||
"Directory": "Каталог",
|
||||
"Overview": "Огляд",
|
||||
"Dashboard": "Панель",
|
||||
"Users": "Користувачі",
|
||||
"User accounts": "Облікові записи користувачів",
|
||||
"Mail domains": "Поштові домени",
|
||||
"Pending": "У черзі",
|
||||
"Waiting in the delivery queue": "Очікують у черзі доставки",
|
||||
"Server memory": "Пам’ять сервера",
|
||||
"As of {time}": "Станом на {time}",
|
||||
"Not recorded on this server": "На цьому сервері не записується",
|
||||
"Last 24 hours": "За останні 24 години",
|
||||
"The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.",
|
||||
"Nothing to show": "Нічого показати",
|
||||
"Could not be loaded": "Не вдалося завантажити",
|
||||
"User": "Користувач",
|
||||
"Administrator": "Адміністратор",
|
||||
"Custom role": "Власна роль",
|
||||
|
||||
@@ -123,6 +123,20 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "请在{settings}中更改您自己的密码。",
|
||||
"Administration": "管理",
|
||||
"Directory": "目录",
|
||||
"Overview": "概览",
|
||||
"Dashboard": "仪表板",
|
||||
"Users": "用户",
|
||||
"User accounts": "用户账户",
|
||||
"Mail domains": "邮件域名",
|
||||
"Pending": "待处理",
|
||||
"Waiting in the delivery queue": "在投递队列中等待",
|
||||
"Server memory": "服务器内存",
|
||||
"As of {time}": "截至 {time}",
|
||||
"Not recorded on this server": "此服务器未记录",
|
||||
"Last 24 hours": "过去 24 小时",
|
||||
"The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。",
|
||||
"Nothing to show": "没有可显示的内容",
|
||||
"Could not be loaded": "无法加载",
|
||||
"User": "用户",
|
||||
"Administrator": "管理员",
|
||||
"Custom role": "自定义角色",
|
||||
|
||||
@@ -1745,6 +1745,22 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
.admin-dns-type { flex: 0 0 48px; font-family: var(--font-mono); font-size: .78em; font-weight: 700; color: var(--accent-soft-fg); padding-top: 2px; }
|
||||
.admin-dns-name { font-size: .88em; font-weight: 550; word-break: break-all; }
|
||||
.admin-dns-value { display: block; font-family: var(--font-mono); font-size: .8em; color: var(--fg-muted); word-break: break-all; margin-top: 2px; max-height: 4.8em; overflow: hidden; }
|
||||
/* Column counts come from balancedColumns, so rows stay even; the widths
|
||||
follow the space the grid actually has, not the window, since the folder
|
||||
pane beside it can be any width. */
|
||||
.admin-dashboard { max-width: calc(var(--cols-wide, 3) * 300px + (var(--cols-wide, 3) - 1) * 12px); }
|
||||
.admin-cards-wrap { container: admin-cards / inline-size; margin-top: 8px; }
|
||||
.admin-cards { display: grid; gap: 12px; grid-template-columns: repeat(var(--cols-wide, 3), minmax(0, 1fr)); max-width: calc(var(--cols-wide, 3) * 300px + (var(--cols-wide, 3) - 1) * 12px); }
|
||||
@container admin-cards (max-width: 760px) { .admin-cards { grid-template-columns: repeat(var(--cols-mid, 2), minmax(0, 1fr)); max-width: calc(var(--cols-mid, 2) * 300px + (var(--cols-mid, 2) - 1) * 12px); } }
|
||||
@container admin-cards (max-width: 480px) { .admin-cards { grid-template-columns: minmax(0, 1fr); max-width: none; } }
|
||||
.admin-card { display: flex; flex-direction: column; gap: 6px; height: 100%; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-elev); color: var(--fg); text-decoration: none; }
|
||||
.admin-card.link:hover { background: var(--bg-hover); }
|
||||
.admin-card.link:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.admin-card-top { display: flex; align-items: center; gap: 8px; color: var(--fg-muted); }
|
||||
.admin-card-icon { display: inline-flex; color: var(--accent-soft-fg); }
|
||||
.admin-card-label { font-size: .85em; font-weight: 650; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.admin-card-value { font-size: 1.9em; font-weight: 650; line-height: 1.15; font-variant-numeric: tabular-nums; min-height: 1.15em; }
|
||||
.admin-card-placeholder { display: inline-block; width: 3.5ch; height: .9em; border-radius: var(--radius-sm); background: var(--bg-sunken); vertical-align: middle; }
|
||||
.admin-kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; align-items: center; margin: 0; font-size: .92em; }
|
||||
.admin-kv dt { color: var(--fg-muted); }
|
||||
.admin-kv dd { margin: 0; }
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { ArrowDownToLine, ArrowUpFromLine, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, Users } from "lucide-react";
|
||||
import { adminSections, dashboardCards, type DashboardCard } from "@/lib/adminAccess";
|
||||
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summariseMetrics, type MessageStats } from "@/lib/adminDashboard";
|
||||
import { formatDayMonthTime, resolvedLocale } from "@/lib/datetime";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { Empty } from "@/ui/misc";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
/** Loading, a number, refused by the server (the card goes), or failed (the card says so). */
|
||||
type Loaded<T> = { state: "loading" } | { state: "ok"; value: T } | { state: "refused" } | { state: "error" };
|
||||
|
||||
const LOADING = { state: "loading" } as const;
|
||||
|
||||
async function settle<T>(work: Promise<T>): Promise<Loaded<T>> {
|
||||
try {
|
||||
return { state: "ok", value: await work };
|
||||
} catch (err) {
|
||||
return isRefused(err) ? { state: "refused" } : { state: "error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Administration's landing page: a card for each number the role may read.
|
||||
*
|
||||
* Which cards appear is `dashboardCards`, from the permissions Stalwart
|
||||
* reported; what they count is whatever Stalwart answers for this account,
|
||||
* which for a tenant administrator is their tenancy. A card whose feed the
|
||||
* server refuses anyway -- the metric history on a Community server -- is left
|
||||
* off rather than shown broken, and one that could not be loaded says so.
|
||||
*/
|
||||
export function AdminDashboard() {
|
||||
const perms = usePermissions();
|
||||
const cards = dashboardCards(perms);
|
||||
const sections = adminSections(perms);
|
||||
const [reload, setReload] = useState(0);
|
||||
const [users, setUsers] = useState<Loaded<number>>(LOADING);
|
||||
const [domains, setDomains] = useState<Loaded<number>>(LOADING);
|
||||
const [pending, setPending] = useState<Loaded<number>>(LOADING);
|
||||
const [messages, setMessages] = useState<Loaded<MessageStats>>(LOADING);
|
||||
const key = cards.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const into = <T,>(set: (v: Loaded<T>) => void, work: () => Promise<T>) => {
|
||||
set(LOADING);
|
||||
void settle(work()).then((v) => !cancelled && set(v));
|
||||
};
|
||||
if (cards.includes("users")) into(setUsers, () => countObjects("Account"));
|
||||
if (cards.includes("domains")) into(setDomains, () => countObjects("Domain"));
|
||||
if (cards.includes("pending")) into(setPending, () => countObjects("QueuedMessage"));
|
||||
if (cards.includes("received")) into(setMessages, async () => summariseMetrics(await loadMetrics(new Date(Date.now() - DASHBOARD_WINDOW_MS))));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// `key` is the card list's contents; the array itself is new every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, reload]);
|
||||
|
||||
const number = new Intl.NumberFormat(resolvedLocale());
|
||||
const count = (v: Loaded<number>) => (v.state === "ok" ? number.format(v.value) : undefined);
|
||||
const stats = messages.state === "ok" ? messages.value : null;
|
||||
const unrecorded = stats !== null && !stats.recorded;
|
||||
|
||||
const every: Array<{ id: DashboardCard; loaded: Loaded<unknown>; node: ReactNode }> = [
|
||||
{ id: "users", loaded: users, node: <Card icon={<Users size={20} />} label={t("Users")} value={count(users)} caption={t("User accounts")} loaded={users} href={sections.includes("accounts") ? "/admin/accounts" : undefined} /> },
|
||||
{ id: "domains", loaded: domains, node: <Card icon={<Globe size={20} />} label={t("Domains")} value={count(domains)} caption={t("Mail domains")} loaded={domains} href={sections.includes("domains") ? "/admin/domains" : undefined} /> },
|
||||
{ id: "pending", loaded: pending, node: <Card icon={<Hourglass size={20} />} label={t("Pending")} value={count(pending)} caption={t("Waiting in the delivery queue")} loaded={pending} /> },
|
||||
{
|
||||
id: "memory",
|
||||
loaded: messages,
|
||||
node: (
|
||||
<Card
|
||||
icon={<MemoryStick size={20} />}
|
||||
label={t("Server memory")}
|
||||
value={stats?.memory ? formatSize(stats.memory.bytes) : undefined}
|
||||
caption={stats?.memory ? t("As of {time}", { time: formatDayMonthTime(new Date(stats.memory.at)) }) : unrecorded ? t("Not recorded on this server") : t("Last 24 hours")}
|
||||
loaded={messages}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ id: "received", loaded: messages, node: <Card icon={<ArrowDownToLine size={20} />} label={t("Received")} value={stats?.recorded ? number.format(stats.received) : undefined} caption={unrecorded ? t("Not recorded on this server") : t("Last 24 hours")} loaded={messages} /> },
|
||||
{ id: "sent", loaded: messages, node: <Card icon={<ArrowUpFromLine size={20} />} label={t("Sent")} value={stats?.recorded ? number.format(stats.sent) : undefined} caption={unrecorded ? t("Not recorded on this server") : t("Last 24 hours")} loaded={messages} /> },
|
||||
];
|
||||
const shown = every.filter((c) => cards.includes(c.id) && c.loaded.state !== "refused");
|
||||
const columns = balancedColumns(shown.length);
|
||||
|
||||
return (
|
||||
// The column counts are set here rather than on the grid, so the heading --
|
||||
// and its Refresh button -- end where the cards do.
|
||||
<div className="admin-dashboard" style={{ "--cols-wide": columns.wide, "--cols-mid": columns.mid } as React.CSSProperties}>
|
||||
<div className="admin-head">
|
||||
<div className="grow">
|
||||
<h1>{t("Dashboard")}</h1>
|
||||
<p className="lead">{t("The numbers your role can see, as the server reports them.")}</p>
|
||||
</div>
|
||||
<button className="icon-btn" aria-label={t("Refresh")} title={t("Refresh")} onClick={() => setReload((n) => n + 1)}>
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{shown.length ? (
|
||||
<div className="admin-cards-wrap">
|
||||
<div className="admin-cards">
|
||||
{shown.map((c) => <div key={c.id}>{c.node}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty icon={<LayoutDashboard size={32} />} title={t("Nothing to show")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ icon, label, value, caption, loaded, href }: { icon: ReactNode; label: string; value: string | undefined; caption: string; loaded: Loaded<unknown>; href?: string }) {
|
||||
const body = (
|
||||
<>
|
||||
<div className="admin-card-top">
|
||||
<span className="admin-card-icon" aria-hidden="true">{icon}</span>
|
||||
<span className="admin-card-label">{label}</span>
|
||||
</div>
|
||||
<div className="admin-card-value" aria-busy={loaded.state === "loading"}>
|
||||
{loaded.state === "loading" ? <span className="admin-card-placeholder" /> : (value ?? "—")}
|
||||
</div>
|
||||
<div className="hint">{loaded.state === "error" ? t("Could not be loaded") : caption}</div>
|
||||
</>
|
||||
);
|
||||
return href ? (
|
||||
<Link href={href} className="admin-card link">{body}</Link>
|
||||
) : (
|
||||
<div className="admin-card">{body}</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Globe, User } from "lucide-react";
|
||||
import { Globe, LayoutDashboard, User } from "lucide-react";
|
||||
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
export const ADMIN_SECTIONS: Record<AdminSection, { group: string; label: string; icon: ReactNode }> = {
|
||||
dashboard: { group: "Overview", label: "Dashboard", icon: <LayoutDashboard size={20} /> },
|
||||
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
|
||||
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
|
||||
};
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { ReactNode } from "react";
|
||||
import { Redirect } from "wouter";
|
||||
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||
import { AccountsAdmin } from "./AccountsAdmin";
|
||||
import { AdminDashboard } from "./AdminDashboard";
|
||||
import { DomainsAdmin } from "./DomainsAdmin";
|
||||
import { currentAdminSection } from "./AdminNav";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
|
||||
dashboard: () => <AdminDashboard />,
|
||||
accounts: (id) => <AccountsAdmin selectedId={id} />,
|
||||
domains: (id) => <DomainsAdmin selectedId={id} />,
|
||||
};
|
||||
@@ -16,8 +18,8 @@ const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
|
||||
*
|
||||
* The page is only the open section. Its list of sections is in the folder
|
||||
* pane (see AdminNav), so the tables here get the width Settings spends on a
|
||||
* second column. A section the role cannot read -- typed into the address bar,
|
||||
* say -- opens the first one it can.
|
||||
* second column. A bare /admin opens the dashboard, and a section the role
|
||||
* cannot read -- typed into the address bar, say -- opens the first one it can.
|
||||
*/
|
||||
export function AdminView({ section, id }: { section?: string; id?: string }) {
|
||||
const allowed = adminSections(usePermissions());
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Router } from "wouter";
|
||||
import { memoryLocation } from "wouter/memory-location";
|
||||
import { JmapMethodError } from "@/jmap/client";
|
||||
import { useSession } from "@/store/session";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
import type { MetricRecord } from "@/lib/adminDashboard";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const feeds = vi.hoisted(() => ({
|
||||
counts: { Account: 35, Domain: 3, QueuedMessage: 9 } as Record<string, number>,
|
||||
metrics: (): Promise<MetricRecord[]> => Promise.resolve([]),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/adminDashboard", async (original) => ({
|
||||
...(await original<typeof import("@/lib/adminDashboard")>()),
|
||||
countObjects: vi.fn(async (object: string) => feeds.counts[object]),
|
||||
loadMetrics: vi.fn(() => feeds.metrics()),
|
||||
}));
|
||||
|
||||
const { AdminDashboard } = await import("../AdminDashboard");
|
||||
|
||||
const signIn = (permissions: string[]) =>
|
||||
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
|
||||
|
||||
const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "sysDomainGet", "sysDomainQuery"];
|
||||
const TENANT = [...HELPDESK, "sysAccountCreate", "sysDomainCreate", "sysQueuedMessageGet", "sysQueuedMessageQuery"];
|
||||
const ADMIN = [...TENANT, "sysMetricGet", "sysMetricQuery"];
|
||||
|
||||
/** The dashboard shows what the role may read, and nothing a server refuses. */
|
||||
describe("the Administration dashboard", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
const render = async () => {
|
||||
const { hook } = memoryLocation({ path: "/admin" });
|
||||
await act(async () => {
|
||||
root.render(<Router hook={hook}><AdminDashboard /></Router>);
|
||||
});
|
||||
await act(async () => {});
|
||||
};
|
||||
const cards = () => [...host.querySelectorAll(".admin-card")].map((c) => [c.querySelector(".admin-card-label")?.textContent, c.querySelector(".admin-card-value")?.textContent, c.querySelector(".hint")?.textContent]);
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
feeds.metrics = () => Promise.resolve([]);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("gives a helpdesk role its two counts and nothing about the server", async () => {
|
||||
signIn(HELPDESK);
|
||||
await render();
|
||||
expect(cards()).toEqual([
|
||||
["Users", "35", "User accounts"],
|
||||
["Domains", "3", "Mail domains"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a tenant administrator the queue as well, but no history", async () => {
|
||||
signIn(TENANT);
|
||||
await render();
|
||||
expect(cards().map((c) => c[0])).toEqual(["Users", "Domains", "Pending"]);
|
||||
});
|
||||
|
||||
it("adds the history for a role that reads metrics", async () => {
|
||||
feeds.metrics = () =>
|
||||
Promise.resolve([
|
||||
{ "@type": "Gauge", metric: "server.memory", count: 360_000_000, timestamp: "2026-09-15T15:00:00Z" },
|
||||
{ "@type": "Counter", metric: "queue.message-queued", count: 93, timestamp: "2026-09-15T15:00:00Z" },
|
||||
{ "@type": "Counter", metric: "queue.report-queued", count: 39, timestamp: "2026-09-15T15:00:00Z" },
|
||||
]);
|
||||
signIn(ADMIN);
|
||||
await render();
|
||||
expect(cards().map((c) => [c[0], c[1]])).toEqual([
|
||||
["Users", "35"],
|
||||
["Domains", "3"],
|
||||
["Pending", "9"],
|
||||
["Server memory", "343 MB"],
|
||||
["Received", "93"],
|
||||
["Sent", "39"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the history off where the server refuses it, as Community does", async () => {
|
||||
feeds.metrics = () => Promise.reject(new JmapMethodError("x:Metric/query", { type: "forbidden", description: "This feature is only available in the Enterprise edition" }));
|
||||
signIn(ADMIN);
|
||||
await render();
|
||||
expect(cards().map((c) => c[0])).toEqual(["Users", "Domains", "Pending"]);
|
||||
});
|
||||
|
||||
it("says the history is not recorded rather than showing a quiet day", async () => {
|
||||
signIn(ADMIN);
|
||||
await render();
|
||||
expect(cards().slice(3)).toEqual([
|
||||
["Server memory", "—", "Not recorded on this server"],
|
||||
["Received", "—", "Not recorded on this server"],
|
||||
["Sent", "—", "Not recorded on this server"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a card that failed for another reason, and says so", async () => {
|
||||
feeds.metrics = () => Promise.reject(new Error("offline"));
|
||||
signIn(ADMIN);
|
||||
await render();
|
||||
expect(cards()[4]).toEqual(["Received", "—", "Could not be loaded"]);
|
||||
});
|
||||
});
|
||||
@@ -35,20 +35,26 @@ describe("the Administration list in the folder pane", () => {
|
||||
it("lists each readable section under its group and marks the open one", async () => {
|
||||
signIn(["sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"]);
|
||||
await render("/admin/domains/d1");
|
||||
expect([...host.querySelectorAll(".nav-section")].map((e) => e.textContent)).toEqual(["Directory", "Mail"]);
|
||||
expect([...host.querySelectorAll(".nav-section")].map((e) => e.textContent)).toEqual(["Overview", "Directory", "Mail"]);
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Domains");
|
||||
});
|
||||
|
||||
it("treats a bare /admin as the first section, which is what the page opens", async () => {
|
||||
it("treats a bare /admin as the dashboard, which is what the page opens", async () => {
|
||||
signIn(["sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"]);
|
||||
await render("/admin");
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Accounts");
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Dashboard");
|
||||
});
|
||||
|
||||
it("leaves out what the role cannot read", async () => {
|
||||
signIn(["sysDomainQuery", "sysDomainGet"]);
|
||||
await render("/admin");
|
||||
await render("/admin/accounts");
|
||||
expect(host.textContent).not.toContain("Accounts");
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Domains");
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Dashboard");
|
||||
});
|
||||
|
||||
it("offers the dashboard alone to a role that can only count", async () => {
|
||||
signIn(["sysQueuedMessageQuery"]);
|
||||
await render("/admin");
|
||||
expect([...host.querySelectorAll(".nav-item")].map((e) => e.textContent)).toEqual(["Dashboard"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user