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:
@@ -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.
|
- **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.
|
- **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)
|
## Not a deviation (for reference)
|
||||||
|
|
||||||
A few other `viewName === '...'` / `objectName === '...'` checks exist in
|
A few other `viewName === '...'` / `objectName === '...'` checks exist in
|
||||||
|
|||||||
@@ -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)
|
* 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.
|
* or Groups, and needs no per-list wiring in this component.
|
||||||
*
|
*
|
||||||
* Real columns compare their own property directly; synthetic deviation
|
* Real columns compare their own property directly; synthetic deviation
|
||||||
* columns (quotaUsage, aliasCount) aren't real properties, so they need an
|
* columns (quotaUsage, and any COUNT_COLUMN_SOURCES entry) aren't real
|
||||||
* override to compute a comparable value from what's actually on the item.
|
* 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> = {
|
const CLIENT_SORT_VALUE_OVERRIDES: Record<string, (item: Record<string, unknown>) => string | number> = {
|
||||||
quotaUsage: (item) => (typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0),
|
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 {
|
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];
|
const override = CLIENT_SORT_VALUE_OVERRIDES[colName];
|
||||||
if (override) return override(item);
|
if (override) return override(item);
|
||||||
const raw = item[colName];
|
const raw = item[colName];
|
||||||
@@ -528,8 +551,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
// (see withAccountListColumns, account-quota-usage-column deviation)
|
// (see withAccountListColumns, account-quota-usage-column deviation)
|
||||||
// include the synthetic `quotaUsage` column gets it resolved and rendered.
|
// include the synthetic `quotaUsage` column gets it resolved and rendered.
|
||||||
const hasQuotaUsageColumn = (resolved?.list?.columns ?? []).some((c) => c.name === 'quotaUsage');
|
const hasQuotaUsageColumn = (resolved?.list?.columns ?? []).some((c) => c.name === 'quotaUsage');
|
||||||
// SCHEMA-DEVIATION: account-alias-count-column (see SCHEMA_DEVIATIONS.md)
|
// SCHEMA-DEVIATION: account-alias-count-column, role-permission-count-columns
|
||||||
const hasAliasCountColumn = (resolved?.list?.columns ?? []).some((c) => c.name === 'aliasCount');
|
// (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 displayColumns = useMemo(() => {
|
||||||
const columns = resolved?.list?.columns ?? [];
|
const columns = resolved?.list?.columns ?? [];
|
||||||
@@ -709,10 +737,10 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
properties.splice(quotaIdx, 1, 'usedDiskQuota', 'quotas');
|
properties.splice(quotaIdx, 1, 'usedDiskQuota', 'quotas');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasAliasCountColumn) {
|
for (const countCol of activeCountColumns) {
|
||||||
const aliasIdx = properties.indexOf('aliasCount');
|
const idx = properties.indexOf(countCol);
|
||||||
if (aliasIdx !== -1) {
|
if (idx !== -1) {
|
||||||
properties.splice(aliasIdx, 1, 'aliases');
|
properties.splice(idx, 1, COUNT_COLUMN_SOURCES[countCol]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isMailboxList && !properties.includes('parentId')) {
|
if (isMailboxList && !properties.includes('parentId')) {
|
||||||
@@ -819,7 +847,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
isWebApplications,
|
isWebApplications,
|
||||||
isAccountsList,
|
isAccountsList,
|
||||||
hasQuotaUsageColumn,
|
hasQuotaUsageColumn,
|
||||||
hasAliasCountColumn,
|
activeCountColumns,
|
||||||
isMailboxList,
|
isMailboxList,
|
||||||
clientSortField,
|
clientSortField,
|
||||||
sort,
|
sort,
|
||||||
@@ -1749,8 +1777,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
formatUserRole(item, schema!)
|
formatUserRole(item, schema!)
|
||||||
) : hasQuotaUsageColumn && col.name === 'quotaUsage' ? (
|
) : hasQuotaUsageColumn && col.name === 'quotaUsage' ? (
|
||||||
renderQuotaUsage(item, t)
|
renderQuotaUsage(item, t)
|
||||||
) : hasAliasCountColumn && col.name === 'aliasCount' ? (
|
) : col.name in COUNT_COLUMN_SOURCES && activeCountColumns.includes(col.name) ? (
|
||||||
Object.keys((item.aliases as Record<string, unknown>) ?? {}).length
|
getCountColumnValue(col.name, item)
|
||||||
) : isMailboxList && col.name === 'name' ? (
|
) : isMailboxList && col.name === 'name' ? (
|
||||||
(() => {
|
(() => {
|
||||||
const depth = mailboxDepths.get(item.id as string) ?? 0;
|
const depth = mailboxDepths.get(item.id as string) ?? 0;
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/cli
|
|||||||
import { withClientLogFilters } from '@/lib/logFilters';
|
import { withClientLogFilters } from '@/lib/logFilters';
|
||||||
import { withAccountListColumns } from '@/lib/accountColumns';
|
import { withAccountListColumns } from '@/lib/accountColumns';
|
||||||
import { withMailingListColumns } from '@/lib/mailingListColumns';
|
import { withMailingListColumns } from '@/lib/mailingListColumns';
|
||||||
|
import { withRoleListColumns } from '@/lib/roleColumns';
|
||||||
import { setLocale } from '@/i18n';
|
import { setLocale } from '@/i18n';
|
||||||
import { TopBar } from '@/components/layout/TopBar';
|
import { TopBar } from '@/components/layout/TopBar';
|
||||||
import { Sidebar } from '@/components/layout/Sidebar';
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
@@ -151,7 +152,7 @@ export default function AdminPanel() {
|
|||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
setSchema(withMailingListColumns(withAccountListColumns(withClientLogFilters(schemaData))));
|
setSchema(withRoleListColumns(withMailingListColumns(withAccountListColumns(withClientLogFilters(schemaData)))));
|
||||||
|
|
||||||
setAccountInfo(accountData.permissions, accountData.edition, accountData.locale);
|
setAccountInfo(accountData.permissions, accountData.edition, accountData.locale);
|
||||||
setLocale(accountData.locale);
|
setLocale(accountData.locale);
|
||||||
|
|||||||
Reference in New Issue
Block a user