Skip to content
19 changes: 19 additions & 0 deletions .changeset/26187-position-permission-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the Setup position page shows, read-only, what each permission set the position names grants

Clause-②: no
Metadata-protocol: none

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.

- **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.
- **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.
- **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".
- **No sets.** A position that names no permission sets says so.

The permission-set page itself is unchanged: its matrix already shows each object's read and write scope.

What to do: nothing. No spec key, stored shape or API changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The Setup position page shows, read-only, what each permission set the
* position names grants (objectstack#26187, ruling 6416233738 Q1).
*
* One `PermissionPreview` per named set, in the order the position names them,
* each under the set's name and a link to the set's page. No union of the sets
* is computed here and nothing is editable. A named set the server does not
* serve says so on one line, for that set only; a set whose read is refused
* says that instead, since a refused read is not an absent set. A position that
* names no set says so.
*
* The sets are read through the metadata client the page already uses
* (`client.get('permission', name)`), stubbed here.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, within, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';

/** The sets the stubbed server serves, by name. */
const SERVED: Record<string, Record<string, unknown>> = {
sales_read: {
name: 'sales_read',
label: 'Sales read',
objects: { account: { allowRead: true, readScope: 'unit_and_below' } },
},
sales_write: {
name: 'sales_write',
label: 'Sales write',
objects: { opportunity: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'own' } },
},
};

/** Names whose read the stubbed server refuses. */
const REFUSED = new Set<string>(['locked_set']);

const mockClient = {
get: vi.fn(async (type: string, name: string) => {
if (type !== 'permission') return null;
if (REFUSED.has(name)) throw new Error('You do not have permission to perform this action.');
return SERVED[name] ?? null;
}),
};

vi.mock('./useMetadata', async (importOriginal) => ({
...(await importOriginal<typeof import('./useMetadata')>()),
useMetadataClient: () => mockClient,
}));

import { PositionPermissionSetsSection } from './PositionPermissionSetsSection';

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

/**
* Mounted as the console mounts the page: the element of the `metadata/:type/:name`
* route (`AppContent.tsx`), since a path-relative link resolves against the
* matched route's pathname.
*/
function mount(permissionSets: unknown) {
render(
<MemoryRouter initialEntries={['/apps/setup/metadata/position/sales_rep?scope=environment']}>
<Routes>
<Route
path="/apps/:appName/metadata/:type/:name"
element={<PositionPermissionSetsSection name="sales_rep" draft={{ name: 'sales_rep', permissionSets }} />}
/>
</Routes>
</MemoryRouter>,
);
}

/** The rendered set blocks, in document order. */
const setBlocks = () => screen.getAllByTestId('position-permission-set');

