Skip to content

Commit 1f37085

Browse files
feat(console): the user page's Permission Sets tab shows the person's effective permission sets and where each comes from (#31370)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XJ2GjUB9198m2yffe4jrqii)_ Fixes #31216 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:用户记录页的「权限集」页签顶部新增一个组件 `security:effective-access`,列出此人在当前组织实际生效的每个权限集(显示目录标签和机器名),并标出每一个的全部来源:直接分配、岗位(岗位名)、默认绑定、或派生(配置的平台管理员 / AI 访问)。原有的直接分配列表保留在它下面,直接分配仍在那里移除。 - 为什么改:页签原来只列 `sys_user_permission_set` 存储行,只靠默认绑定和岗位拿到权限的人显示「暂无相关记录」,误导管理员(#31216,数据来自 #31215 / PR #31336 的 server 读口)。 - 风险与回滚:只加一个只读组件和一行页面放置,不改服务端、不改 spec;读口经 `@objectstack/client` 的 `security.describeEffectiveAccess(userId)`。403(无权管理此人)和 501(服务端不提供此读口)只显示一行安静提示、不显示列表。回滚即 revert。 - 先看:`ui/packages/app-shell/src/console/security/UserEffectiveAccess.tsx`(读口、403/501 分支、行渲染),`packages/platform-objects/src/pages/sys-user.page.ts` 的放置那一行,`ui/packages/types/src/zod/security-effective-access.zod.ts`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx`,预期 13 个用例全绿;其中「只靠默认绑定和岗位」一例显示两行及各自来源,直接分配对照一例只显示那一个集。 - 需要你做的:无,席位落地。 ## What changes - **`UserEffectiveAccess.tsx`** and **`effectiveAccess.ts`** (`@object-ui/app-shell`, new; the second holds the answer types, the origin wording and the refusal readers, so the widget module exports components only): the SDUI widget `security:effective-access`, registered under the `security` namespace with `skipFallback` (one key, no bare `effective-access`). It reads the person from the bound record (`useRecordContext()`, a `sys_user` record with an id; off such a record it renders nothing and reads nothing) and calls `adapter.getClient().security.describeEffectiveAccess(userId)`. Its answer types are derived from the adapter's client (`EffectiveAccess` / `EffectivePermissionSetOrigin` of `@objectstack/spec/contracts`), not restated. Each set is one row, labelled from the permission-set catalog (`ensureType('permission')`, the same `catalogEntries` the Setup assignment pages use; a set the catalog does not list shows its name), with one badge per origin in the answer's order: direct grant, `Position: NAME`, default binding, configured platform administrator, AI access turned on. A `403` (`httpStatus` the client stamps) and a `501` each leave one quiet muted line and no list; any other failure names itself; an empty answer says so. The widget reads again when `sys_user_permission_set` or `sys_user_position` data changes (`useDataInvalidation`), so a grant added or removed in the list below shows here in place. - **`sys-user.page.ts`** (`@objectstack/platform-objects`): the Permission Sets tab's children are now `{ type: 'security:effective-access', properties: {} }` followed by the unchanged `record:related_list` over `sys_user_permission_set`. Diff: one node line and one comment line. - **`@object-ui/types`**: `SecurityEffectiveAccessSchema` (zod arm in `AnyComponentSchema`) and its TypeScript twin in `AnySchema`, modelled on `mcp:authorized-assistants`: `properties` may only be `{}`, and `body` / `children` are refused by name. Zod README entry, mirror-parity pair (`EXPECTED_MIRROR_PAIRS` 198 to 199) and the emit-headroom member row. - **`@object-ui/cli`**: `known-schema-types.ts` regenerated by `node scripts/regenerate-known-schema-types.mjs` (one added line), and the namespaced-types ratchet gains the "registered WITH its arm" case. - **`@object-ui/i18n`**: `effectiveAccess.*` (7 keys plus 5 under `origin`) in all eleven packs; `zh-Hant.ts` produced by `pnpm --filter @object-ui/i18n gen:zh-hant`. - **`canonical-expression-envelopes.test.ts`**: `security:effective-access` joins `UNMAPPED_TYPE_EXEMPTIONS` with its reason (no props, so no expression key for door 3 to miss), as the `mcp:authorized-assistants` precedent did. - ⛔ No `packages/spec` edit and no server code. ## Tests - **Widget, through the real input pipeline** (`UserEffectiveAccess.test.tsx`, 13 cases): the authored node `{ type: 'security:effective-access', properties: {} }` is rendered by `SchemaRenderer` inside `RecordContextProvider` and a `MetadataCtx` serving a permission catalog, and the adapter's client answers `describeEffectiveAccess`. - §1 default + position only: two rows, `sales_rep` with `Position: sales` and `member_default` with the default binding, not the empty line; the catalog label shows with the name beside it; an uncatalogued set shows by name. - §2 CONTROL: one direct grant shows exactly that one set, as a direct grant. - §3 a set reached two ways is one row with both origins in the answer's order (direct + position; direct + configured platform administrator), and the AI-access derived origin; `describeOrigin` covers every origin kind of the contract. - §4 `403` and `501` show their quiet line and no list (and no empty line); a `500` names itself; an empty answer says so. - §5 off a `sys_user` record (and with no id) nothing renders and nothing is read; a `notifyDataChanged` on `sys_user_permission_set` reads again. - **Arm** (`security-effective-access-arm.test.ts`, 10 cases): accepted on every face, claimed at `type` (control: an unclaimed `security:` literal is refused there), refuses keys in the bag, a non-object bag, a flat key on the strict face, and both content channels by name. - **Page** (`sys-user-effective-access.test.ts`, 5 cases): `PageSchema` parses; the tab opens with the widget and no props; the grant list stays below it; the widget is mounted once; no tab-level gate. - **Ablation A (widget)**, on committed head `cf2908d8` (the widget file is unchanged since), `scripts/ablation-replace.mjs`: rows filtered to sets with a direct origin, i.e. the stored rows only (anchor `read.value.permissionSets.map(` 1 to 0, blob `8525c107` to `36529e68`). Result: 4 of 13 red, the three §1 cases and the §3 two-ways case (its AI-access-only row is dropped); the §2 CONTROL stays green. Restored: blob equal to HEAD (`8525c107`), `git diff HEAD` empty. The test imports the widget from source by relative path, so no build sat between mutation and run. - **Ablation B (page)**, on committed head `5959bfab` (the page file is unchanged since): the widget node deleted from `sys-user.page.ts` (anchor 1 to 0, blob `3b5c6540` to `c70ae662`). Result: 4 of 38 red across the four page test files: the three widget-placement cases and the exemption-liveness case in `canonical-expression-envelopes.test.ts`. Restored: blob equal to HEAD (`3b5c6540`), `git diff HEAD` empty. Baseline before it: 38 of 38 green. ## Acceptance notes - **Mechanism hypothesis, measured by reading `origin/main` `e7354255`:** the Permission Sets tab's only child was the `record:related_list` over `sys_user_permission_set`, so a person with no grant row had nothing to list. Not run on a live Console. - **Placement reading.** The card allows "next to, or instead of" the grant list; this PR places the widget above it. Removing the list would also remove its New (grant form) and its window columns (`valid_from` / `valid_until`), which the effective answer does not carry. So "Direct rows keep their remove action" is held by the list below: the widget's direct badge says the grant is direct, and the widget's description line tells the reader the direct grants are listed below, where they can be removed. The widget itself writes nothing. - **Origins say where they come from in words**, not links: the position origin names the position; default and derived origins name their channel. No link to the position holders page was added. - **Visibility.** The tab carries no viewer gate: the server's door decides (a `403` is the widget's quiet refused line), and the grant list beside it is decided by the viewer's own grant on `sys_user_permission_set`, as before. - **Declaration lines.** The claim opened at `Clause-②: no` / `Metadata-protocol: none`. Measured on this diff: a new registered widget key, a new `@object-ui/types` zod arm and twelve new `@object-ui/i18n` keys per pack are published widening, so this PR and its changeset declare `yes (widening)`. `Metadata-protocol: none`: the page edit places a node on a shipped page and changes nothing a metadata author writes or a save door judges (the `mcp:authorized-assistants` page placement shipped with `none`). - **Docs.** `ui/AGENTS.md` #2 asks for a docs page; the precedent widget shipped with its zod README entry only, and so does this one. Not added under `content/docs/`. ## Verification Run on head `ce83a535` (base `e7354255`). Diff: 30 files, +1081 / -1, under the 3,000-line human-merge threshold. <details><summary>Build, tests and type-check</summary> - Builds under the verify lock: `pnpm --workspace-concurrency=2 --filter '@objectstack/client...' --filter '@objectstack/formula...' --filter '@objectstack/spec...' --filter '@objectstack/lint...' --filter '@objectstack/sdui-parser...' build` VERDICT command-exit 0; `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' --filter '@objectstack/platform-objects^...' --filter '@object-ui/cli^...' build` VERDICT command-exit 0; the `check:i18n` prerequisite closure (`turbo run build --filter=@objectstack/cli --filter=@objectstack/platform-objects ...`) VERDICT command-exit 0. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/types/ packages/cli/ packages/i18n/ packages/app-shell/src/console/`: Test Files 784 passed, 2 failed (786) on the first run. The two failures were `content-channel-parser-tier-clause-10928.test.ts` and `silence-clause-parser-tier-closure-10981.test.ts`: the new arm's refusal text said "no render-time error or warning" without naming the parser tier. Fixed in `ce83a535` (the clause the `mcp:authorized-assistants` arm carries); rerun of those two plus the arm and mirror-parity files: Test Files 4 passed (4), Tests 72 passed (72). - `pnpm --dir ui exec vitest run packages/app-shell/src/console/security/`: Test Files 1 passed (1), Tests 13 passed (13). - `pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2` then `pnpm --filter @objectstack/platform-objects typecheck`: Test Files 120 passed (120), Tests 1751 passed (1751); typecheck exit 0. - `pnpm --filter @object-ui/app-shell --filter @object-ui/types --filter @object-ui/i18n --filter @object-ui/cli run type-check`: types, i18n and cli Done; app-shell first failed in `tsc -p tsconfig.test.json` (the test's node literal was an untyped `{ type: string }`), fixed by typing it as `SecurityEffectiveAccessSchema`; rerun `pnpm --filter @object-ui/app-shell run type-check` VERDICT command-exit 0. - `pnpm exec eslint packages/app-shell/src/console/security/` (from `ui/`): exit 0, no warnings. `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. - `node scripts/regenerate-known-schema-types.mjs` (from `ui/`): exit 0, one line added. `pnpm --filter @object-ui/i18n gen:zh-hant`: exit 0, `zh-Hant.ts` rewritten from `zh-Hans.ts`. </details> <details><summary>Derived gates (82)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 82 families on this diff, re-derived on head `ce83a535` (identical list). All 82 exited 0 on that head, each exit code captured before any pipe. `pnpm --dir ui exec vitest run scripts/` under the lock: Test Files 170 passed, 2 skipped (172); Tests 4996 passed, 2 skipped. `--ran` reconciliation: 82 derived, 82 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass (an earlier head) three families answered exit 3 (prerequisite not met, nothing measured): `check-plugin-teardown-shape.mjs --self-test` (shallow clone; fixed by fetching its fixture commit), `check:dual-build-cjs-loads` and `check:i18n` (missing `dist/`; fixed by the builds above). All three exit 0 on the final head. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kxrRdCiSKcPqREG4w)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 44a030b commit 1f37085

30 files changed

Lines changed: 1081 additions & 1 deletion
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+
'@object-ui/types': patch
5+
'@object-ui/cli': patch
6+
'@objectstack/platform-objects': patch
7+
---
8+
9+
feat(console): the user page's Permission Sets tab shows the person's effective permission sets and where each comes from
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: none
13+
14+
- **`@object-ui/app-shell`**: new SDUI widget `security:effective-access` for a `sys_user` record page. It reads the person from the bound record (no props) and lists every permission set in force for them in the caller's organization through `security.describeEffectiveAccess(userId)` of `@objectstack/client`. Each set appears once, by its catalog label with its name beside it, with every origin the server reports: a direct grant, a position by name, the default binding, or a derived standing (the configured platform administrator, AI access). A `403` (a person the viewer may not manage) and a `501` (a server that does not report effective access) each show one quiet line and no list; any other failure is named. The widget reads again when a grant or a position is written on the page. Off a `sys_user` record it renders nothing.
15+
- **`@objectstack/platform-objects`**: the `sys_user` record page's Permission Sets tab opens with the `security:effective-access` widget, above the list of stored grants. A person who holds access only through the default binding or a position no longer reads as having no permission sets; direct grants keep their remove action in the list below.
16+
- **`@object-ui/i18n`**: `effectiveAccess.*` (the widget's title, body, refused, unserved, failed and empty lines, and one label per origin kind) added in all eleven packs.
17+
- **`@object-ui/types`**: `SecurityEffectiveAccessSchema` (zod arm) and its TypeScript twin, so a page placing `{ type: 'security:effective-access' }` validates. The widget reads only `className`: `properties` may only be `{}`, and `body` / `children` are refused by name.
18+
- **`@object-ui/cli`**: `objectui check` knows the `security:effective-access` type (the generated known-types list).

‎packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ const UNMAPPED_TYPE_EXEMPTIONS: Readonly<Record<string, string>> = {
172172
'mcp:authorized-assistants':
173173
'the user page\'s AI Assistants tab body (@object-ui/app-shell): the widget takes no props and reads '
174174
+ 'the bound sys_user record, so its authored `properties` is `{}` and holds no expression key',
175+
'security:effective-access':
176+
'the user page\'s Permission Sets tab, above the grant list (@object-ui/app-shell): the widget takes no props '
177+
+ 'and reads the bound sys_user record, so its authored `properties` is `{}` and holds no expression key',
175178
};
176179

177180
/** The authored node at an audit path such as `regions[0].components[2].properties.items[10].children[0]`. */
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The user page's "Permission Sets" tab shows the person's EFFECTIVE sets, not
5+
* only their stored grant rows.
6+
*
7+
* A person who holds access only through the default binding or a position has
8+
* no `sys_user_permission_set` row, so a tab holding only the grant list reads
9+
* as "no records" for them. The tab's first child is the console widget
10+
* `security:effective-access` (`@object-ui/app-shell`), which reads the bound
11+
* `sys_user` record and lists every set the server resolves for the person
12+
* with its origins. The grant list stays below it: a direct grant keeps its
13+
* remove action there.
14+
*
15+
* Read from the shipped page metadata, so the pin fails if the widget leaves
16+
* the tab, moves below the list, or starts authoring props.
17+
*/
18+
19+
import { describe, expect, it } from 'vitest';
20+
import { PageSchema } from '@objectstack/spec/ui';
21+
import { SysUserDetailPage } from './sys-user.page.js';
22+
23+
type Rec = Record<string, unknown>;
24+
25+
describe('sys_user_detail — the Permission Sets tab', () => {
26+
const tabs = (SysUserDetailPage.regions![0]!.components as Rec[]).find((c) => c.type === 'page:tabs') as Rec;
27+
const items = (tabs.properties as Rec).items as Rec[];
28+
const tab = items.find((it) => (it.label as Rec).en === 'Permission Sets')!;
29+
const children = tab.children as Rec[];
30+
31+
it('parses through PageSchema', () => {
32+
const parsed = PageSchema.safeParse(SysUserDetailPage);
33+
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
34+
});
35+
36+
it('opens with the `security:effective-access` widget, with no props', () => {
37+
expect(children[0]).toEqual({ type: 'security:effective-access', properties: {} });
38+
});
39+
40+
it('keeps the stored grant list below it, where a direct grant is removed', () => {
41+
expect(children.map((c) => c.type)).toEqual(['security:effective-access', 'record:related_list']);
42+
expect((children[1]!.properties as Rec).objectName).toBe('sys_user_permission_set');
43+
});
44+
45+
it('mounts the widget nowhere else on the page', () => {
46+
const found: unknown[] = [];
47+
const walk = (n: unknown): void => {
48+
if (Array.isArray(n)) { n.forEach(walk); return; }
49+
if (!n || typeof n !== 'object') return;
50+
if ((n as Rec).type === 'security:effective-access') found.push(n);
51+
Object.values(n as Rec).forEach(walk);
52+
};
53+
walk(SysUserDetailPage);
54+
expect(found).toHaveLength(1);
55+
});
56+
57+
it('carries no tab-level gate: the server refuses a viewer who may not manage the person', () => {
58+
expect(tab.visibleWhen).toBeUndefined();
59+
});
60+
});

‎packages/platform-objects/src/pages/sys-user.page.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,8 @@ export const SysUserDetailPage: Page = {
287287
label: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos', 'zh-Hant': '權限集', ar: 'مجموعات الأذونات', fr: 'Ensembles d’autorisations' },
288288
icon: 'lock',
289289
children: [
290+
// Effective sets with their origins (default / position / direct / derived), read from the server; the stored grant rows stay below.
291+
{ type: 'security:effective-access', properties: {} },
290292
{
291293
type: 'record:related_list',
292294
properties: {

‎ui/packages/app-shell/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"./dist/console/connect/ManagedPersonAssistants.js",
1313
"./dist/console/diagnostics/CloudAiModelStatus.js",
1414
"./dist/console/home/CloudWorkspaceTimezoneNotice.js",
15+
"./dist/console/security/UserEffectiveAccess.js",
1516
"./dist/services/builtinComponents.js",
1617
"./dist/views/global-notifications-renderer.js",
1718
"./dist/views/global-search-renderer.js",
@@ -31,6 +32,7 @@
3132
"./src/console/connect/ManagedPersonAssistants.tsx",
3233
"./src/console/diagnostics/CloudAiModelStatus.tsx",
3334
"./src/console/home/CloudWorkspaceTimezoneNotice.tsx",
35+
"./src/console/security/UserEffectiveAccess.tsx",
3436
"./src/services/builtinComponents.tsx",
3537
"./src/views/global-notifications-renderer.tsx",
3638
"./src/views/global-search-renderer.tsx",
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `security:effective-access` — a person's effective permission sets on their
5+
* `sys_user` record page, each with every origin the server's answer carries.
6+
*
7+
* Driven through the real input pipeline: the node a page authors
8+
* (`{ type: 'security:effective-access', properties: {} }`) is rendered by
9+
* `SchemaRenderer` inside the record context the page binds, and the widget
10+
* is fed `security.describeEffectiveAccess(userId)`'s answer through the
11+
* adapter's client.
12+
*
13+
* - §1 a person with access only through the default binding and a position
14+
* shows those sets, each with its origin, and the set label;
15+
* - §2 CONTROL: a person with one direct grant shows exactly that set;
16+
* - §3 a set reached two ways is one row with both origins; derived origins
17+
* say which standing;
18+
* - §4 a 403 and a 501 show no list, each a quiet line; another failure
19+
* names itself; an empty answer says so;
20+
* - §5 off a `sys_user` record it renders nothing and reads nothing; a grant
21+
* written on the page reads the answer again.
22+
*
23+
* `t` echoes the key with any interpolation values appended, so assertions
24+
* read which string was asked for and with what.
25+
*/
26+
27+
import '@testing-library/jest-dom/vitest';
28+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
29+
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
30+
31+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
32+
...(await importOriginal<Record<string, unknown>>()),
33+
useObjectTranslation: () => ({
34+
t: (key: string, opts?: Record<string, unknown>) => {
35+
const vals = opts ? Object.values(opts).filter((v) => typeof v === 'string' || typeof v === 'number') : [];
36+
return vals.length ? `«${key}|${vals.join('|')}»` : `«${key}»`;
37+
},
38+
language: 'en',
39+
}),
40+
}));
41+
42+
let adapter: unknown = null;
43+
vi.mock('@object-ui/react', async (importOriginal) => ({
44+
...(await importOriginal<Record<string, unknown>>()),
45+
useAdapter: () => adapter,
46+
}));
47+
48+
import '@object-ui/components';
49+
import { ComponentRegistry } from '@object-ui/core';
50+
import {
51+
MetadataCtx,
52+
notifyDataChanged,
53+
RecordContextProvider,
54+
SchemaRenderer,
55+
type MetadataContextValue,
56+
} from '@object-ui/react';
57+
import './UserEffectiveAccess';
58+
import type { SecurityEffectiveAccessSchema } from '@object-ui/types';
59+
import { describeOrigin, type EffectiveAccessAnswer } from './effectiveAccess';
60+
61+
/** The node a page authors, typed on its `@object-ui/types` twin. */
62+
const NODE: SecurityEffectiveAccessSchema = { type: 'security:effective-access', properties: {} };
63+
64+
/** The permission-set catalog the labels come from (`/meta/permission`). */
65+
const CATALOG = [
66+
{ name: 'member_default', label: 'Member default' },
67+
{ name: 'sales_rep', label: 'Sales rep' },
68+
{ name: 'admin_full_access', label: 'Full administrator' },
69+
];
70+
71+
const describeMock = vi.fn<(userId: string) => Promise<EffectiveAccessAnswer>>();
72+
const ensureTypeMock = vi.fn(async (_type: string): Promise<unknown[]> => CATALOG);
73+
74+
function refusal(status: number, message: string): Error {
75+
return Object.assign(new Error(message), { httpStatus: status });
76+
}
77+
78+
const metadata = {
79+
ensureType: (type: string) => ensureTypeMock(type),
80+
} as unknown as MetadataContextValue;
81+
82+
function renderOn(objectName: string, recordId: string | null) {
83+
return render(
84+
<MetadataCtx.Provider value={metadata}>
85+
<RecordContextProvider objectName={objectName} recordId={recordId}>
86+
<SchemaRenderer schema={NODE} />
87+
</RecordContextProvider>
88+
</MetadataCtx.Provider>,
89+
);
90+
}
91+
92+
/** The rendered rows, as set name → origin badge texts. */
93+
async function rowsShown(): Promise<Record<string, string[]>> {
94+
const list = await screen.findByTestId('effective-access-list');
95+
const out: Record<string, string[]> = {};
96+
for (const row of within(list).getAllByRole('listitem').filter((li) => li.dataset.testid?.startsWith('effective-access-row-'))) {
97+
const name = row.dataset.testid!.slice('effective-access-row-'.length);
98+
const origins = within(row).getByRole('list');
99+
out[name] = within(origins).getAllByRole('listitem').map((li) => li.textContent ?? '');
100+
}
101+
return out;
102+
}
103+
104+
beforeEach(() => {
105+
describeMock.mockReset();
106+
ensureTypeMock.mockClear();
107+
adapter = { getClient: () => ({ security: { describeEffectiveAccess: describeMock } }) };
108+
});
109+
110+
afterEach(() => {
111+
cleanup();
112+
});
113+
114+
describe('§0 registration', () => {
115+
it('is registered as security:effective-access, with no bare fallback', () => {
116+
expect(ComponentRegistry.get('security:effective-access')).toBeDefined();
117+
expect(ComponentRegistry.get('effective-access')).toBeUndefined();
118+
});
119+
});
120+
121+
describe('§1 access only through the default binding and a position', () => {
122+
it('shows those sets with their origins, not an empty list', async () => {
123+
describeMock.mockResolvedValueOnce({
124+
userId: 'user_mia',
125+
organizationId: 'org_here',
126+
positions: ['org_member', 'sales', 'everyone'],
127+
permissionSets: [
128+
{ name: 'sales_rep', origins: [{ kind: 'position', position: 'sales' }] },
129+
{ name: 'member_default', origins: [{ kind: 'default' }] },
130+
],
131+
});
132+
renderOn('sys_user', 'user_mia');
133+
134+
expect(await rowsShown()).toEqual({
135+
sales_rep: ['«effectiveAccess.origin.position|sales»'],
136+
member_default: ['«effectiveAccess.origin.default»'],
137+
});
138+
expect(describeMock).toHaveBeenCalledWith('user_mia');
139+
expect(screen.queryByTestId('effective-access-empty')).toBeNull();
140+
});
141+
142+
it('shows each set by its catalog label, with the name beside it', async () => {
143+
describeMock.mockResolvedValueOnce({
144+
userId: 'user_mia',
145+
organizationId: 'org_here',
146+
positions: ['everyone'],
147+
permissionSets: [{ name: 'member_default', origins: [{ kind: 'default' }] }],
148+
});
149+
renderOn('sys_user', 'user_mia');
150+
const row = await screen.findByTestId('effective-access-row-member_default');
151+
await waitFor(() => expect(within(row).getByText('Member default')).toBeInTheDocument());
152+
expect(within(row).getByText('member_default')).toBeInTheDocument();
153+
expect(ensureTypeMock).toHaveBeenCalledWith('permission');
154+
});
155+
156+
it('shows a set the catalog does not list by its name, never drops it', async () => {
157+
ensureTypeMock.mockResolvedValueOnce([]);
158+
describeMock.mockResolvedValueOnce({
159+
userId: 'user_mia',
160+
organizationId: 'org_here',
161+
positions: [],
162+
permissionSets: [{ name: 'uncatalogued_set', origins: [{ kind: 'default' }] }],
163+
});
164+
renderOn('sys_user', 'user_mia');
165+
const row = await screen.findByTestId('effective-access-row-uncatalogued_set');
166+
expect(within(row).getAllByText('uncatalogued_set')).toHaveLength(1);
167+
});
168+
});
169+
170+
describe('§2 CONTROL — one direct grant', () => {
171+
it('shows exactly that set, as a direct grant', async () => {
172+
describeMock.mockResolvedValueOnce({
173+
userId: 'user_ana',
174+
organizationId: 'org_here',
175+
positions: [],
176+
permissionSets: [{ name: 'sales_rep', origins: [{ kind: 'direct' }] }],
177+
});
178+
renderOn('sys_user', 'user_ana');
179+
expect(await rowsShown()).toEqual({ sales_rep: ['«effectiveAccess.origin.direct»'] });
180+
});
181+
});
182+
183+
describe('§3 a set reached two ways', () => {
184+
it('is one row carrying both origins, in the answer\'s order', async () => {
185+
describeMock.mockResolvedValueOnce({
186+
userId: 'user_mia',
187+
organizationId: 'org_here',
188+
positions: ['sales'],
189+
permissionSets: [
190+
{ name: 'sales_rep', origins: [{ kind: 'direct' }, { kind: 'position', position: 'sales' }] },
191+
{
192+
name: 'admin_full_access',
193+
origins: [{ kind: 'direct' }, { kind: 'derived', source: 'platform_admin_config' }],
194+
},
195+
{ name: 'ai_seat', origins: [{ kind: 'derived', source: 'ai_access' }] },
196+
],
197+
});
198+
renderOn('sys_user', 'user_mia');
199+
expect(await rowsShown()).toEqual({
200+
sales_rep: ['«effectiveAccess.origin.direct»', '«effectiveAccess.origin.position|sales»'],
201+
admin_full_access: ['«effectiveAccess.origin.direct»', '«effectiveAccess.origin.derivedPlatformAdmin»'],
202+
ai_seat: ['«effectiveAccess.origin.derivedAiAccess»'],
203+
});
204+
expect(screen.getAllByTestId(/^effective-access-row-/)).toHaveLength(3);
205+
});
206+
207+
it('describes every origin kind the contract declares', () => {
208+
const t = (key: string, opts?: Record<string, unknown>) => (opts ? `${key}|${Object.values(opts).join('|')}` : key);
209+
expect(describeOrigin({ kind: 'direct' }, t)).toBe('effectiveAccess.origin.direct');
210+
expect(describeOrigin({ kind: 'position', position: 'org_external' }, t)).toBe('effectiveAccess.origin.position|org_external');
211+
expect(describeOrigin({ kind: 'default' }, t)).toBe('effectiveAccess.origin.default');
212+
expect(describeOrigin({ kind: 'derived', source: 'platform_admin_config' }, t)).toBe('effectiveAccess.origin.derivedPlatformAdmin');
213+
expect(describeOrigin({ kind: 'derived', source: 'ai_access' }, t)).toBe('effectiveAccess.origin.derivedAiAccess');
214+
});
215+
});
216+
217+
describe('§4 refusals never draw a list', () => {
218+
it('a 403 (a person the caller may not manage) leaves one quiet line and no list', async () => {
219+
describeMock.mockRejectedValueOnce(refusal(403, 'Forbidden'));
220+
renderOn('sys_user', 'user_other');
221+
expect(await screen.findByTestId('effective-access-refused')).toHaveTextContent('«effectiveAccess.refused»');
222+
expect(screen.queryByTestId('effective-access-list')).toBeNull();
223+
expect(screen.queryByTestId('effective-access-empty')).toBeNull();
224+
});
225+
226+
it('a 501 (a server without the read) leaves one quiet line and no list', async () => {
227+
describeMock.mockRejectedValueOnce(refusal(501, 'Not implemented'));
228+
renderOn('sys_user', 'user_mia');
229+
expect(await screen.findByTestId('effective-access-unserved')).toHaveTextContent('«effectiveAccess.unserved»');
230+
expect(screen.queryByTestId('effective-access-list')).toBeNull();
231+
expect(screen.queryByTestId('effective-access-empty')).toBeNull();
232+
});
233+
234+
it('any other failure names itself, and draws no list', async () => {
235+
describeMock.mockRejectedValueOnce(refusal(500, 'authorization store could not be read'));
236+
renderOn('sys_user', 'user_mia');
237+
expect(await screen.findByTestId('effective-access-failed')).toHaveTextContent(
238+
'«effectiveAccess.loadFailed|authorization store could not be read»',
239+
);
240+
expect(screen.queryByTestId('effective-access-list')).toBeNull();
241+
});
242+
243+
it('an answer with no set says so', async () => {
244+
describeMock.mockResolvedValueOnce({ userId: 'user_mia', organizationId: null, positions: [], permissionSets: [] });
245+
renderOn('sys_user', 'user_mia');
246+
expect(await screen.findByTestId('effective-access-empty')).toHaveTextContent('«effectiveAccess.empty»');
247+
});
248+
});
249+
250+
describe('§5 where it draws, and when it reads again', () => {
251+
it('renders nothing and reads nothing off a sys_user record', () => {
252+
const other = renderOn('crm_account', 'acc_1');
253+
expect(other.container).toBeEmptyDOMElement();
254+
other.unmount();
255+
const noId = renderOn('sys_user', null);
256+
expect(noId.container).toBeEmptyDOMElement();
257+
expect(describeMock).not.toHaveBeenCalled();
258+
});
259+
260+
it('reads the answer again when a grant on the page changes', async () => {
261+
describeMock.mockResolvedValue({
262+
userId: 'user_mia',
263+
organizationId: 'org_here',
264+
positions: [],
265+
permissionSets: [{ name: 'sales_rep', origins: [{ kind: 'direct' }] }],
266+
});
267+
renderOn('sys_user', 'user_mia');
268+
await screen.findByTestId('effective-access-list');
269+
expect(describeMock).toHaveBeenCalledTimes(1);
270+
notifyDataChanged({ objectName: 'sys_user_permission_set' });
271+
await waitFor(() => expect(describeMock).toHaveBeenCalledTimes(2));
272+
});
273+
});

0 commit comments

Comments
 (0)