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:
co-authored by
Claude Sonnet 5
parent
3113e6309d
commit
3dda6d1527
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user