Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/25287-hide-2fa-display-when-off.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
unverified: {
label: "Unverified"
},
two_factor: {
label: "2FA Enabled"
},
banned: {
label: "Banned"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
unverified: {
label: "No verificados"
},
two_factor: {
label: "2FA habilitado"
},
banned: {
label: "Bloqueado"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
unverified: {
label: "未確認"
},
two_factor: {
label: "2FA 有効"
},
banned: {
label: "利用停止"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
unverified: {
label: "未验证"
},
two_factor: {
label: "已启用 2FA"
},
banned: {
label: "已封禁"
}
Expand Down
21 changes: 9 additions & 12 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
Expand All @@ -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 },
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, any>;

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<string, Rec>;
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);
});
});
55 changes: 48 additions & 7 deletions packages/platform-objects/src/pages/sys-user.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*
Expand All @@ -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).
*
Expand All @@ -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
Expand Down Expand Up @@ -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'],
},
},

Expand Down Expand Up @@ -170,7 +194,7 @@ export const SysUserDetailPage: Page = {
'email',
'phone_number',
'email_verified',
'two_factor_enabled',
'two_factor_enabled', // not a highlight: drawn in the Security tab, behind the 2FA gate
'role',
],
sections: [
Expand Down Expand Up @@ -415,8 +439,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: {
Expand All @@ -429,6 +459,7 @@ export const SysUserDetailPage: Page = {
},
{
type: 'element:text',
visibleWhen: TWO_FACTOR_OFFERED,
properties: {
variant: 'caption',
content: {
Expand All @@ -439,15 +470,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: {
Expand Down
Loading