From 503cae465f9d0440ba78905def5a7a1bc8f35caa Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Sat, 1 Aug 2026 22:23:48 +0200 Subject: [PATCH] feat(roles): add Enabled/Disabled Permissions count columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- SCHEMA_DEVIATIONS.md | 7 ++++ src/components/lists/DynamicList.tsx | 52 +++++++++++++++++++++------- src/lib/roleColumns.ts | 36 +++++++++++++++++++ src/pages/AdminPanel.tsx | 3 +- 4 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 src/lib/roleColumns.ts diff --git a/SCHEMA_DEVIATIONS.md b/SCHEMA_DEVIATIONS.md index 0cf44fa..3de6095 100644 --- a/SCHEMA_DEVIATIONS.md +++ b/SCHEMA_DEVIATIONS.md @@ -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 diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index b7386a1..c36e0c4 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -272,6 +272,28 @@ function renderQuotaUsage(item: Record, 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 = { + aliasCount: 'aliases', + enabledPermissionCount: 'enabledPermissions', + disabledPermissionCount: 'disabledPermissions', +}; + +function getCountColumnValue(colName: string, item: Record): number { + const source = COUNT_COLUMN_SOURCES[colName]; + return Object.keys((item[source] as Record) ?? {}).length; +} + /** * SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md) * @@ -282,15 +304,16 @@ function renderQuotaUsage(item: Record, 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 | number> = { quotaUsage: (item) => (typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0), - aliasCount: (item) => Object.keys((item.aliases as Record) ?? {}).length, }; function getClientSortValue(colName: string, item: Record): 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) ?? {}).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; diff --git a/src/lib/roleColumns.ts b/src/lib/roleColumns.ts new file mode 100644 index 0000000..d87b070 --- /dev/null +++ b/src/lib/roleColumns.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * 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' }, + ], + }, + }, + }; +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 6458bb4..68779dc 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -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);