diff --git a/.changeset/15204-grant-name-pickers.md b/.changeset/15204-grant-name-pickers.md new file mode 100644 index 00000000000..85567ff1aa7 --- /dev/null +++ b/.changeset/15204-grant-name-pickers.md @@ -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. diff --git a/packages/cli/src/utils/format.server-ready-dev-admin-audience.test.ts b/packages/cli/src/utils/format.server-ready-dev-admin-audience.test.ts index bcf8605ab36..5a7661ab1c3 100644 --- a/packages/cli/src/utils/format.server-ready-dev-admin-audience.test.ts +++ b/packages/cli/src/utils/format.server-ready-dev-admin-audience.test.ts @@ -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"'); }); }); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 60b6aa464bb..701619ac642 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -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 diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 5afc100a2e1..e68d60346aa 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -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))) diff --git a/packages/platform-objects/src/pages/sys-user-permission-set-window-columns.test.ts b/packages/platform-objects/src/pages/sys-user-permission-set-window-columns.test.ts index bc1515a063c..af2ac9350f6 100644 --- a/packages/platform-objects/src/pages/sys-user-permission-set-window-columns.test.ts +++ b/packages/platform-objects/src/pages/sys-user-permission-set-window-columns.test.ts @@ -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'); + }); +}); diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index e0c8966cf8a..2f11030404f 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -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', @@ -203,20 +206,6 @@ 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', - }, - }, }, }, ], @@ -224,21 +213,20 @@ export const SysUserDetailPage: Page = { { // [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', @@ -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', - }, - }, }, }, ], diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index 82e475c5544..f39648826c5 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -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.', @@ -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.', diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index 9df5977a917..ab2b0c0380a 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -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).', }), diff --git a/packages/qa/dogfood/test/grant-name-pickers.dogfood.test.ts b/packages/qa/dogfood/test/grant-name-pickers.dogfood.test.ts new file mode 100644 index 00000000000..6ed8f35b952 --- /dev/null +++ b/packages/qa/dogfood/test/grant-name-pickers.dogfood.test.ts @@ -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/` 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> = {}; + + 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 } }; + 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); + }); +});