Skip to content

Commit 77a52cf

Browse files
fix(app-shell): the Setup position page previews each permission set it names, read-only (#26187) (#26360)
Fixes #26187 Clause-②: no Metadata-protocol: none ## 维护者速读 改了什么:Setup → 岗位页面上,岗位列出的每个权限集各显示一个只读的权限预览(对象 × 增删改查、读写记录范围、字段、行级策略),按岗位列出的顺序排列,标题是权限集名并链接到该权限集页面;找不到的权限集只显示一行「未找到」,岗位没列权限集时显示空状态。 为什么改:#26187 的裁决(Q1)要求岗位页能看到权限集授予的范围;之前岗位页只列权限集名字,管理员必须逐个打开权限集才知道岗位到底给了什么。权限集页面本身已由 PR 1(#26284)的矩阵选择器显示范围,席位裁定不再加第二份只读副本。 风险与回滚:只改 app-shell 的岗位页面(新增一个只读区块 + 环境区块从单个改为列表)和 i18n 新键,不改任何写入、规范或接口;最坏情况是岗位页多出一个显示不对的区块,不影响保存。回滚 = revert 本 PR。 先看:`ui/packages/app-shell/src/views/metadata-admin/PositionPermissionSetsSection.tsx`(风险 hunk:按名读取权限集的 effect,404 → 未找到、其它错误 → 拒绝原文)→ `ResourceEditPage.tsx`(`ENVIRONMENT_SECTIONS` 改为数组并把页面草稿传入)→ `i18n.ts`(en + zh 各 7 个新键)。 两分钟验证:在 `ui/` 下跑 `pnpm exec vitest run packages/app-shell/src/views/metadata-admin/PositionPermissionSetsSection.test.tsx packages/app-shell/src/views/metadata-admin/ResourceEditPage.positionPermissionSets-26187.test.tsx --maxWorkers=2`,预期 `Test Files 2 passed (2)` / `Tests 9 passed (9)`;或打开 `/apps/setup/metadata/position/POSITION_NAME?scope=environment`,持有人区块下方出现「Permission sets」区块。 需要你做的:无,席位落地 ## What changes PR 2 of 2 (PR 1, the matrix scope pickers, merged as #26284). This PR touches the position page only: the seat ruled that the permission-set page needs no second, read-only preview, because its matrix already shows each object's read and write scope since PR 1. - **New `PositionPermissionSetsSection`** (`metadata-admin/PositionPermissionSetsSection.tsx`). On the Setup position page (environment scope, not create mode, not embedded), under the definition and after the holders, it renders one `PermissionPreview` per permission set the position names in `permissionSets`: - in declared order, each under an `h3` with the set's name, linking to `../../permission/NAME` (path-relative, keeping `?scope=environment`), so the link lands on the set's own page in the same catalog scope; - each preview is the set's own body, read with `client.get('permission', name)` through the console metadata client the page already uses. No union of sets is computed and nothing is editable (the preview gets no `onPatch`; the section holds no control); - a named set the server does not serve (`get` answers `null` on 404) shows one line, `Permission set "NAME" was not found.`, for that set only; the other previews still render; - a refused or failed read shows `Permission set "NAME" could not be read.` plus the server's sentence, not "not found": a refused read is not an absent set; - a position that names no sets shows `This position names no permission sets.` and reads nothing. - The names come from the page's draft (as `PositionPreview` above it reads them), and the read effect keys on the list's content, so editing another field does not re-read the sets. - **`ResourceEditPage.tsx`**: `ENVIRONMENT_SECTIONS` maps a type to an ordered list of sections, each handed `{ name, draft }`; `position` is `[PositionHoldersSection, PositionPermissionSetsSection]`. `PositionHoldersSection` is unchanged and still reads only `name`. - **`i18n.ts`**: 7 new `engine.position.sets.*` keys, en and zh, beside the existing `engine.position.*` block. No existing key changes. - **`PermissionPreview.tsx`**: unchanged. Without `onPatch` it is already read-only, so no prop was needed. - Changeset: `.changeset/26187-position-permission-preview.md`, `@object-ui/app-shell` patch. ## Tests - New `PositionPermissionSetsSection.test.tsx` (6 tests): two named sets give two previews in declared order, each with its own objects and scopes (no union), each headed by the set name with the right href; read-only (no checkbox, combobox or button); a missing set gives one not-found line only for that set; a refused read is said as a refusal, not "not found"; no sets, and no `permissionSets` key, give the empty state and no read. - New `ResourceEditPage.positionPermissionSets-26187.test.tsx` (3 tests): the Setup position page mounts the previews in order beside the holders; Studio (no scope) and another type in the Setup scope do not. - Red first, ablation, green, the importer suite and the type-check are in Verification below. ## Acceptance notes - **Declared sets only.** The section shows exactly the sets the definition names. The position-name fold (a set sharing the position's name) is retired on `main` (`.changeset/24757-retire-position-name-fold.md`), so the declared list is the whole grant; the list page's served `_grantedPermissionSets` column remains the server's own answer. - **Draft, not saved state.** The names are read from the definition on screen, so an unsaved edit to `permissionSets` previews the edited list. The Setup position page renders read-only for a caller without `manage_metadata`, where the draft equals the stored definition. - **Layout.** Under the designer, the environment sections share the existing bounded strip (`max-h-[35vh]`, scrolling), so several previews scroll inside it rather than squeezing the canvas. - Card relation is declared once, in this body. ## Verification <details><summary>Red first — the two new files against `origin/main` 46255b1's implementation (tree only), then restored</summary> `ResourceEditPage.tsx` and `i18n.ts` restored to `46255b19` in the working tree only (blob equal to the `main` blob; 0 `engine.position.sets` keys) and the new component removed, under `scripts/pm/os-verify-lock.sh`: - `ResourceEditPage.positionPermissionSets-26187.test.tsx`: `Tests 1 failed | 2 passed (3)`. The failing test is the positive pin (`renders one preview per named set, in declared order, beside the holders`); the two passing ones are its absence controls (Studio, another type). - `PositionPermissionSetsSection.test.tsx`: fails to resolve `./PositionPermissionSetsSection` (the module does not exist on `main`). - Restore proven: every restored blob equals its `HEAD` blob, `git diff HEAD` empty. </details> <details><summary>Ablation, once — a refused read reported as missing</summary> `node scripts/ablation-replace.mjs` replaced the failed-read arm `(err: unknown): SetRead => ({ state: 'failed', ... })` with `(): SetRead => ({ state: 'missing' })` (anchor x1 -> x0, blob `83ca6312` -> `60eba45d`). `PositionPermissionSetsSection.test.tsx`: `Tests 1 failed | 5 passed (6)`, the failing test being `a refused read is said as a refusal, not as "not found"`. Restored: blob == HEAD (`83ca6312`), `git diff HEAD` empty. No `dist` is involved: app-shell tests import `src`. </details> <details><summary>Green on the final head 771f48f (after merging `main` with #26340), each under the verify lock</summary> - Closure `pnpm exec turbo run build --filter='@object-ui/app-shell^...' --concurrency=2`: exit 0. - The two new files: `Test Files 2 passed (2)` / `Tests 9 passed (9)`. - Every app-shell test file that imports `ResourceEditPage`, `PermissionPreview`, `PositionHoldersSection`, the new section or `metadata-admin/i18n` (resolved by real import path, 291 files), plus #26191's `PermissionAdvancedFacets.companionGrants-26191.test.tsx`, which shares `i18n.ts`: `Test Files 292 passed (292)` / `Tests 2726 passed (2726)`, exit 0. - `pnpm --filter @object-ui/app-shell type-check` (both tsc programs, `tsconfig.test.json` included): exit 0. - The full app-shell suite was not run locally (it holds the shared lock for 50+ minutes); CI's Test UI shards run it. </details> <details><summary>Derived gates — `dispatch-gates --commands --repo objectstack-ai/objectstack` at 771f48f: 35 commands, all exit 0; `--ran`: 35 derived, 35 run, 0 NOT-MEASURED</summary> Derivation header: `gate list derived from the tree of 'objectstack-ai/objectstack' at commit 771f48f`; change set 6 paths, 495 changed lines (+489 / -6), under the 3000 human-merge threshold. Each command via `bash -c`, exit code captured before any pipe. | command | exit | wall | |:--|:--|:--| | `node scripts/check-adr-0087-registration.mjs --base origin/main` | 0 | 4s | | `node scripts/check-adr-0087-registration.mjs --self-test` | 0 | 48s | | `node scripts/check-changeset-no-major.mjs --base origin/main` | 0 | 0s | | `node scripts/check-changeset-no-major.mjs --self-test` | 0 | 8s | | `node scripts/check-closing-keyword-parity.mjs` | 0 | 2s | | `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 | 4s | | `node scripts/check-comment-mask-corpus.mjs` | 0 | 190s | | `node scripts/check-empty-changeset.mjs --base origin/main` | 0 | 0s | | `node scripts/check-empty-changeset.mjs --self-test` | 0 | 2s | | `node scripts/check-sdui-manifest.mjs` | 0 | 0s | | `node scripts/check-sdui-manifest.mjs --self-test` | 0 | 3s | | `node scripts/check-undeclared-dep-imports.mjs` | 0 | 5s | | `node scripts/check-undeclared-dep-imports.mjs --self-test` | 0 | 5s | | `node scripts/pm/release-rehearsal-clone.mjs --self-test` | 0 | 2s | | `node scripts/release-pending-publish.mjs --self-test` | 0 | 5s | | `pnpm --filter @objectstack/spec run check:duration-unit-keys` | 0 | 8s | | `pnpm check:changeset-gate-self-tests` | 0 | 61s | | `pnpm check:closed-ceremony-text` | 0 | 3s | | `pnpm check:cross-package-test-inputs` | 0 | 28s | | `pnpm check:dispatcher-error-vocabulary` | 0 | 6s | | `pnpm check:driver-memory-census` | 0 | 5s | | `pnpm check:engine-double-contract` | 0 | 53s | | `pnpm check:gitlink-declared` | 0 | 1s | | `pnpm check:nul-bytes` | 0 | 3s | | `pnpm check:pm-changeset-deadline-census` | 0 | 1s | | `pnpm check:published-files` | 0 | 1s | | `pnpm check:query-options-erasure` | 0 | 299s | | `pnpm check:refd-timer-probe` | 0 | 19s | | `pnpm check:skill-identifier-liveness` | 0 | 9s | | `pnpm check:test-source-alias` | 0 | 26s | | `pnpm check:tier-file-adoption` | 0 | 0s | | `pnpm check:type-check-coverage` | 0 | 5s | | `pnpm check:watch-hint-literal` | 0 | 6s | | `pnpm check:where-matcher` | 0 | 11s | | `pnpm check:type-check-debt` (run under the verify lock) | 0 | 110s re-measure | `check-sdui-manifest`: `sdui.manifest.json is present, intact (sha256 1f6e1ccb925a…, 106 components)`; app-shell has no sdui record and `scripts/sdui-manifest.*` is untouched. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4ddf6df commit 77a52cf

6 files changed

Lines changed: 489 additions & 6 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the Setup position page shows, read-only, what each permission set the position names grants
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A position grants access only through the permission sets its definition names in `permissionSets`. The position page in Setup → Positions listed those names but showed nothing of what they grant, so an admin had to open every set to see its objects, capabilities and record scopes.
11+
12+
- **One preview per named set.** Under the position's definition, beside its holders, the page now shows each named set's permission preview: the object × capability grid with each object's read and write scope, field-level overrides, row-level policies, system permissions and tabs. The previews follow the order the position names the sets. Each is headed by the set's name, which links to the set's own page.
13+
- **No merge, no editing.** Each preview shows one set as stored. The page does not compute what the sets add up to, and nothing in the previews is editable. A set is changed on its own page.
14+
- **A set that does not load.** A named set the server does not serve shows one line, `Permission set "NAME" was not found.`, for that set only; the other previews still show. A read the server refuses says that, with the server's sentence, instead of "not found".
15+
- **No sets.** A position that names no permission sets says so.
16+
17+
The permission-set page itself is unchanged: its matrix already shows each object's read and write scope.
18+
19+
What to do: nothing. No spec key, stored shape or API changes.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The Setup position page shows, read-only, what each permission set the
5+
* position names grants (objectstack#26187, ruling 6416233738 Q1).
6+
*
7+
* One `PermissionPreview` per named set, in the order the position names them,
8+
* each under the set's name and a link to the set's page. No union of the sets
9+
* is computed here and nothing is editable. A named set the server does not
10+
* serve says so on one line, for that set only; a set whose read is refused
11+
* says that instead, since a refused read is not an absent set. A position that
12+
* names no set says so.
13+
*
14+
* The sets are read through the metadata client the page already uses
15+
* (`client.get('permission', name)`), stubbed here.
16+
*/
17+
18+
import '@testing-library/jest-dom/vitest';
19+
import * as React from 'react';
20+
import { describe, it, expect, vi, afterEach } from 'vitest';
21+
import { render, screen, cleanup, within, waitFor } from '@testing-library/react';
22+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
23+
24+
/** The sets the stubbed server serves, by name. */
25+
const SERVED: Record<string, Record<string, unknown>> = {
26+
sales_read: {
27+
name: 'sales_read',
28+
label: 'Sales read',
29+
objects: { account: { allowRead: true, readScope: 'unit_and_below' } },
30+
},
31+
sales_write: {
32+
name: 'sales_write',
33+
label: 'Sales write',
34+
objects: { opportunity: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'own' } },
35+
},
36+
};
37+
38+
/** Names whose read the stubbed server refuses. */
39+
const REFUSED = new Set<string>(['locked_set']);
40+
41+
const mockClient = {
42+
get: vi.fn(async (type: string, name: string) => {
43+
if (type !== 'permission') return null;
44+
if (REFUSED.has(name)) throw new Error('You do not have permission to perform this action.');
45+
return SERVED[name] ?? null;
46+
}),
47+
};
48+
49+
vi.mock('./useMetadata', async (importOriginal) => ({
50+
...(await importOriginal<typeof import('./useMetadata')>()),
51+
useMetadataClient: () => mockClient,
52+
}));
53+
54+
import { PositionPermissionSetsSection } from './PositionPermissionSetsSection';
55+
56+
afterEach(() => {
57+
cleanup();
58+
vi.clearAllMocks();
59+
});
60+
61+
/**
62+
* Mounted as the console mounts the page: the element of the `metadata/:type/:name`
63+
* route (`AppContent.tsx`), since a path-relative link resolves against the
64+
* matched route's pathname.
65+
*/
66+
function mount(permissionSets: unknown) {
67+
render(
68+
<MemoryRouter initialEntries={['/apps/setup/metadata/position/sales_rep?scope=environment']}>
69+
<Routes>
70+
<Route
71+
path="/apps/:appName/metadata/:type/:name"
72+
element={<PositionPermissionSetsSection name="sales_rep" draft={{ name: 'sales_rep', permissionSets }} />}
73+
/>
74+
</Routes>
75+
</MemoryRouter>,
76+
);
77+
}
78+
79+
/** The rendered set blocks, in document order. */
80+
const setBlocks = () => screen.getAllByTestId('position-permission-set');
81+
82+
describe('the position page shows each named permission set read-only (#26187)', () => {
83+
it('two named sets give two previews, in declared order, each with its own scopes', async () => {
84+
mount(['sales_write', 'sales_read']);
85+
86+
await waitFor(() => expect(screen.getAllByTestId('position-permission-set-preview')).toHaveLength(2));
87+
const blocks = setBlocks();
88+
expect(blocks.map((b) => b.getAttribute('data-set-name'))).toEqual(['sales_write', 'sales_read']);
89+
90+
// Each block's heading is the set's name, linking to the set's page in the
91+
// same (environment) scope.
92+
const headings = screen.getAllByRole('heading', { level: 3 });
93+
expect(headings.map((h) => h.textContent)).toEqual(['sales_write', 'sales_read']);
94+
expect(within(blocks[0]).getByRole('link', { name: 'sales_write' })).toHaveAttribute(
95+
'href',
96+
'/apps/setup/metadata/permission/sales_write?scope=environment',
97+
);
98+
expect(within(blocks[1]).getByRole('link', { name: 'sales_read' })).toHaveAttribute(
99+
'href',
100+
'/apps/setup/metadata/permission/sales_read?scope=environment',
101+
);
102+
103+
// Each preview shows its OWN set's object rows and scopes: no union.
104+
expect(within(blocks[0]).getByText('opportunity')).toBeInTheDocument();
105+
expect(within(blocks[0]).queryByText('account')).toBeNull();
106+
expect(within(blocks[0]).getByText('org')).toBeInTheDocument();
107+
expect(within(blocks[0]).getByText('own')).toBeInTheDocument();
108+
expect(within(blocks[1]).getByText('account')).toBeInTheDocument();
109+
expect(within(blocks[1]).queryByText('opportunity')).toBeNull();
110+
expect(within(blocks[1]).getByText('unit+below')).toBeInTheDocument();
111+
112+
expect(mockClient.get).toHaveBeenCalledWith('permission', 'sales_write');
113+
expect(mockClient.get).toHaveBeenCalledWith('permission', 'sales_read');
114+
});
115+
116+
it('is read-only: no form control is offered', async () => {
117+
mount(['sales_write']);
118+
await screen.findByTestId('position-permission-set-preview');
119+
const block = setBlocks()[0];
120+
expect(within(block).queryAllByRole('checkbox')).toHaveLength(0);
121+
expect(within(block).queryAllByRole('combobox')).toHaveLength(0);
122+
expect(within(block).queryAllByRole('button')).toHaveLength(0);
123+
});
124+
125+
it('a named set the server does not serve shows one "not found" line, and only for that set', async () => {
126+
mount(['sales_read', 'ghost_set', 'sales_write']);
127+
128+
const missing = await screen.findByTestId('position-permission-set-missing');
129+
expect(missing).toHaveTextContent('ghost_set');
130+
expect(missing).toHaveTextContent(/not found/i);
131+
await waitFor(() => expect(screen.getAllByTestId('position-permission-set-preview')).toHaveLength(2));
132+
expect(setBlocks().map((b) => b.getAttribute('data-set-name'))).toEqual(['sales_read', 'ghost_set', 'sales_write']);
133+
expect(within(setBlocks()[1]).queryByTestId('position-permission-set-preview')).toBeNull();
134+
expect(screen.getAllByTestId('position-permission-set-missing')).toHaveLength(1);
135+
});
136+
137+
it('a refused read is said as a refusal, not as "not found"', async () => {
138+
mount(['locked_set', 'sales_read']);
139+
140+
const failed = await screen.findByTestId('position-permission-set-read-failed');
141+
expect(failed).toHaveTextContent('locked_set');
142+
expect(failed).toHaveTextContent('You do not have permission to perform this action.');
143+
expect(screen.queryByTestId('position-permission-set-missing')).toBeNull();
144+
await waitFor(() => expect(screen.getAllByTestId('position-permission-set-preview')).toHaveLength(1));
145+
});
146+
147+
it('a position that names no set shows the empty state and reads nothing', async () => {
148+
mount([]);
149+
expect(await screen.findByTestId('position-permission-sets-empty')).toBeInTheDocument();
150+
expect(screen.queryByTestId('position-permission-set')).toBeNull();
151+
expect(mockClient.get).not.toHaveBeenCalled();
152+
});
153+
154+
it('a position with no permissionSets key at all shows the same empty state', async () => {
155+
mount(undefined);
156+
expect(await screen.findByTestId('position-permission-sets-empty')).toBeInTheDocument();
157+
expect(mockClient.get).not.toHaveBeenCalled();
158+
});
159+
});
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PositionPermissionSetsSection — what the permission sets a position names
5+
* grant, read-only, on the Setup catalog's position page (objectstack#26187,
6+
* ruling 6416233738 Q1; ADR-0131 D4).
7+
*
8+
* A position grants access only through the permission sets its definition
9+
* names (`permissionSets`). The position page used to list those names and show
10+
* nothing of what they grant, so an admin reviewing a position could not see an
11+
* object, a capability or a record scope without opening every set in turn.
12+
* This section renders, for each named set, that set's own `PermissionPreview`:
13+
*
14+
* - one preview per named set, in the order the position names them, each
15+
* under the set's name and a link to the set's page (same catalog scope);
16+
* - no union of the sets is computed here: what a holder ends up with is the
17+
* server's answer (the authorization resolver), not a client-side merge;
18+
* - nothing is editable: the preview gets no `onPatch`, and a set is changed
19+
* on its own page;
20+
* - a named set the server does not serve (`client.get` answers `null` on a
21+
* 404) shows one "not found" line for that set only; a refused or failed
22+
* read is said as such, in the server's words, because a refused read is not
23+
* an absent set;
24+
* - a position that names no set says so.
25+
*
26+
* The names are read from the page's draft, as the position preview above this
27+
* section reads them, so the list follows the definition on screen. Each set is
28+
* read through the console metadata client the page itself uses.
29+
*/
30+
31+
import * as React from 'react';
32+
import { Link, useSearchParams } from 'react-router-dom';
33+
import { AlertCircle, Loader2, ShieldCheck } from 'lucide-react';
34+
import { useMetadataClient } from './useMetadata.js';
35+
import { useMetadataLocale, t, tFormat } from './i18n.js';
36+
import { environmentScopeSuffix } from './catalog-scope.js';
37+
import { PermissionPreview } from './previews/PermissionPreview.js';
38+
39+
export interface PositionPermissionSetsSectionProps {
40+
/** The position's machine name. */
41+
name: string;
42+
/** The position definition on screen; only `permissionSets` is read. */
43+
draft: Readonly<Record<string, unknown>>;
44+
}
45+
46+
/** One named set's read, as this section shows it. */
47+
type SetRead =
48+
| { state: 'loading' }
49+
| { state: 'loaded'; body: Record<string, unknown> }
50+
| { state: 'missing' }
51+
| { state: 'failed'; sentence: string };
52+
53+
/** The names a position definition declares, in order; anything else declares none. */
54+
export function declaredPermissionSets(draft: Readonly<Record<string, unknown>>): string[] {
55+
const value = draft.permissionSets;
56+
return Array.isArray(value) ? value.filter((s): s is string => typeof s === 'string' && s !== '') : [];
57+
}
58+
59+
/** The server's sentence out of a failed read, or `''` when it gave none. */
60+
function failureSentence(err: unknown): string {
61+
return err instanceof Error && err.message ? err.message : '';
62+
}
63+
64+
export function PositionPermissionSetsSection({ draft }: PositionPermissionSetsSectionProps) {
65+
const client = useMetadataClient();
66+
const locale = useMetadataLocale();
67+
const [searchParams] = useSearchParams();
68+
const scopeSuffix = environmentScopeSuffix(searchParams);
69+
70+
const declared = declaredPermissionSets(draft);
71+
// The read effect keys on the list's content, not on the draft object, so an
72+
// edit to another field of the position does not re-read every set.
73+
const declaredKey = JSON.stringify(declared);
74+
const [reads, setReads] = React.useState<Record<string, SetRead>>({});
75+
76+
React.useEffect(() => {
77+
const names: string[] = JSON.parse(declaredKey);
78+
const unique = Array.from(new Set(names));
79+
let cancelled = false;
80+
setReads(Object.fromEntries(unique.map((n) => [n, { state: 'loading' } as SetRead])));
81+
for (const setName of unique) {
82+
client
83+
.get<Record<string, unknown>>('permission', setName)
84+
.then(
85+
(body): SetRead => (body ? { state: 'loaded', body } : { state: 'missing' }),
86+
(err: unknown): SetRead => ({ state: 'failed', sentence: failureSentence(err) }),
87+
)
88+
.then((read) => {
89+
if (!cancelled) setReads((prev) => ({ ...prev, [setName]: read }));
90+
});
91+
}
92+
return () => {
93+
cancelled = true;
94+
};
95+
}, [client, declaredKey]);
96+
97+
return (
98+
<section className="mt-6 rounded-md border" data-testid="position-permission-sets">
99+
<div className="px-4 py-3 border-b">
100+
<div className="flex items-center gap-2 text-sm font-medium">
101+
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
102+
<span>{t('engine.position.sets.title', locale)}</span>
103+
{declared.length > 0 && (
104+
<span className="text-xs text-muted-foreground font-normal">{declared.length}</span>
105+
)}
106+
</div>
107+
{declared.length > 0 && (
108+
<p className="mt-0.5 text-xs text-muted-foreground">{t('engine.position.sets.hint', locale)}</p>
109+
)}
110+
</div>
111+
<div className="px-4 py-3">
112+
{declared.length === 0 ? (
113+
<p className="text-xs text-muted-foreground" data-testid="position-permission-sets-empty">
114+
{t('engine.position.sets.empty', locale)}
115+
</p>
116+
) : (
117+
<ol className="space-y-3">
118+
{declared.map((setName, i) => {
119+
const read = reads[setName] ?? { state: 'loading' };
120+
return (
121+
<li
122+
key={`${i}:${setName}`}
123+
data-testid="position-permission-set"
124+
data-set-name={setName}
125+
className="space-y-1.5"
126+
>
127+
<h3 className="text-xs font-medium">
128+
<Link
129+
to={`../../permission/${encodeURIComponent(setName)}${scopeSuffix}`}
130+
relative="path"
131+
className="font-mono hover:underline"
132+
title={t('engine.position.sets.open', locale)}
133+
>
134+
{setName}
135+
</Link>
136+
</h3>
137+
{read.state === 'loading' && (
138+
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
139+
<Loader2 className="h-3 w-3 animate-spin" />
140+
{t('engine.position.sets.loading', locale)}
141+
</p>
142+
)}
143+
{read.state === 'missing' && (
144+
<p className="text-xs text-muted-foreground" data-testid="position-permission-set-missing">
145+
{tFormat('engine.position.sets.notFound', locale, { name: setName })}
146+
</p>
147+
)}
148+
{read.state === 'failed' && (
149+
<p
150+
className="flex items-start gap-1.5 text-xs text-destructive"
151+
data-testid="position-permission-set-read-failed"
152+
>
153+
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
154+
<span>
155+
{tFormat('engine.position.sets.readFailed', locale, { name: setName })}
156+
{read.sentence && <> {read.sentence}</>}
157+
</span>
158+
</p>
159+
)}
160+
{read.state === 'loaded' && (
161+
<div data-testid="position-permission-set-preview">
162+
<PermissionPreview type="permission" name={setName} draft={read.body} locale={locale} />
163+
</div>
164+
)}
165+
</li>
166+
);
167+
})}
168+
</ol>
169+
)}
170+
</div>
171+
</section>
172+
);
173+
}

0 commit comments

Comments
 (0)