feat(roles): add Enabled/Disabled Permissions count columns

New SCHEMA-DEVIATION: role-permission-count-columns — the Roles list
only shows Description, so seeing how broad or restrictive a role is
required opening it and counting permissions by hand.

Generalized DynamicList's existing alias-count handling (previously
hardcoded to the single `aliasCount` -> `aliases` mapping) into
COUNT_COLUMN_SOURCES, a table of synthetic "*Count" column names to the
real set/objectList property they count. account-alias-count-column
now runs through the same generic path with no behavior change;
role-permission-count-columns (enabledPermissionCount/
disabledPermissionCount -> enabledPermissions/disabledPermissions) is
the second consumer, added with zero new DynamicList.tsx branching.

Verified against the live dev server: correct counts for both seeded
custom roles (Support Agent: 3/0, Read-only Auditor: 1/0) and the
built-in roles (System Administrator: 452/0, User: 244/0, etc.).
Confirmed no regression on Accounts' existing Aliases column/sort.
This commit is contained in:
Steven RYDELL
2026-08-01 22:23:48 +02:00
parent 9cd21a2d34
commit 503cae465f
4 changed files with 85 additions and 13 deletions
+7
View File
@@ -81,6 +81,13 @@ itself stays byte-for-byte alignable with upstream's version of the file.
- **Why**: neither list's schema declares any sortable property at all (`list.sort` is absent) — confirmed against a live server: `x:Account/query` with `sort: [{"property":"emailAddress",...}]` returns `unsupportedSort` for every property tried, including the real ones. This is a systemic gap in the current Stalwart server, not specific to this fork's synthetic columns.
- **Ideal fix**: the server's `x:Account/User`/`x:Account/Group` query methods accept `sort` on at least `emailAddress`, `description`, `usedDiskQuota`, and the schema declares them in `list.sort`; `withAccountListColumns` stops tagging those columns `clientSortable` and they fall through to the normal server-paginated `sortableFields` path already used elsewhere. The generic mechanism itself only goes away once nothing tags any column `clientSortable` anymore.
### `role-permission-count-columns` 🟡
- **Where**: [`src/lib/roleColumns.ts`](src/lib/roleColumns.ts); resolved generically by the same `COUNT_COLUMN_SOURCES` table in [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) used by `account-alias-count-column`
- **What**: adds synthetic `enabledPermissionCount`/`disabledPermissionCount` columns to the `x:Role` list — not real schema properties; resolved from the real `enabledPermissions`/`disabledPermissions` set properties and rendered as entry counts.
- **Why**: the Roles list schema only exposes Description as a column; seeing how broad or restrictive a role is requires opening it and counting permissions by hand.
- **Ideal fix**: the server's `x:Role` list schema includes computed enabled/disabled permission count columns natively; this column definition is deleted.
## Not a deviation (for reference)
A few other `viewName === '...'` / `objectName === '...'` checks exist in
+40 -12
View File
@@ -272,6 +272,28 @@ function renderQuotaUsage(item: Record<string, unknown>, t: TFn): React.ReactNod
);
}
/**
* SCHEMA-DEVIATION: account-alias-count-column, role-permission-count-columns
* (see SCHEMA_DEVIATIONS.md)
*
* Synthetic "count" columns, keyed by column name, each naming the real
* set/objectList property whose entry count they render. Generic and
* table-level: whichever list's schema patch tags a column with one of
* these names (see withAccountListColumns, withMailingListColumns,
* withRoleListColumns) gets it resolved and rendered automatically, with
* no per-list wiring in this component.
*/
const COUNT_COLUMN_SOURCES: Record<string, string> = {
aliasCount: 'aliases',
enabledPermissionCount: 'enabledPermissions',
disabledPermissionCount: 'disabledPermissions',
};
function getCountColumnValue(colName: string, item: Record<string, unknown>): number {
const source = COUNT_COLUMN_SOURCES[colName];
return Object.keys((item[source] as Record<string, unknown>) ?? {}).length;
}
/**
* SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
*
@@ -282,15 +304,16 @@ function renderQuotaUsage(item: Record<string, unknown>, t: TFn): React.ReactNod
* or Groups, and needs no per-list wiring in this component.
*
* Real columns compare their own property directly; synthetic deviation
* columns (quotaUsage, aliasCount) aren't real properties, so they need an
* override to compute a comparable value from what's actually on the item.
* columns (quotaUsage, and any COUNT_COLUMN_SOURCES entry) aren't real
* properties, so they need an override to compute a comparable value from
* what's actually on the item.
*/
const CLIENT_SORT_VALUE_OVERRIDES: Record<string, (item: Record<string, unknown>) => string | number> = {
quotaUsage: (item) => (typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0),
aliasCount: (item) => Object.keys((item.aliases as Record<string, unknown>) ?? {}).length,
};
function getClientSortValue(colName: string, item: Record<string, unknown>): string | number {
if (colName in COUNT_COLUMN_SOURCES) return getCountColumnValue(colName, item);
const override = CLIENT_SORT_VALUE_OVERRIDES[colName];
if (override) return override(item);
const raw = item[colName];
@@ -528,8 +551,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
// (see withAccountListColumns, account-quota-usage-column deviation)
// include the synthetic `quotaUsage` column gets it resolved and rendered.
const hasQuotaUsageColumn = (resolved?.list?.columns ?? []).some((c) => c.name === 'quotaUsage');
// SCHEMA-DEVIATION: account-alias-count-column (see SCHEMA_DEVIATIONS.md)
const hasAliasCountColumn = (resolved?.list?.columns ?? []).some((c) => c.name === 'aliasCount');
// SCHEMA-DEVIATION: account-alias-count-column, role-permission-count-columns
// (see SCHEMA_DEVIATIONS.md) — which COUNT_COLUMN_SOURCES entries this
// particular list's columns actually declare.
const activeCountColumns = useMemo(
() => (resolved?.list?.columns ?? []).filter((c) => c.name in COUNT_COLUMN_SOURCES).map((c) => c.name),
[resolved?.list?.columns],
);
const displayColumns = useMemo(() => {
const columns = resolved?.list?.columns ?? [];
@@ -709,10 +737,10 @@ export function DynamicList({ viewName }: DynamicListProps) {
properties.splice(quotaIdx, 1, 'usedDiskQuota', 'quotas');
}
}
if (hasAliasCountColumn) {
const aliasIdx = properties.indexOf('aliasCount');
if (aliasIdx !== -1) {
properties.splice(aliasIdx, 1, 'aliases');
for (const countCol of activeCountColumns) {
const idx = properties.indexOf(countCol);
if (idx !== -1) {
properties.splice(idx, 1, COUNT_COLUMN_SOURCES[countCol]);
}
}
if (isMailboxList && !properties.includes('parentId')) {
@@ -819,7 +847,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
isWebApplications,
isAccountsList,
hasQuotaUsageColumn,
hasAliasCountColumn,
activeCountColumns,
isMailboxList,
clientSortField,
sort,
@@ -1749,8 +1777,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
formatUserRole(item, schema!)
) : hasQuotaUsageColumn && col.name === 'quotaUsage' ? (
renderQuotaUsage(item, t)
) : hasAliasCountColumn && col.name === 'aliasCount' ? (
Object.keys((item.aliases as Record<string, unknown>) ?? {}).length
) : col.name in COUNT_COLUMN_SOURCES && activeCountColumns.includes(col.name) ? (
getCountColumnValue(col.name, item)
) : isMailboxList && col.name === 'name' ? (
(() => {
const depth = mailboxDepths.get(item.id as string) ?? 0;
+36
View File
@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Schema } from '@/types/schema';
/**
* SCHEMA-DEVIATION: role-permission-count-columns (see SCHEMA_DEVIATIONS.md)
*
* The Roles list only shows Description, hiding how many permissions a
* role actually grants or explicitly revokes without opening it. Adds two
* synthetic columns — DynamicList's generic count-column handling
* resolves them from the real `enabledPermissions`/`disabledPermissions`
* set properties and renders their entry counts.
*/
export function withRoleListColumns(schema: Schema): Schema {
const list = schema.lists['x:Role'];
if (!list) return schema;
return {
...schema,
lists: {
...schema.lists,
'x:Role': {
...list,
columns: [
...list.columns,
{ name: 'enabledPermissionCount', label: 'Enabled Permissions' },
{ name: 'disabledPermissionCount', label: 'Disabled Permissions' },
],
},
},
};
}
+2 -1
View File
@@ -15,6 +15,7 @@ import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/cli
import { withClientLogFilters } from '@/lib/logFilters';
import { withAccountListColumns } from '@/lib/accountColumns';
import { withMailingListColumns } from '@/lib/mailingListColumns';
import { withRoleListColumns } from '@/lib/roleColumns';
import { setLocale } from '@/i18n';
import { TopBar } from '@/components/layout/TopBar';
import { Sidebar } from '@/components/layout/Sidebar';
@@ -151,7 +152,7 @@ export default function AdminPanel() {
if (cancelled) return;
setSchema(withMailingListColumns(withAccountListColumns(withClientLogFilters(schemaData))));
setSchema(withRoleListColumns(withMailingListColumns(withAccountListColumns(withClientLogFilters(schemaData)))));
setAccountInfo(accountData.permissions, accountData.edition, accountData.locale);
setLocale(accountData.locale);