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
11 changes: 11 additions & 0 deletions .changeset/24938-sys-user-security-tab-own-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/platform-objects': patch
---

fix(platform-objects): the user page's Security tab shows only on the viewer's own record

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)

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.

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #24938 — the user page's Security tab shows only on the viewer's own record.
*
* Every action the tab names is gated to the viewer's own record on the
* object, so on another user's record each `record:quick_actions` draws
* nothing and only the "your account" headings and captions remain. The tab
* item therefore carries the own-record gate itself, in the CEL envelope: a
* bare string on this raw `Page` literal would reach the console's legacy
* evaluator, which has no `has()`, and fail soft by showing the tab to every
* viewer.
*
* Read from the shipped page metadata and the shipped object metadata, so the
* pin fails if the gate leaves the tab, loses its envelope, or if the tab
* starts naming an action that is NOT own-record — at which point gating the
* whole tab would hide that action from an administrator.
*/

import { describe, expect, it } from 'vitest';
import { PageSchema } from '@objectstack/spec/ui';
import { SysUserDetailPage } from './sys-user.page.js';
import { SysUser } from '../identity/sys-user.object.js';

type Rec = Record<string, unknown>;

const OWN_RECORD = 'has(record.id) && record.id == ctx.user.id';

describe('sys_user_detail — the Security tab is gated to the viewer\'s own record', () => {
const tabs = (SysUserDetailPage.slots as Rec).tabs as Rec;
const items = (tabs.properties as Rec).items as Rec[];
const security = items.find((it) => (it.label as Rec).en === 'Security')!;

it('parses through PageSchema', () => {
const parsed = PageSchema.safeParse(SysUserDetailPage);
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
});

it('carries the own-record gate on the tab item, in the CEL envelope', () => {
expect(security).toBeDefined();
expect(security.visibleWhen).toEqual({ dialect: 'cel', source: OWN_RECORD });
});

it('gates only the Security tab — every other tab item carries no item-level gate', () => {
const gated = items.filter((it) => it.visibleWhen !== undefined).map((it) => (it.label as Rec).en);
expect(gated).toEqual(['Security']);
});

it('names only actions that are themselves own-record on the object', () => {
const names = (security.children as Rec[])
.filter((c) => c.type === 'record:quick_actions')
.flatMap((c) => (c.properties as Rec).actionNames as string[]);
expect(names.length).toBeGreaterThan(0);
const actions = (SysUser.actions ?? []) as Rec[];
for (const name of names) {
const action = actions.find((a) => a.name === name);
expect(action, name).toBeDefined();
// `ObjectSchema.create` parses the bare string into its envelope.
const visible = action!.visible as { source?: string } | string | undefined;
const source = typeof visible === 'string' ? visible : visible?.source;
// A pure conjunction led by the own-record guard — the 2FA actions wrap
// it in a parenthesised group `(…) && features.twoFactor == true`.
expect(source, name).toMatch(/^\(?has\(record\.id\) && record\.id == ctx\.user\.id(?: && |\)|$)/);
expect(source, name).not.toContain('||');
}
});
});
14 changes: 14 additions & 0 deletions packages/platform-objects/src/pages/sys-user.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,9 +364,23 @@ export const SysUserDetailPage: Page = {
// (DRY — definitions stay on the object) and filters by
// `location: 'record_section'` so they only render here, not
// in the global header row.
//
// The whole tab is self-service, so the whole tab carries the
// own-record gate. Every action it names is gated to the viewer's
// own record on the object (`record.id == ctx.user.id`), so on
// another user's record each `record:quick_actions` draws nothing
// and only the "your account" headings would remain — speaking to
// an administrator about an account that is not theirs. An
// item-level `visibleWhen` omits the tab header AND panel (the
// renderer falls back to the first visible tab); a per-node gate
// would leave an empty Security tab behind. Same `P` envelope and
// `has()` guard as the unverified-email alert above, for the same
// reasons: this raw `Page` literal reaches the wire verbatim, and
// only the `{ dialect: 'cel' }` envelope routes to CEL.
{
label: { en: 'Security', 'zh-CN': '安全', 'ja-JP': 'セキュリティ', 'es-ES': 'Seguridad' },
icon: 'shield',
visibleWhen: P`has(record.id) && record.id == ctx.user.id`,
children: [
{
type: 'element:text',
Expand Down
Loading