diff --git a/.changeset/7611-setup-catalog-registry.md b/.changeset/7611-setup-catalog-registry.md new file mode 100644 index 0000000000..b3edb7e07a --- /dev/null +++ b/.changeset/7611-setup-catalog-registry.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/fields': minor +'@object-ui/console': minor +--- + +The Setup catalog for positions and permission sets reads the registry. It is built from the metadata-admin list and editors in their environment scope, not from new pages (objectui#7611, objectstack ADR-0131 D3/D7). + +- **The environment scope (`?scope=environment`).** `…/metadata/position` and `…/metadata/permission` with this parameter list every item the registry serves for the type, including the platform's own sets. Without it, the list shows one project package's items, as Studio does. Every link the pages emit keeps the scope: the item links, the create link and the editor's breadcrumb. `ENVIRONMENT_SCOPE_QUERY` is exported for hosts that route their own URLs onto it. +- **Gated for Setup.** A caller without `manage_metadata` gets no create affordance on the list and a read-only editor, and the page says why. Under `single`, the reason is that the platform administrator defines these items and the caller's organization assigns them. Under a wall, it is that the operator defines them for every organization. The generic editor and the permission-set editor now apply this caller gate wherever they render. The metadata door refuses that caller's save anyway; this states the refusal before the click. +- **The active switch.** The catalog list has an active column and an active/inactive filter. Both show the activation ledger's state (`sys_metadata_activation`), which is what the authorization resolver reads; an item with no ledger row is active. The switch writes through the ledger's door, `POST /api/v1/security/_activation/:type/:name` with `{ enabled }`, so switching a set or a position off takes it away from its holders, and switching it on gives it back. It reads and writes no catalog row, so the catalog row's own `active` column is neither shown nor changed. The door's refusals are shown in the server's own words. The audience anchors `everyone` and `guest`, which the door refuses to switch, show a disabled switch and the reason. The door is objectstack's ADR-0131 stage 2c. +- **Holders.** In the environment scope, the permission-set editor shows who holds the set under its definition, and the position editor shows who holds the position. A set's holders are read by name: grants come from the `permission_set` column, and the positions that distribute the set come from the registry's position definitions (`permissionSets`). They are no longer read from `sys_position_permission_set` or `sys_position` rows, so on a framework from before objectstack's ADR-0131 stage 1, where positions declare no `permissionSets`, a set lists no holders through a position. A new grant still carries `permission_set_id`, because the server does not accept a grant by name alone yet. A position is assigned by its name. +- **The sharing-rule recipient picker lists the `position` registry** through the console's metadata store. It no longer reads `sys_position` rows. Without a metadata store mounted, the `position` kind falls back to the plain text input. +- **The console's `system/roles`, `system/positions` and `system/permissions`** now go to the catalog list, not to the object pages. diff --git a/apps/console/src/AppContent.tsx b/apps/console/src/AppContent.tsx index 4dd7d7bf62..18eecb62c5 100644 --- a/apps/console/src/AppContent.tsx +++ b/apps/console/src/AppContent.tsx @@ -10,7 +10,7 @@ import { lazy, Suspense, useMemo } from 'react'; import { Route, useParams, useLocation, useNavigate, Navigate } from 'react-router-dom'; -import { DefaultAppContent, LoadingScreen, RecordDetailView, useAdapter, useMetadata } from '@object-ui/app-shell'; +import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LoadingScreen, RecordDetailView, useAdapter, useMetadata } from '@object-ui/app-shell'; import { MePermissionsProvider } from '@object-ui/permissions'; import { createAuthenticatedFetch } from '@object-ui/auth'; import type { DataSource } from '@object-ui/types'; @@ -129,17 +129,12 @@ function MetadataRedirect() { * record-scoped `nav_organization` * needs a runtime `{current_org_id}` * that a static redirect cannot resolve) - * roles -> sys_position (ADR-0090 D3 renamed `sys_role` -> - * `sys_position`; the sidebar's "Roles" - * and the retired hub's "Positions" were - * the same surface under old/new - * vocabulary) - * positions -> sys_position (`nav_positions`) - * permissions -> sys_permission_set(`nav_permission_sets`; this one was - * held back in PR #3673 and is resolved - * by objectui#3655's decision A — the - * reasoning is recorded at the route - * block below) + * + * Three of the five no longer land on an object at all (objectui#7611): + * `roles`, `positions` and `permissions` name the security CATALOG, which + * ADR-0131 D3 moves into the environment registry, so they forward onto the + * Setup catalog — the metadata-admin list in its environment scope — through + * {@link SystemCatalogRedirect} below. * * Same shape as `ObjectRedirect` / `MetadataRedirect` above (a legacy URL is * translated, the page is not resurrected), including their treatment of @@ -154,6 +149,27 @@ function SystemObjectRedirect({ objectName }: { objectName: string }) { return ; } +/** + * Forwards the retired `system/{roles,positions,permissions}` console pages + * onto the Setup CATALOG (objectui#7611): the metadata-admin list of the + * registry type, in its environment scope (`…/metadata/?scope=environment`, + * `catalog-scope.ts` in `@object-ui/app-shell`). + * + * ADR-0131 D3 gives positions and permission sets one home, the environment + * registry, and D7 says Setup lists that registry; the ruling that unblocked + * objectui#7611 says the Setup pages are the existing metadata-admin pages, + * re-routed. The words map as they always did — `roles` was ADR-0090 D3's old + * name for positions, and objectui#3655 decision A bound `permissions` to the + * permission-set surface (layer 2), not the capability definitions (layer 1). + * Same prefix handling, and the same treatment of `location.search` / + * `location.hash`, as {@link SystemObjectRedirect}. + */ +function SystemCatalogRedirect({ type }: { type: 'position' | 'permission' }) { + const location = useLocation(); + const prefix = location.pathname.replace(/\/system\/[^/]+\/?$/, ''); + return ; +} + /** * The bare `…/system` landing: forwards onto `…/system/settings`, the settings * hub (objectui#3743). @@ -238,8 +254,11 @@ export const systemRoutes = ( } /> } /> } /> - {/* Legacy URL redirects → the framework-owned system objects (objectui#3655). - All five resolve now. `system/permissions` was the one held back in PR + {/* Legacy URL redirects → the framework-owned system objects (objectui#3655), + and — since objectui#7611 — the Setup catalog for the last three + (`SystemCatalogRedirect`: positions and permission sets are registry + items, ADR-0131 D3). The history below is why `permissions` means the + permission-set surface. All five resolve. `system/permissions` was the one held back in PR #3673: the framework splits what this console calls "Permissions" into TWO Setup entries, and picking one on a hunch would have bound every future click and bookmark to a surface nobody chose. @@ -269,9 +288,9 @@ export const systemRoutes = ( distinction that actually holds. */} } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> ); diff --git a/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx b/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx index 230b3f878f..ab7e7ccc60 100644 --- a/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx +++ b/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx @@ -240,7 +240,9 @@ import { systemRoutes } from '../AppContent'; const chain: string[] = []; function ChainRecorder() { const location = useLocation(); - const here = location.pathname; + // The query string rides along: the Setup catalog's scope is a query + // parameter (objectui#7611), and every other landing here carries none. + const here = location.pathname + location.search; if (chain[chain.length - 1] !== here) chain.push(here); return null; } @@ -269,19 +271,12 @@ beforeEach(() => { describe('system-hub entries reach the framework system objects (objectui#3655)', () => { /** - * All five. `roles` and `positions` converge on ONE object on purpose: - * ADR-0090 D3 renamed `sys_role` -> `sys_position`, so the sidebar's "Roles" - * and the retired hub's "Positions" were the same surface in old and new - * vocabulary. - * `permissions` -> `sys_permission_set` is objectui#3655's decision A (see - * the file docblock); it landed one PR after the other four. + * The two that still name an OBJECT. The other three name the security + * catalog and are measured in the next describe (objectui#7611). */ it.each([ ['/apps/setup/system/users', '/apps/setup/sys_user', 'sys_user'], ['/apps/setup/system/organizations', '/apps/setup/sys_organization', 'sys_organization'], - ['/apps/setup/system/roles', '/apps/setup/sys_position', 'sys_position'], - ['/apps/setup/system/positions', '/apps/setup/sys_position', 'sys_position'], - ['/apps/setup/system/permissions', '/apps/setup/sys_permission_set', 'sys_permission_set'], ])('%s reaches %s in ONE hop', async (url, target, objectName) => { renderConsoleAt(url); @@ -347,6 +342,54 @@ describe('system-hub entries reach the framework system objects (objectui#3655)' }); }); +describe('the catalog entries reach the Setup catalog (objectui#7611)', () => { + /** + * `roles`, `positions` and `permissions` name the security CATALOG, which + * ADR-0131 D3 moves into the environment registry, so they land on the + * metadata-admin list of the registry type in its environment scope. `roles` + * and `positions` still converge on ONE surface (ADR-0090 D3 renamed + * `sys_role` -> `sys_position`), and `permissions` still means the + * permission-set surface (objectui#3655 decision A). + */ + it.each([ + ['/apps/setup/system/roles', '/apps/setup/metadata/position?scope=environment', 'position'], + ['/apps/setup/system/positions', '/apps/setup/metadata/position?scope=environment', 'position'], + ['/apps/setup/system/permissions', '/apps/setup/metadata/permission?scope=environment', 'permission'], + ])('%s reaches %s in ONE hop', async (url, target, type) => { + renderConsoleAt(url); + + expect(await screen.findByTestId('metadata-resource-list-page')).toHaveTextContent(type); + expect(chain).toEqual([url, target]); + // Never the row page the URL used to land on. + expect(screen.queryByTestId('object-view')).not.toBeInTheDocument(); + }); + + it('preserves the app prefix', () => { + renderConsoleAt('/apps/my-app/system/permissions'); + + expect(chain).toEqual([ + '/apps/my-app/system/permissions', + '/apps/my-app/metadata/permission?scope=environment', + ]); + }); + + /** + * The zero-app branch declares the metadata routes too, so the catalog is + * reachable on a deployment with no app — unlike the object pages the three + * URLs used to land on, which end at the "No Apps Configured" guard there. + */ + it.each([ + ['/apps/setup/system/positions', '/apps/setup/metadata/position?scope=environment'], + ['/apps/setup/system/permissions', '/apps/setup/metadata/permission?scope=environment'], + ])('with no apps, %s still reaches the catalog', async (url, target) => { + metadataApps = []; + renderConsoleAt(url); + + expect(await screen.findByTestId('metadata-resource-list-page')).toBeInTheDocument(); + expect(chain).toEqual([url, target]); + }); +}); + describe('zero-app branch — measured, not asserted away (objectui#3655)', () => { /** * On a zero-app deployment this is the branch the sidebar's Users / @@ -369,9 +412,6 @@ describe('zero-app branch — measured, not asserted away (objectui#3655)', () = it.each([ ['/apps/setup/system/users', '/apps/setup/sys_user'], ['/apps/setup/system/organizations', '/apps/setup/sys_organization'], - ['/apps/setup/system/roles', '/apps/setup/sys_position'], - ['/apps/setup/system/positions', '/apps/setup/sys_position'], - ['/apps/setup/system/permissions', '/apps/setup/sys_permission_set'], ])('%s still redirects, and the target is the no-apps empty state', async (url, target) => { metadataApps = []; renderConsoleAt(url); diff --git a/content/docs/guide/console-architecture.md b/content/docs/guide/console-architecture.md index adcc965179..fc93607d08 100644 --- a/content/docs/guide/console-architecture.md +++ b/content/docs/guide/console-architecture.md @@ -98,6 +98,14 @@ full list: Read those two route trees in the source rather than trusting a hand-copied table to stay current. +The Setup catalog for positions and permission sets is not a separate route family. It is +the metadata-admin list and editor with the query parameter `scope=environment`, for +example `/apps/setup/metadata/permission?scope=environment`. That scope lists every item +the registry serves for the type and adds the Setup half of an item, which is who holds +it. The console's legacy `system/roles`, `system/positions` and `system/permissions` +URLs forward there. The `@object-ui/app-shell` README describes the scope under "The +Setup catalog". + | Route Pattern | Component | Purpose | |---------------|-----------|---------| | `/apps/:appName` | Home redirect | Redirects to the first object in navigation | diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index cb1b6433de..66ccc92be4 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -478,6 +478,43 @@ enable, or disable packages; direct `/metadata/package` links redirect there. The Studio sidebar also flattens the root Overview group so Home and package navigation sit directly under the package selector. +### The Setup catalog: the environment scope (`?scope=environment`) + +Positions and permission sets live in the environment registry (objectstack +ADR-0131 D3), and Setup lists that registry (D7). Setup does not add a page +family for them: it uses the same metadata-admin list and editors at +`…/metadata/position` and `…/metadata/permission`, with the query parameter +`scope=environment` (`ENVIRONMENT_SCOPE_QUERY`, from +`views/metadata-admin/catalog-scope.ts`). The scope changes three things: + +- **The list** shows every item the registry serves for the type, the + platform's own sets included. It is not narrowed to one project package. + Every link it emits keeps the scope, and so does the editor's breadcrumb. +- **The gates.** A caller without `manage_metadata` gets no create affordance + and a read-only editor, and the page says why in the deployment's own terms. + Under `single` the platform administrator defines these items. Under a wall + the operator defines them in Studio. In both cases the organization assigns + them. The editors apply the caller gate in every scope, because the metadata + door refuses that caller's save in every scope. +- **The Setup half of an item.** It renders under the definition: a permission + set's holders (`AssignedUsersSection`), and a position's holders + (`PositionHoldersSection`). Both read and write assignment rows by the + item's name. + +The list has an active switch and an active/inactive filter, through +`catalog-activation.ts`. Both show the activation ledger's state +(`sys_metadata_activation`, read through the data door), which is what the +authorization resolver reads: an item with no ledger row is active. The switch +writes through the ledger's door, `POST /api/v1/security/_activation/:type/:name` +with `{ enabled }` (objectstack's ADR-0131 stage 2c), and reads or writes no +catalog row. A refusal from the door (`403` for a caller who may not switch, or +for switching off the last administrator who can sign in; `404`; `503`) is +shown in the server's own words. The audience anchors `everyone` and `guest`, +which the door refuses to switch, show a disabled switch and the reason. +Capabilities are not in this scope yet: the Capabilities page and the +capability picker still read `sys_capability` rows, and a capability has no +switch. + ### Package-less flows (`/studio/~org/automations`) A flow that belongs to no package — a clone of a packaged flow is one, by diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 8a6f3be1bf..f3deae6925 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -425,6 +425,10 @@ export { MetadataQuickFind } from './views/metadata-admin/QuickFind.js'; export { PageShell as MetadataPageShell } from './views/metadata-admin/PageShell.js'; export { SchemaForm } from './views/metadata-admin/SchemaForm.js'; export { LayeredDiff } from './views/metadata-admin/LayeredDiff.js'; +// objectui#7611 — the Setup catalog's URL scope (`?scope=environment`), for a +// host that routes its own legacy URLs onto the catalog (the console's +// `system/positions` / `system/permissions`). A dependency-free leaf. +export { ENVIRONMENT_SCOPE_QUERY } from './views/metadata-admin/catalog-scope.js'; export { registerMetadataResource, getMetadataResource, diff --git a/packages/app-shell/src/services/builtinComponents.tsx b/packages/app-shell/src/services/builtinComponents.tsx index 634332509b..177b14d0e1 100644 --- a/packages/app-shell/src/services/builtinComponents.tsx +++ b/packages/app-shell/src/services/builtinComponents.tsx @@ -197,15 +197,14 @@ registerMetadataResource({ domain: 'security', EditPage: PermissionMatrixEditPage, searchableFields: ['name', 'label'], + // objectui#7611 — no `managedBy` column: the registry serves no such key on a + // permission set (measured on objectstack main), so it read "Custom" for every + // set, the platform's own included. Provenance is the list's own Source badge, + // read from the served `_packageId` / `_provenance`. listColumns: [ { key: 'name', label: 'Name', width: '30%' }, { key: 'label', label: 'Label', width: '30%' }, - { - key: 'managedBy', - label: 'Source', - width: '15%', - render: (v) => (v === 'package' ? 'Package' : 'Custom'), - }, + { key: 'description', label: 'Description' }, ], }); diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.contractEnvelope-5945.test.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.contractEnvelope-5945.test.tsx index 159fe7528f..75448a25d4 100644 --- a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.contractEnvelope-5945.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.contractEnvelope-5945.test.tsx @@ -47,12 +47,17 @@ const asBareArray: Envelope = (rows) => rows; const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length }); const asItems: Envelope = (rows) => ({ items: rows, total: rows.length }); -/** A directory holding one set with one DIRECT grantee — nothing else. */ +/** + * A directory holding one set with one DIRECT grantee — nothing else. The + * grant names its set (objectui#7611: holders are read by the set's NAME); no + * position distributes it (no metadata store is mounted, so the registry + * answers no positions). + */ const DIRECTORY: Record[]> = { sys_permission_set: [{ id: 'ps_1', name: 'showcase_contributor' }], - sys_user_permission_set: [{ id: 'grant_1', permission_set_id: 'ps_1', user_id: 'u_direct' }], - sys_position_permission_set: [], - sys_position: [], + sys_user_permission_set: [ + { id: 'grant_1', permission_set_id: 'ps_1', permission_set: 'showcase_contributor', user_id: 'u_direct' }, + ], sys_user_position: [], sys_user: [{ id: 'u_direct', name: 'Direct Dana', email: 'dana@example.com' }], }; diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx index 0eba147355..0c6ad7bb23 100644 --- a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx @@ -5,6 +5,11 @@ // direct-grants-only list told the admin "0 users" for any // normally-administered set (positions are THE distribution channel in // ADR-0090), right before they edit or delete it. +// +// objectui#7611 — by NAME, against the registry: grants are read by their +// `permission_set` name column, and the positions that distribute the set are +// the registry's position definitions whose `permissionSets` names it +// (ADR-0131 D4) — never `sys_position_permission_set` / `sys_position` rows. import * as React from 'react'; import { describe, expect, it, vi } from 'vitest'; @@ -14,22 +19,25 @@ import { AssignedUsersSection } from './AssignedUsersSection'; vi.mock('@object-ui/react', async (importOriginal) => ({ ...(await importOriginal>()), useAdapter: () => mockAdapter, + useMetadata: () => mockMetadataStore, })); vi.mock('@object-ui/fields', async (importOriginal) => ({ ...(await importOriginal()), RecordPickerDialog: () => null, })); +// The registry's position definitions — the binding is part of the definition. +const registryPositions = [ + { name: 'contributor', label: 'Contributor', permissionSets: ['showcase_contributor'] }, + { name: 'everyone', label: 'Everyone', permissionSets: ['member_default', 'showcase_contributor'] }, + { name: 'auditor', label: 'Auditor', permissionSets: ['showcase_auditor'] }, +]; +const mockMetadataStore = { ensureType: vi.fn(async () => registryPositions) }; + const data: Record = { sys_permission_set: [{ id: 'ps_1', name: 'showcase_contributor' }], - sys_user_permission_set: [{ id: 'grant_1', permission_set_id: 'ps_1', user_id: 'u_direct' }], - sys_position_permission_set: [ - { id: 'bind_1', position_id: 'pos_contrib', permission_set_id: 'ps_1' }, - { id: 'bind_2', position_id: 'pos_everyone', permission_set_id: 'ps_1' }, - ], - sys_position: [ - { id: 'pos_contrib', name: 'contributor', label: 'Contributor' }, - { id: 'pos_everyone', name: 'everyone', label: 'Everyone' }, + sys_user_permission_set: [ + { id: 'grant_1', permission_set_id: 'ps_1', permission_set: 'showcase_contributor', user_id: 'u_direct' }, ], sys_user_position: [ { id: 'up_1', user_id: 'u_held', position: 'contributor' }, @@ -85,3 +93,27 @@ describe('AssignedUsersSection — effective holders (objectui#2382)', () => { expect(screen.getAllByLabelText('Remove')).toHaveLength(1); }); }); + +describe('AssignedUsersSection — by name, against the registry (objectui#7611)', () => { + it('reads grants by the set NAME and the distributing positions from the registry', async () => { + mockAdapter.find.mockClear(); + render(); + await waitFor(() => expect(screen.getByText('Held Henry')).toBeTruthy()); + + const reads = mockAdapter.find.mock.calls.map(([object]) => object); + // No catalog row is read to LIST holders. + expect(reads).not.toContain('sys_position_permission_set'); + expect(reads).not.toContain('sys_position'); + expect(reads).not.toContain('sys_permission_set'); + const grantQuery = mockAdapter.find.mock.calls.find(([o]) => o === 'sys_user_permission_set')?.[1]; + expect(grantQuery?.$filter).toEqual({ permission_set: 'showcase_contributor' }); + expect(mockMetadataStore.ensureType).toHaveBeenCalledWith('position'); + }); + + it('a position whose definition does not name the set contributes nobody', async () => { + render(); + // `auditor` names `showcase_auditor`, but nobody holds it in the fixture, + // and the direct grant above is for another set: an empty list. + await waitFor(() => expect(screen.getByText(/No users assigned yet/)).toBeTruthy()); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx index df3254ce5f..0575e37d82 100644 --- a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx +++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx @@ -14,6 +14,24 @@ * rules on the junction insert (e.g. the AI-seat cap) are caught and shown as a * friendly, localized inline message — not a raw developer error. * + * ## By name, against the registry (objectui#7611, ADR-0131 D3/D4) + * + * The set is addressed by its machine NAME throughout: + * + * - direct grants are the `sys_user_permission_set` rows whose `permission_set` + * column names the set — the by-name column the platform's grant readers + * key on (a grant with no name grants nothing through them); + * - the positions that distribute the set are the registry's `position` + * definitions whose `permissionSets` list names it (ADR-0131 D4 — the + * binding is part of the position's definition), read through the console's + * metadata store, never `sys_position_permission_set` / `sys_position` rows. + * + * ⚠️ One row read remains, and only on ADD: the grant door still requires + * `permission_set_id` and refuses a grant that carries the name alone + * (measured on objectstack `main`: 400 `VALIDATION_FAILED` on + * `permission_set`). {@link resolveGrantRowId} is that read, pending the + * server half that accepts a grant by name. + * * Permission-set-agnostic: every role gets the same UI, and the AI seat * (`ai_seat`) is just one of them. The generic add-by-picker engine (spec * RecordRelatedListProps.add) powers the capability; this is the polished @@ -23,7 +41,7 @@ import * as React from 'react'; import { Button } from '@object-ui/components'; import { RecordPickerDialog } from '@object-ui/fields'; -import { useAdapter } from '@object-ui/react'; +import { useAdapter, useMetadata } from '@object-ui/react'; import { Plus, X, Users, Loader2, AlertCircle } from 'lucide-react'; import { useMetadataLocale } from './i18n.js'; @@ -62,6 +80,7 @@ function useCopy() { direct: '直授', viaPosition: (p: string) => '经岗位 ' + p, everyoneNote: '已绑定到 everyone 锚点 — 所有登录成员都持有此权限集。', + noGrantRow: '此权限集还没有目录行,服务端暂时无法按名称分配。', positionHeldHint: '经岗位持有 — 在岗位的指派中移除。', } : { @@ -78,6 +97,7 @@ function useCopy() { direct: 'direct', viaPosition: (p: string) => 'via position ' + p, everyoneNote: 'Bound to the everyone anchor — every signed-in member holds this set.', + noGrantRow: 'This set has no catalog row yet, and the server does not accept a grant by name alone yet.', positionHeldHint: 'Held via a position — remove it on the position’s assignments.', }, [zh], @@ -108,11 +128,44 @@ const asArray = (res: any): any[] => const personLabel = (u: any): string => u?.full_name || u?.name || u?.display_name || u?.email || String(u?.id ?? ''); +/** The audience anchors: implicit memberships, noted rather than enumerated. */ +const ANCHOR_POSITIONS = new Set(['everyone', 'guest']); + +/** + * The positions whose DEFINITION distributes `setName` — its `permissionSets` + * list names the set (ADR-0131 D4; `PositionSchema.permissionSets`). + */ +export function positionsDistributing(positions: readonly unknown[], setName: string): string[] { + const out: string[] = []; + for (const raw of positions) { + const p = raw as { name?: unknown; permissionSets?: unknown } | null; + if (typeof p?.name !== 'string' || !p.name) continue; + if (Array.isArray(p.permissionSets) && p.permissionSets.includes(setName)) out.push(p.name); + } + return out; +} + +/** + * The `sys_permission_set` row id a NEW grant must carry — the one row read + * left here, pending the server half: the grant door requires + * `permission_set_id` and stamps the name from it (see the module doc). + */ +async function resolveGrantRowId(adapter: any, setName: string): Promise { + const rows = asArray( + await adapter.find('sys_permission_set', { $filter: { name: setName }, $select: ['id'], $top: 1 }), + ); + return rows[0]?.id != null ? String(rows[0].id) : null; +} + export function AssignedUsersSection({ permissionSetName }: AssignedUsersSectionProps) { const adapter = useAdapter() as any; const c = useCopy(); - const [setId, setSetId] = React.useState(null); + const metadataStore = useMetadata(); + // Read through a ref: the store's context value is rebuilt whenever any + // cached type settles, and AGENTS.md #10 forbids keying `load` on it. + const metadataStoreRef = React.useRef(metadataStore); + metadataStoreRef.current = metadataStore; const [rows, setRows] = React.useState([]); const [everyoneBound, setEveryoneBound] = React.useState(false); const [loading, setLoading] = React.useState(true); @@ -123,43 +176,25 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection const load = React.useCallback(async () => { setLoading(true); try { - const sets = asArray( - await adapter.find('sys_permission_set', { $filter: { name: permissionSetName }, $top: 1 }), - ); - const id = sets[0]?.id ? String(sets[0].id) : null; - setSetId(id); - if (!id) { - setRows([]); - setEveryoneBound(false); - return; - } - - // Effective holders = direct grants ∪ holders of every position bound to - // the set (objectui#2382). In the ADR-0090 model positions are THE - // distribution channel — a direct-grants-only list told the admin - // "0 users" for any normally-administered set. + // Direct grants, by the set's NAME. const grants = asArray( - await adapter.find('sys_user_permission_set', { $filter: { permission_set_id: id }, $top: 500 }), + await adapter.find('sys_user_permission_set', { $filter: { permission_set: permissionSetName }, $top: 500 }), ); + // Effective holders = direct grants ∪ holders of every position that + // distributes the set (objectui#2382). In the ADR-0090 model positions + // are THE distribution channel — a direct-grants-only list told the + // admin "0 users" for any normally-administered set. let positionNames: string[] = []; let boundEveryone = false; try { - const bindings = asArray( - await adapter.find('sys_position_permission_set', { $filter: { permission_set_id: id }, $top: 200 }), - ); - const positionIds = [...new Set(bindings.map((b: any) => b.position_id).filter(Boolean).map(String))]; - if (positionIds.length) { - const positions = asArray( - await adapter.find('sys_position', { $filter: { id: { $in: positionIds } }, $top: 200 }), - ); - const names = positions.map((p: any) => String(p.name ?? '')).filter(Boolean); - // The audience anchors are implicit memberships — `everyone` is every - // signed-in member; enumerating them as rows would be noise. Surface - // a note instead and expand only the explicit positions. - boundEveryone = names.includes('everyone'); - positionNames = names.filter((n) => n !== 'everyone' && n !== 'guest'); - } + const positions = await metadataStoreRef.current.ensureType('position'); + const names = positionsDistributing(positions, permissionSetName); + // The audience anchors are implicit memberships — `everyone` is every + // signed-in member; enumerating them as rows would be noise. Surface + // a note instead and expand only the explicit positions. + boundEveryone = names.includes('everyone'); + positionNames = names.filter((n) => !ANCHOR_POSITIONS.has(n)); } catch { /* position expansion is additive — direct grants still render */ } @@ -218,14 +253,21 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection const addUsers = React.useCallback( async (records: any[]) => { - if (!setId) return; setBusy(true); setError(null); try { + const setId = await resolveGrantRowId(adapter, permissionSetName); + if (!setId) throw new Error(c.noGrantRow); for (const u of records || []) { const uid = u?.id != null ? String(u.id) : null; if (!uid || assignedIds.has(uid)) continue; - await adapter.create('sys_user_permission_set', { permission_set_id: setId, user_id: uid }); + // The id is what the grant door requires today; the name rides + // beside it and the door refuses a mismatch (module doc). + await adapter.create('sys_user_permission_set', { + permission_set_id: setId, + permission_set: permissionSetName, + user_id: uid, + }); } await load(); } catch (err: any) { @@ -242,7 +284,7 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection setPickerOpen(false); } }, - [adapter, setId, assignedIds, load, rows.length, c], + [adapter, permissionSetName, assignedIds, load, rows.length, c], ); const removeUser = React.useCallback( @@ -271,7 +313,7 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection + +
+ {error && ( +
+ + {error} +
+ )} + {loading ? ( +
+ + {c.loading} +
+ ) : rows.length === 0 ? ( +
{c.empty}
+ ) : ( +
    + {rows.map((r) => ( +
  • +
    +
    {r.label}
    + {r.email && r.email !== r.label && ( +
    {r.email}
    + )} +
    + +
  • + ))} +
+ )} +
+ {adapter && ( + setPickerOpen(o)} + multiple + dataSource={adapter} + objectName="sys_user" + title={c.pickTitle} + onSelect={() => {}} + onSelectRecords={(records: any[]) => void addHolders(records)} + /> + )} + + ); +} + +export default PositionHoldersSection; diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.setupCatalog-7611.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.setupCatalog-7611.test.tsx new file mode 100644 index 0000000000..debd8bdb52 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.setupCatalog-7611.test.tsx @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7611 — the generic editor as the Setup catalog's position page: + * + * - a caller without `manage_metadata` gets the read-only page and its reason + * (the metadata door refuses that caller's save — measured on objectstack + * `main`), with a holder as the control; + * - in the environment scope a position shows its holders under the + * definition; Studio (no scope) and another type do not. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const POSITION = { name: 'env_position', label: 'Environment Position' }; + +const mockClient = { + list: vi.fn(async () => []), + listDrafts: vi.fn(async () => []), + get: vi.fn(async () => null), + getDraft: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async () => ({ + code: null, + overlay: POSITION, + overlayScope: null, + effective: POSITION, + provenance: 'org', + })), +}; + +const SCHEMA = { type: 'object', properties: { name: { type: 'string' }, label: { type: 'string' } } }; + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [ + { type: 'position', name: 'position', label: 'Position', allowOrgOverride: false, allowRuntimeCreate: true, schema: SCHEMA }, + { type: 'dashboard', name: 'dashboard', label: 'Dashboard', allowOrgOverride: false, allowRuntimeCreate: true, schema: SCHEMA }, + ], + }), + }; +}); +vi.mock('./PositionHoldersSection', () => ({ + PositionHoldersSection: ({ name }: { name: string }) =>
{name}
, +})); + +let canAuthor = true; +vi.mock('../../hooks/useCanAuthorMetadata', () => ({ + useCanAuthorMetadata: () => canAuthor, +})); + +import { MetadataResourceEditPage } from './ResourceEditPage'; + +beforeEach(() => { + canAuthor = true; +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function mount(type: string, search: string) { + render( + + + , + ); + await waitFor(() => expect(mockClient.layered).toHaveBeenCalled()); +} + +describe('the caller gate (#22621 → A)', () => { + it('a caller without manage_metadata gets the read-only page and the reason', async () => { + canAuthor = false; + await mount('position', '?scope=environment'); + expect(await screen.findByTestId('capability-readonly-banner')).toHaveTextContent(/manage_metadata/); + }); + + it('a holder does not (control)', async () => { + await mount('position', '?scope=environment'); + await screen.findByTestId('position-holders-stub'); + expect(screen.queryByTestId('capability-readonly-banner')).toBeNull(); + }); +}); + +describe("a position's holders in the Setup catalog (objectui#7611)", () => { + it('render under the definition in the environment scope', async () => { + await mount('position', '?scope=environment'); + expect(await screen.findByTestId('position-holders-stub')).toHaveTextContent('env_position'); + }); + + it('do not render in Studio (no scope)', async () => { + await mount('position', ''); + await waitFor(() => expect(mockClient.layered).toHaveBeenCalled()); + // Let the load settle before asserting an absence. + await screen.findByDisplayValue('Environment Position').catch(() => undefined); + expect(screen.queryByTestId('position-holders-stub')).toBeNull(); + }); + + it('do not render for another type in the scope', async () => { + await mount('dashboard', '?scope=environment'); + await screen.findByDisplayValue('Environment Position').catch(() => undefined); + expect(screen.queryByTestId('position-holders-stub')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index e084982875..39106b97f2 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -136,6 +136,19 @@ import { validateMetadataDraft, hasClientValidator, type DraftMode } from './cli import { describeIssuePath, issueSlicePath, issueSliceFingerprint } from './issuePath.js'; import { buildCreateModeBody } from './createBody.js'; import { errorCodeIs, errorCodeIsAnyOf } from '@object-ui/types'; +import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; +import { environmentScopeSuffix, isEnvironmentScope } from './catalog-scope.js'; +import { PositionHoldersSection } from './PositionHoldersSection.js'; + +/** + * objectui#7611 — the Setup half of an item, rendered under its definition in + * the ENVIRONMENT scope (`catalog-scope.ts`): who holds it. Keyed by type and + * internal to this page; the permission set's half lives in its own editor, + * `PermissionMatrixEditPage`, which renders `AssignedUsersSection`. + */ +const ENVIRONMENT_SECTIONS: Readonly>> = { + position: PositionHoldersSection, +}; /** * ADR-0010 §3.6 lock state -> the lock banner's headline sentence. @@ -1718,8 +1731,8 @@ function MetadataResourceEditPageImpl({ setEditing(false); } } else { - // No artifact baseline → return to the list view. - navigate(`../`, { relative: 'path' }); + // No artifact baseline → return to the list view (in its scope). + navigate(`../${scopeSuffix}`, { relative: 'path' }); } } catch (err: any) { setError(err?.message ?? String(err)); @@ -1840,8 +1853,17 @@ function MetadataResourceEditPageImpl({ : isArtifactItem ? !!entry?.allowOrgOverride : !!(entry?.allowOrgOverride || entry?.allowRuntimeCreate); - const canWrite = canWriteByType && (createMode || lockEditable); + // objectui#7611 — the CALLER tier. The metadata door refuses every save + // without `manage_metadata` (measured on objectstack main: 403 "Saving a + // metadata item requires the `manage_metadata` capability."), so a caller + // without it gets the read-only page and the reason, not a Save that fails + // at the end. Unknown fails open (`useCanAuthorMetadata`). + const canAuthor = useCanAuthorMetadata(); + const canWrite = canWriteByType && (createMode || lockEditable) && canAuthor; const readOnly = !canWrite && !createMode; + const envScope = isEnvironmentScope(searchParams); + const scopeSuffix = environmentScopeSuffix(searchParams); + const EnvironmentSection = envScope && !createMode && !embedded ? ENVIRONMENT_SECTIONS[type] : undefined; // #2272 — designer deep-link: `?sel=nav:` selects the nav item with // that spec `id` (stable across reorders, unlike the positional selection @@ -2012,7 +2034,8 @@ function MetadataResourceEditPageImpl({ // specific item is locked because it comes from a code package, we // show a different message inviting the user to create their own. const showArtifactLockedBanner = - readOnly && isArtifactItem && !!entry?.allowRuntimeCreate; + // objectui#7611 — and only for a caller who could author the new item. + readOnly && isArtifactItem && !!entry?.allowRuntimeCreate && canAuthor; // Preview tab — opt-in via `registerMetadataPreview()`. Hidden in // create mode (nothing to preview yet) and inside the embedded @@ -2419,7 +2442,7 @@ function MetadataResourceEditPageImpl({ size="sm" variant="outline" className="shrink-0 h-7 bg-background/60" - onClick={() => navigate(`../new`, { relative: 'path' })} + onClick={() => navigate(`../new${scopeSuffix}`, { relative: 'path' })} > {t('engine.list.create', locale)} @@ -2461,7 +2484,16 @@ function MetadataResourceEditPageImpl({ this read-only notice is redundant (and its CTA has been folded into the lock banner). Only render it for the non-locked read-only cases. */} - {readOnly && !isLocked && ( + {readOnly && !isLocked && !canAuthor && ( +
+ + {t('engine.edit.capabilityReadOnly', locale)} +
+ )} + {readOnly && !isLocked && canAuthor && (
{showArtifactLockedBanner ? ( @@ -2495,7 +2527,7 @@ function MetadataResourceEditPageImpl({ size="sm" variant="outline" className="shrink-0" - onClick={() => navigate(`../new`, { relative: 'path' })} + onClick={() => navigate(`../new${scopeSuffix}`, { relative: 'path' })} > {t('engine.list.create', locale)} @@ -2951,6 +2983,14 @@ function MetadataResourceEditPageImpl({ widgetContext={widgetContext} /> )} + {/* objectui#7611 — under the designer, too: `position` renders + through its preview, so the section takes a bounded strip + below the canvas instead of squeezing it. */} + {EnvironmentSection && ( +
+ +
+ )}
diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.environmentScope-7611.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.environmentScope-7611.test.tsx new file mode 100644 index 0000000000..f703d4c1eb --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.environmentScope-7611.test.tsx @@ -0,0 +1,249 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7611 — the metadata list in its ENVIRONMENT scope is the Setup + * catalog (objectstack ADR-0131 D3/D7; `catalog-scope.ts`). + * + * What is pinned, each against a control that discriminates it: + * + * - the environment scope lists EVERY registry item of the type — the + * platform's own sets included — where the package scope (the control) + * lists one project package's slice; + * - its links keep the scope, so the editor and its breadcrumb return to the + * catalog; + * - re-gated for Setup (#22621 → A): a caller without `manage_metadata` gets + * no create affordance and the posture's own reason, and a holder gets the + * create affordance and no reason; + * - the active switch: per item, the activation ledger's state (no row is + * active) with no catalog row read, written through the activation door by + * name, the door's refusal shown in its own words, disabled for a caller + * who cannot author, and disabled with the reason on an audience anchor. + */ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, cleanup, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +// The switch column waits on `audienceAnchorsOf`'s dynamic import of this exact +// specifier. Loaded here, its cost lands in the import phase rather than inside +// a `findBy` window (AGENTS.md, test discipline). +import '@objectstack/spec/identity'; + +const PROJECT_PKG = 'com.example.showcase'; +const TYPE = 'permission'; + +const POSITIONS: Record[] = [ + { name: 'everyone', label: 'Everyone', _packageId: 'com.objectstack.plugin-security', _provenance: 'package' }, + { name: 'auditor', label: 'Auditor', _packageId: PROJECT_PKG, _provenance: 'package' }, +]; + +const ITEMS: Record[] = [ + { name: 'admin_full_access', label: 'Administrator', _packageId: 'com.objectstack.plugin-security', _provenance: 'package' }, + { name: 'showcase_auditor', label: 'Showcase Auditor', _packageId: PROJECT_PKG, _provenance: 'package' }, + { name: 'env_authored_set', label: 'Environment Set' }, +]; + +/** Stable singleton — the page's load effect keys on the client identity. */ +const CLIENT = { + list: async (type: string) => + type === 'package' + ? [{ manifest: { id: PROJECT_PKG, scope: 'project', name: 'Showcase' } }] + : type === TYPE + ? ITEMS + : type === 'position' + ? POSITIONS + : [], +}; + +const ENTRY = { type: TYPE, label: 'Permission Set', allowOrgOverride: false, allowRuntimeCreate: true }; +const POSITION_ENTRY = { type: 'position', label: 'Position', allowOrgOverride: false, allowRuntimeCreate: true }; + +vi.mock('./useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => CLIENT, + useMetadataTypes: () => ({ loading: false, error: null, entries: [ENTRY, POSITION_ENTRY] }), +})); + +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => 'en-US', +})); + +let canAuthor = true; +vi.mock('../../hooks/useCanAuthorMetadata', () => ({ + useCanAuthorMetadata: () => canAuthor, +})); + +let posture: string | undefined = 'single'; +vi.mock('../../hooks/useTenancyPosture', async (importOriginal) => ({ + ...(await importOriginal>()), + useTenancyPosture: () => posture, +})); + +/** The activation ledger the switch reads: one row, switching a set off. */ +let ledgerRows: Record[] = []; +/** What the activation door answers. */ +let doorAnswer: { status: number; body: unknown } = { status: 200, body: {} }; +/** Every request the page sent through `fetch`. */ +const requests: Array<{ url: string; method: string; body?: string }> = []; + +// Imported AFTER the mocks so the page picks them up. +import { MetadataResourceListPage } from './ResourceListPage'; +import { t, tFormat, translateMetadataType } from './i18n'; + +beforeEach(() => { + canAuthor = true; + posture = 'single'; + ledgerRows = [{ metadata_type: 'permission', name: 'showcase_auditor', active: false }]; + doorAnswer = { status: 200, body: {} }; + requests.length = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + requests.push({ url: String(url), method, body: init?.body as string | undefined }); + const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + if (method === 'GET' && String(url).startsWith('/api/v1/data/sys_metadata_activation')) { + return json(200, { object: 'sys_metadata_activation', records: ledgerRows, hasMore: false }); + } + if (method === 'POST' && String(url).startsWith('/api/v1/security/_activation/')) { + return json(doorAnswer.status, doorAnswer.body); + } + return json(404, { success: false, error: { message: `unexpected ${method} ${url}` } }); + }), + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function mount(search: string, type: string = TYPE) { + return render( + + + , + ); +} + +/** The row link for an item, by its href (column 0 renders the name). */ +const linkTo = (container: HTMLElement, name: string): HTMLAnchorElement | null => + container.querySelector(`a[href*="/${name}"]`); + +describe('the environment scope lists the whole registry (objectui#7611)', () => { + it('lists every item — the platform set included — where the package scope lists one slice', async () => { + const env = mount('?scope=environment'); + await waitFor(() => expect(linkTo(env.container, 'admin_full_access')).not.toBeNull()); + expect(linkTo(env.container, 'showcase_auditor')).not.toBeNull(); + expect(linkTo(env.container, 'env_authored_set')).not.toBeNull(); + env.unmount(); + + // Control: the package scope (no `scope` param) shows the project slice only. + const pkg = mount(''); + await waitFor(() => expect(linkTo(pkg.container, 'showcase_auditor')).not.toBeNull()); + expect(linkTo(pkg.container, 'admin_full_access')).toBeNull(); + expect(linkTo(pkg.container, 'env_authored_set')).toBeNull(); + }); + + it('keeps the scope on every item link', async () => { + const { container } = mount('?scope=environment'); + await waitFor(() => expect(linkTo(container, 'admin_full_access')).not.toBeNull()); + for (const name of ['admin_full_access', 'showcase_auditor', 'env_authored_set']) { + expect(linkTo(container, name)?.getAttribute('href')).toMatch(/\?scope=environment$/); + } + }); +}); + +describe('re-gated for Setup (#22621 → A)', () => { + it('a caller without manage_metadata gets the reason under `single` and no create affordance', async () => { + canAuthor = false; + mount('?scope=environment'); + const reason = await screen.findByTestId('catalog-readonly-reason'); + expect(reason).toHaveTextContent( + tFormat('engine.catalog.readOnly.single', 'en-US', { + type: translateMetadataType(TYPE, 'en-US', ENTRY.label), + }), + ); + expect(screen.queryByRole('button', { name: new RegExp(t('engine.list.create', 'en-US')) })).toBeNull(); + }); + + it('under a wall the reason names the operator', async () => { + canAuthor = false; + posture = 'isolated'; + mount('?scope=environment'); + const reason = await screen.findByTestId('catalog-readonly-reason'); + expect(reason.textContent).toMatch(/platform operator/); + }); + + it('a holder gets the create affordance and no reason', async () => { + mount('?scope=environment'); + await screen.findByRole('button', { name: new RegExp(t('engine.list.create', 'en-US')) }); + expect(screen.queryByTestId('catalog-readonly-reason')).toBeNull(); + }); +}); + +describe('the active switch (the activation ledger, through its door)', () => { + it("shows each item's ledger state, an item with no row as active, and reads no catalog row", async () => { + mount('?scope=environment'); + const admin = await screen.findByTestId('catalog-active-admin_full_access'); + expect(admin).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByTestId('catalog-active-showcase_auditor')).toHaveAttribute('aria-checked', 'false'); + // An environment-authored set has no catalog row, and is switchable all the same. + expect(screen.getByTestId('catalog-active-env_authored_set')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByTestId('catalog-active-env_authored_set')).not.toBeDisabled(); + expect(requests.map((r) => r.url)).toContain('/api/v1/data/sys_metadata_activation?top=1000'); + expect(requests.some((r) => /sys_permission_set|sys_position/.test(r.url))).toBe(false); + }); + + it('switches through the activation door, by the item name', async () => { + doorAnswer = { status: 200, body: { success: true, data: { type: 'permission', name: 'showcase_auditor', enabled: true } } }; + mount('?scope=environment'); + fireEvent.click(await screen.findByTestId('catalog-active-showcase_auditor')); + await waitFor(() => + expect(screen.getByTestId('catalog-active-showcase_auditor')).toHaveAttribute('aria-checked', 'true'), + ); + expect(requests.filter((r) => r.method === 'POST')).toEqual([ + { url: '/api/v1/security/_activation/permission/showcase_auditor', method: 'POST', body: '{"enabled":true}' }, + ]); + }); + + it("shows the door's refusal in the server's own words, and leaves the switch as it was", async () => { + const refusal = 'A refusal sentence the server wrote.'; + doorAnswer = { status: 403, body: { success: false, error: { code: 'PERMISSION_DENIED', message: refusal, httpStatus: 403 } } }; + mount('?scope=environment'); + fireEvent.click(await screen.findByTestId('catalog-active-admin_full_access')); + expect( + await screen.findByText(tFormat('engine.catalog.active.failed', 'en-US', { message: refusal })), + ).toBeInTheDocument(); + expect(screen.getByTestId('catalog-active-admin_full_access')).toHaveAttribute('aria-checked', 'true'); + }); + + it('is disabled for a caller who cannot author, and states the state without offering a click', async () => { + canAuthor = false; + mount('?scope=environment'); + const on = await screen.findByTestId('catalog-active-admin_full_access'); + expect(on).toBeDisabled(); + expect(on).toHaveAttribute('aria-label', t('engine.catalog.status.active', 'en-US')); + expect(screen.getByTestId('catalog-active-showcase_auditor')).toHaveAttribute( + 'aria-label', + t('engine.catalog.status.inactive', 'en-US'), + ); + }); + + it('on positions, an audience anchor is a disabled switch that says why; CONTROL: another position is live', async () => { + mount('?scope=environment', 'position'); + const anchor = await screen.findByTestId('catalog-active-everyone'); + expect(anchor).toBeDisabled(); + expect(anchor).toHaveAttribute('aria-label', t('engine.catalog.active.anchor', 'en-US')); + expect(anchor.parentElement).toHaveAttribute('title', t('engine.catalog.active.anchor', 'en-US')); + expect(screen.getByTestId('catalog-active-auditor')).not.toBeDisabled(); + }); + + it('is absent outside the environment scope', async () => { + const { container } = mount(''); + await waitFor(() => expect(linkTo(container, 'showcase_auditor')).not.toBeNull()); + expect(screen.queryByTestId('catalog-active-showcase_auditor')).toBeNull(); + expect(requests.some((r) => r.url.includes('sys_metadata_activation'))).toBe(false); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx index 1733fc7ed7..1a806f91cc 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx @@ -18,6 +18,7 @@ import { Plus, Search, RefreshCw, AlertTriangle, Lock } from 'lucide-react'; import { Button } from '@object-ui/components'; import { Input } from '@object-ui/components'; import { Badge } from '@object-ui/components'; +import { Switch } from '@object-ui/components'; import { Select, SelectContent, @@ -41,6 +42,17 @@ import { } from './registry.js'; import { t, tFormat, translateMetadataType, useMetadataLocale } from './i18n.js'; import { buildPackageScopeOptions } from './package-scope.js'; +import { ENVIRONMENT_SCOPE_QUERY, isEnvironmentScope } from './catalog-scope.js'; +import { + audienceAnchorsOf, + hasCatalogActivation, + isCatalogItemActive, + readCatalogActivation, + writeCatalogActivation, + type CatalogActivation, +} from './catalog-activation.js'; +import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; +import { postureHasOrgWall, useTenancyPosture } from '../../hooks/useTenancyPosture.js'; export interface MetadataResourceListPageProps { type?: string; @@ -121,6 +133,21 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string const [sourceFilter, setSourceFilter] = React.useState('all'); const [searchParams, setSearchParams] = useSearchParams(); const [refreshKey, setRefreshKey] = React.useState(0); + // objectui#7611 — the ENVIRONMENT scope (`?scope=environment`): the Setup + // catalog. The list is the registry's whole list for the type, not one + // project package's slice, and it is re-gated for Setup — see + // `catalog-scope.ts`. + const envScope = isEnvironmentScope(searchParams); + const canAuthor = useCanAuthorMetadata(); + const posture = useTenancyPosture(); + const activation = envScope && hasCatalogActivation(type); + const [statusFilter, setStatusFilter] = React.useState<'all' | 'active' | 'inactive'>('all'); + // The activation ledger's answer for the switch column, with the names the + // door refuses to switch (`catalog-activation.ts`). `null` while unread, + // `'error'` when the read was refused or failed. + const [ledger, setLedger] = React.useState(null); + const [switching, setSwitching] = React.useState(null); + const [switchError, setSwitchError] = React.useState(null); // Studio is scoped to a single *project* package at a time. Load the // installed packages and keep only project-scoped ones — anything not @@ -201,6 +228,8 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string // navigation all agree on the active scope. Runs once packages resolve // and the URL holds no valid project package. React.useEffect(() => { + // The environment scope lists every package's items: no package to repair. + if (envScope) return; if (!projectPackages || projectPackages.length === 0) return; if (urlPackage && projectPackages.some((p) => p.id === urlPackage)) return; // If the current app's package is known we can repair immediately; otherwise @@ -218,15 +247,22 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string // Carry the active package into create/edit navigation as `?package=` so // the editor binds newly-saved rows to that software package. - const pkgSuffix = activePackage - ? `?package=${encodeURIComponent(activePackage)}` - : ''; + const pkgSuffix = envScope + ? `?${ENVIRONMENT_SCOPE_QUERY}` + : activePackage + ? `?package=${encodeURIComponent(activePackage)}` + : ''; // ADR-0070 D3 — never start a create that would orphan the item. When a real // writable base exists, create into it (defaulting away from the Local/null // scope); when none exists yet, prompt to create a base first. const [showCreateBase, setShowCreateBase] = React.useState(false); const handleCreate = React.useCallback(() => { + // An environment-authored item belongs to no package (ADR-0131 D3). + if (envScope) { + navigate(`./new?${ENVIRONMENT_SCOPE_QUERY}`); + return; + } const bases = projectPackages ?? []; if (projectPackages !== null && bases.length === 0) { setShowCreateBase(true); @@ -237,7 +273,47 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string return; } navigate(`./new${pkgSuffix}`); - }, [projectPackages, activePackage, pkgSuffix, navigate]); + }, [projectPackages, activePackage, pkgSuffix, navigate, envScope]); + + // objectui#7611 — the activation state the switch column shows. Re-read on + // the list's own refresh, never on an unrelated render. The anchors arrive + // with the states, so no anchor is ever drawn as a live switch. + React.useEffect(() => { + if (!activation) { + setLedger(null); + return; + } + const controller = new AbortController(); + Promise.all([readCatalogActivation(type, controller.signal), audienceAnchorsOf(type)]) + .then(([states, anchors]) => { + if (!controller.signal.aborted) setLedger({ states, anchors }); + }) + .catch(() => { + if (!controller.signal.aborted) setLedger('error'); + }); + return () => controller.abort(); + }, [activation, type, refreshKey]); + + const toggleActive = React.useCallback( + async (itemName: string, enabled: boolean) => { + setSwitching(itemName); + setSwitchError(null); + try { + const now = await writeCatalogActivation(type, itemName, !enabled); + setLedger((prev) => { + if (!prev || prev === 'error') return prev; + const states = new Map(prev.states); + states.set(itemName, now); + return { ...prev, states }; + }); + } catch (err: any) { + setSwitchError(err?.message ?? String(err)); + } finally { + setSwitching(null); + } + }, + [type], + ); React.useEffect(() => { let cancelled = false; @@ -287,6 +363,8 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string // Per-type hide hook (e.g. `view` drops the bare aggregated // container the framework keeps for runtime dual-read). if (config.listFilter && !config.listFilter(row.item)) return false; + // objectui#7611 — the environment scope is the whole registry list. + if (envScope) return true; // Mandatory project-package scope: show nothing until a concrete // project package is active, then only rows tagged with it. The // 'sys_metadata' sentinel and untagged rows never match. @@ -297,13 +375,19 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string // (ADR-0070 D5 — the package-less "Local / Custom" scope is removed). return pkg === activePackage; }), - [items, activePackage, config], + [items, activePackage, config, envScope], ); // User-driven filters (search query + source provenance) on top of scope. const filtered = scopedItems.filter((row) => { if (!matchesQuery(row.item, query, searchableFields)) return false; if (sourceFilter !== 'all' && row.source !== sourceFilter) return false; + if (activation && statusFilter !== 'all') { + // Only a KNOWN state filters: while the ledger is unread or unreadable, + // no item is either active or inactive to this filter. + if (!ledger || ledger === 'error') return false; + if ((statusFilter === 'active') !== isCatalogItemActive(ledger.states, String(row.item.name ?? ''))) return false; + } return true; }); @@ -337,6 +421,15 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string const columns = config.listColumns ?? defaultColumns(config.primaryKey ?? 'name'); const locale = useMetadataLocale(); const typeLabel = translateMetadataType(type, locale, entry?.label ?? type); + // The type offers SOME runtime write channel (the editors' type tier), and — + // in the environment scope — the caller holds the capability the metadata + // door requires. The server refuses either way; this is the half that says + // so before the click (objectui#7611, the #22621 → A parity gate). + const typeWritable = !!(entry?.allowOrgOverride || entry?.allowRuntimeCreate); + const canCreate = typeWritable && (!envScope || canAuthor); + // Packages load only for the package scope; the environment scope never + // waits on them. + const packagesPending = !envScope && projectPackages === null; // Localise default column labels — registered columns keep their // hand-authored labels (consumers may want bespoke wording). @@ -397,7 +490,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string > - {(entry?.allowOrgOverride || entry?.allowRuntimeCreate) && ( + {canCreate && (