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();
|
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> {
|
function getFieldsRecord(resolvedSchema: ResolvedSchema): Record<string, Field> {
|
||||||
if (resolvedSchema.type === 'single') {
|
if (resolvedSchema.type === 'single') {
|
||||||
return resolvedSchema.fields.properties;
|
return resolvedSchema.fields.properties;
|
||||||
@@ -307,7 +336,14 @@ function renderCellValue(
|
|||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
const obj = value as Record<string, unknown>;
|
const obj = value as Record<string, unknown>;
|
||||||
if ('@type' in obj && typeof obj['@type'] === 'string') {
|
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>;
|
return <span className="text-muted-foreground">-</span>;
|
||||||
@@ -397,6 +433,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
const objectName = resolved?.obj.objectName;
|
const objectName = resolved?.obj.objectName;
|
||||||
const isWebApplications = viewName === 'x:Application' || objectName === 'x:Application';
|
const isWebApplications = viewName === 'x:Application' || objectName === 'x:Application';
|
||||||
const isLogEntries = viewName === 'x:Log' || objectName === 'x:Log';
|
const isLogEntries = viewName === 'x:Log' || objectName === 'x:Log';
|
||||||
|
const isAccountsList = viewName === 'x:Account/User';
|
||||||
|
|
||||||
const displayColumns = useMemo(() => {
|
const displayColumns = useMemo(() => {
|
||||||
const columns = resolved?.list?.columns ?? [];
|
const columns = resolved?.list?.columns ?? [];
|
||||||
@@ -555,6 +592,12 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
if (isWebApplications && !properties.includes('enabled')) {
|
if (isWebApplications && !properties.includes('enabled')) {
|
||||||
properties.push('enabled');
|
properties.push('enabled');
|
||||||
}
|
}
|
||||||
|
if (isAccountsList) {
|
||||||
|
const quotaIdx = properties.indexOf('quotaUsage');
|
||||||
|
if (quotaIdx !== -1) {
|
||||||
|
properties.splice(quotaIdx, 1, 'usedDiskQuota', 'quotas');
|
||||||
|
}
|
||||||
|
}
|
||||||
const filter = buildFilter();
|
const filter = buildFilter();
|
||||||
const sortArr = buildSort();
|
const sortArr = buildSort();
|
||||||
|
|
||||||
@@ -626,7 +669,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[resolved, schema, buildFilter, buildSort, isWebApplications, activeClientFilters],
|
[resolved, schema, buildFilter, buildSort, isWebApplications, isAccountsList, activeClientFilters],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1529,6 +1572,10 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
) : (
|
) : (
|
||||||
<X className="h-4 w-4 text-red-500" />
|
<X className="h-4 w-4 text-red-500" />
|
||||||
)
|
)
|
||||||
|
) : isAccountsList && col.name === 'roles' ? (
|
||||||
|
formatUserRole(item, schema!)
|
||||||
|
) : isAccountsList && col.name === 'quotaUsage' ? (
|
||||||
|
formatQuotaUsage(item, t)
|
||||||
) : (
|
) : (
|
||||||
renderCellValue(
|
renderCellValue(
|
||||||
item[col.name],
|
item[col.name],
|
||||||
|
|||||||
+2
-1
@@ -313,7 +313,8 @@
|
|||||||
"showing": "Showing {{from}}-{{to}} of {{total}} {{name}}",
|
"showing": "Showing {{from}}-{{to}} of {{total}} {{name}}",
|
||||||
"showingItems": "Showing {{count}} items",
|
"showingItems": "Showing {{count}} items",
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
"unknownError": "Unknown error"
|
"unknownError": "Unknown error",
|
||||||
|
"unlimitedQuota": "Unlimited"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"continue": "Continue",
|
"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 { useUIStore } from '@/stores/uiStore';
|
||||||
import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/client';
|
import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/client';
|
||||||
import { withClientLogFilters } from '@/lib/logFilters';
|
import { withClientLogFilters } from '@/lib/logFilters';
|
||||||
|
import { withAccountListColumns } from '@/lib/accountColumns';
|
||||||
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';
|
||||||
@@ -148,7 +149,7 @@ export default function AdminPanel() {
|
|||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
setSchema(withClientLogFilters(schemaData));
|
setSchema(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