describe('the position page shows each named permission set read-only (#26187)', () => {
it('two named sets give two previews, in declared order, each with its own scopes', async () => {
mount(['sales_write', 'sales_read']);

await waitFor(() => expect(screen.getAllByTestId('position-permission-set-preview')).toHaveLength(2));
const blocks = setBlocks();
expect(blocks.map((b) => b.getAttribute('data-set-name'))).toEqual(['sales_write', 'sales_read']);

// Each block's heading is the set's name, linking to the set's page in the
// same (environment) scope.
const headings = screen.getAllByRole('heading', { level: 3 });
expect(headings.map((h) => h.textContent)).toEqual(['sales_write', 'sales_read']);
expect(within(blocks[0]).getByRole('link', { name: 'sales_write' })).toHaveAttribute(
'href',
'/apps/setup/metadata/permission/sales_write?scope=environment',
);
expect(within(blocks[1]).getByRole('link', { name: 'sales_read' })).toHaveAttribute(
'href',
'/apps/setup/metadata/permission/sales_read?scope=environment',
);

// Each preview shows its OWN set's object rows and scopes: no union.
expect(within(blocks[0]).getByText('opportunity')).toBeInTheDocument();
expect(within(blocks[0]).queryByText('account')).toBeNull();
expect(within(blocks[0]).getByText('org')).toBeInTheDocument();
expect(within(blocks[0]).getByText('own')).toBeInTheDocument();
expect(within(blocks[1]).getByText('account')).toBeInTheDocument();
expect(within(blocks[1]).queryByText('opportunity')).toBeNull();
expect(within(blocks[1]).getByText('unit+below')).toBeInTheDocument();

expect(mockClient.get).toHaveBeenCalledWith('permission', 'sales_write');
expect(mockClient.get).toHaveBeenCalledWith('permission', 'sales_read');
});

it('is read-only: no form control is offered', async () => {
mount(['sales_write']);
await screen.findByTestId('position-permission-set-preview');
const block = setBlocks()[0];
expect(within(block).queryAllByRole('checkbox')).toHaveLength(0);
expect(within(block).queryAllByRole('combobox')).toHaveLength(0);
expect(within(block).queryAllByRole('button')).toHaveLength(0);
});

it('a named set the server does not serve shows one "not found" line, and only for that set', async () => {
mount(['sales_read', 'ghost_set', 'sales_write']);

const missing = await screen.findByTestId('position-permission-set-missing');
expect(missing).toHaveTextContent('ghost_set');
expect(missing).toHaveTextContent(/not found/i);
await waitFor(() => expect(screen.getAllByTestId('position-permission-set-preview')).toHaveLength(2));
expect(setBlocks().map((b) => b.getAttribute('data-set-name'))).toEqual(['sales_read', 'ghost_set', 'sales_write']);
expect(within(setBlocks()[1]).queryByTestId('position-permission-set-preview')).toBeNull();
expect(screen.getAllByTestId('position-permission-set-missing')).toHaveLength(1);
});

it('a refused read is said as a refusal, not as "not found"', async () => {
mount(['locked_set', 'sales_read']);

const failed = await screen.findByTestId('position-permission-set-read-failed');
expect(failed).toHaveTextContent('locked_set');
expect(failed).toHaveTextContent('You do not have permission to perform this action.');
expect(screen.queryByTestId('position-permission-set-missing')).toBeNull();
await waitFor(() => expect(screen.getAllByTestId('position-permission-set-preview')).toHaveLength(1));
});

it('a position that names no set shows the empty state and reads nothing', async () => {
mount([]);
expect(await screen.findByTestId('position-permission-sets-empty')).toBeInTheDocument();
expect(screen.queryByTestId('position-permission-set')).toBeNull();
expect(mockClient.get).not.toHaveBeenCalled();
});

it('a position with no permissionSets key at all shows the same empty state', async () => {
mount(undefined);
expect(await screen.findByTestId('position-permission-sets-empty')).toBeInTheDocument();
expect(mockClient.get).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* PositionPermissionSetsSection — what the permission sets a position names
* grant, read-only, on the Setup catalog's position page (objectstack#26187,
* ruling 6416233738 Q1; ADR-0131 D4).
*
* A position grants access only through the permission sets its definition
* names (`permissionSets`). The position page used to list those names and show
* nothing of what they grant, so an admin reviewing a position could not see an
* object, a capability or a record scope without opening every set in turn.
* This section renders, for each named set, that set's own `PermissionPreview`:
*
* - one preview per named set, in the order the position names them, each
* under the set's name and a link to the set's page (same catalog scope);
* - no union of the sets is computed here: what a holder ends up with is the
* server's answer (the authorization resolver), not a client-side merge;
* - nothing is editable: the preview gets no `onPatch`, and a set is changed
* on its own page;
* - a named set the server does not serve (`client.get` answers `null` on a
* 404) shows one "not found" line for that set only; a refused or failed
* read is said as such, in the server's words, because a refused read is not
* an absent set;
* - a position that names no set says so.
*
* The names are read from the page's draft, as the position preview above this
* section reads them, so the list follows the definition on screen. Each set is
* read through the console metadata client the page itself uses.
*/

import * as React from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { AlertCircle, Loader2, ShieldCheck } from 'lucide-react';
import { useMetadataClient } from './useMetadata.js';
import { useMetadataLocale, t, tFormat } from './i18n.js';
import { environmentScopeSuffix } from './catalog-scope.js';
import { PermissionPreview } from './previews/PermissionPreview.js';

export interface PositionPermissionSetsSectionProps {
/** The position's machine name. */
name: string;
/** The position definition on screen; only `permissionSets` is read. */
draft: Readonly<Record<string, unknown>>;
}

/** One named set's read, as this section shows it. */
type SetRead =
| { state: 'loading' }
| { state: 'loaded'; body: Record<string, unknown> }
| { state: 'missing' }
| { state: 'failed'; sentence: string };

/** The names a position definition declares, in order; anything else declares none. */
export function declaredPermissionSets(draft: Readonly<Record<string, unknown>>): string[] {
const value = draft.permissionSets;
return Array.isArray(value) ? value.filter((s): s is string => typeof s === 'string' && s !== '') : [];
}

/** The server's sentence out of a failed read, or `''` when it gave none. */
function failureSentence(err: unknown): string {
return err instanceof Error && err.message ? err.message : '';
}

export function PositionPermissionSetsSection({ draft }: PositionPermissionSetsSectionProps) {
const client = useMetadataClient();
const locale = useMetadataLocale();
const [searchParams] = useSearchParams();
const scopeSuffix = environmentScopeSuffix(searchParams);

const declared = declaredPermissionSets(draft);
// The read effect keys on the list's content, not on the draft object, so an
// edit to another field of the position does not re-read every set.
const declaredKey = JSON.stringify(declared);
const [reads, setReads] = React.useState<Record<string, SetRead>>({});

React.useEffect(() => {
const names: string[] = JSON.parse(declaredKey);
const unique = Array.from(new Set(names));
let cancelled = false;
setReads(Object.fromEntries(unique.map((n) => [n, { state: 'loading' } as SetRead])));
for (const setName of unique) {
client
.get<Record<string, unknown>>('permission', setName)
.then(
(body): SetRead => (body ? { state: 'loaded', body } : { state: 'missing' }),
(err: unknown): SetRead => ({ state: 'failed', sentence: failureSentence(err) }),
)
.then((read) => {
if (!cancelled) setReads((prev) => ({ ...prev, [setName]: read }));
});
}
return () => {
cancelled = true;
};
}, [client, declaredKey]);

return (
<section className="mt-6 rounded-md border" data-testid="position-permission-sets">
<div className="px-4 py-3 border-b">
<div className="flex items-center gap-2 text-sm font-medium">
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
<span>{t('engine.position.sets.title', locale)}</span>
{declared.length > 0 && (
<span className="text-xs text-muted-foreground font-normal">{declared.length}</span>
)}
</div>
{declared.length > 0 && (
<p className="mt-0.5 text-xs text-muted-foreground">{t('engine.position.sets.hint', locale)}</p>
)}
</div>
<div className="px-4 py-3">
{declared.length === 0 ? (
<p className="text-xs text-muted-foreground" data-testid="position-permission-sets-empty">
{t('engine.position.sets.empty', locale)}
</p>
) : (
<ol className="space-y-3">
{declared.map((setName, i) => {
const read = reads[setName] ?? { state: 'loading' };
return (
<li
key={`${i}:${setName}`}
data-testid="position-permission-set"
data-set-name={setName}
className="space-y-1.5"
>
<h3 className="text-xs font-medium">
<Link
to={`../../permission/${encodeURIComponent(setName)}${scopeSuffix}`}
relative="path"
className="font-mono hover:underline"
title={t('engine.position.sets.open', locale)}
>
{setName}
</Link>
</h3>
{read.state === 'loading' && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{t('engine.position.sets.loading', locale)}
</p>
)}
{read.state === 'missing' && (
<p className="text-xs text-muted-foreground" data-testid="position-permission-set-missing">
{tFormat('engine.position.sets.notFound', locale, { name: setName })}
</p>
)}
{read.state === 'failed' && (
<p
className="flex items-start gap-1.5 text-xs text-destructive"
data-testid="position-permission-set-read-failed"
>
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>
{tFormat('engine.position.sets.readFailed', locale, { name: setName })}
{read.sentence && <> {read.sentence}</>}
</span>
</p>
)}
{read.state === 'loaded' && (
<div data-testid="position-permission-set-preview">
<PermissionPreview type="permission" name={setName} draft={read.body} locale={locale} />
</div>
)}
</li>
);
})}
</ol>
)}
</div>
</section>
);
}
Loading
Loading