Skip to content

Commit 066dabc

Browse files
fix(platform-objects): the sys_user page's Security tab shows only on the viewer's own record (#25100)
Fixes #24938 Clause-②: no (platform metadata only: the Security tab's self-service sections are shown on the viewer's own record only; no action, param, response, spec key or export moves) ## What changed The `sys_user` record page's **Security** tab item (`packages/platform-objects/src/pages/sys-user.page.ts`) now carries an item-level gate: `visibleWhen: P` + "`has(record.id) && record.id == ctx.user.id`". This is the same `P` (CEL) envelope and `has()` guard that the page's unverified-email `record:alert` uses. On the viewer's own record, the tab is unchanged. On any other user's record, both the tab header and its panel are omitted. Nothing else moves: the actions and their own gates in `sys-user.object.ts`, the other tabs and the alert are all unchanged. ## Measured before the edit - **Tab item `visibleWhen` is declared and honoured.** - Spec: `PageTabsProps.items[].visibleWhen` (`packages/spec/src/ui/component.zod.ts`, an `EvaluatedExpressionInputSchema`). The docs say a FALSE gate omits the whole tab (header and panel), and that a child component's own `visibleWhen` "hides only the panel content and would leave an empty tab header behind". - Renderer, objectui at the pinned `.objectui-sha` `cf153eb2e535`: `packages/components/src/renderers/layout/containers.tsx`, `isItemVisible` in `PageTabsRenderer`. It calls `ExpressionEvaluator.evaluateCondition` (`packages/core/src/evaluator/ExpressionEvaluator.ts`), which sends an explicit `{ dialect: 'cel' }` envelope to the CEL engine. Bare strings stay on the legacy evaluator. The scope is `buildNodeEvaluationScope`: the ambient scope binds `ctx: { user }` (`packages/app-shell/src/providers/ExpressionProvider.tsx`), with `record` set to the row. - **Component-node `visibleWhen` is honoured too.** `PageComponentSchema.visibleWhen` (`packages/spec/src/ui/page.zod.ts`) is evaluated by objectui's `SchemaRenderer`. The alert above already relies on it. - **The whole Security tab is self-service.** It holds four headed sections whose `record:quick_actions` name `change_my_password`, `change_my_email`, `enable_two_factor`, `disable_two_factor`, `generate_backup_codes`, `resend_verification_email` and `delete_my_account`. Each of those actions has a `visible` led by `has(record.id) && record.id == ctx.user.id` in `sys-user.object.ts`. Nothing in the tab serves an administrator. On another user's record, an administrator sees only the four headings and captions. - **Choice: gate the tab, not the sections.** A per-node gate would leave an empty "Security" tab header on every other user's record. The item-level gate is the shape the spec documents for this case. - **Pin census.** Before this change, no test asserted the Security tab's structure. `sys-user-details-tab.test.ts` pins only the first (Details) tab. `canonical-expression-envelopes.test.ts` covers every expression position on the page, and stays green with the new envelope. ## Pin `packages/platform-objects/src/pages/sys-user-security-tab-own-record.test.ts` asserts: - the page parses through `PageSchema`; - the Security item's `visibleWhen` equals `{ dialect: 'cel', source: 'has(record.id) && record.id == ctx.user.id' }`; - Security is the only tab item that carries an item-level gate; - every action the tab names exists on `SysUser` and is a pure conjunction (no `||`) led by the own-record guard. If someone later adds an administrator action to this tab, the test goes red, because gating the whole tab would hide that action. **Ablation.** I deleted the `visibleWhen` line with `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `c4dd9db6ae30` → `200f0d8d63f6`). The pin went red: 2 failed, 2 passed ("carries the own-record gate…" and "gates only the Security tab…"). After restore: blob == HEAD (`c4dd9db6ae30`) and `git diff HEAD` is empty. The test imports the page from `src/`, so no build or `dist/` leg is involved. ## Verification (head `0c2c418b8`, after merging `origin/main` `28c9a2cb6`) - `pnpm exec turbo run build --concurrency=2`: 77/77 tasks, lock VERDICT command-exit 0. - `pnpm --filter @objectstack/platform-objects typecheck`: exit 0 (`check:test-typecheck: OK`). - `pnpm --filter @objectstack/platform-objects test`: exit 0, Test Files 85 passed (85), Tests 1247 passed (1247). - `pnpm --filter @objectstack/core test:repo`: exit 0, Test Files 6 passed (6), Tests 82 passed (82). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 66 lines, all run on this head, every one exit 0. Before the full build, three families exited 3 (`check:dual-build-cjs-loads`, `check:i18n`, `check:lean-entry-closure`); after it, all three exit 0. `--ran`: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero — all 66 recorded an exit code and none of them is 3)". - Browser reading: not taken. ## Acceptance notes - **Tab URL tokens shift on another user's record.** No item on this page sets `value`, so the renderer derives the `?tab=` token from the index in the FILTERED item list (`tab-INDEX`). On another user's record, hiding Security moves API Keys from `tab-9` to `tab-8` and Preferences from `tab-10` to `tab-9`. On the viewer's own record the tokens are unchanged. A `?tab=` link copied from your own record to someone else's can therefore open the tab after the intended one. The spec already recommends a semantic `value` for durable links. Giving the items one touches the other tabs, which this card leaves out of scope. Carrier: none. - `#24277` is not addressed here. It cites this tab as precedent for a platform-default Settings tab. --- _Generated by [Claude Code](https://claude.ai/code/session_011VbHLLCsMcBUhfcGuZikJp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5050442 commit 066dabc

3 files changed

Lines changed: 92 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): the user page's Security tab shows only on the viewer's own record
6+
7+
Clause-②: no (platform metadata only: the Security tab's self-service sections are shown on the viewer's own record only; no action, param, response, spec key or export moves)
8+
9+
The `sys_user` record page's Security tab holds only self-service controls: change password and email, two-factor authentication, email verification and account deletion. Every one of those actions is already gated to the viewer's own record. On another user's record the actions were withheld, but the tab still showed its four "your account" headings and captions with nothing under them, addressed to an administrator about an account that is not theirs.
10+
11+
The tab item now carries the own-record gate `has(record.id) && record.id == ctx.user.id` as an item-level `visibleWhen` in the CEL envelope (the same guard the page's unverified-email alert carries). On your own record the tab is unchanged. On anyone else's record the tab header and its panel are both omitted. The actions, their own gates and the other tabs are unchanged.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24938 — the user page's Security tab shows only on the viewer's own record.
5+
*
6+
* Every action the tab names is gated to the viewer's own record on the
7+
* object, so on another user's record each `record:quick_actions` draws
8+
* nothing and only the "your account" headings and captions remain. The tab
9+
* item therefore carries the own-record gate itself, in the CEL envelope: a
10+
* bare string on this raw `Page` literal would reach the console's legacy
11+
* evaluator, which has no `has()`, and fail soft by showing the tab to every
12+
* viewer.
13+
*
14+
* Read from the shipped page metadata and the shipped object metadata, so the
15+
* pin fails if the gate leaves the tab, loses its envelope, or if the tab
16+
* starts naming an action that is NOT own-record — at which point gating the
17+
* whole tab would hide that action from an administrator.
18+
*/
19+
20+
import { describe, expect, it } from 'vitest';
21+
import { PageSchema } from '@objectstack/spec/ui';
22+
import { SysUserDetailPage } from './sys-user.page.js';
23+
import { SysUser } from '../identity/sys-user.object.js';
24+
25+
type Rec = Record<string, unknown>;
26+
27+
const OWN_RECORD = 'has(record.id) && record.id == ctx.user.id';
28+
29+
describe('sys_user_detail — the Security tab is gated to the viewer\'s own record', () => {
30+
const tabs = (SysUserDetailPage.slots as Rec).tabs as Rec;
31+
const items = (tabs.properties as Rec).items as Rec[];
32+
const security = items.find((it) => (it.label as Rec).en === 'Security')!;
33+
34+
it('parses through PageSchema', () => {
35+
const parsed = PageSchema.safeParse(SysUserDetailPage);
36+
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
37+
});
38+
39+
it('carries the own-record gate on the tab item, in the CEL envelope', () => {
40+
expect(security).toBeDefined();
41+
expect(security.visibleWhen).toEqual({ dialect: 'cel', source: OWN_RECORD });
42+
});
43+
44+
it('gates only the Security tab — every other tab item carries no item-level gate', () => {
45+
const gated = items.filter((it) => it.visibleWhen !== undefined).map((it) => (it.label as Rec).en);
46+
expect(gated).toEqual(['Security']);
47+
});
48+
49+
it('names only actions that are themselves own-record on the object', () => {
50+
const names = (security.children as Rec[])
51+
.filter((c) => c.type === 'record:quick_actions')
52+
.flatMap((c) => (c.properties as Rec).actionNames as string[]);
53+
expect(names.length).toBeGreaterThan(0);
54+
const actions = (SysUser.actions ?? []) as Rec[];
55+
for (const name of names) {
56+
const action = actions.find((a) => a.name === name);
57+
expect(action, name).toBeDefined();
58+
// `ObjectSchema.create` parses the bare string into its envelope.
59+
const visible = action!.visible as { source?: string } | string | undefined;
60+
const source = typeof visible === 'string' ? visible : visible?.source;
61+
// A pure conjunction led by the own-record guard — the 2FA actions wrap
62+
// it in a parenthesised group `(…) && features.twoFactor == true`.
63+
expect(source, name).toMatch(/^\(?has\(record\.id\) && record\.id == ctx\.user\.id(?: && |\)|$)/);
64+
expect(source, name).not.toContain('||');
65+
}
66+
});
67+
});

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,9 +364,23 @@ export const SysUserDetailPage: Page = {
364364
// (DRY — definitions stay on the object) and filters by
365365
// `location: 'record_section'` so they only render here, not
366366
// in the global header row.
367+
//
368+
// The whole tab is self-service, so the whole tab carries the
369+
// own-record gate. Every action it names is gated to the viewer's
370+
// own record on the object (`record.id == ctx.user.id`), so on
371+
// another user's record each `record:quick_actions` draws nothing
372+
// and only the "your account" headings would remain — speaking to
373+
// an administrator about an account that is not theirs. An
374+
// item-level `visibleWhen` omits the tab header AND panel (the
375+
// renderer falls back to the first visible tab); a per-node gate
376+
// would leave an empty Security tab behind. Same `P` envelope and
377+
// `has()` guard as the unverified-email alert above, for the same
378+
// reasons: this raw `Page` literal reaches the wire verbatim, and
379+
// only the `{ dialect: 'cel' }` envelope routes to CEL.
367380
{
368381
label: { en: 'Security', 'zh-CN': '安全', 'ja-JP': 'セキュリティ', 'es-ES': 'Seguridad' },
369382
icon: 'shield',
383+
visibleWhen: P`has(record.id) && record.id == ctx.user.id`,
370384
children: [
371385
{
372386
type: 'element:text',

0 commit comments

Comments
 (0)