Skip to content

Commit e2772b4

Browse files
feat(plugin-security): Setup People & Organization entries for position holders and permission-set assignment (#28332)
Fixes #28316 Clause-②: no Metadata-protocol: none Skills-docs: none changed. `skills/**` has zero hits for People & Organization Setup navigation text. `content/docs/permissions/administrator-guide.mdx` (lines 51, 75, 209-211) and `content/docs/ui/apps.mdx` (line 813, the `group_people_org` row) list the group's entries and do not yet name the two new pages; they are outside this claim's file surface and are worth a docs pass once the pages ship. Lands after PR #28310 (the two app-shell pages). Before it merges, the entries are harmless: a component ref with no registration opens the console's not-found page. ## 维护者速读 - 改了什么:Setup → 人员与组织 新增两个菜单项「岗位持有人」(security:position-holders) 和「权限集分配」(security:permission-set-assignment),由 plugin-security 贡献,四种语言标签齐全。 - 为什么改:PR #28310 新增的两个分配页面只能手敲网址进入;Setup 菜单是服务端元数据,必须由服务端声明入口,管理员才能点到。 - 风险与回滚:极低,只加两条导航数据,不改任何权限或数据路径;回滚即 revert 本 PR。#28310 合并前点击会看到“组件不存在”页。 - 先看:packages/plugins/plugin-security/src/security-plugin.ts(新增的 group_people_org 贡献块)、packages/plugins/plugin-security/src/people-org-assignment-nav-contribution.test.ts、packages/platform-objects/src/apps/translations/zh-CN.ts。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run src/people-org-assignment-nav-contribution.test.ts`,预期 5 个测试全部通过。 - 需要你做的:无,席位落地(等 PR #28310 合并后再进队列)。 ## What changes - `@objectstack/plugin-security` contributes a second Setup navigation block, `group: 'group_people_org'`, priority 200 (after platform-objects' base entries at 100), with two items: - `nav_position_holders` · `type: 'component'` · label "Position Holders" · `componentRef: 'security:position-holders'` · icon `user-check` - `nav_permission_set_assignment` · `type: 'component'` · label "Permission Set Assignment" · `componentRef: 'security:permission-set-assignment'` · icon `key-round` - **Gate:** copied from the plugin's Positions / Permission Sets entries, which carry none of their own (no `requiredPermissions`, `requiresService`, `requiresObject` or `visible`): the Setup app's `requiredPermissions: ['setup.access']` and the ungated group anchor decide who sees them (ADR-0146 D7 names the pages as the scope of `setup.access` and the delegated-administrator gate). The writes the pages make keep their own server-side gates. Riding plugin-security's manifest, the entries disappear wherever the plugin is not composed, exactly like the catalog entries. - **Labels (measured owner):** plugin-security's own translation bundle has no `apps` section; every Setup nav label, plugin-security's catalog entries included, lives in `@objectstack/platform-objects`' hand-authored Setup bundles (`apps.setup.navigation.ID.label`). Both ids are added to all four locales that carry the group (`en`, `zh-CN`, `ja-JP`, `es-ES`), and each translated leaf's source digest is recorded in `LOCALE.source-hashes.ts` via `collectSourceHashes(en)` (control: the computed digest for `nav_positions` equals its recorded `517a2743f949b5f0`). No `*.generated.ts` file is involved; `pnpm check:i18n` confirms all generated bundles stay in sync. - Changeset: `@objectstack/plugin-security` patch, `@objectstack/platform-objects` patch. **Before (measured on `origin/main` b339465):** `git grep "group: 'group_people_org'"` outside tests and translations names one contributor, `packages/platform-objects/src/apps/setup-nav.contributions.ts`, whose block has 0 `componentRef` items; `git grep` for either ref outside `ui/` returns 0 files (control: `componentRef: 'automation:packaged'` returns 1 source hit). ## Tests New `packages/plugins/plugin-security/src/people-org-assignment-nav-contribution.test.ts` (5 cases), modelled on plugin-audit's `audit-nav-contribution.test.ts`: it runs the plugin's real `init()` and folds the Setup app through the real `SchemaRegistry`. 1. Both entries in one `group_people_org` contribution, with the exact `componentRef`s, as registry keys, and every contribution parses with `NavigationContributionSchema`. 2. Same gate as `nav_positions` and `nav_permission_sets`: the four gate keys compare equal (control: both catalog entries exist). 3. The rendered Setup nav serves both under People & Organization beside `nav_users`, the group anchor's gate equals Access Control's, and each ref opens in exactly one place. 4. Without plugin-security, neither entry appears, exactly where `nav_positions` / `nav_permission_sets` are also absent (control: `nav_users` still served). 5. Labels: ids are read off the manifest; every locale of the **served** `SetupAppTranslations` that carries `group_people_org` (measured: the four) labels both, `en` equals the declared label, and no translated locale serves the English string. No existing test enumerates plugin-security's Setup entries; `setup-nav-dead-key-tombstone.test.ts` and `app-nav-translation-parity.test.ts` deliberately do not list Setup's contributed leaves (that is `pnpm check:app-nav-i18n`, which boots plugin-security and now judges 60 merged Setup ids, OK in 4 locales), so neither was extended. ### Ablations (each reverted, red shown, restored; `scripts/ablation-replace.mjs`, restore proven by blob equal to HEAD and an empty `git diff HEAD`) | # | Mutation | Red | |---|---|---| | A1 | delete both items from the plugin's contribution | cases 1, 2, 3, 5 (4 failed, 1 passed) | | A2 | `security:position-holders` to `security:positions` | cases 1, 3 | | A3 | add `requiredPermissions: ['manage_platform_settings']` to `nav_permission_set_assignment` | case 2 | | A4 | contribute the block to `group_access_control` | cases 1, 3, 5 | | A5 | delete the zh-CN `nav_position_holders` label (platform-objects rebuilt) | case 5 (`zh-CN nav_position_holders: expected undefined to be truthy`) | | A6 | platform-objects itself contributes `nav_position_holders` (rebuilt; dist preflight: marker present in 4 built files) | cases 3, 4 | | A7 | es-ES `nav_permission_set_assignment` label set to the English string (rebuilt) | case 5 | After the platform-objects legs, the package was rebuilt from HEAD; `dist/` carries the restored labels and the file reruns 5/5 green. ## Acceptance notes - The ja-JP labels follow the Setup menu's own vocabulary ("ポジション", as the neighbouring Positions entry reads), not the page title PR #28310 ships ("役職の保持者"); es-ES likewise follows "Posiciones". The zh-CN labels match the page titles. - `scripts/ablation-dist-preflight.mjs --absent` read the A5 / A7 markers as absent vacuously: esbuild escapes non-ASCII, so `dist/` never carries the raw spelling. The red runs themselves prove those mutations reached `dist/` (an undefined label; the English string served), and the restored escaped spellings were read back from `dist/apps/index.mjs` directly. ## Verification Head `d1bf4821`, tree clean: - `pnpm --filter @objectstack/plugin-security` vitest: Test Files 238 passed (238), Tests 5124 passed, 45 skipped. Exit 0. - `@objectstack/platform-objects` vitest: Test Files 104 passed (104), Tests 1474 passed. Exit 0. - `pnpm --filter @objectstack/plugin-security typecheck` exit 0; `pnpm --filter @objectstack/platform-objects typecheck` exit 0. - `pnpm lint` exit 0. - `pnpm build` (full, under the verify lock) exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 62 families, each run with its exit code recorded before any pipe. Three first exited 3 (PREREQUISITE NOT MET: no built tree, or the lock queue) and were rerun after the full build: `check:i18n` exit 0 (10 packages, all bundles in sync), `check:dual-build-cjs-loads` exit 0, `check:type-check-debt` exit 0. `--ran`: "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)". - Not derived, run as well: `pnpm check:app-nav-i18n` exit 0, "13 contributor(s), 4 locale(s), 2 app(s) — setup: 60 merged nav id(s) … every id labelled in every locale". 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4czVnUdQQCVKSW5J2uk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 488b3ed commit e2772b4

10 files changed

Lines changed: 254 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
feat(plugin-security): Setup → People & Organization opens the Position Holders and Permission Set Assignment pages
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The Setup app's People & Organization group gains two entries, contributed by `@objectstack/plugin-security` beside its Positions / Capabilities / Permission Sets entries:
12+
13+
- **Position Holders** (`nav_position_holders`) — `type: 'component'`, `componentRef: 'security:position-holders'`.
14+
- **Permission Set Assignment** (`nav_permission_set_assignment`) — `type: 'component'`, `componentRef: 'security:permission-set-assignment'`.
15+
16+
The console registers the two pages under those refs; until then an entry opens the console's not-found page. The entries carry no gate of their own, exactly like the catalog entries: the Setup app's `setup.access` and the ungated group anchor decide who sees them, and they disappear wherever the plugin is not installed.
17+
18+
`@objectstack/platform-objects`' Setup bundles carry both labels in `en`, `zh-CN`, `ja-JP` and `es-ES`, with each translation's source digest recorded.

‎packages/platform-objects/src/apps/translations/en.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ export const en: TranslationData = {
8888
nav_teams: { label: 'Teams' },
8989
nav_organizations: { label: 'Organizations' },
9090
nav_invitations: { label: 'Invitations' },
91+
// Contributed by @objectstack/plugin-security (ADR-0146 D7): the two
92+
// assignment pages, `security:position-holders` and
93+
// `security:permission-set-assignment`.
94+
nav_position_holders: { label: 'Position Holders' },
95+
nav_permission_set_assignment: { label: 'Permission Set Assignment' },
9196

9297
// Access Control
9398
nav_positions: { label: 'Positions' },

‎packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export const esESSourceHashes: Readonly<Record<string, string>> = {
9090
'apps.setup.navigation.nav_teams.label': '3fe8519872f65108',
9191
'apps.setup.navigation.nav_organizations.label': '07488357631b973d',
9292
'apps.setup.navigation.nav_invitations.label': '4ebb9fd13de7a546',
93+
'apps.setup.navigation.nav_position_holders.label': '627cacb50fd1b0d8',
94+
'apps.setup.navigation.nav_permission_set_assignment.label': 'fcf7d1b69ee9c298',
9395
'apps.setup.navigation.nav_positions.label': '517a2743f949b5f0',
9496
'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f',
9597
'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a',

‎packages/platform-objects/src/apps/translations/es-ES.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export const esES: TranslationData = {
5959
nav_teams: { label: 'Equipos' },
6060
nav_organizations: { label: 'Organizaciones' },
6161
nav_invitations: { label: 'Invitaciones' },
62+
nav_position_holders: { label: 'Titulares de posiciones' },
63+
nav_permission_set_assignment: { label: 'Asignación de conjuntos de permisos' },
6264

6365
nav_positions: { label: 'Posiciones' },
6466
nav_capabilities: { label: 'Capacidades' },

‎packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export const jaJPSourceHashes: Readonly<Record<string, string>> = {
9090
'apps.setup.navigation.nav_teams.label': '3fe8519872f65108',
9191
'apps.setup.navigation.nav_organizations.label': '07488357631b973d',
9292
'apps.setup.navigation.nav_invitations.label': '4ebb9fd13de7a546',
93+
'apps.setup.navigation.nav_position_holders.label': '627cacb50fd1b0d8',
94+
'apps.setup.navigation.nav_permission_set_assignment.label': 'fcf7d1b69ee9c298',
9395
'apps.setup.navigation.nav_positions.label': '517a2743f949b5f0',
9496
'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f',
9597
'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a',

‎packages/platform-objects/src/apps/translations/ja-JP.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export const jaJP: TranslationData = {
5959
nav_teams: { label: 'チーム' },
6060
nav_organizations: { label: 'すべての組織' },
6161
nav_invitations: { label: '招待' },
62+
nav_position_holders: { label: 'ポジション保持者' },
63+
nav_permission_set_assignment: { label: '権限セットの割り当て' },
6264

6365
nav_positions: { label: 'ポジション' },
6466
nav_capabilities: { label: 'ケイパビリティ' },

‎packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export const zhCNSourceHashes: Readonly<Record<string, string>> = {
9090
'apps.setup.navigation.nav_teams.label': '3fe8519872f65108',
9191
'apps.setup.navigation.nav_organizations.label': '07488357631b973d',
9292
'apps.setup.navigation.nav_invitations.label': '4ebb9fd13de7a546',
93+
'apps.setup.navigation.nav_position_holders.label': '627cacb50fd1b0d8',
94+
'apps.setup.navigation.nav_permission_set_assignment.label': 'fcf7d1b69ee9c298',
9395
'apps.setup.navigation.nav_positions.label': '517a2743f949b5f0',
9496
'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f',
9597
'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a',

‎packages/platform-objects/src/apps/translations/zh-CN.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export const zhCN: TranslationData = {
6262
nav_teams: { label: '团队' },
6363
nav_organizations: { label: '所有组织' },
6464
nav_invitations: { label: '邀请' },
65+
nav_position_holders: { label: '岗位持有人' },
66+
nav_permission_set_assignment: { label: '权限集分配' },
6567

6668
nav_positions: { label: '岗位' },
6769
nav_capabilities: { label: '能力' },
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [ADR-0146 D7] The Security plugin's two Setup → People & Organization
4+
// entries: "Position Holders" and "Permission Set Assignment".
5+
//
6+
// The pages are console components the app shell registers under
7+
// `security:position-holders` and `security:permission-set-assignment`; Setup's
8+
// navigation is server metadata, so without an entry naming each ref the pages
9+
// are reachable by typed URL only. This file pins the half this repository
10+
// owns — the two entries, the refs they name, the group they sit in, and their
11+
// gate. The console pins the ref → page half on its side.
12+
//
13+
// Why plugin-security and not `@objectstack/platform-objects`: the pages read
14+
// and write this plugin's assignment rows, so the entries live and die with the
15+
// plugin, exactly like its Positions / Capabilities / Permission Sets entries.
16+
// That is also their gate: none of their own. The catalog entries carry no
17+
// `requiredPermissions`, `requiresService` or `requiresObject`; the Setup app's
18+
// `setup.access` and the group anchor decide who sees them.
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { SchemaRegistry } from '@objectstack/objectql';
22+
import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS, SetupAppTranslations } from '@objectstack/platform-objects/apps';
23+
import { NavigationContributionSchema } from '@objectstack/spec/ui';
24+
import { SecurityPlugin } from './security-plugin.js';
25+
26+
type NavItem = {
27+
id?: string;
28+
type?: string;
29+
label?: string;
30+
componentRef?: string;
31+
requiredPermissions?: string[];
32+
requiresService?: string;
33+
requiresObject?: string;
34+
visible?: unknown;
35+
children?: NavItem[];
36+
};
37+
38+
type Contribution = { app: string; group?: string; priority?: number; items: NavItem[] };
39+
40+
/** Run the plugin's init() and return the manifest it registered. */
41+
async function registeredManifest(): Promise<any> {
42+
const registered: any[] = [];
43+
const ctx: any = {
44+
logger: { info() {}, warn() {}, error() {}, debug() {} },
45+
getService: (name: string) => {
46+
if (name === 'manifest') return { register: (m: unknown) => registered.push(m) };
47+
throw new Error(`service not registered: ${name}`);
48+
},
49+
registerService() {},
50+
hook() {},
51+
};
52+
await new SecurityPlugin().init(ctx);
53+
expect(registered).toHaveLength(1);
54+
return registered[0];
55+
}
56+
57+
const POSITION_HOLDERS = {
58+
id: 'nav_position_holders',
59+
type: 'component',
60+
label: 'Position Holders',
61+
componentRef: 'security:position-holders',
62+
};
63+
const PERMISSION_SET_ASSIGNMENT = {
64+
id: 'nav_permission_set_assignment',
65+
type: 'component',
66+
label: 'Permission Set Assignment',
67+
componentRef: 'security:permission-set-assignment',
68+
};
69+
70+
/** The item keys that decide who sees an entry. */
71+
const GATE_KEYS = ['requiredPermissions', 'requiresService', 'requiresObject', 'visible'] as const;
72+
const gateOf = (item: NavItem) => Object.fromEntries(GATE_KEYS.map((k) => [k, item[k]]));
73+
74+
/**
75+
* The Setup app as `GET /api/v1/meta/app` folds it: the real `SchemaRegistry`
76+
* over `SETUP_APP`, platform-objects' own contributions, and — when
77+
* `withSecurity` — this plugin's, registered under its manifest id.
78+
*/
79+
async function renderedSetup(withSecurity: boolean): Promise<NavItem[]> {
80+
const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
81+
(registry as any).logLevel = 'silent';
82+
registry.registerApp(structuredClone(SETUP_APP), '@objectstack/platform-objects');
83+
for (const contribution of SETUP_NAV_CONTRIBUTIONS) {
84+
registry.registerAppNavContribution(structuredClone(contribution) as any, '@objectstack/platform-objects');
85+
}
86+
if (withSecurity) {
87+
const manifest = await registeredManifest();
88+
for (const contribution of manifest.navigationContributions) {
89+
registry.registerAppNavContribution(structuredClone(contribution), manifest.id);
90+
}
91+
}
92+
return (registry.getApp('setup')?.navigation ?? []) as NavItem[];
93+
}
94+
95+
const groupChildren = (nav: NavItem[], group: string): NavItem[] =>
96+
nav.find((n) => n.id === group)?.children ?? [];
97+
98+
describe('SecurityPlugin — Setup People & Organization assignment entries (ADR-0146 D7)', () => {
99+
it('contributes both entries into group_people_org, naming the two component refs, and it parses', async () => {
100+
const manifest = await registeredManifest();
101+
const contributions = manifest.navigationContributions as Contribution[];
102+
const peopleOrg = contributions.filter((c) => c.app === 'setup' && c.group === 'group_people_org');
103+
expect(peopleOrg).toHaveLength(1);
104+
expect(peopleOrg[0].items).toEqual([
105+
expect.objectContaining(POSITION_HOLDERS),
106+
expect.objectContaining(PERMISSION_SET_ASSIGNMENT),
107+
]);
108+
// Registry KEYS, never console paths.
109+
for (const item of peopleOrg[0].items) {
110+
expect(item.componentRef).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/);
111+
}
112+
for (const contribution of contributions) {
113+
expect(() => NavigationContributionSchema.parse(contribution)).not.toThrow();
114+
}
115+
});
116+
117+
it('carries the same gate as the Positions / Permission Sets entries: none of its own', async () => {
118+
const manifest = await registeredManifest();
119+
const items = (manifest.navigationContributions as Contribution[]).flatMap((c) => c.items);
120+
const byId = new Map(items.map((i) => [i.id, i]));
121+
const positions = byId.get('nav_positions');
122+
const permissionSets = byId.get('nav_permission_sets');
123+
// Control: the catalog entries really are in this manifest, so the
124+
// comparison below is against a real gate, not two undefined lookups.
125+
expect(positions).toBeDefined();
126+
expect(permissionSets).toBeDefined();
127+
for (const id of [POSITION_HOLDERS.id, PERMISSION_SET_ASSIGNMENT.id]) {
128+
const entry = byId.get(id);
129+
expect(entry, id).toBeDefined();
130+
expect(gateOf(entry!), id).toEqual(gateOf(positions!));
131+
expect(gateOf(entry!), id).toEqual(gateOf(permissionSets!));
132+
}
133+
});
134+
135+
it('the rendered Setup nav serves both under People & Organization, beside the base entries', async () => {
136+
const nav = await renderedSetup(true);
137+
const children = groupChildren(nav, 'group_people_org');
138+
// Control: platform-objects' base entries fold into the same group.
139+
expect(children.map((i) => i.id)).toContain('nav_users');
140+
expect(children.find((i) => i.id === POSITION_HOLDERS.id)).toMatchObject(POSITION_HOLDERS);
141+
expect(children.find((i) => i.id === PERMISSION_SET_ASSIGNMENT.id)).toMatchObject(PERMISSION_SET_ASSIGNMENT);
142+
// The group anchor they land in is ungated, like Access Control's.
143+
const anchor = nav.find((n) => n.id === 'group_people_org');
144+
const accessControl = nav.find((n) => n.id === 'group_access_control');
145+
expect(anchor?.requiredPermissions ?? []).toEqual(accessControl?.requiredPermissions ?? []);
146+
// Both entries open in exactly one place.
147+
const all = nav.flatMap((n) => n.children ?? []);
148+
expect(all.filter((i) => i.componentRef === POSITION_HOLDERS.componentRef)).toHaveLength(1);
149+
expect(all.filter((i) => i.componentRef === PERMISSION_SET_ASSIGNMENT.componentRef)).toHaveLength(1);
150+
});
151+
152+
it('without this plugin, neither entry appears — hidden exactly where the catalog entries are', async () => {
153+
const nav = await renderedSetup(false);
154+
const ids = nav.flatMap((n) => n.children ?? []).map((i) => i.id);
155+
// Control: the fold still serves platform-objects' entries.
156+
expect(ids).toContain('nav_users');
157+
expect(ids).not.toContain('nav_positions');
158+
expect(ids).not.toContain('nav_permission_sets');
159+
expect(ids).not.toContain(POSITION_HOLDERS.id);
160+
expect(ids).not.toContain(PERMISSION_SET_ASSIGNMENT.id);
161+
});
162+
});
163+
164+
// The labels. Setup's nav labels — this plugin's catalog entries included —
165+
// live in `@objectstack/platform-objects`' hand-authored Setup bundles
166+
// (`apps.setup.navigation.<id>.label`); this plugin's own bundle carries no
167+
// `apps` section. So the population is every locale of the SERVED Setup bundle
168+
// that carries the People & Organization group, and the ids are read off this
169+
// plugin's manifest, not retyped. Served, not raw: a translation whose recorded
170+
// source digest no longer matches is served as the English source
171+
// (`withSourceFallback`), and that is what an administrator reads.
172+
describe('SecurityPlugin — People & Organization assignment entries are labelled in every Setup locale', () => {
173+
it('each locale carrying group_people_org labels both entries; translated locales do not serve the English', async () => {
174+
const manifest = await registeredManifest();
175+
const ids = (manifest.navigationContributions as Contribution[])
176+
.filter((c) => c.app === 'setup' && c.group === 'group_people_org')
177+
.flatMap((c) => c.items.map((i) => i.id!));
178+
expect(ids).toEqual([POSITION_HOLDERS.id, PERMISSION_SET_ASSIGNMENT.id]);
179+
180+
const navOf = (locale: string) =>
181+
((SetupAppTranslations as any)[locale]?.apps?.setup?.navigation ?? {}) as Record<string, { label?: string }>;
182+
const locales = Object.keys(SetupAppTranslations).filter((l) => navOf(l).group_people_org?.label);
183+
// Control: the four Setup locales carry the group, so the loop below judges something.
184+
expect(locales.sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);
185+
186+
const en = navOf('en');
187+
expect(en[POSITION_HOLDERS.id]?.label).toBe(POSITION_HOLDERS.label);
188+
expect(en[PERMISSION_SET_ASSIGNMENT.id]?.label).toBe(PERMISSION_SET_ASSIGNMENT.label);
189+
for (const locale of locales.filter((l) => l !== 'en')) {
190+
const nav = navOf(locale);
191+
for (const id of ids) {
192+
expect(nav[id]?.label, `${locale} ${id}`).toBeTruthy();
193+
expect(nav[id]?.label, `${locale} ${id} serves the English source`).not.toBe(en[id]?.label);
194+
}
195+
}
196+
});
197+
});

‎packages/plugins/plugin-security/src/security-plugin.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1645,6 +1645,28 @@ export class SecurityPlugin implements Plugin {
16451645
{ id: 'nav_permission_sets', type: 'url', label: 'Permission Sets', url: '/apps/setup/metadata/permission?scope=environment', icon: 'lock' },
16461646
],
16471647
},
1648+
{
1649+
// [ADR-0146 D7] The two ASSIGNMENT pages — who holds a position, and
1650+
// which users carry a permission set. They sit with the people they
1651+
// assign (People & Organization), not with the catalog above. The
1652+
// console binds each `componentRef` to its page (app-shell
1653+
// `builtinComponents`), and the pages read and write this plugin's
1654+
// assignment rows, so the entries live and die with this plugin like
1655+
// the catalog entries. Gate: none of their own, exactly like
1656+
// Positions / Permission Sets — the Setup app's `setup.access` and the
1657+
// ungated group anchor decide who sees them (ADR-0146 D7: the scope of
1658+
// `setup.access` and the delegated-administrator gate), and the writes
1659+
// the pages make keep their own server-side gates (the ADR-0090 D12
1660+
// delegated-admin gate among them). Priority 200 keeps them after
1661+
// platform-objects' base People & Organization entries (100).
1662+
app: 'setup',
1663+
group: 'group_people_org',
1664+
priority: 200,
1665+
items: [
1666+
{ id: 'nav_position_holders', type: 'component', label: 'Position Holders', componentRef: 'security:position-holders', icon: 'user-check' },
1667+
{ id: 'nav_permission_set_assignment', type: 'component', label: 'Permission Set Assignment', componentRef: 'security:permission-set-assignment', icon: 'key-round' },
1668+
],
1669+
},
16481670
],
16491671
});
16501672

0 commit comments

Comments
 (0)