diff --git a/.changeset/27858-sys-user-setup-rows.md b/.changeset/27858-sys-user-setup-rows.md new file mode 100644 index 00000000000..623b9e1bdf7 --- /dev/null +++ b/.changeset/27858-sys-user-setup-rows.md @@ -0,0 +1,15 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): Setup's user screens show a user's real state, and the copy-password line only when a password was generated + +Clause-②: no +Metadata-protocol: none + +Four rows of the Setup → Users pass, all on the platform's own `sys_user` metadata: + +- **Create User / Set Password result.** The dialog no longer carries the sentence "If a temporary password was generated, copy it now — it is shown only once and never stored." on every result. A dialog-level description shows on both outcomes, and a typed password returns no temporary password, so the line asked the administrator to copy something that was not there. The instruction is now the label of the Temporary password field itself ("Temporary password: copy it now, it is shown only once and never stored"), which the dialog draws only when the server generated one. The zh-CN, ja-JP and es-ES packs carry the same change. +- **User record, Details tab.** Details now shows the saved Locale (in Identity) and an Account Status section with Banned, Ban Reason and Ban Expires. These fields were hidden or in no section, and the details grid draws only its authored sections, so a banned user read as active. +- **Users list.** The default `all_users` view gains Banned and Platform Role columns, so a banned user or an admin is spotted without opening each record. +- **Locale help.** The help now says the setting is the language the user sees the interface in as well as their notifications. It used to say notifications only, while the console applies it to the whole interface. All four shipped packs carry it. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index c1248a40c1e..5006c51bd90 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -93,7 +93,7 @@ export const enObjects: NonNullable = { }, locale: { label: "Locale", - help: "Preferred language for this user's notifications, as a language tag such as zh-CN or ja-JP. When unset, the deployment's default language applies." + help: "The language this user sees the interface in and receives notifications in, as a language tag such as zh-CN or ja-JP. When unset, the deployment's default language applies." }, manager_id: { label: "Manager", @@ -195,13 +195,12 @@ export const enObjects: NonNullable = { }, resultDialog: { title: "User Created", - description: "If a temporary password was generated, copy it now — it is shown only once and never stored.", acknowledge: "Done", fields: { "user.email": "Email", "user.phoneNumber": "Phone Number", permissionSet: "Permission Set", - temporaryPassword: "Temporary Password" + temporaryPassword: "Temporary password: copy it now, it is shown only once and never stored" } } }, @@ -222,10 +221,9 @@ export const enObjects: NonNullable = { }, resultDialog: { title: "Password Updated", - description: "If a temporary password was generated, copy it now — it is shown only once and never stored.", acknowledge: "Done", fields: { - temporaryPassword: "Temporary Password" + temporaryPassword: "Temporary password: copy it now, it is shown only once and never stored" } } }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 7d60c1cfed4..d84eed83595 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -93,7 +93,7 @@ export const esESObjects: NonNullable = { }, locale: { label: "Idioma", - help: "Idioma preferido para las notificaciones de este usuario, como una etiqueta de idioma, por ejemplo zh-CN o ja-JP. Si no se define, se aplica el idioma predeterminado del despliegue." + help: "Idioma en el que este usuario ve la interfaz y recibe las notificaciones, como una etiqueta de idioma, por ejemplo zh-CN o ja-JP. Si no se define, se aplica el idioma predeterminado del despliegue." }, manager_id: { label: "Gerente", @@ -195,13 +195,12 @@ export const esESObjects: NonNullable = { }, resultDialog: { title: "Usuario creado", - description: "Si se generó una contraseña temporal, cópiela ahora: se muestra una sola vez y no se almacena.", acknowledge: "Hecho", fields: { "user.email": "Correo electrónico", "user.phoneNumber": "Número de teléfono", permissionSet: "Conjunto de permisos", - temporaryPassword: "Contraseña temporal" + temporaryPassword: "Contraseña temporal: cópiela ahora, se muestra una sola vez y no se almacena" } } }, @@ -222,10 +221,9 @@ export const esESObjects: NonNullable = { }, resultDialog: { title: "Contraseña actualizada", - description: "Si se generó una contraseña temporal, cópiela ahora: se muestra una sola vez y no se almacena.", acknowledge: "Hecho", fields: { - temporaryPassword: "Contraseña temporal" + temporaryPassword: "Contraseña temporal: cópiela ahora, se muestra una sola vez y no se almacena" } } }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 6c66a9d97f0..a9bde0728f6 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -93,7 +93,7 @@ export const jaJPObjects: NonNullable = { }, locale: { label: "言語", - help: "このユーザーへの通知に使用する優先言語。zh-CN や ja-JP のような言語タグで指定します。未設定の場合はデプロイのデフォルト言語が適用されます。" + help: "このユーザーの画面表示と通知に使用する言語。zh-CN や ja-JP のような言語タグで指定します。未設定の場合はデプロイのデフォルト言語が適用されます。" }, manager_id: { label: "マネージャー", @@ -195,13 +195,12 @@ export const jaJPObjects: NonNullable = { }, resultDialog: { title: "ユーザーを作成しました", - description: "一時パスワードが生成された場合は今すぐコピーしてください。表示は一度きりで、保存されません。", acknowledge: "完了", fields: { "user.email": "メールアドレス", "user.phoneNumber": "電話番号", permissionSet: "権限セット", - temporaryPassword: "一時パスワード" + temporaryPassword: "一時パスワード:今すぐコピーしてください。表示は一度きりで、保存されません" } } }, @@ -222,10 +221,9 @@ export const jaJPObjects: NonNullable = { }, resultDialog: { title: "パスワードを更新しました", - description: "一時パスワードが生成された場合は今すぐコピーしてください。表示は一度きりで、保存されません。", acknowledge: "完了", fields: { - temporaryPassword: "一時パスワード" + temporaryPassword: "一時パスワード:今すぐコピーしてください。表示は一度きりで、保存されません" } } }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 3b7e6359a9e..1f44d8b2c3b 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -93,7 +93,7 @@ export const zhCNObjects: NonNullable = { }, locale: { label: "语言", - help: "该用户接收通知时使用的首选语言,填写语言标签,如 zh-CN 或 ja-JP。未设置时采用部署的默认语言。" + help: "该用户界面显示和接收通知所用的语言,填写语言标签,如 zh-CN 或 ja-JP。未设置时采用部署的默认语言。" }, manager_id: { label: "经理", @@ -195,13 +195,12 @@ export const zhCNObjects: NonNullable = { }, resultDialog: { title: "用户已创建", - description: "如果生成了临时密码,请立即复制——它只显示一次,不会被保存。", acknowledge: "完成", fields: { "user.email": "邮箱", "user.phoneNumber": "手机号", permissionSet: "权限集", - temporaryPassword: "临时密码" + temporaryPassword: "临时密码:请立即复制,它只显示一次,不会被保存" } } }, @@ -222,10 +221,9 @@ export const zhCNObjects: NonNullable = { }, resultDialog: { title: "密码已更新", - description: "如果生成了临时密码,请立即复制——它只显示一次,不会被保存。", acknowledge: "完成", fields: { - temporaryPassword: "临时密码" + temporaryPassword: "临时密码:请立即复制,它只显示一次,不会被保存" } } }, diff --git a/packages/platform-objects/src/identity/create-user-result-dialog-copy.test.ts b/packages/platform-objects/src/identity/create-user-result-dialog-copy.test.ts index 65b2474b027..9167e638aea 100644 --- a/packages/platform-objects/src/identity/create-user-result-dialog-copy.test.ts +++ b/packages/platform-objects/src/identity/create-user-result-dialog-copy.test.ts @@ -1,17 +1,17 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * `sys_user.create_user` — the result dialog reads true whether the admin - * typed the password or left it empty to generate one. + * `sys_user.create_user` and `set_user_password` — the result dialog says + * "copy it now" only when there is a temporary password to copy. * * The endpoint (`plugin-auth`, `admin-user-endpoints.ts`) returns * `temporaryPassword` only when it minted one, and the dialog skips a field - * whose path is absent from the response. So after a typed password the - * dialog shows the email alone; a sentence ordering the admin to "copy the - * temporary password now" beside an "I have saved this password" button then - * describes a password that was never shown. `ActionSchema.resultDialog` has - * no per-outcome copy, so one sentence must hold for both outcomes — the - * hedged shape `set_user_password` in the same file already uses. + * whose path is absent from the response. `ActionSchema.resultDialog` has no + * per-outcome copy, so a dialog-level `description` shows on both outcomes: + * the hedged "If a temporary password was generated, copy it now" sentence + * sat above a typed password's result, where nothing was generated and + * nothing could be copied. The instruction now rides the `temporaryPassword` + * field's own label, which is drawn only beside the password itself. * * Pinned in the source and in all four shipped locales: the translated leaves * are hand-written and carry no source-hash entry, so a stale translation of @@ -35,14 +35,25 @@ const LOCALES: readonly (readonly [string, any])[] = [ ['es-ES', esESObjects], ]; -describe('create_user — the result dialog holds for a typed and a generated password', () => { - it('the source sentence is conditional on a password having been generated', () => { - const rd = action('create_user')?.resultDialog; - expect(rd, 'create_user declares a resultDialog').toBeDefined(); - expect(rd?.description).toBe( - 'If a temporary password was generated, copy it now — it is shown only once and never stored.', - ); - }); +describe('create_user / set_user_password — the copy line shows only beside a generated password', () => { + for (const name of ['create_user', 'set_user_password']) { + it(`${name}: no dialog-level description, which would show on both outcomes`, () => { + const rd = action(name)?.resultDialog; + expect(rd, `${name} declares a resultDialog`).toBeDefined(); + expect(rd).not.toHaveProperty('description'); + }); + + it(`${name}: the copy instruction is the temporaryPassword field's own label`, () => { + const field = action(name)?.resultDialog?.fields?.find((f) => f.path === 'temporaryPassword'); + expect(field?.format).toBe('secret'); + expect(field?.label).toMatch(/^Temporary password: copy it now/); + }); + + it(`${name}: no other field label asks the admin to copy a password`, () => { + const others = (action(name)?.resultDialog?.fields ?? []).filter((f) => f.path !== 'temporaryPassword'); + for (const f of others) expect(String(f.label ?? ''), f.path).not.toMatch(/copy|password/i); + }); + } it('the button claims no saved password, since a typed one shows none', () => { const ack = action('create_user')?.resultDialog?.acknowledge ?? ''; @@ -60,12 +71,20 @@ describe('create_user — the result dialog holds for a typed and a generated pa ]); }); - it('every locale says the same thing as set_user_password, whose dialog has the same two outcomes', () => { + it('every locale drops the description and translates the field label, the same for both actions', () => { + const source = action('create_user')?.resultDialog?.fields?.find((f) => f.path === 'temporaryPassword')?.label; for (const [locale, bundle] of LOCALES) { const created = dialog(bundle, 'create_user'); const updated = dialog(bundle, 'set_user_password'); - expect(created?.description, `${locale} create_user description`).toBeTruthy(); - expect(created.description, `${locale} description`).toBe(updated?.description); + for (const [name, d] of [['create_user', created], ['set_user_password', updated]] as const) { + expect(d, `${locale} ${name} resultDialog`).toBeDefined(); + expect(d, `${locale} ${name} description`).not.toHaveProperty('description'); + } + const label = created?.fields?.temporaryPassword; + expect(label, `${locale} temporaryPassword label`).toBeTruthy(); + expect(updated?.fields?.temporaryPassword, `${locale} same label on both`).toBe(label); + if (locale === 'en') expect(label).toBe(source); + else expect(label, `${locale} temporaryPassword label is the English source`).not.toBe(source); expect(created.acknowledge, `${locale} acknowledge`).toBe(updated?.acknowledge); } }); diff --git a/packages/platform-objects/src/identity/sys-user-list-status-role-columns.test.ts b/packages/platform-objects/src/identity/sys-user-list-status-role-columns.test.ts new file mode 100644 index 00000000000..6de520e8a52 --- /dev/null +++ b/packages/platform-objects/src/identity/sys-user-list-status-role-columns.test.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → Users: the default list shows whether a user is banned and what role + * they hold, so a banned user or an admin is spotted without opening each + * record (#27863). + * + * `all_users` is the view Setup's Users entry opens (`viewName: 'all_users'`, + * pinned in `setup-users-nav-view.test.ts`). Its columns used to be name, + * email, phone, email verified, identity source and created: nothing on the + * list said a user was banned. The `banned` view keeps its own filter; this + * pin is about the default list. + */ + +import { describe, expect, it } from 'vitest'; +import { SysUser } from './sys-user.object.js'; + +type ListView = { columns?: unknown[] }; + +const view = (name: string): ListView => + ((SysUser as { listViews?: Record }).listViews ?? {})[name] ?? {}; + +describe('sys_user all_users — Status and Role columns (#27863)', () => { + it('pins the default list columns', () => { + expect(view('all_users').columns).toEqual([ + 'name', 'email', 'phone_number', 'email_verified', 'banned', 'role', 'source', 'created_at', + ]); + }); + + it('every column is a declared sys_user field', () => { + const fields = Object.keys(SysUser.fields ?? {}); + for (const c of view('all_users').columns ?? []) expect(fields, String(c)).toContain(c); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-user-locale-help.test.ts b/packages/platform-objects/src/identity/sys-user-locale-help.test.ts new file mode 100644 index 00000000000..5c4437b24bc --- /dev/null +++ b/packages/platform-objects/src/identity/sys-user-locale-help.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_user.locale` help says what the setting does: it sets the language the + * user sees the interface in, as well as their notifications (#27869). + * + * The console applies the signed-in user's `sys_user.locale` to the whole UI + * (`useUserLocale`, and the globe switcher writes the same column), while the + * help said "Preferred language for this user's notifications", so a user + * reading it did not expect the interface to switch. Pinned in the source and + * in every shipped pack, because a translated leaf is hand-written and a stale + * one is not flagged anywhere else. + */ + +import { describe, expect, it } from 'vitest'; +import { SysUser } from './sys-user.object.js'; +import { enObjects } from '../apps/translations/en.objects.generated.js'; +import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; +import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; +import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; + +const help = (bundle: any): string => bundle?.sys_user?.fields?.locale?.help ?? ''; + +/** Per pack: the word for the interface, and the old notifications-only opening that must be gone. */ +const PACKS: readonly (readonly [string, any, RegExp, RegExp])[] = [ + ['en', enObjects, /interface/, /^Preferred language for this user's notifications/], + ['zh-CN', zhCNObjects, /界面/, /^该用户接收通知时使用的首选语言/], + ['ja-JP', jaJPObjects, /画面/, /^このユーザーへの通知に使用する優先言語/], + ['es-ES', esESObjects, /interfaz/, /^Idioma preferido para las notificaciones/], +]; + +describe('sys_user.locale help — it sets the interface language (#27869)', () => { + it('the source description names the interface', () => { + const d = String(SysUser.fields?.locale?.description ?? ''); + expect(d).toMatch(/^The language this user sees the interface in and receives notifications in/); + }); + + for (const [locale, bundle, names, old] of PACKS) { + it(`${locale} pack says it sets the interface language`, () => { + expect(help(bundle)).toMatch(names); + expect(help(bundle)).not.toMatch(old); + }); + } +}); diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index b6157e34321..5b87039d20d 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -39,6 +39,15 @@ import { INVITE_USER_OUTCOME_MESSAGES } from './invite-outcome-messages.js'; */ export const SYS_USER_LOCALE_TAG_PATTERN = '^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$'; +/** + * The label of the `temporaryPassword` field on the `create_user` and + * `set_user_password` result dialogs. It carries the copy-it-now instruction + * because the dialog skips a field absent from the response, and the server + * returns `temporaryPassword` only when it generated one: the instruction + * shows exactly when there is a password to copy. + */ +const TEMPORARY_PASSWORD_LABEL = 'Temporary password: copy it now, it is shown only once and never stored'; + /** * sys_user — System User Object * @@ -318,11 +327,12 @@ export const SysUser = ObjectSchema.create({ ], resultDialog: { title: 'User Created', - // Hedged for both outcomes: a typed password returns no - // `temporaryPassword`, so the dialog must not claim one to copy. - // Same sentence and button as `set_user_password` below. - description: - 'If a temporary password was generated, copy it now — it is shown only once and never stored.', + // No dialog-level description: it shows on both outcomes, and a typed + // password returns no `temporaryPassword`, so any copy-it-now sentence + // there talks about a password that is not on screen. The instruction + // rides the `temporaryPassword` field's own label instead, which the + // dialog draws only when the server minted one. Same shape and button + // as `set_user_password` below. acknowledge: 'Done', fields: [ // The dialog skips fields whose path is absent from the response, so @@ -331,7 +341,7 @@ export const SysUser = ObjectSchema.create({ { path: 'user.email', label: 'Email', format: 'text' }, { path: 'user.phoneNumber', label: 'Phone Number', format: 'text' }, { path: 'permissionSet', label: 'Permission Set', format: 'text' }, - { path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' }, + { path: 'temporaryPassword', label: TEMPORARY_PASSWORD_LABEL, format: 'secret' }, ], }, }, @@ -388,11 +398,10 @@ export const SysUser = ObjectSchema.create({ ], resultDialog: { title: 'Password Updated', - description: - 'If a temporary password was generated, copy it now — it is shown only once and never stored.', + // No description, for the reason given on `create_user` above. acknowledge: 'Done', fields: [ - { path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' }, + { path: 'temporaryPassword', label: TEMPORARY_PASSWORD_LABEL, format: 'secret' }, ], }, }, @@ -740,7 +749,9 @@ export const SysUser = ObjectSchema.create({ // has no feature gate, and on a deployment with 2FA off (the default) // it read a dead "No" on every row. The state is shown on the user's // own record page, in the Security tab, behind the `twoFactor` gate. - columns: ['name', 'email', 'phone_number', 'email_verified', 'source', 'created_at'], + // `banned` and `role` sit beside the email so a banned user or an + // admin is spotted from the list, without opening each record. + columns: ['name', 'email', 'phone_number', 'email_verified', 'banned', 'role', 'source', 'created_at'], sort: [{ field: 'name', order: 'asc' }], pagination: { pageSize: 50 }, }, @@ -1076,8 +1087,8 @@ export const SysUser = ObjectSchema.create({ maxLength: 35, group: 'Profile', description: - 'Preferred language for this user\'s notifications, as a language tag such as zh-CN or ja-JP. ' + - 'When unset, the deployment\'s default language applies.', + 'The language this user sees the interface in and receives notifications in, as a language tag ' + + 'such as zh-CN or ja-JP. When unset, the deployment\'s default language applies.', }), // ── Organization ───────────────────────────────────────────── diff --git a/packages/platform-objects/src/pages/sys-user-details-tab.test.ts b/packages/platform-objects/src/pages/sys-user-details-tab.test.ts index 95981890505..06ac47d093e 100644 --- a/packages/platform-objects/src/pages/sys-user-details-tab.test.ts +++ b/packages/platform-objects/src/pages/sys-user-details-tab.test.ts @@ -23,12 +23,9 @@ import { SysUserDetailPage } from './sys-user.page.js'; type Rec = Record; -/** The details body as the page authored it before the move — sections and hidden fields unchanged. */ +/** The details body: its sections and hidden fields. */ const HIDE_FIELDS = [ 'id', - 'banned', - 'ban_reason', - 'ban_expires', 'email', 'phone_number', 'email_verified', @@ -38,7 +35,13 @@ const HIDE_FIELDS = [ const SECTIONS = [ { label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' }, - fields: ['name', 'image'], + // #27859: a saved Locale is shown; the renderer draws only authored sections. + fields: ['name', 'image', 'locale'], + }, + { + // #27859: a banned user reads as banned, with the reason, not as active. + label: { en: 'Account Status', 'zh-CN': '账号状态', 'ja-JP': 'アカウントの状態', 'es-ES': 'Estado de la cuenta' }, + fields: ['banned', 'ban_reason', 'ban_expires'], }, { label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' }, @@ -72,6 +75,15 @@ describe('sys_user_detail — the details body lives in the first tab', () => { expect(props.sections).toEqual(SECTIONS); }); + it('shows locale and the ban state, and hides none of them (#27859)', () => { + const props = (items[0]!.children as Rec[])[0]!.properties as Rec; + const shown = (props.sections as { fields: string[] }[]).flatMap((s) => s.fields); + for (const f of ['locale', 'banned', 'ban_reason', 'ban_expires']) { + expect(shown, f).toContain(f); + expect(props.hideFields, f).not.toContain(f); + } + }); + it('is the one `record:details` on the page — no second copy anywhere in its body', () => { const found: unknown[] = []; const walk = (n: unknown): void => { diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 1067bd8bea1..592626ada00 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -51,9 +51,8 @@ const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == tr * of them: it is drawn only in the Security tab, behind the * deployment's `twoFactor` gate (see {@link TWO_FACTOR_OFFERED}). * - The FIRST tab is the details grid: a `record:details` that - * re-groups remaining fields into sections and hides admin-internal - * audit columns. Banned / ban metadata is still editable from the - * header actions — we just don't show it in every user's body. + * re-groups remaining fields into sections (locale on Identity, the ban + * state and reason in Account Status) and hides admin-internal columns. * - The other tabs are **explicitly curated** to the related lists * that matter on a user profile (Positions / Permission Sets / Business * Units / Sessions / Linked Accounts / Organizations / OAuth Apps / API @@ -199,9 +198,10 @@ export const SysUserDetailPage: Page = { { // ── Body / details grid ─────────────────────────────── // Re-groups the remaining fields into sections and hides - // admin-internal columns. Banned / ban metadata is still - // editable from the header actions — it is just not shown in - // every user's body. + // admin-internal columns. The renderer draws only the + // authored sections, so a field none of them names is not on + // the page: `locale` rides Identity, and the ban state has its + // own section, so a banned user does not read as active. label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, icon: 'file-text', children: [ @@ -210,9 +210,6 @@ export const SysUserDetailPage: Page = { properties: { hideFields: [ 'id', - 'banned', - 'ban_reason', - 'ban_expires', // already promoted to highlights: 'email', 'phone_number', @@ -223,7 +220,11 @@ export const SysUserDetailPage: Page = { sections: [ { label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' }, - fields: ['name', 'image'], + fields: ['name', 'image', 'locale'], + }, + { + label: { en: 'Account Status', 'zh-CN': '账号状态', 'ja-JP': 'アカウントの状態', 'es-ES': 'Estado de la cuenta' }, + fields: ['banned', 'ban_reason', 'ban_expires'], }, { label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },