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/15204-grant-name-pickers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/platform-objects': patch
'@objectstack/plugin-security': patch
---

Setup's user page grants a position or a permission set by picking its name from the security catalog (ADR-0131 D4)

Clause-②: no

- `@objectstack/plugin-security`: `sys_user_position.position` declares `widget: 'position-ref'` and `sys_user_permission_set.permission_set` declares `widget: 'permission-ref'`, so the grant form offers a picker over the positions and permission sets the catalog serves, and stores the chosen name. A console without those widgets keeps the text input. `sys_user_permission_set.permission_set_id`, the legacy id that grants nothing, is now `hidden` and no longer appears on the grant form; the column stays until a later release drops its data.
- `@objectstack/platform-objects`: the user page's Positions and Permission Sets lists no longer carry the "Assign position" / "Grant permission set" buttons. Those buttons listed `sys_position` / `sys_permission_set` rows, which a deployment need not hold. Grant with the list's **New**, which opens the grant form; removing a grant is unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,12 @@ describe('#17081 — the claim and the route are the platform\'s own declaration
});

it('"grant it a permission set" is a surface the sys_user page really offers', () => {
// [#15204 B2] The grant is the Permission Sets list's "New" (the host's
// create on `sys_user_permission_set`), so the pin is that list, by title.
const page = JSON.stringify(SysUserDetailPage);
expect(page).toContain('sys_user_permission_set');
expect(page).toContain('Grant permission set');
expect(page).toContain('"objectName":"sys_user_permission_set"');
expect(page).toContain('"en":"Permission Sets"');
// Fabricated control: the same haystack does not answer to an invented name.
expect(page).not.toContain('Grant fabricated permission set');
expect(page).not.toContain('"objectName":"sys_user_fabricated_grant"');
});
});
2 changes: 1 addition & 1 deletion packages/cli/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1136,7 +1136,7 @@ export function printServerReady(opts: ServerReadyOptions) {
// `Setup → Users` is `SETUP_APP` (`requiredPermissions:
// ['setup.access']`, which this account holds) → the `nav_users`
// contribution (`sys_user`, ungated), whose detail page carries the
// "Grant permission set" related list. Pinned against those
// "Permission Sets" related list (its New grants a set). Pinned against those
// declarations in `format.server-ready-dev-admin-audience.test.ts`, so
// a rename there cannot leave this sentence pointing at nothing.
// • It changes no seed. What the first run CREATES is a product-shape
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/test/platform-page-i18n-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,8 +838,9 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => {
const maps = RECORD_PAGES.flatMap((page) => inlineLocaleMaps(page, page.name));

// Same refusal as the population floor: zero maps means the walk broke, not
// that the pages went monolingual.
expect(maps.length).toBeGreaterThanOrEqual(45);
// that the pages went monolingual. [#15204 B2] 44: the user page's two
// grant lists dropped their `add` pickers, and with them two label maps.
expect(maps.length).toBeGreaterThanOrEqual(44);

const incomplete = maps
.filter((m) => SHIPPED_LOCALES.some((locale) => !m.locales.includes(locale)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,32 @@ describe('sys_user page — the permission-set assignments list', () => {
expect(columns).toEqual(expect.arrayContaining(['permission_set', 'valid_from', 'valid_until']));
});

it('[ADR-0131 D4] the Add picker writes the set BY NAME, and the list never shows the legacy id', () => {
it('[ADR-0131 D4] the list never shows the legacy id', () => {
const list = lists[0] as any;
expect(list.add.linkField).toBe('permission_set');
expect(list.add.picker.valueField).toBe('name');
expect(list.columns).not.toContain('permission_set_id');
});
});

/**
* [#15204 B2] Neither grant list carries an `add` picker. Such a picker lists
* object ROWS, and positions and permission sets live in the security catalog,
* so it would offer nothing on a database that holds no such row. "New" opens
* the grant form, whose name field picks from the catalog (`widget:
* 'position-ref'` / `'permission-ref'` on the objects); Remove is the host's
* row delete, which does not depend on `add` (measured on #15204, 6116275825).
*/
describe('sys_user page — the grant lists have no row-object add picker', () => {
for (const objectName of ['sys_user_position', 'sys_user_permission_set']) {
it(`${objectName}: one related list, and no \`add\``, () => {
const found = relatedListsOf(SysUserDetailPage, objectName);
expect(found).toHaveLength(1);
expect(found[0]).not.toHaveProperty('add');
});
}

it('control: the business-unit list keeps its add picker (an object-row binding)', () => {
const found = relatedListsOf(SysUserDetailPage, 'sys_business_unit_member');
expect(found).toHaveLength(1);
expect(found[0]).toHaveProperty('add');
});
});
60 changes: 17 additions & 43 deletions packages/platform-objects/src/pages/sys-user.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,14 @@ export const SysUserDetailPage: Page = {
icon: 'shield-check',
children: [
{
// [ADR-0090 D3] Position assignments (岗位分派) — pure SDUI:
// the Add picker creates sys_user_position rows storing the
// position's MACHINE NAME (valueField: 'name'), and every
// server-side rule (the D12 delegated-admin gate, audience-
// anchor rejection) surfaces its error in the dialog.
// [ADR-0090 D3] Position assignments (岗位分派) — pure SDUI.
// [ADR-0131 D4, #15204 B2] No `add` picker: it could list
// `sys_position` ROWS only, and positions live in the security
// catalog. "New" opens the assignment form, whose `position`
// field picks a catalog position by name (`widget:
// 'position-ref'` on the object); Remove is the host's row
// delete. Every server-side rule (the D12 delegated-admin gate,
// audience-anchor rejection) surfaces its error in the form.
type: 'record:related_list',
properties: {
objectName: 'sys_user_position',
Expand All @@ -203,42 +206,27 @@ export const SysUserDetailPage: Page = {
limit: 25,
showViewAll: true,
title: { en: 'Positions', 'zh-CN': '岗位', 'ja-JP': 'ポジション', 'es-ES': 'Puestos' },
add: {
picker: {
object: 'sys_position',
valueField: 'name',
labelField: 'label',
},
linkField: 'position',
label: {
en: 'Assign position',
'zh-CN': '分配岗位',
'ja-JP': 'ポジションを割り当て',
'es-ES': 'Asignar puesto',
},
},
},
},
],
},
{
// [ADR-0091 D1] Direct permission-set grants (直接授权) — pure SDUI.
// The list keys on `relationshipField: 'user_id'` (a lookup keyed by
// record id, no relationshipValueField). [ADR-0131 D4] A grant names
// its set BY NAME, so the Add picker stores the set's MACHINE NAME
// (valueField: 'name', linkField: 'permission_set'), exactly as the
// position picker above does; server-side gates (the catalog name
// refusal, delegated-admin D12) surface their reason in the dialog.
// The picker still lists `sys_permission_set` rows: the related
// list's Add picker reads object records only, and listing the
// security catalog itself is an objectui change.
// record id, no relationshipValueField). [ADR-0131 D4, #15204 B2] A
// grant names its set BY NAME, and the set lives in the security
// catalog, not in a `sys_permission_set` row, so there is no `add`
// picker (it could list object rows only). "New" opens the grant
// form, whose `permission_set` field picks a catalog set by name
// (`widget: 'permission-ref'` on the object); Remove is the host's
// row delete. Server-side gates (the catalog name refusal,
// delegated-admin D12) surface their reason in the form.
//
// [ADR-0091 D1/D2] `valid_from` / `valid_until` are listed because
// they decide whether the grant confers anything: the resolver drops
// an assignment outside its half-open window at every evaluation, so
// a row showing only the set would read as a live grant when it is
// not. The Add affordance writes the two link keys alone; the window
// is set by editing the assignment row, whose object refuses a
// not. The same form sets the window, and the object refuses a
// window whose end is not after its start.
label: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' },
icon: 'lock',
Expand All @@ -253,20 +241,6 @@ export const SysUserDetailPage: Page = {
limit: 25,
showViewAll: true,
title: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' },
add: {
picker: {
object: 'sys_permission_set',
valueField: 'name',
labelField: 'label',
},
linkField: 'permission_set',
label: {
en: 'Grant permission set',
'zh-CN': '授予权限集',
'ja-JP': '権限セットを付与',
'es-ES': 'Otorgar conjunto de permisos',
},
},
},
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export const SysUserPermissionSet = ObjectSchema.create({
permission_set_id: Field.lookup('sys_permission_set', {
label: 'Permission Set (legacy id)',
required: false,
// [#15204 B2] Off the forms: it grants nothing, so an editable lookup
// beside `permission_set` only invites a meaningless value. The column
// stays until its data is dropped (ADR-0131 D10).
hidden: true,
description:
'[ADR-0131 D10] Legacy id reference to a sys_permission_set row, dropped with its data by a later stage. ' +
'Not written and not read to grant anything: the grant names its set by permission_set.',
Expand All @@ -100,6 +104,10 @@ export const SysUserPermissionSet = ObjectSchema.create({
label: 'Permission Set',
required: true,
maxLength: 100,
// [ADR-0131 D4, #15204 B2] Picked, not typed: the form lists the sets
// the security catalog serves and stores the chosen NAME. Falls back to
// a text input where the widget is not registered.
widget: 'permission-ref',
description:
'[ADR-0131 D4] Machine name of the permission set this grant holds, as the security catalog names it. ' +
'A name the catalog does not hold is refused. NULL only on a grant written before the column existed.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ export const SysUserPosition = ObjectSchema.create({
label: 'Position',
required: true,
maxLength: 100,
// [ADR-0131 D4, #15204 B2] Picked, not typed: the form lists the
// positions the security catalog serves and stores the chosen NAME.
// Falls back to a text input where the widget is not registered.
widget: 'position-ref',
description: 'Position machine name (references sys_position.name).',
}),

Expand Down
59 changes: 59 additions & 0 deletions packages/qa/dogfood/test/grant-name-pickers.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#15204 B2, ADR-0131 D4] The two grant forms pick their name from the
* security catalog, as SERVED.
*
* A grant names its position / permission set, and the catalog (not a
* `sys_position` / `sys_permission_set` row) is where those names live. The
* user page's related-list `add` pickers could list object rows only, so they
* are gone; "New" opens the grant form, and the form's name field carries a
* widget hint the console resolves to a catalog picker (`field:position-ref`
* lists `GET /meta/position`, `field:permission-ref` lists
* `GET /meta/permission`). The legacy `permission_set_id` lookup grants
* nothing and is hidden from the forms (maintainer ruling, 2026-10-12).
*
* Read from `GET /meta/object/<name>` on a booted showcase: what the console
* actually receives, not the source declaration, so the pin fails if the spec
* strips the hint or a later layer drops it.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { type VerifyStack } from '@objectstack/verify';
import { bootShowcase } from './showcase-boot.js';

type ServedField = { widget?: unknown; hidden?: unknown; type?: unknown };

describe('[#15204 B2] grant forms pick names from the catalog', () => {
let stack: VerifyStack;
const served: Record<string, Record<string, ServedField>> = {};

beforeAll(async () => {
stack = await bootShowcase({});
const token = await stack.signIn();
for (const object of ['sys_user_position', 'sys_user_permission_set']) {
const res = await stack.apiAs(token, 'GET', `/meta/object/${object}`);
expect(res.status, `GET /meta/object/${object}`).toBe(200);
const body = (await res.json()) as { item?: { fields?: Record<string, ServedField> } };
served[object] = body.item?.fields ?? {};
}
}, 240_000);

afterAll(async () => {
await stack?.stop?.();
});

it('sys_user_position.position is served with the position picker hint', () => {
expect(served.sys_user_position?.position?.widget).toBe('position-ref');
});

it('sys_user_permission_set.permission_set is served with the permission picker hint', () => {
expect(served.sys_user_permission_set?.permission_set?.widget).toBe('permission-ref');
});

it('permission_set_id is served hidden, and the name field it sits beside is not', () => {
expect(served.sys_user_permission_set?.permission_set_id?.hidden).toBe(true);
// Control: the read is not answering `hidden` for every field.
expect(served.sys_user_permission_set?.permission_set?.hidden).not.toBe(true);
});
});
Loading