From ae568e7f3537cb51fb9d1117dc075862083b24d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:50:01 +0000 Subject: [PATCH 1/3] wip(platform-objects): hide sys_user 2FA display when twoFactor is off Claude-Session: https://claude.ai/code/session_01Ukg7cK1DZiCfUdo5FH4G7A Co-authored-by: Claude --- .../src/identity/sys-user.object.ts | 21 ++-- .../pages/sys-user-two-factor-display.test.ts | 106 ++++++++++++++++++ .../src/pages/sys-user.page.ts | 56 +++++++-- 3 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 packages/platform-objects/src/pages/sys-user-two-factor-display.test.ts diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index d13bca9b5b3..d0f490e0a76 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -706,7 +706,11 @@ export const SysUser = ObjectSchema.create({ name: 'all_users', label: 'All Users', data: { provider: 'object', object: 'sys_user' }, - columns: ['name', 'email', 'phone_number', 'email_verified', 'source', 'two_factor_enabled', 'created_at'], + // No `two_factor_enabled` column here or in `me` (#25287): a list column + // 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'], sort: [{ field: 'name', order: 'asc' }], pagination: { pageSize: 50 }, }, @@ -723,7 +727,7 @@ export const SysUser = ObjectSchema.create({ name: 'me', label: 'My Profile', data: { provider: 'object', object: 'sys_user' }, - columns: ['name', 'email', 'phone_number', 'email_verified', 'two_factor_enabled', 'updated_at'], + columns: ['name', 'email', 'phone_number', 'email_verified', 'updated_at'], filter: [{ field: 'id', operator: 'equals', value: '{current_user_id}' }], sort: [{ field: 'name', order: 'asc' }], pagination: { pageSize: 1 }, @@ -738,16 +742,9 @@ export const SysUser = ObjectSchema.create({ sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, }, - two_factor: { - type: 'grid', - name: 'two_factor', - label: '2FA Enabled', - data: { provider: 'object', object: 'sys_user' }, - columns: ['name', 'email', 'two_factor_enabled', 'updated_at'], - filter: [{ field: 'two_factor_enabled', operator: 'equals', value: true }], - sort: [{ field: 'name', order: 'asc' }], - pagination: { pageSize: 50 }, - }, + // No "2FA Enabled" view (#25287): a list view has no feature gate, so on + // a deployment with 2FA off it was a tab that could only ever be empty. + // Retired rather than gated — gating it would need a new authorable key. banned: { type: 'grid', name: 'banned', diff --git a/packages/platform-objects/src/pages/sys-user-two-factor-display.test.ts b/packages/platform-objects/src/pages/sys-user-two-factor-display.test.ts new file mode 100644 index 00000000000..601cad5206d --- /dev/null +++ b/packages/platform-objects/src/pages/sys-user-two-factor-display.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25287 — 2FA is either offered or not shown. + * + * `plugin-auth` resolves `twoFactor` to `false` unless a deployment turns it + * on, and the platform's own `sys_user` declarations used to show a 2FA state + * regardless: a highlights chip, two list columns, a "2FA Enabled" view tab + * and a Security-tab heading over an empty action bar. None of those surfaces + * has a `requiresFeature`, so each one is either dropped or gated with a node + * `visibleWhen` over `features.twoFactor`. + * + * Pinned here, from the shipped metadata: + * - off: no list view, no highlights chip, and no Security-tab node reads or + * names 2FA state unless it carries the gate, and the gate answers `false` + * — including while the auth config has not loaded (`features` is `{}`), + * without faulting; + * - on: the gate answers `true`, and the Security tab then carries both the + * state (`two_factor_enabled`) and the enable action. + */ + +import { describe, expect, it } from 'vitest'; +import { celEngine } from '@objectstack/formula'; +import { featureGatePredicate } from '@objectstack/spec/kernel'; +import { PageSchema } from '@objectstack/spec/ui'; +import { SysUser } from '../identity/sys-user.object.js'; +import { SysUserDetailPage } from './sys-user.page.js'; + +type Rec = Record; + +const slots = SysUserDetailPage.slots as Rec; +const tabItems = (slots.tabs.properties as Rec).items as Rec[]; +const security = tabItems.find((it) => it.label?.en === 'Security') as Rec; +const securityNodes = security.children as Rec[]; + +/** Does this node show 2FA state or a 2FA control? Judged on what it names, not on its position. */ +function namesTwoFactor(node: Rec): boolean { + return JSON.stringify(node.properties ?? {}).match(/two_factor|Two-Factor|TOTP/) !== null; +} + +function evaluateGate(features: unknown): boolean | string { + const gate = securityNodes.find(namesTwoFactor)?.visibleWhen as { dialect: string; source: string }; + const r = celEngine.evaluate(gate as never, { record: { id: 'u1' }, extra: { features } }); + if (!r.ok) return `FAULT ${r.error.message.split('\n')[0]}`; + return r.value as boolean; +} + +describe('sys_user — 2FA display only where 2FA is offered (#25287)', () => { + it('the page still parses through PageSchema', () => { + const parsed = PageSchema.safeParse(SysUserDetailPage); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); + }); + + it('no list view shows a 2FA column or filters on 2FA state, and the "2FA Enabled" view is retired', () => { + const views = SysUser.listViews as Record; + expect(Object.keys(views)).not.toContain('two_factor'); + for (const [name, view] of Object.entries(views)) { + expect(JSON.stringify(view), name).not.toContain('two_factor_enabled'); + } + }); + + it('the highlights chip strip does not carry the 2FA state', () => { + expect(slots.highlights.properties.fields).not.toContain('two_factor_enabled'); + }); + + it('the details grid still hides the field, so dropping the chip does not move it into the body', () => { + const details = (tabItems[0].children as Rec[]).find((c) => c.type === 'record:details') as Rec; + expect(details.properties.hideFields).toContain('two_factor_enabled'); + }); + + it('every Security-tab node that names 2FA — and the divider closing the block — carries the one gate', () => { + const gated = securityNodes.filter((n) => n.visibleWhen !== undefined); + const twoFactorNodes = securityNodes.filter(namesTwoFactor); + expect(twoFactorNodes.map((n) => n.type)).toEqual([ + 'element:text', + 'element:text', + 'record:highlights', + 'record:quick_actions', + ]); + const closingDivider = securityNodes[securityNodes.indexOf(twoFactorNodes[3]) + 1]; + expect(closingDivider.type).toBe('element:divider'); + expect(gated).toEqual([...twoFactorNodes, closingDivider]); + const sources = new Set(gated.map((n) => JSON.stringify(n.visibleWhen))); + expect(sources.size).toBe(1); + const gate = gated[0].visibleWhen as { dialect: string; source: string }; + expect(gate.dialect).toBe('cel'); + // The same conjunct the object's 2FA actions carry through `requiresFeature`. + expect(gate.source.split(' && ')).toContain(featureGatePredicate('twoFactor')); + }); + + it('off: the gate is closed — with the flag false, absent, or the config not loaded — and never faults', () => { + expect(evaluateGate({ twoFactor: false })).toBe(false); + expect(evaluateGate({ multiOrgEnabled: true })).toBe(false); + expect(evaluateGate({})).toBe(false); + }); + + it('on: the gate is open, and the Security tab carries the 2FA state and the enable action', () => { + expect(evaluateGate({ twoFactor: true })).toBe(true); + const state = securityNodes.find((n) => n.type === 'record:highlights') as Rec; + expect(state.properties.fields).toEqual(['two_factor_enabled']); + const actions = securityNodes.find( + (n) => n.type === 'record:quick_actions' && n.properties.actionNames.includes('enable_two_factor'), + ) as Rec; + expect(actions.visibleWhen).toEqual(state.visibleWhen); + }); +}); diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index c4dd9db6ae3..056b0b40737 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -3,6 +3,25 @@ import { P } from '@objectstack/spec/shared'; import type { Page } from '@objectstack/spec/ui'; +/** + * The gate on every node that shows 2FA state or 2FA controls (#25287). + * + * 2FA is either offered or not shown: `plugin-auth` resolves `twoFactor` to + * `false` unless a deployment turns it on, and `/api/v1/auth/config` then + * serves `features.twoFactor: false`. Without this gate a deployment with + * 2FA off shows a "Two-Factor Enabled: No" chip and a Security heading over + * an empty action bar — a state with no way to act on it. + * + * The right half is `featureGatePredicate('twoFactor')`, the conjunct the + * object's 2FA actions carry through `requiresFeature`; page components have + * no `requiresFeature`, so it is written out here. The `has()` guard keeps + * the gate CLOSED rather than faulting while the auth config has not loaded + * yet (the console binds `features` to `{}` until then, and on CEL a missing + * map key is a fault). Same `P` envelope as the alert below, for the same + * reason: this raw `Page` literal reaches the wire verbatim. + */ +const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == true`; + /** * sys_user — Record Detail Page (slotted, default for ALL sys_user records) * @@ -11,7 +30,7 @@ import type { Page } from '@objectstack/spec/ui'; * The Account App's "Profile" entry no longer routes here — it points at * the `account:profile_card` Console component for a settings-form-style * personal profile. This page therefore optimizes for the admin - * use case: scanning a user's signals (email/verification/2FA/role), + * use case: scanning a user's signals (email/verification/role), * reviewing related sessions/orgs/oauth/api-keys, and triggering * admin actions (ban / impersonate). * @@ -23,9 +42,11 @@ import type { Page } from '@objectstack/spec/ui'; * (`update_my_profile / change_my_password / resend_verification_email * / ban_user / impersonate_user / …`) still appear * in the header overflow menu automatically. - * - `highlights` promotes the four signals worth scanning at the top: - * email, verification state, 2FA, platform role. Highlight fields - * are auto-dropped from the details grid below. + * - `highlights` promotes the signals worth scanning at the top: + * email, phone, verification state, platform role. Highlight fields + * are auto-dropped from the details grid below. 2FA state is not one + * of them: it is drawn only in the Security tab, behind the + * deployment's `twoFactor` gate (see {@link TWO_FACTOR_OFFERED}). * - The FIRST `tabs` item 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 @@ -128,7 +149,10 @@ export const SysUserDetailPage: Page = { highlights: { type: 'record:highlights', properties: { - fields: ['email', 'phone_number', 'email_verified', 'two_factor_enabled', 'role'], + // No `two_factor_enabled` (#25287): a highlight field has no + // per-field gate, and on a deployment with 2FA off the chip read a + // dead "No". The state is drawn in the Security tab instead. + fields: ['email', 'phone_number', 'email_verified', 'role'], }, }, @@ -170,8 +194,9 @@ export const SysUserDetailPage: Page = { 'email', 'phone_number', 'email_verified', - 'two_factor_enabled', 'role', + // drawn only in the Security tab, behind the 2FA gate: + 'two_factor_enabled', ], sections: [ { @@ -415,8 +440,14 @@ export const SysUserDetailPage: Page = { }, }, { type: 'element:divider' }, + // ── Two-factor block: every node carries TWO_FACTOR_OFFERED + // (#25287), so with 2FA off the block and its trailing divider + // are gone and Password flows straight into Email Verification. + // Per node rather than one wrapper, so the tab keeps its flat + // layout; a node gate that faults is withheld, not drawn. { type: 'element:text', + visibleWhen: TWO_FACTOR_OFFERED, properties: { variant: 'h3', content: { @@ -429,6 +460,7 @@ export const SysUserDetailPage: Page = { }, { type: 'element:text', + visibleWhen: TWO_FACTOR_OFFERED, properties: { variant: 'caption', content: { @@ -439,15 +471,25 @@ export const SysUserDetailPage: Page = { }, }, }, + { + // The 2FA state, moved here from the highlights strip: shown + // only where it can be acted on. + type: 'record:highlights', + visibleWhen: TWO_FACTOR_OFFERED, + properties: { + fields: ['two_factor_enabled'], + }, + }, { type: 'record:quick_actions', + visibleWhen: TWO_FACTOR_OFFERED, properties: { location: 'record_section', align: 'start', actionNames: ['enable_two_factor', 'disable_two_factor', 'generate_backup_codes'], }, }, - { type: 'element:divider' }, + { type: 'element:divider', visibleWhen: TWO_FACTOR_OFFERED }, { type: 'element:text', properties: { From c362f964ddca7c8be06eccbf305e50d1b8d365a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:54:34 +0000 Subject: [PATCH 2/3] wip: keep hideFields order Claude-Session: https://claude.ai/code/session_01Ukg7cK1DZiCfUdo5FH4G7A Co-authored-by: Claude --- packages/platform-objects/src/pages/sys-user.page.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 056b0b40737..3b968571258 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -194,9 +194,8 @@ export const SysUserDetailPage: Page = { 'email', 'phone_number', 'email_verified', + 'two_factor_enabled', // not a highlight: drawn in the Security tab, behind the 2FA gate 'role', - // drawn only in the Security tab, behind the 2FA gate: - 'two_factor_enabled', ], sections: [ { From 2a544030a714bfa3cb1e8998a384fbf6adcc412a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 05:01:26 +0000 Subject: [PATCH 3/3] fix(platform-objects): hide sys_user 2FA display when twoFactor is off Drop two_factor_enabled from the highlights chip and the all_users / me list columns, retire the two_factor list view, and gate the Security tab's 2FA block (now also showing the state) on features.twoFactor. Regenerate the platform-objects translation bundles. Claude-Session: https://claude.ai/code/session_01Ukg7cK1DZiCfUdo5FH4G7A Co-authored-by: Claude --- .changeset/25287-hide-2fa-display-when-off.md | 16 ++++++++++++++++ .../apps/translations/en.objects.generated.ts | 3 --- .../apps/translations/es-ES.objects.generated.ts | 3 --- .../apps/translations/ja-JP.objects.generated.ts | 3 --- .../apps/translations/zh-CN.objects.generated.ts | 3 --- 5 files changed, 16 insertions(+), 12 deletions(-) create mode 100644 .changeset/25287-hide-2fa-display-when-off.md diff --git a/.changeset/25287-hide-2fa-display-when-off.md b/.changeset/25287-hide-2fa-display-when-off.md new file mode 100644 index 00000000000..764e8074387 --- /dev/null +++ b/.changeset/25287-hide-2fa-display-when-off.md @@ -0,0 +1,16 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix: the `sys_user` page and list views show no 2FA state on a deployment where two-factor authentication is off + +Clause-②: no +Metadata-protocol: none + +`plugin-auth` resolves `twoFactor` to `false` unless a deployment turns it on, and `/api/v1/auth/config` then serves `features.twoFactor: false`. The platform's own `sys_user` declarations showed a 2FA state anyway, with no way to act on it: a "Two-Factor Enabled: No" highlights chip, a 2FA column in the `all_users` and `me` list views, a "2FA Enabled" view tab that could only ever be empty, and a Security-tab "Two-Factor Authentication" heading above an empty action bar. + +- **Record page, Security tab:** the 2FA heading, its caption, the 2FA actions and the divider that closes the block carry one component `visibleWhen`, `has(features.twoFactor) && features.twoFactor == true`. With 2FA off, or before the auth config has loaded, the block is not drawn. With 2FA on, the block also shows the user's 2FA state (`two_factor_enabled`) beside the enable, disable and backup-code actions. +- **Record page, highlights:** `two_factor_enabled` is no longer a highlight chip. A highlight field has no per-field gate, so the state moved into the gated Security block. The details grid still hides the field. +- **List views:** `all_users` and `me` drop the `two_factor_enabled` column, and the `two_factor` ("2FA Enabled") list view is retired. A list column or view has no feature gate, and adding one would be a new authorable key. + +The `two_factor_enabled` field itself is unchanged. A deployment with 2FA on that wants the column back can add it to its own list view. 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 495f45a6171..e830a9db04f 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -131,9 +131,6 @@ export const enObjects: NonNullable = { unverified: { label: "Unverified" }, - two_factor: { - label: "2FA Enabled" - }, banned: { label: "Banned" } 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 fea3218412e..d8640c0311a 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 @@ -131,9 +131,6 @@ export const esESObjects: NonNullable = { unverified: { label: "No verificados" }, - two_factor: { - label: "2FA habilitado" - }, banned: { label: "Bloqueado" } 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 a82570c2582..39ebce7f18f 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 @@ -131,9 +131,6 @@ export const jaJPObjects: NonNullable = { unverified: { label: "未確認" }, - two_factor: { - label: "2FA 有効" - }, banned: { label: "利用停止" } 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 82c69565cc9..542233808e5 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 @@ -131,9 +131,6 @@ export const zhCNObjects: NonNullable = { unverified: { label: "未验证" }, - two_factor: { - label: "已启用 2FA" - }, banned: { label: "已封禁" }