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 <noreply@anthropic.com>
This commit is contained in:
Steven RYDELL
2026-07-30 16:47:26 +02:00
co-authored by Claude Sonnet 5
parent 3113e6309d
commit 3dda6d1527
4 changed files with 87 additions and 4 deletions
+49 -2
View File
@@ -185,6 +185,35 @@ function formatNumber(value: unknown): string {
return value.toLocaleString();
}
function formatUserRole(item: Record<string, unknown>, 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<string, unknown> | undefined;
const type = roles && typeof roles['@type'] === 'string' ? roles['@type'] : undefined;
if (!type) return <span className="text-muted-foreground">-</span>;
const variantSchema = schema.schemas['x:UserRoles'];
if (variantSchema?.type === 'multiple') {
const variant = variantSchema.variants.find((v) => v.name === type);
if (variant) return <Badge variant="secondary">{variant.label}</Badge>;
}
return type;
}
function formatQuotaUsage(item: Record<string, unknown>, t: TFn): string {
const used = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0;
const quotas = item.quotas as Record<string, unknown> | 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<string, Field> {
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<string, unknown>;
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 <Badge variant="secondary">{variant.label}</Badge>;
}
}
return String(obj['@type']);
}
}
return <span className="text-muted-foreground">-</span>;
@@ -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) {
) : (
<X className="h-4 w-4 text-red-500" />
)
) : isAccountsList && col.name === 'roles' ? (
formatUserRole(item, schema!)
) : isAccountsList && col.name === 'quotaUsage' ? (
formatQuotaUsage(item, t)
) : (
renderCellValue(
item[col.name],
+2 -1
View File
@@ -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",
+34
View File
@@ -0,0 +1,34 @@
/*
* 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';
/**
* 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 },
},
};
}
+2 -1
View File
@@ -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);