Skip to content

Commit 501c864

Browse files
feat(app-shell): Setup pages for position holders and permission-set assignment (#28310)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao8LAaW8BQegvVaxCTUNw8Zk)_ Part of #28241 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Setup 新增两个页面——「岗位持有人」(选一个岗位,查看、添加、移除持有人)和「权限集分配」(选一个权限集,查看直接持有和经岗位持有的用户,分配或取消分配)。两页直接复用岗位详情页和权限集编辑器里现有的那两个区块,选中的项写进 URL。 - 为什么改:给人分配岗位和权限集是管理员的日常工作,现在只能进元数据详情页里做;目录迁到 Studio(N-26)后,管理员需要在 Setup 里有自己的入口,不必为加一个持有人打开 Studio。 - 风险与回滚:只新增页面和翻译,不改任何读写;谁能增删、服务端的拒绝(含委派管理员越界)都沿用区块原样,原文显示。详情页里的两个区块照常工作。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/views/setup/CatalogAssignmentPage.tsx`(目录读取失败时如实报错而非显示空列表)、`ui/packages/app-shell/src/services/builtinComponents.tsx` 新增的两个注册。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/setup/`,预期全绿;或在本地 Console 打开 `/apps/setup/component/security/position-holders`,左侧列出岗位,点一个看到持有人。 - 需要你做的:无,席位落地。注意 Setup 左侧导航里的两个入口属于服务端安全插件的导航贡献,不在本车道,已作为发现交席位另立;入口落地前,两页只能经 URL 打开。 ## What changes Two Setup component pages, registered in `services/builtinComponents.tsx` the way Packaged Automation and Connector Credentials are: - **Position holders** (`security:position-holders`, `/apps/setup/component/security/position-holders`): the registry's `position` catalog on the left with a filter; the picked position's `PositionHoldersSection` on the right. - **Permission set assignment** (`security:permission-set-assignment`, `/apps/setup/component/security/permission-set-assignment`): the registry's `permission` catalog; the picked set's `AssignedUsersSection`, in the bordered box the permission set editor draws around it. Both share `views/setup/CatalogAssignmentPage.tsx`: - The picked item rides a page-scoped query parameter (`?position=` / `?set=`, constants in `views/setup/peopleAssignments.ts`), written with `replace`, so a shared or refreshed link opens the same item (ui AGENTS.md #8). - The catalog is read through the metadata store's `ensureType`. The store answers a failed list with an empty one and records the failure on the type's status, so the page reads `getTypeStatus` and says "Could not read the positions." with a retry (`refresh(type)`, past the store's failure cooldown), never "No positions". - A URL naming an item the catalog does not hold says so, and reads no holders for it. - The store is read through a ref, so no read keys on the context value's identity (ui AGENTS.md #10). - The section is keyed by the item's name, so its local state (an open confirm, an inline refusal) belongs to that item and is not carried to the next one. The two sections are imported, not moved or rewritten. Who may add or remove (the section's grant check on `sys_user_position` / `sys_user_permission_set`), the member-only picker and every server refusal stay the section's own. The item pages keep rendering them until N-28 retires those routes. 20 new keys under `setupAssignments` in all ten packs, one contiguous block per pack after `connectorCredentials`. The de pack's `notFound` adds one correctly paired „…“ span, so `de-quote-pairing-3876.test.ts` counts 102 instead of 101, with the reason line in its ledger comment. Changeset: `.changeset/28241-setup-people-assignment-pages.md`, `@object-ui/app-shell` and `@object-ui/i18n` patch. ## Tests - `views/setup/PositionHoldersPage.test.tsx` (8): lists the catalog and reads no holder before a pick; picking writes `?position=` and lists holders; add round-trip (create by name, list reads the new holder back); remove round-trip (confirm, delete by row id, list reads it gone and shows the empty line); a delegated administrator's out-of-scope refusal (`DELEGATION_SCOPE_VIOLATION`, 403) shown as the server's sentence, verbatim; a failed catalog read shown as a failure, then a retry through `refresh('position')`; an unknown `?position=`; the filter. - `views/setup/PermissionSetAssignmentPage.test.tsx` (5): the same for sets, including that a holder through a position stays when a direct grant is unassigned. - `views/setup/peopleAssignments.navContribution.test.tsx` (4): both refs are registered by the production registration module, address `component/security/…`, and mount through the component route. - `views/setup/peopleAssignments.test.ts` (3): `catalogEntries` and `filterCatalogEntries`. - CONTROL, unchanged and green: `ResourceEditPage.setupCatalog-7611.test.tsx` and `PermissionMatrixEditor.setupCatalog-7611.test.tsx` (the sections still render on the item pages), plus every `PositionHoldersSection*` / `AssignedUsersSection*` test and `setupHoldersGrant-12082.test.tsx`. - Ablation (one-off, on the head before the literal-key commit, which does not touch that file; restored by `scripts/ablation-replace.mjs`, blob back to HEAD): deleting the `getTypeStatus(type) === 'error'` branch in `CatalogAssignmentPage.tsx` turns exactly "says a failed catalog read failed, never "no positions", and retries past the cache" red (1 failed, 7 passed); restored, 8 passed. ## Acceptance notes - **The Setup navigation entries are not in this PR.** Setup's nav is server metadata: `group_people_org` is an empty anchor in `packages/platform-objects/src/apps/setup.app.ts`, and the Positions / Permission Sets entries are the `navigationContributions` of `packages/plugins/plugin-security/src/security-plugin.ts` (group `group_access_control`). The two entries (`type: 'component'`, `componentRef: 'security:position-holders'` and `'security:permission-set-assignment'`, group `group_people_org`) belong there, outside this ui lane. Hence `Part of`; the card's "both pages reachable from Setup nav" closes when that entry lands. Until then the pages open only by URL. - "Refused verbatim" reads the sentence as the console shows every write refusal: the server's text without the `[Security] ` machine prefix (`extractWriteErrorMessage`). The tests pin that exact string. - The pages list the audience anchors (`everyone`, `guest`) like any other position, as the item pages do; the page adds no rule of its own about them. - The section components still carry their own inline English / Chinese copy (`useMetadataLocale`); only the page chrome uses the ten-language packs. ## Verification Local, against head `eab3e80a`, on a shared box: <details><summary>Tests and type-check</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/setup/ …item-page and section tests… packages/i18n/` → 119 files, 1691 passed, before the de pin and literal-key fixes; after them `packages/i18n/ packages/app-shell/src/views/setup/` → 105 files, 1577 passed, 13 skipped, exit 0. - `pnpm --dir ui exec vitest run scripts/__tests__/check-i18n-call-site-keys.test.ts scripts/__tests__/check-i18n-dead-keys.test.ts packages/app-shell/src/views/setup/` → 14 files, 427 passed, exit 0. - `pnpm --filter @object-ui/i18n run type-check && pnpm --filter @object-ui/app-shell run type-check` → exit 0. - `pnpm --dir ui check:new-line-citations` → 0 new citations, exit 0. </details> <details><summary>Derived gates (dispatch-gates --commands, 34 lines)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran LIST` → 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. The ui scripts suite read 169 files passed, 2 skipped (4901 tests). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvdtrVLjcNWmcBoHnP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d9175d commit 501c864

21 files changed

Lines changed: 1140 additions & 3 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(console): Setup pages for position holders and permission set assignment
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
An administrator can now manage who holds a position, and who holds a permission set, without opening the position's or the set's own page:
12+
13+
- **Position holders** (`security:position-holders`): pick a position from the environment's catalog, then list, add and remove its holders.
14+
- **Permission set assignment** (`security:permission-set-assignment`): pick a permission set, then list its users (direct grants and holders through a position), and assign or unassign direct grants.
15+
16+
Each page renders the same section the item page renders, so who may add or remove, and every refusal the server answers (the delegated-administration gate's included), are unchanged and shown in the server's own words. The picked item rides the page's URL (`?position=` / `?set=`), so a shared or refreshed link opens it again. A failed read of the catalog says so and offers a retry instead of showing an empty list. The sections inside the position and permission set pages keep working as before.
17+
18+
The pages are registered as component refs and open at `/apps/setup/component/security/position-holders` and `/apps/setup/component/security/permission-set-assignment`. No Setup navigation entry names them yet: those entries are contributed by the server's security plugin and follow separately. New copy is translated in all ten console languages.

‎ui/packages/app-shell/src/services/builtinComponents.tsx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import { PositionGrantedSetsCell, PositionHolderCountCell } from '../views/metad
2424
import { PackagedAutomationPage } from '../views/setup/PackagedAutomationPage.js';
2525
import { ConnectorCredentialsPage } from '../views/setup/ConnectorCredentialsPage.js';
2626
import { CONNECTOR_CREDENTIALS_REF } from '../views/setup/connectorCredentials.js';
27+
import { PositionHoldersPage } from '../views/setup/PositionHoldersPage.js';
28+
import { PermissionSetAssignmentPage } from '../views/setup/PermissionSetAssignmentPage.js';
29+
import { PERMISSION_SET_ASSIGNMENT_REF, POSITION_HOLDERS_REF } from '../views/setup/peopleAssignments.js';
2730
import {
2831
isAggregatedViewContainer,
2932
viewDisplayType,
@@ -164,6 +167,26 @@ registerAppComponent({
164167
component: ConnectorCredentialsPage,
165168
});
166169

170+
/**
171+
* ADR-0146 D7 — Setup → People & organization: who holds a position, and who
172+
* holds a permission set. Each page picks a catalog item and renders the
173+
* section that item's own page renders; the entries naming these refs are the
174+
* security plugin's Setup navigation contributions.
175+
*/
176+
registerAppComponent({
177+
ref: POSITION_HOLDERS_REF,
178+
label: 'Position Holders',
179+
source: '@object-ui/app-shell',
180+
component: PositionHoldersPage,
181+
});
182+
183+
registerAppComponent({
184+
ref: PERMISSION_SET_ASSIGNMENT_REF,
185+
label: 'Permission Set Assignment',
186+
source: '@object-ui/app-shell',
187+
component: PermissionSetAssignmentPage,
188+
});
189+
167190
/* -------------------------------------------------------------------------- */
168191
/* 2) Generic resources — list + JSONSchema-driven form for every type. */
169192
/* -------------------------------------------------------------------------- */
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The shape both Setup assignment pages share (ADR-0146 D7): the catalog items
5+
* of one registry type on the left, the picked item's assignment section on
6+
* the right. The picked item lives in the URL (AGENTS.md #8), so a refreshed
7+
* or shared link opens it again.
8+
*
9+
* Honesty: a failed read of the catalog is said as a failure with a retry,
10+
* never drawn as "no items" — the metadata store answers a failed list with
11+
* an empty one and records the failure on the type's status.
12+
*
13+
* Who may add or remove is not judged here. The section asks the caller's
14+
* grant on the assignment object and the server judges every write, its
15+
* delegated-administration gate included; this page only picks the item.
16+
*/
17+
18+
import * as React from 'react';
19+
import { useSearchParams } from 'react-router-dom';
20+
import {
21+
Button,
22+
Empty,
23+
EmptyDescription,
24+
EmptyTitle,
25+
Input,
26+
Skeleton,
27+
cn,
28+
} from '@object-ui/components';
29+
import { useMetadata } from '@object-ui/react';
30+
import { AlertCircle } from 'lucide-react';
31+
import { PageHeader } from '../../layout/PageHeader.js';
32+
import { catalogEntries, filterCatalogEntries, type CatalogEntry } from './peopleAssignments.js';
33+
34+
export interface CatalogAssignmentCopy {
35+
title: string;
36+
subtitle: string;
37+
listLabel: string;
38+
filterPlaceholder: string;
39+
emptyTitle: string;
40+
emptyBody: string;
41+
pick: string;
42+
loadFailed: string;
43+
noMatch: string;
44+
notFound: (name: string) => string;
45+
retry: string;
46+
refresh: string;
47+
}
48+
49+
export interface CatalogAssignmentPageProps {
50+
/** The registry type whose items are picked (`position`, `permission`). */
51+
type: string;
52+
/** The page-scoped query parameter that names the picked item. */
53+
param: string;
54+
icon: React.ReactNode;
55+
copy: CatalogAssignmentCopy;
56+
/** The assignment section for the picked item. */
57+
renderSection: (name: string) => React.ReactNode;
58+
testId: string;
59+
}
60+
61+
type CatalogRead = { kind: 'loading' } | { kind: 'failed' } | { kind: 'ready'; entries: CatalogEntry[] };
62+
63+
export function CatalogAssignmentPage({ type, param, icon, copy, renderSection, testId }: CatalogAssignmentPageProps) {
64+
const metadata = useMetadata();
65+
// Read through a ref: the store's context value is rebuilt whenever any
66+
// cached type settles, and AGENTS.md #10 forbids keying the read on it.
67+
const metadataRef = React.useRef(metadata);
68+
metadataRef.current = metadata;
69+
70+
const [read, setRead] = React.useState<CatalogRead>({ kind: 'loading' });
71+
const [nonce, setNonce] = React.useState(0);
72+
const [query, setQuery] = React.useState('');
73+
const [search, setSearch] = useSearchParams();
74+
const picked = search.get(param) ?? '';
75+
76+
React.useEffect(() => {
77+
let live = true;
78+
setRead({ kind: 'loading' });
79+
metadataRef.current.ensureType(type).then((items) => {
80+
if (!live) return;
81+
if (metadataRef.current.getTypeStatus?.(type) === 'error') setRead({ kind: 'failed' });
82+
else setRead({ kind: 'ready', entries: catalogEntries(items) });
83+
});
84+
return () => {
85+
live = false;
86+
};
87+
}, [type, nonce]);
88+
89+
/** Read the catalog again, past the store's cache and its failure cooldown. */
90+
const reload = React.useCallback(() => {
91+
setRead({ kind: 'loading' });
92+
void metadataRef.current.refresh(type).finally(() => setNonce((n) => n + 1));
93+
}, [type]);
94+
95+
const pick = (name: string) => {
96+
setSearch(
97+
(prev) => {
98+
const next = new URLSearchParams(prev);
99+
next.set(param, name);
100+
return next;
101+
},
102+
{ replace: true },
103+
);
104+
};
105+
106+
const entries = read.kind === 'ready' ? read.entries : [];
107+
const shown = filterCatalogEntries(entries, query);
108+
const pickedKnown = entries.some((e) => e.name === picked);
109+
110+
let list: React.ReactNode;
111+
if (read.kind === 'loading') {
112+
list = (
113+
<div className="space-y-2 p-3" data-testid={`${testId}-loading`}>
114+
<Skeleton className="h-8 w-full" />
115+
<Skeleton className="h-8 w-full" />
116+
<Skeleton className="h-8 w-full" />
117+
</div>
118+
);
119+
} else if (read.kind === 'failed') {
120+
list = (
121+
<div role="alert" data-testid={`${testId}-load-failed`} className="space-y-2 p-3 text-sm">
122+
<div className="flex items-start gap-2 text-destructive">
123+
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
124+
<span>{copy.loadFailed}</span>
125+
</div>
126+
<Button variant="outline" size="sm" onClick={reload}>
127+
{copy.retry}
128+
</Button>
129+
</div>
130+
);
131+
} else if (entries.length === 0) {
132+
list = (
133+
<Empty data-testid={`${testId}-empty`}>
134+
<EmptyTitle>{copy.emptyTitle}</EmptyTitle>
135+
<EmptyDescription>{copy.emptyBody}</EmptyDescription>
136+
</Empty>
137+
);
138+
} else {
139+
list = (
140+
<div className="space-y-2 p-3">
141+
<Input
142+
value={query}
143+
onChange={(e) => setQuery(e.target.value)}
144+
placeholder={copy.filterPlaceholder}
145+
aria-label={copy.filterPlaceholder}
146+
className="h-8 text-sm"
147+
/>
148+
{shown.length === 0 ? (
149+
<p className="px-1 py-2 text-xs text-muted-foreground">{copy.noMatch}</p>
150+
) : (
151+
<ul aria-label={copy.listLabel} className="space-y-0.5">
152+
{shown.map((e) => (
153+
<li key={e.name}>
154+
<button
155+
type="button"
156+
onClick={() => pick(e.name)}
157+
aria-current={e.name === picked ? 'true' : undefined}
158+
className={cn(
159+
'w-full rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted',
160+
e.name === picked && 'bg-muted font-medium',
161+
)}
162+
>
163+
<span className="block truncate">{e.label}</span>
164+
{e.label !== e.name && (
165+
<code className="block truncate font-mono text-xs text-muted-foreground">{e.name}</code>
166+
)}
167+
</button>
168+
</li>
169+
))}
170+
</ul>
171+
)}
172+
</div>
173+
);
174+
}
175+
176+
let detail: React.ReactNode;
177+
if (!picked) {
178+
detail = <p className="text-sm text-muted-foreground" data-testid={`${testId}-pick`}>{copy.pick}</p>;
179+
} else if (read.kind === 'ready' && !pickedKnown) {
180+
detail = (
181+
<p role="status" className="text-sm text-muted-foreground" data-testid={`${testId}-not-found`}>
182+
{copy.notFound(picked)}
183+
</p>
184+
);
185+
} else if (read.kind !== 'ready') {
186+
detail = null;
187+
} else {
188+
const entry = entries.find((e) => e.name === picked)!;
189+
detail = (
190+
<div>
191+
<h2 className="text-base font-semibold">{entry.label}</h2>
192+
<code className="font-mono text-xs text-muted-foreground">{entry.name}</code>
193+
{entry.description && <p className="mt-1 text-sm text-muted-foreground">{entry.description}</p>}
194+
{/* Keyed by the item: the section's state is that item's, never carried to the next one. */}
195+
<React.Fragment key={entry.name}>{renderSection(entry.name)}</React.Fragment>
196+
</div>
197+
);
198+
}
199+
200+
return (
201+
<div data-testid={testId}>
202+
<PageHeader
203+
title={copy.title}
204+
subtitle={copy.subtitle}
205+
icon={icon}
206+
refresh={{ onRefresh: reload, label: copy.refresh, busy: read.kind === 'loading' }}
207+
/>
208+
<div className="grid gap-4 p-6 md:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
209+
<nav aria-label={copy.listLabel} className="min-w-0 rounded-md border">
210+
{list}
211+
</nav>
212+
<section className="min-w-0">{detail}</section>
213+
</div>
214+
</div>
215+
);
216+
}

0 commit comments

Comments
 (0)