Skip to content

Commit c109bfc

Browse files
fix(platform-objects): hide the sys_user 2FA display when twoFactor is off (#25387)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoXFSLU1DJHNjbPNr5sCZdkJ)_ Fixes #25287 Clause-②: no Metadata-protocol: none Unblocks objectstack-ai/cloud#3160 (picks this up with a pin bump). Shape 1 per triage's retriage answer 6357013838: no new authorable key; shape 2 (`requiresFeature` on display surfaces) is not taken. ## What changes 2FA is either offered, or not shown where it is not wired. `plugin-auth` resolves `twoFactor` to `false` unless a deployment turns it on, so on the default deployment the four surfaces below showed a 2FA state nobody could act on. | Surface | Before | After | |---|---|---| | Record page, highlights chip | `two_factor_enabled` ("Two-Factor Enabled: No") | dropped; the details grid still hides the field | | Record page, Security tab | 2FA heading + caption over an empty action bar | heading, caption, a new `record:highlights` showing `two_factor_enabled`, the 2FA quick actions and the closing divider each carry one component `visibleWhen` | | `all_users` / `me` list columns | `two_factor_enabled` column | dropped | | "2FA Enabled" view (`listViews.two_factor`) | a tab that could only ever be empty | retired (its three translated labels leave the generated bundles) | The gate is `has(features.twoFactor) && features.twoFactor == true`, in the `P` (CEL envelope) form the page's other node gates use. The right half is exactly `featureGatePredicate('twoFactor')`, the conjunct `requiresFeature: 'twoFactor'` lowers onto the object's 2FA actions. The `has()` half keeps the gate closed without faulting while the auth config has not loaded (the console binds `features` to an empty map until then). ## Where each gate is evaluable (measured at the pinned objectui `0d12e953`) - **Page component `visibleWhen`:** evaluable. `SchemaRenderer`'s node scope spreads the ambient predicate scope, and `AppContent` / `RecordFormPage` mount `ExpressionProvider` with the raw `/api/v1/auth/config` `features`, so `features.twoFactor` is bound. A declared node predicate that faults is withheld, not drawn (objectui#13299, present at the pin), so the card's fail-open caveat does not apply to this surface; the `has()` guard avoids the fault anyway. - **Highlights field, list column, list view:** not evaluable. `record:highlights.fields`, `ListView.columns` and the `listViews` map carry no predicate key; `ViewTabSchema.visible` is a static boolean. Gating them would need a new authorable key, so they are removed from the defaults instead. ## Verification - New pin `packages/platform-objects/src/pages/sys-user-two-factor-display.test.ts` (7 tests). Off: no list view names `two_factor_enabled` and `two_factor` is gone; the chip strip omits the field; every Security-tab node that names 2FA, plus the closing divider, carries the one gate; the gate evaluates `false` (not a fault) for `{ twoFactor: false }`, a map without the key, and `{}`. On: the gate evaluates `true` and the Security tab carries both the state (`two_factor_enabled`) and the enable action under it. Evaluated with `@objectstack/formula`'s `celEngine`, the engine the package's other predicate pins use. - Reverse check: with the fix committed (`2a544030`), removing `visibleWhen` from the Security tab's 2FA caption node turns the pin red (`every Security-tab node that names 2FA … carries the one gate`: 1 failed, 6 passed); restored with `git checkout HEAD --`, blob hash identical to HEAD, 7/7 green again. One-off, no permanent mutation test. - `platform-objects`: 89 test files, 1325 tests pass (`vitest run`, at `2a544030`). `pnpm --filter @objectstack/platform-objects typecheck` exit 0. - `node scripts/check-i18n-bundles.mjs --write`: exit 0; the only change is the retired view's label in en / zh-CN / ja-JP / es-ES. - Local gate subset (`dispatch-gates.mjs --commands`, reconciled with `--ran`): 66 derived, 64 run with exit 0, 2 NOT MEASURED (exit 3, prerequisite refused): `check-plugin-teardown-shape.mjs --self-test` (its pinned positive-control commit is outside this shallow clone) and `check:dual-build-cjs-loads` (needs every package's `dist/`, i.e. a full `pnpm build`). Both are left to CI. ## Acceptance notes - The `two_factor_enabled` field itself stays: the 2FA actions' predicates read it. A deployment with 2FA on that wants the column or the "2FA Enabled" tab back can declare them in its own list view. - `plugin-auth/src/auth-plugin.ts` has a comment naming `sys_user.listViews.{all_users,unverified,two_factor}` as an example of schema-embedded list views; it now names one retired view. Left alone to keep this PR inside its claimed file surface. - `object-collapsed-sections-echo-decisions.test.ts` cites the retired view's translated label as precedent in a decision string (prose, not an assertion); the precedent stays valid as history. - Not this PR, the card's own objectui findings (measured by the cloud seat at objectui `616df659`, not re-measured here), reported for the seat to file in objectui: with `twoFactor` on, `enable_two_factor`'s `visible` predicate faults in the `record_section` scope of `record:quick_actions` (`features` not bound there), so the enable button never renders; and objectui's login page has no second-factor step, so a user who enrolled through the API is stuck on `/_console/login`. Until the first is fixed, the on-case shows the gated heading and state but its action bar stays empty in the real console. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Ukg7cK1DZiCfUdo5FH4G7A --- _Generated by [Claude Code](https://claude.ai/code/session_01Ukg7cK1DZiCfUdo5FH4G7A)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ca381a8 commit c109bfc

8 files changed

Lines changed: 179 additions & 31 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix: the `sys_user` page and list views show no 2FA state on a deployment where two-factor authentication is off
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`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.
11+
12+
- **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.
13+
- **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.
14+
- **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.
15+
16+
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.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
131131
unverified: {
132132
label: "Unverified"
133133
},
134-
two_factor: {
135-
label: "2FA Enabled"
136-
},
137134
banned: {
138135
label: "Banned"
139136
}

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
131131
unverified: {
132132
label: "No verificados"
133133
},
134-
two_factor: {
135-
label: "2FA habilitado"
136-
},
137134
banned: {
138135
label: "Bloqueado"
139136
}

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
131131
unverified: {
132132
label: "未確認"
133133
},
134-
two_factor: {
135-
label: "2FA 有効"
136-
},
137134
banned: {
138135
label: "利用停止"
139136
}

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
131131
unverified: {
132132
label: "未验证"
133133
},
134-
two_factor: {
135-
label: "已启用 2FA"
136-
},
137134
banned: {
138135
label: "已封禁"
139136
}

‎packages/platform-objects/src/identity/sys-user.object.ts‎

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,11 @@ export const SysUser = ObjectSchema.create({
706706
name: 'all_users',
707707
label: 'All Users',
708708
data: { provider: 'object', object: 'sys_user' },
709-
columns: ['name', 'email', 'phone_number', 'email_verified', 'source', 'two_factor_enabled', 'created_at'],
709+
// No `two_factor_enabled` column here or in `me` (#25287): a list column
710+
// has no feature gate, and on a deployment with 2FA off (the default)
711+
// it read a dead "No" on every row. The state is shown on the user's
712+
// own record page, in the Security tab, behind the `twoFactor` gate.
713+
columns: ['name', 'email', 'phone_number', 'email_verified', 'source', 'created_at'],
710714
sort: [{ field: 'name', order: 'asc' }],
711715
pagination: { pageSize: 50 },
712716
},
@@ -723,7 +727,7 @@ export const SysUser = ObjectSchema.create({
723727
name: 'me',
724728
label: 'My Profile',
725729
data: { provider: 'object', object: 'sys_user' },
726-
columns: ['name', 'email', 'phone_number', 'email_verified', 'two_factor_enabled', 'updated_at'],
730+
columns: ['name', 'email', 'phone_number', 'email_verified', 'updated_at'],
727731
filter: [{ field: 'id', operator: 'equals', value: '{current_user_id}' }],
728732
sort: [{ field: 'name', order: 'asc' }],
729733
pagination: { pageSize: 1 },
@@ -738,16 +742,9 @@ export const SysUser = ObjectSchema.create({
738742
sort: [{ field: 'created_at', order: 'desc' }],
739743
pagination: { pageSize: 50 },
740744
},
741-
two_factor: {
742-
type: 'grid',
743-
name: 'two_factor',
744-
label: '2FA Enabled',
745-
data: { provider: 'object', object: 'sys_user' },
746-
columns: ['name', 'email', 'two_factor_enabled', 'updated_at'],
747-
filter: [{ field: 'two_factor_enabled', operator: 'equals', value: true }],
748-
sort: [{ field: 'name', order: 'asc' }],
749-
pagination: { pageSize: 50 },
750-
},
745+
// No "2FA Enabled" view (#25287): a list view has no feature gate, so on
746+
// a deployment with 2FA off it was a tab that could only ever be empty.
747+
// Retired rather than gated — gating it would need a new authorable key.
751748
banned: {
752749
type: 'grid',
753750
name: 'banned',
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25287 — 2FA is either offered or not shown.
5+
*
6+
* `plugin-auth` resolves `twoFactor` to `false` unless a deployment turns it
7+
* on, and the platform's own `sys_user` declarations used to show a 2FA state
8+
* regardless: a highlights chip, two list columns, a "2FA Enabled" view tab
9+
* and a Security-tab heading over an empty action bar. None of those surfaces
10+
* has a `requiresFeature`, so each one is either dropped or gated with a node
11+
* `visibleWhen` over `features.twoFactor`.
12+
*
13+
* Pinned here, from the shipped metadata:
14+
* - off: no list view, no highlights chip, and no Security-tab node reads or
15+
* names 2FA state unless it carries the gate, and the gate answers `false`
16+
* — including while the auth config has not loaded (`features` is `{}`),
17+
* without faulting;
18+
* - on: the gate answers `true`, and the Security tab then carries both the
19+
* state (`two_factor_enabled`) and the enable action.
20+
*/
21+
22+
import { describe, expect, it } from 'vitest';
23+
import { celEngine } from '@objectstack/formula';
24+
import { featureGatePredicate } from '@objectstack/spec/kernel';
25+
import { PageSchema } from '@objectstack/spec/ui';
26+
import { SysUser } from '../identity/sys-user.object.js';
27+
import { SysUserDetailPage } from './sys-user.page.js';
28+
29+
type Rec = Record<string, any>;
30+
31+
const slots = SysUserDetailPage.slots as Rec;
32+
const tabItems = (slots.tabs.properties as Rec).items as Rec[];
33+
const security = tabItems.find((it) => it.label?.en === 'Security') as Rec;
34+
const securityNodes = security.children as Rec[];
35+
36+
/** Does this node show 2FA state or a 2FA control? Judged on what it names, not on its position. */
37+
function namesTwoFactor(node: Rec): boolean {
38+
return JSON.stringify(node.properties ?? {}).match(/two_factor|Two-Factor|TOTP/) !== null;
39+
}
40+
41+
function evaluateGate(features: unknown): boolean | string {
42+
const gate = securityNodes.find(namesTwoFactor)?.visibleWhen as { dialect: string; source: string };
43+
const r = celEngine.evaluate(gate as never, { record: { id: 'u1' }, extra: { features } });
44+
if (!r.ok) return `FAULT ${r.error.message.split('\n')[0]}`;
45+
return r.value as boolean;
46+
}
47+
48+
describe('sys_user — 2FA display only where 2FA is offered (#25287)', () => {
49+
it('the page still parses through PageSchema', () => {
50+
const parsed = PageSchema.safeParse(SysUserDetailPage);
51+
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
52+
});
53+
54+
it('no list view shows a 2FA column or filters on 2FA state, and the "2FA Enabled" view is retired', () => {
55+
const views = SysUser.listViews as Record<string, Rec>;
56+
expect(Object.keys(views)).not.toContain('two_factor');
57+
for (const [name, view] of Object.entries(views)) {
58+
expect(JSON.stringify(view), name).not.toContain('two_factor_enabled');
59+
}
60+
});
61+
62+
it('the highlights chip strip does not carry the 2FA state', () => {
63+
expect(slots.highlights.properties.fields).not.toContain('two_factor_enabled');
64+
});
65+
66+
it('the details grid still hides the field, so dropping the chip does not move it into the body', () => {
67+
const details = (tabItems[0].children as Rec[]).find((c) => c.type === 'record:details') as Rec;
68+
expect(details.properties.hideFields).toContain('two_factor_enabled');
69+
});
70+
71+
it('every Security-tab node that names 2FA — and the divider closing the block — carries the one gate', () => {
72+
const gated = securityNodes.filter((n) => n.visibleWhen !== undefined);
73+
const twoFactorNodes = securityNodes.filter(namesTwoFactor);
74+
expect(twoFactorNodes.map((n) => n.type)).toEqual([
75+
'element:text',
76+
'element:text',
77+
'record:highlights',
78+
'record:quick_actions',
79+
]);
80+
const closingDivider = securityNodes[securityNodes.indexOf(twoFactorNodes[3]) + 1];
81+
expect(closingDivider.type).toBe('element:divider');
82+
expect(gated).toEqual([...twoFactorNodes, closingDivider]);
83+
const sources = new Set(gated.map((n) => JSON.stringify(n.visibleWhen)));
84+
expect(sources.size).toBe(1);
85+
const gate = gated[0].visibleWhen as { dialect: string; source: string };
86+
expect(gate.dialect).toBe('cel');
87+
// The same conjunct the object's 2FA actions carry through `requiresFeature`.
88+
expect(gate.source.split(' && ')).toContain(featureGatePredicate('twoFactor'));
89+
});
90+
91+
it('off: the gate is closed — with the flag false, absent, or the config not loaded — and never faults', () => {
92+
expect(evaluateGate({ twoFactor: false })).toBe(false);
93+
expect(evaluateGate({ multiOrgEnabled: true })).toBe(false);
94+
expect(evaluateGate({})).toBe(false);
95+
});
96+
97+
it('on: the gate is open, and the Security tab carries the 2FA state and the enable action', () => {
98+
expect(evaluateGate({ twoFactor: true })).toBe(true);
99+
const state = securityNodes.find((n) => n.type === 'record:highlights') as Rec;
100+
expect(state.properties.fields).toEqual(['two_factor_enabled']);
101+
const actions = securityNodes.find(
102+
(n) => n.type === 'record:quick_actions' && n.properties.actionNames.includes('enable_two_factor'),
103+
) as Rec;
104+
expect(actions.visibleWhen).toEqual(state.visibleWhen);
105+
});
106+
});

‎packages/platform-objects/src/pages/sys-user.page.ts‎

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@
33
import { P } from '@objectstack/spec/shared';
44
import type { Page } from '@objectstack/spec/ui';
55

6+
/**
7+
* The gate on every node that shows 2FA state or 2FA controls (#25287).
8+
*
9+
* 2FA is either offered or not shown: `plugin-auth` resolves `twoFactor` to
10+
* `false` unless a deployment turns it on, and `/api/v1/auth/config` then
11+
* serves `features.twoFactor: false`. Without this gate a deployment with
12+
* 2FA off shows a "Two-Factor Enabled: No" chip and a Security heading over
13+
* an empty action bar — a state with no way to act on it.
14+
*
15+
* The right half is `featureGatePredicate('twoFactor')`, the conjunct the
16+
* object's 2FA actions carry through `requiresFeature`; page components have
17+
* no `requiresFeature`, so it is written out here. The `has()` guard keeps
18+
* the gate CLOSED rather than faulting while the auth config has not loaded
19+
* yet (the console binds `features` to `{}` until then, and on CEL a missing
20+
* map key is a fault). Same `P` envelope as the alert below, for the same
21+
* reason: this raw `Page` literal reaches the wire verbatim.
22+
*/
23+
const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == true`;
24+
625
/**
726
* sys_user — Record Detail Page (slotted, default for ALL sys_user records)
827
*
@@ -11,7 +30,7 @@ import type { Page } from '@objectstack/spec/ui';
1130
* The Account App's "Profile" entry no longer routes here — it points at
1231
* the `account:profile_card` Console component for a settings-form-style
1332
* personal profile. This page therefore optimizes for the admin
14-
* use case: scanning a user's signals (email/verification/2FA/role),
33+
* use case: scanning a user's signals (email/verification/role),
1534
* reviewing related sessions/orgs/oauth/api-keys, and triggering
1635
* admin actions (ban / impersonate).
1736
*
@@ -23,9 +42,11 @@ import type { Page } from '@objectstack/spec/ui';
2342
* (`update_my_profile / change_my_password / resend_verification_email
2443
* / ban_user / impersonate_user / …`) still appear
2544
* in the header overflow menu automatically.
26-
* - `highlights` promotes the four signals worth scanning at the top:
27-
* email, verification state, 2FA, platform role. Highlight fields
28-
* are auto-dropped from the details grid below.
45+
* - `highlights` promotes the signals worth scanning at the top:
46+
* email, phone, verification state, platform role. Highlight fields
47+
* are auto-dropped from the details grid below. 2FA state is not one
48+
* of them: it is drawn only in the Security tab, behind the
49+
* deployment's `twoFactor` gate (see {@link TWO_FACTOR_OFFERED}).
2950
* - The FIRST `tabs` item is the details grid: a `record:details` that
3051
* re-groups remaining fields into sections and hides admin-internal
3152
* audit columns. Banned / ban metadata is still editable from the
@@ -128,7 +149,10 @@ export const SysUserDetailPage: Page = {
128149
highlights: {
129150
type: 'record:highlights',
130151
properties: {
131-
fields: ['email', 'phone_number', 'email_verified', 'two_factor_enabled', 'role'],
152+
// No `two_factor_enabled` (#25287): a highlight field has no
153+
// per-field gate, and on a deployment with 2FA off the chip read a
154+
// dead "No". The state is drawn in the Security tab instead.
155+
fields: ['email', 'phone_number', 'email_verified', 'role'],
132156
},
133157
},
134158

@@ -170,7 +194,7 @@ export const SysUserDetailPage: Page = {
170194
'email',
171195
'phone_number',
172196
'email_verified',
173-
'two_factor_enabled',
197+
'two_factor_enabled', // not a highlight: drawn in the Security tab, behind the 2FA gate
174198
'role',
175199
],
176200
sections: [
@@ -415,8 +439,14 @@ export const SysUserDetailPage: Page = {
415439
},
416440
},
417441
{ type: 'element:divider' },
442+
// ── Two-factor block: every node carries TWO_FACTOR_OFFERED
443+
// (#25287), so with 2FA off the block and its trailing divider
444+
// are gone and Password flows straight into Email Verification.
445+
// Per node rather than one wrapper, so the tab keeps its flat
446+
// layout; a node gate that faults is withheld, not drawn.
418447
{
419448
type: 'element:text',
449+
visibleWhen: TWO_FACTOR_OFFERED,
420450
properties: {
421451
variant: 'h3',
422452
content: {
@@ -429,6 +459,7 @@ export const SysUserDetailPage: Page = {
429459
},
430460
{
431461
type: 'element:text',
462+
visibleWhen: TWO_FACTOR_OFFERED,
432463
properties: {
433464
variant: 'caption',
434465
content: {
@@ -439,15 +470,25 @@ export const SysUserDetailPage: Page = {
439470
},
440471
},
441472
},
473+
{
474+
// The 2FA state, moved here from the highlights strip: shown
475+
// only where it can be acted on.
476+
type: 'record:highlights',
477+
visibleWhen: TWO_FACTOR_OFFERED,
478+
properties: {
479+
fields: ['two_factor_enabled'],
480+
},
481+
},
442482
{
443483
type: 'record:quick_actions',
484+
visibleWhen: TWO_FACTOR_OFFERED,
444485
properties: {
445486
location: 'record_section',
446487
align: 'start',
447488
actionNames: ['enable_two_factor', 'disable_two_factor', 'generate_backup_codes'],
448489
},
449490
},
450-
{ type: 'element:divider' },
491+
{ type: 'element:divider', visibleWhen: TWO_FACTOR_OFFERED },
451492
{
452493
type: 'element:text',
453494
properties: {

0 commit comments

Comments
 (0)