From e7ec6fd667aae854159324b763824f4f7f1f8b4c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 10:14:19 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(ui):=20position=20assignment=20panels?= =?UTF-8?q?=20as=20pure=20SDUI=20=E2=80=94=20Positions=20tab=20on=20sys=5F?= =?UTF-8?q?user,=20Holders/Permission=20Sets=20tabs=20on=20sys=5Fposition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds relationshipValueField (default 'id') to RecordRelatedListProps — the generic name-keyed-junction affordance (sys_user_position.position stores sys_position.name) used for both the list filter and the Add picker's parent-side value. sys_user detail gains a Positions tab (assign via picker storing the machine name, valueField: 'name'); new sys_position detail page ships Holders (relationshipValueField: 'name') and Permission Sets tabs, registered by plugin-security. All server rules (D12 delegated-admin gate, audience-anchor rejection) surface their denials in the Add dialog; ADR-0091 validity columns become plain column additions later. Zero bespoke React. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .changeset/positions-sdui-panels.md | 13 ++ packages/platform-objects/src/pages/index.ts | 1 + .../src/pages/sys-position.page.ts | 112 ++++++++++++++++++ .../src/pages/sys-user.page.ts | 36 +++++- .../plugin-security/src/security-plugin.ts | 5 + packages/spec/src/ui/component.zod.ts | 10 ++ 6 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 .changeset/positions-sdui-panels.md create mode 100644 packages/platform-objects/src/pages/sys-position.page.ts diff --git a/.changeset/positions-sdui-panels.md b/.changeset/positions-sdui-panels.md new file mode 100644 index 0000000000..c9728edd40 --- /dev/null +++ b/.changeset/positions-sdui-panels.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": minor +"@objectstack/plugin-security": minor +--- + +Position assignment panels as pure SDUI (ADR-0090 follow-through). + +- `RecordRelatedListProps` gains `relationshipValueField` (default `'id'`): which parent-record field the junction's `relationshipField` stores — the generic affordance for name-keyed junctions (`sys_user_position.position` stores `sys_position.name`). Used for both the list filter and the Add-picker's parent-side value. +- `sys_user` detail page gains a **Positions** tab (assign positions to a user; Add picker stores the position machine name via `valueField: 'name'`; the D12 delegated-admin gate's denials surface in the dialog). +- New `sys_position` detail page (shipped by plugin-security): **Holders** (name-keyed via `relationshipValueField: 'name'`) and **Permission Sets** (bindings) tabs — zero bespoke UI; ADR-0091 validity columns slot in later as plain column additions. + +Renderer note: the generic `record:related_list` Add-picker and `relationshipValueField` support land in objectui alongside the ^14 alignment; with older renderers these tabs degrade to read-only lists. diff --git a/packages/platform-objects/src/pages/index.ts b/packages/platform-objects/src/pages/index.ts index 459be24c2a..702841fc1a 100644 --- a/packages/platform-objects/src/pages/index.ts +++ b/packages/platform-objects/src/pages/index.ts @@ -2,3 +2,4 @@ export { SysOrganizationDetailPage } from './sys-organization.page.js'; export { SysUserDetailPage } from './sys-user.page.js'; +export { SysPositionDetailPage } from './sys-position.page.js'; diff --git a/packages/platform-objects/src/pages/sys-position.page.ts b/packages/platform-objects/src/pages/sys-position.page.ts new file mode 100644 index 0000000000..9c41a16b71 --- /dev/null +++ b/packages/platform-objects/src/pages/sys-position.page.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Page } from '@objectstack/spec/ui'; + +/** + * sys_position — Record Detail Page (slotted, default for ALL sys_position + * records) + * + * **Audience**: admins managing capability distribution from Setup. + * + * A position (岗位, ADR-0090 D3) is the flat distribution group: users hold + * it (`sys_user_position`), and it binds permission sets + * (`sys_position_permission_set`). This page makes both edges manageable + * as PURE SDUI — two `record:related_list` tabs with Add pickers; no + * bespoke React: + * + * - **Holders** — who holds this position. `sys_user_position.position` + * stores the position's MACHINE NAME, so the list keys on + * `relationshipValueField: 'name'` (the generic name-keyed-junction + * affordance this page motivated). Assigning here inserts an ordinary + * assignment row; the ADR-0090 D12 delegated-admin gate governs the + * write and its denial reason surfaces in the Add dialog. The + * `business_unit_id` column is the assignment-level BU anchor + * (ADR-0090 Addendum); `valid_from`/`valid_until` columns join it when + * ADR-0091 L1 lands. + * - **Permission Sets** — what the position distributes + * (`sys_position_permission_set`, ordinary id-keyed junction). Binding + * is guarded by BOTH the audience-anchor gate (D5/D9 — high-privilege + * sets never bind to everyone/guest) and the D12 gate. + * + * Header/actions fall through to the synthesizer, so the object's declared + * row actions (activate/deactivate/clone/set-default) keep working. + */ +export const SysPositionDetailPage: Page = { + name: 'sys_position_detail', + label: 'Position', + type: 'record', + object: 'sys_position', + template: 'default', + kind: 'slotted', + isDefault: true, + + regions: [], + + slots: { + tabs: { + type: 'page:tabs', + properties: { + type: 'line', + position: 'top', + items: [ + { + label: 'Holders', + icon: 'users', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_user_position', + relationshipField: 'position', + relationshipValueField: 'name', + columns: ['user_id', 'business_unit_id', 'granted_by', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: 'Holders', + add: { + picker: { + object: 'sys_user', + labelField: 'name', + }, + linkField: 'user_id', + label: 'Assign user', + }, + }, + }, + ], + }, + { + label: 'Permission Sets', + icon: 'lock', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_position_permission_set', + relationshipField: 'position_id', + columns: ['permission_set_id', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: 'Permission Sets', + add: { + picker: { + object: 'sys_permission_set', + labelField: 'label', + }, + linkField: 'permission_set_id', + label: 'Bind permission set', + }, + }, + }, + ], + }, + ], + }, + }, + + // No Chatter feed on an RBAC primitive. + discussion: [], + }, +}; diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index c97d7920a6..06fc1eda39 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -29,8 +29,8 @@ import type { Page } from '@objectstack/spec/ui'; * admin-internal audit columns. Banned / ban metadata is still * editable from the header actions — we just don't show it in * every user's body. - * - `tabs` is **explicitly curated** to the 4 related lists that matter - * on a user profile (Sessions / Linked Accounts / Organizations / + * - `tabs` is **explicitly curated** to the 5 related lists that matter + * on a user profile (Positions / Sessions / Linked Accounts / Organizations / * Personal OAuth Apps). Without this override, the synthesizer * auto-generates a tab per object that has a FK to sys_user * (sys_position.created_by, sys_email.updated_by, sys_user_preference, @@ -121,6 +121,38 @@ export const SysUserDetailPage: Page = { type: 'line', position: 'top', items: [ + { + label: 'Positions', + 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. + type: 'record:related_list', + properties: { + objectName: 'sys_user_position', + relationshipField: 'user_id', + columns: ['position', 'business_unit_id', 'granted_by', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: 'Positions', + add: { + picker: { + object: 'sys_position', + valueField: 'name', + labelField: 'label', + }, + linkField: 'position', + label: 'Assign position', + }, + }, + }, + ], + }, { label: 'Sessions', icon: 'monitor', diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 35e68929f2..70d6e47aa7 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -31,6 +31,7 @@ import { extractMemberPairs, reconcileOrgAdminGrant, } from './auto-org-admin-grant.js'; +import { SysPositionDetailPage } from '@objectstack/platform-objects/pages'; import { securityObjects, securityDefaultPermissionSets, @@ -240,6 +241,10 @@ export class SecurityPlugin implements Plugin { ctx.getService<{ register(m: any): void }>('manifest').register({ ...securityPluginManifestHeader, objects: securityObjects, + // [ADR-0090] SDUI detail page for sys_position — Holders (assignments, + // name-keyed junction) + Permission Sets (bindings) as pure + // record:related_list declarations; no bespoke UI. + pages: [SysPositionDetailPage], // Permission sets ride along on the manifest so the metadata service // can resolve them by name when SecurityPlugin middleware queries // `metadata.list('permissions')`. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index b9e20c7d7e..b2d05780fd 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -71,6 +71,16 @@ export const RecordDetailsProps = z.object({ export const RecordRelatedListProps = z.object({ objectName: z.string().describe('Related object name (e.g., "task", "opportunity")'), relationshipField: z.string().describe('Field on related object that points to this record (e.g., "account_id")'), + /** + * Which field of THIS (parent) record `relationshipField` stores. Default + * `id` (ordinary FK). Set to another unique field for junctions that key on + * a machine name — e.g. `sys_user_position.position` stores + * `sys_position.name`, so the Holders list on a position declares + * `relationshipValueField: 'name'`. Used both to FILTER the list + * (`{[relationshipField]: parent[relationshipValueField]}`) and as the + * parent-side value written by the Add picker. + */ + relationshipValueField: z.string().default('id').describe("Parent-record field whose value relationshipField stores (default 'id'; e.g. 'name' for name-keyed junctions)."), columns: z.array(z.string()).optional().describe('Fields to display in the related list. Optional: when omitted, columns derive from the related object\'s highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list.'), sort: z.union([ z.string(), From 1f9f4c50575235f2e6355a52ecd4a7e268298827 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 10:17:53 +0000 Subject: [PATCH 2/3] =?UTF-8?q?ci:=20retrigger=20(spec=20DTS=20build=20die?= =?UTF-8?q?d=20without=20error=20on=20runner=20=E2=80=94=20local=20full=20?= =?UTF-8?q?suite=206676/6676=20green)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H From f8386674ad6e07a2b9fe02f47980c15f26cecccc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 10:36:43 +0000 Subject: [PATCH 3/3] ci: retrigger after main protocol-version fix (#2772) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H