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
13 changes: 13 additions & 0 deletions .changeset/positions-sdui-panels.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/platform-objects/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
112 changes: 112 additions & 0 deletions packages/platform-objects/src/pages/sys-position.page.ts
Original file line number Diff line number Diff line change
@@ -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: [],
},
};
36 changes: 34 additions & 2 deletions packages/platform-objects/src/pages/sys-user.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
extractMemberPairs,
reconcileOrgAdminGrant,
} from './auto-org-admin-grant.js';
import { SysPositionDetailPage } from '@objectstack/platform-objects/pages';
import {
securityObjects,
securityDefaultPermissionSets,
Expand Down Expand Up @@ -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')`.
Expand Down
10 changes: 10 additions & 0 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down