From 3dda6d15278f390f6e4612a8f740a4e14147a173 Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Thu, 30 Jul 2026 16:47:26 +0200 Subject: [PATCH] feat: show Role and Usage/Quota columns on the Accounts list Created At is replaced with two columns that previously required opening each account individually: Role (badge, resolved against x:UserRoles specifically since the list's merged User/Group field definitions otherwise resolve `roles` against the wrong object) and Usage / Quota (usedDiskQuota vs quotas.maxDiskQuota, "Unlimited" when no limit is set). Also makes renderCellValue's generic 'object' case resolve variant labels via schema.schemas, instead of only ever printing the raw @type string. Co-Authored-By: Claude Sonnet 5 --- src/components/lists/DynamicList.tsx | 51 ++++++++++++++++++++++++++-- src/i18n/en.json | 3 +- src/lib/accountColumns.ts | 34 +++++++++++++++++++ src/pages/AdminPanel.tsx | 3 +- 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 src/lib/accountColumns.ts diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index d9fff01..0d6f27c 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -185,6 +185,35 @@ function formatNumber(value: unknown): string { return value.toLocaleString(); } +function formatUserRole(item: Record, schema: Schema): React.ReactNode { + // The x:Account list merges field definitions across its User/Group + // variants (see getFieldsRecord), and Group's `roles` property points to + // a different object (`x:Roles`) that overrides User's (`x:UserRoles`) + // in that merge. This list only ever shows Users, so resolve the label + // directly against x:UserRoles instead of the ambiguous merged field. + const roles = item.roles as Record | undefined; + const type = roles && typeof roles['@type'] === 'string' ? roles['@type'] : undefined; + if (!type) return -; + + const variantSchema = schema.schemas['x:UserRoles']; + if (variantSchema?.type === 'multiple') { + const variant = variantSchema.variants.find((v) => v.name === type); + if (variant) return {variant.label}; + } + return type; +} + +function formatQuotaUsage(item: Record, t: TFn): string { + const used = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0; + const quotas = item.quotas as Record | undefined; + const limit = quotas && typeof quotas.maxDiskQuota === 'number' ? quotas.maxDiskQuota : 0; + const usedLabel = formatSize(used); + if (!limit) { + return `${usedLabel} / ${t('list.unlimitedQuota', 'Unlimited')}`; + } + return `${usedLabel} / ${formatSize(limit)}`; +} + function getFieldsRecord(resolvedSchema: ResolvedSchema): Record { if (resolvedSchema.type === 'single') { return resolvedSchema.fields.properties; @@ -307,7 +336,14 @@ function renderCellValue( if (value && typeof value === 'object' && !Array.isArray(value)) { const obj = value as Record; if ('@type' in obj && typeof obj['@type'] === 'string') { - return obj['@type']; + const variantSchema = schema.schemas[ft.objectName]; + if (variantSchema?.type === 'multiple') { + const variant = variantSchema.variants.find((v) => v.name === obj['@type']); + if (variant) { + return {variant.label}; + } + } + return String(obj['@type']); } } return -; @@ -397,6 +433,7 @@ export function DynamicList({ viewName }: DynamicListProps) { const objectName = resolved?.obj.objectName; const isWebApplications = viewName === 'x:Application' || objectName === 'x:Application'; const isLogEntries = viewName === 'x:Log' || objectName === 'x:Log'; + const isAccountsList = viewName === 'x:Account/User'; const displayColumns = useMemo(() => { const columns = resolved?.list?.columns ?? []; @@ -555,6 +592,12 @@ export function DynamicList({ viewName }: DynamicListProps) { if (isWebApplications && !properties.includes('enabled')) { properties.push('enabled'); } + if (isAccountsList) { + const quotaIdx = properties.indexOf('quotaUsage'); + if (quotaIdx !== -1) { + properties.splice(quotaIdx, 1, 'usedDiskQuota', 'quotas'); + } + } const filter = buildFilter(); const sortArr = buildSort(); @@ -626,7 +669,7 @@ export function DynamicList({ viewName }: DynamicListProps) { setLoading(false); } }, - [resolved, schema, buildFilter, buildSort, isWebApplications, activeClientFilters], + [resolved, schema, buildFilter, buildSort, isWebApplications, isAccountsList, activeClientFilters], ); useEffect(() => { @@ -1529,6 +1572,10 @@ export function DynamicList({ viewName }: DynamicListProps) { ) : ( ) + ) : isAccountsList && col.name === 'roles' ? ( + formatUserRole(item, schema!) + ) : isAccountsList && col.name === 'quotaUsage' ? ( + formatQuotaUsage(item, t) ) : ( renderCellValue( item[col.name], diff --git a/src/i18n/en.json b/src/i18n/en.json index 374c71c..e9b21f3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -313,7 +313,8 @@ "showing": "Showing {{from}}-{{to}} of {{total}} {{name}}", "showingItems": "Showing {{count}} items", "sort": "Sort", - "unknownError": "Unknown error" + "unknownError": "Unknown error", + "unlimitedQuota": "Unlimited" }, "login": { "continue": "Continue", diff --git a/src/lib/accountColumns.ts b/src/lib/accountColumns.ts new file mode 100644 index 0000000..0622713 --- /dev/null +++ b/src/lib/accountColumns.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import type { Schema } from '@/types/schema'; + +/** + * The Accounts list only shows Email/Full Name/Created At, hiding role and + * storage usage that otherwise require opening each account individually. + * `createdAt` is dropped to make room; `roles` is a real property so it + * renders through the normal field pipeline, but `quotaUsage` is synthetic + * (not a real server property) — DynamicList resolves it to the + * `usedDiskQuota` + `quotas.maxDiskQuota` pair and formats it specially. + */ +export function withAccountListColumns(schema: Schema): Schema { + const list = schema.lists['x:Account/User']; + if (!list) return schema; + + const columns = [ + ...list.columns.filter((c) => c.name !== 'createdAt'), + { name: 'roles', label: 'Role' }, + { name: 'quotaUsage', label: 'Usage / Quota' }, + ]; + + return { + ...schema, + lists: { + ...schema.lists, + 'x:Account/User': { ...list, columns }, + }, + }; +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 813ea84..f7aa384 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -13,6 +13,7 @@ import { useAccountStore } from '@/stores/accountStore'; import { useUIStore } from '@/stores/uiStore'; import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/client'; import { withClientLogFilters } from '@/lib/logFilters'; +import { withAccountListColumns } from '@/lib/accountColumns'; import { setLocale } from '@/i18n'; import { TopBar } from '@/components/layout/TopBar'; import { Sidebar } from '@/components/layout/Sidebar'; @@ -148,7 +149,7 @@ export default function AdminPanel() { if (cancelled) return; - setSchema(withClientLogFilters(schemaData)); + setSchema(withAccountListColumns(withClientLogFilters(schemaData))); setAccountInfo(accountData.permissions, accountData.edition, accountData.locale); setLocale(accountData.locale);