From 6986574dab995f2edeb050a8604ca4d667b138b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:53:14 +0000 Subject: [PATCH 1/3] feat(app-shell): the locked permission matrix offers "Clone to customize" through the published clone_permission_set action (objectui#5987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A permission set a code package ships is locked by the artifact tier (`isArtifactBackedLayer`, unchanged). Its guidance offered only the pre-ruling remedies; the ruled path — lock the base, clone to customize — was never on the screen, although the server's 403 refusal names it. - The locked editor renders "Clone to customize" in the slot Save would occupy and names cloning first in its guidance (en-US and zh-CN rows). - The button runs the `clone_permission_set` record action published on `sys_permission_set`: resolved by name off the object definition the console holds, the set's row stashed under `params._rowRecord`, the declared params surfaced to the runner's dialog, dispatched through the shared console action runner. No clone is assembled from create/update calls; a missing action or row is a named refusal. - The returned clone is opened (`../name`) on the routed metadata admin, where it loads with no code layer and is writable; an embedded host is told the clone's name. - A save that still reaches the environment door and is refused 403 NOT_OVERRIDABLE shows the same guidance and action, keyed on the code, beside the server's sentence. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/5987-permset-clone-to-customize.md | 33 + ...sionMatrixEditor.cloneToCustomize.test.tsx | 562 ++++++++++++++++++ .../metadata-admin/PermissionMatrixEditor.tsx | 214 ++++++- .../src/views/metadata-admin/i18n.ts | 46 +- 4 files changed, 851 insertions(+), 4 deletions(-) create mode 100644 .changeset/5987-permset-clone-to-customize.md create mode 100644 packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.cloneToCustomize.test.tsx diff --git a/.changeset/5987-permset-clone-to-customize.md b/.changeset/5987-permset-clone-to-customize.md new file mode 100644 index 0000000000..ba296bc838 --- /dev/null +++ b/.changeset/5987-permset-clone-to-customize.md @@ -0,0 +1,33 @@ +--- +'@object-ui/app-shell': patch +--- + +feat(app-shell): a package-provided permission set offers "Clone to customize" as its primary action, and names it first (objectui#5987) + +The permission matrix already locks a set a code package ships (the artifact +tier: `isArtifactBackedLayer`, the client mirror of the server's +`isArtifactBacked`), and its guidance offered only the pre-ruling remedies — +edit the source artifact and redeploy, a new runtime set, the +`OS_METADATA_WRITABLE` hatch. The ruled path (objectstack#11513, 「同意 第一步 +(创业阶段,Salesforce 式)」: lock the base, clone to customize) was never on the +screen, although the server's own `403 not_overridable` refusal names it. + +The locked editor now renders **Clone to customize** in the slot Save would +occupy. It runs the `clone_permission_set` record action the server publishes +on `sys_permission_set` — resolved by name off the object definition the +console holds, the set's row stashed under `params._rowRecord`, the declared +`params` surfaced to the runner's dialog — through the console's shared action +runner, so the clone's payload, dialog and toasts are the published action's, +not a copy assembled here. On the routed metadata admin the returned clone is +opened (`../`), where it loads with no code layer and is writable; an +embedded host is told the clone's name. A server that publishes no such action, +or a set with no `sys_permission_set` row, gets a refusal naming what is +missing — never a fallback clone. The lock's guidance names cloning first, with +the former remedies kept as secondary routes. + +A save that still reaches the server and is refused `403 NOT_OVERRIDABLE` at the +environment door now shows the same guidance beside the refusal, with the same +primary action, keyed on the refusal's code rather than its prose; it used to +surface the transport's sentence alone. + +Lock predicate unchanged — no second predicate was added. diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.cloneToCustomize.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.cloneToCustomize.test.tsx new file mode 100644 index 0000000000..5163e5cb66 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.cloneToCustomize.test.tsx @@ -0,0 +1,562 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * "Clone to customize" — the locked permission matrix's primary action + * (objectui#5987, the UI half of objectstack#11513). + * + * ## What is pinned + * + * The artifact tier (`isArtifactBackedLayer`, objectui#4518 / #4526) locks a set + * a code package ships. The maintainer's ruling on objectstack#11513 — 「同意 + * 第一步(创业阶段,Salesforce 式)」: lock the base, clone to customize — makes + * cloning the sanctioned edit path, and the server's own `403 not_overridable` + * refusal names the `clone_permission_set` action as the remedy. This suite pins + * that the editor: + * + * 1. renders "Clone to customize" as the locked editor's PRIMARY action, in + * the slot Save would occupy, and names cloning FIRST in its guidance + * (en-US and zh-CN); + * 2. runs the PUBLISHED `clone_permission_set` action — the one declared on + * the `sys_permission_set` object the console holds — through the shared + * action runner, with THIS set's row as its identity, never a clone + * assembled from create / update calls; + * 3. opens the returned clone, which loads with no code layer and is + * writable; + * 4. shows no clone prompt on an org-owned set, a type-locked set or a + * host-locked package (the controls); + * 5. surfaces the same guidance when a save that reached the server is + * refused `403 NOT_OVERRIDABLE` (the card's item 3), keyed on the code. + * + * ## Red-first + * + * Every case below that asserts the clone affordance fails on the base this + * branch was cut from (`origin/main` at `a2f361b8c1`): the locked editor offered + * no clone action, its guidance never named cloning, and a `NOT_OVERRIDABLE` + * refusal rendered the transport's message alone. + * + * ## The action fixture is the shipped declaration + * + * `@objectstack/plugin-security` is not a dependency of this repo, so the Clone + * action cannot be imported. `CLONE_ACTION` below is transcribed from the + * `clone_permission_set` action on `SysPermissionSet` + * (`sys-permission-set.object.ts` in that package, identical at the published + * tag `@objectstack/plugin-security@17.4.0` and on `main` when read). The suite + * asserts the DISPATCH carries that declaration — not a list of this editor's + * own — which is what "wired to the published action" means here. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useParams } from 'react-router-dom'; + +/* ── The shipped declaration ─────────────────────────────────────────────── */ + +const SYS_PERMISSION_SET = 'sys_permission_set'; +const BASE_SET = 'showcase_contributor'; +const CLONE_NAME = 'my_clone'; + +/** The five facets the shipped action declares `carryOver` on. */ +const CARRIED = [ + 'object_permissions', + 'field_permissions', + 'system_permissions', + 'row_level_security', + 'tab_permissions', +] as const; + +/** `clone_permission_set`, as `SysPermissionSet` declares it. */ +const CLONE_ACTION = { + name: 'clone_permission_set', + label: 'Clone', + icon: 'copy', + variant: 'secondary', + mode: 'custom', + locations: ['list_item', 'record_header'], + type: 'api', + method: 'POST', + target: '/api/v1/data/sys_permission_set', + bodyExtra: { active: true }, + description: + 'Copies this set\'s permissions into a new organization-owned set you can edit. ' + + 'Delegated-admin scope is not copied — grant it deliberately on the new set if it needs one.', + successMessage: 'Permission set cloned', + refreshAfter: true, + params: [ + { name: 'label', label: 'New Display Name', type: 'text', required: true }, + { name: 'name', label: 'New API Name', type: 'text', required: true, helpText: 'snake_case machine name, unique per organization' }, + { field: 'description', defaultFromRow: true }, + { field: 'object_permissions', defaultFromRow: true, carryOver: true }, + { field: 'field_permissions', defaultFromRow: true, carryOver: true }, + { field: 'system_permissions', defaultFromRow: true, carryOver: true }, + { field: 'row_level_security', defaultFromRow: true, carryOver: true }, + { field: 'tab_permissions', defaultFromRow: true, carryOver: true }, + ], +}; + +/** The object the action is declared on, as the console's metadata store holds it. */ +const SYS_PERMISSION_SET_DEF = { + name: SYS_PERMISSION_SET, + label: 'Permission Set', + fields: { + label: { type: 'text', label: 'Display Name', required: true }, + name: { type: 'text', label: 'API Name', required: true }, + description: { type: 'textarea', label: 'Description' }, + object_permissions: { type: 'textarea', label: 'Object Permissions' }, + field_permissions: { type: 'textarea', label: 'Field Permissions' }, + system_permissions: { type: 'textarea', label: 'System Permissions' }, + row_level_security: { type: 'textarea', label: 'Row-Level Security' }, + tab_permissions: { type: 'textarea', label: 'Tab Permissions' }, + }, + actions: [CLONE_ACTION], +}; + +/** The set's `sys_permission_set` row — the identity the clone runs against. */ +const ROW = { + id: 'ps_1', + name: BASE_SET, + label: 'Contributor', + description: 'Ships with the showcase', + managed_by: 'package', + object_permissions: JSON.stringify({ a_account: { allowRead: true, allowCreate: true } }), + field_permissions: '{}', + system_permissions: '[]', + row_level_security: '{}', + tab_permissions: '{}', +}; + +/* ── Fakes ───────────────────────────────────────────────────────────────── */ + +// Which sets a code package ships, by name: the layered envelope's `code` slot. +let codeLayerByName: Record | null> = {}; +// What `client.save` does — the default resolves; item 3 makes it refuse. +let saveImpl: (type: string, name: string, payload: Record) => Promise = + async (_t, _n, payload) => payload; + +const SET_BODY = { + label: 'Contributor', + objects: { a_account: { allowRead: true, allowCreate: true } }, + fields: {}, +} as Record; + +function makeClient() { + return { + layered: vi.fn(async (_type: string, name: string) => ({ + effective: { ...SET_BODY, name }, + code: codeLayerByName[name] ?? null, + overlay: null, + overlayScope: null, + })), + getDraft: async () => null, + list: async (type: string) => + type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : [], + get: async (type: string) => + type === 'object' ? { fields: [{ name: 'name', label: 'Name' }] } : null, + save: async (type: string, name: string, payload: Record) => saveImpl(type, name, payload), + } as any; +} + +// The stock-boot `permission` shape (objectstack#6483) unless a case says otherwise. +let typeFlags: { allowOrgOverride?: boolean; allowRuntimeCreate?: boolean } = { + allowOrgOverride: false, + allowRuntimeCreate: true, +}; +let clientImpl: any; + +vi.mock('./useMetadata', () => ({ + useMetadataClient: () => clientImpl, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'permission', label: 'Permission', ...typeFlags }], + }), +})); + +let localeFixture: 'en-US' | 'zh-CN' = 'en-US'; +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => localeFixture, +})); + +// The console's record adapter and metadata store, as the editor reaches them +// through `@object-ui/react`. `ActionProvider` / `useAction` stay REAL: the +// dispatch under test goes through the genuine runner. +let rows: Array> = [ROW]; +const adapterFind = vi.fn(async (object: string, params: { $filter?: { name?: string } }) => ({ + data: object === SYS_PERMISSION_SET ? rows.filter((r) => r.name === params?.$filter?.name) : [], +})); +let objectDefs: Array> = [SYS_PERMISSION_SET_DEF]; +const ensureType = vi.fn(async (type: string) => (type === 'object' ? objectDefs : [])); + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal()), + useAdapter: () => ({ find: adapterFind }), + useMetadata: () => ({ + apps: [], + objects: objectDefs, + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType, + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, + }), +})); + +import { ActionProvider } from '@object-ui/react'; +import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; + +/* ── The runner's seams ──────────────────────────────────────────────────── */ + +// What the console api handler would answer for the POST — the data door's +// `CreateDataResponse` (`{ object, id, record }`) after the handler unwraps the +// `{ success, data }` envelope. +let apiResult: { success: boolean; data?: unknown; error?: string } = { + success: true, + data: { object: SYS_PERMISSION_SET, id: 'ps_2', record: { id: 'ps_2', name: CLONE_NAME, label: 'My clone' } }, +}; +// Snapshots of what the api handler was handed: the runner rewrites +// `action.params` in place after collection, so the object is copied at the +// moment of the call. +const dispatched: any[] = []; +const apiHandler = vi.fn(async (action: any, _ctx: any) => { + dispatched.push(JSON.parse(JSON.stringify(action))); + return apiResult; +}); +// The param dialog: records the definitions it was asked to render and answers +// with the two typed values plus the seeded carry-overs, as the real dialog's +// serialization would. +let paramAnswer: Record | null = { + label: 'My clone', + name: CLONE_NAME, + description: ROW.description, + ...Object.fromEntries(CARRIED.map((f) => [f, ROW[f]])), +}; +const paramCollection = vi.fn(async (_params: any[], _action?: any) => paramAnswer); +const toastSpy = vi.fn(); + +function RoutedEditor() { + const { name = '' } = useParams<{ name?: string }>(); + return ; +} + +/** + * The routed metadata admin: `metadata/:type/:name`, the one surface where the + * artifact tier engages (environment scope, no `packageId`), wrapped in the + * real `ActionProvider` with the console runtime's three seams faked. + */ +async function renderRouted() { + clientImpl = makeClient(); + render( + + + + } /> + + + , + ); + await screen.findByText('Account'); +} + +async function renderPlain(props?: { packageId?: string; readOnly?: boolean; embedded?: boolean }) { + clientImpl = makeClient(); + render( + + + + + , + ); + await screen.findByText('Account'); +} + +const CLONE_EN = /^Clone to customize$/; +const CLONE_ZH = /^克隆后自定义$/; +const cloneButtons = () => screen.queryAllByRole('button', { name: CLONE_EN }); +const saveButton = () => screen.queryByRole('button', { name: /^Save$/ }); + +beforeEach(() => { + codeLayerByName = { [BASE_SET]: { _packageId: 'com.example.showcase', name: BASE_SET } }; + saveImpl = async (_t, _n, payload) => payload; + typeFlags = { allowOrgOverride: false, allowRuntimeCreate: true }; + localeFixture = 'en-US'; + rows = [ROW]; + objectDefs = [SYS_PERMISSION_SET_DEF]; + apiResult = { + success: true, + data: { object: SYS_PERMISSION_SET, id: 'ps_2', record: { id: 'ps_2', name: CLONE_NAME, label: 'My clone' } }, + }; + paramAnswer = { + label: 'My clone', + name: CLONE_NAME, + description: ROW.description, + ...Object.fromEntries(CARRIED.map((f) => [f, ROW[f]])), + }; + dispatched.length = 0; + apiHandler.mockClear(); + paramCollection.mockClear(); + adapterFind.mockClear(); + ensureType.mockClear(); + toastSpy.mockClear(); +}); + +afterEach(() => { + cleanup(); +}); + +/* ────────────────────────────────────────────────────────────────────────── */ + +describe('PermissionMatrixEditPage — the locked editor offers Clone to customize (objectui#5987)', () => { + it('renders "Clone to customize" as the primary action where Save would be, and no Save', async () => { + await renderRouted(); + + expect(saveButton()).toBeNull(); + const [clone] = cloneButtons(); + expect(clone).toBeInTheDocument(); + expect(clone).toBeEnabled(); + // Its hint names the published action it runs. + expect(clone).toHaveAttribute('title', expect.stringContaining('clone_permission_set')); + // The visible locked-state guidance names cloning. + expect(screen.getByRole('note')).toHaveTextContent(/Clone to customize/); + }); + + it('the artifact badge hint names cloning FIRST; the former remedies follow as secondary routes', async () => { + await renderRouted(); + + const badge = screen.getByText(/provided by a code package/); + const hint = badge.getAttribute('title') ?? ''; + const clone = hint.indexOf('Clone to customize'); + expect(clone).toBeGreaterThan(-1); + for (const secondary of ['redeploy', 'new runtime set', 'OS_METADATA_WRITABLE']) { + const at = hint.indexOf(secondary); + expect(at, `"${secondary}" must be in the hint`).toBeGreaterThan(-1); + expect(clone, `cloning must come before "${secondary}"`).toBeLessThan(at); + } + // The server's own reason and code are still stated (objectui#4518's pins + // on this hint are unchanged). + expect(hint).toContain('allowOrgOverride'); + expect(hint).toContain('not_overridable'); + }); + + it('zh-CN: the same primary action and guidance', async () => { + localeFixture = 'zh-CN'; + await renderRouted(); + + expect(screen.getByRole('button', { name: CLONE_ZH })).toBeInTheDocument(); + expect(cloneButtons()).toHaveLength(0); + expect(screen.getByRole('note')).toHaveTextContent(/克隆后自定义/); + // Exact: the guidance note above also says 由代码包提供. + const badge = screen.getByText('只读(该权限集由代码包提供)', { exact: true }); + const hint = badge.getAttribute('title') ?? ''; + expect(hint.indexOf('克隆后自定义')).toBeGreaterThan(-1); + expect(hint.indexOf('克隆后自定义')).toBeLessThan(hint.indexOf('重新部署')); + }); +}); + +describe('PermissionMatrixEditPage — Clone to customize runs the PUBLISHED clone_permission_set action (objectui#5987)', () => { + it('dispatches the action declared on sys_permission_set, with THIS set\'s row as its identity', async () => { + await renderRouted(); + fireEvent.click(cloneButtons()[0]); + + await waitFor(() => expect(apiHandler).toHaveBeenCalledTimes(1)); + + // The identity was read off the set's own record, by name. + expect(adapterFind).toHaveBeenCalledWith(SYS_PERMISSION_SET, { $filter: { name: BASE_SET }, $top: 1 }); + // The action was resolved off the object definition the console holds. + expect(ensureType).toHaveBeenCalledWith('object'); + + // The dialog was asked to render the DECLARED params — the shipped list, + // carry-overs included — not a list of this editor's own. + expect(paramCollection).toHaveBeenCalledTimes(1); + const [askedParams, askedAction] = paramCollection.mock.calls[0]; + expect(askedParams.map((p: any) => p.name ?? p.field)).toEqual( + CLONE_ACTION.params.map((p: any) => p.name ?? p.field), + ); + expect(askedAction.name).toBe('clone_permission_set'); + + // What reached the api handler IS the published declaration… + const [action] = dispatched; + expect(action.name).toBe('clone_permission_set'); + expect(action.type).toBe('api'); + expect(action.method).toBe('POST'); + expect(action.target).toBe('/api/v1/data/sys_permission_set'); + expect(action.objectName).toBe(SYS_PERMISSION_SET); + expect(action.bodyExtra).toEqual({ active: true }); + expect(action.actionParams.map((p: any) => p.name ?? p.field)).toEqual( + CLONE_ACTION.params.map((p: any) => p.name ?? p.field), + ); + // …carrying the set's identity and the collected values. + expect(action.params._rowRecord).toEqual(ROW); + expect(action.params.name).toBe(CLONE_NAME); + expect(action.params.label).toBe('My clone'); + for (const facet of CARRIED) expect(action.params[facet]).toBe(ROW[facet]); + }); + + it('the returned clone opens writable: the route hops to it, and it offers Save with no clone prompt', async () => { + await renderRouted(); + fireEvent.click(cloneButtons()[0]); + + // The clone loads with no code layer (`codeLayerByName` names only the + // base), so the artifact tier does not engage. + const save = await screen.findByRole('button', { name: /^Save$/ }); + expect(save).toBeEnabled(); + expect(cloneButtons()).toHaveLength(0); + expect(screen.queryByRole('note')).toBeNull(); + expect(screen.queryByText(/provided by a code package/)).toBeNull(); + // The editor is on the clone, not the base: it read the clone's envelope. + expect(clientImpl.layered.mock.calls.some((c: unknown[]) => c[1] === CLONE_NAME)).toBe(true); + }); + + it('an embedded host has no route: the clone is announced by name instead', async () => { + await renderPlain({ embedded: true }); + fireEvent.click(cloneButtons()[0]); + + const notice = await screen.findByRole('status'); + expect(notice).toHaveTextContent(new RegExp(`Cloned as ${CLONE_NAME}`)); + }); + + it('a cancelled dialog runs nothing and shows no error', async () => { + paramAnswer = null; + await renderRouted(); + fireEvent.click(cloneButtons()[0]); + + await waitFor(() => expect(paramCollection).toHaveBeenCalledTimes(1)); + expect(apiHandler).not.toHaveBeenCalled(); + expect(screen.queryByRole('alert')).toBeNull(); + // Still on the locked base. + expect(cloneButtons()).toHaveLength(1); + }); + + it('⛔ no fallback clone: an object that publishes no clone action is a refusal, not a create call', async () => { + objectDefs = [{ ...SYS_PERMISSION_SET_DEF, actions: [] }]; + await renderRouted(); + fireEvent.click(cloneButtons()[0]); + + await screen.findByText(/publishes no clone_permission_set action/); + expect(apiHandler).not.toHaveBeenCalled(); + expect(paramCollection).not.toHaveBeenCalled(); + }); + + it('⛔ no fallback clone: a set with no sys_permission_set row is a refusal naming the set', async () => { + rows = []; + await renderRouted(); + fireEvent.click(cloneButtons()[0]); + + await screen.findByText(new RegExp(`no sys_permission_set record named ${BASE_SET}`)); + expect(apiHandler).not.toHaveBeenCalled(); + expect(paramCollection).not.toHaveBeenCalled(); + }); +}); + +describe('PermissionMatrixEditPage — no clone prompt where the artifact tier is not the gate (objectui#5987)', () => { + it('an org-owned set (no code layer) is writable and shows no clone prompt', async () => { + codeLayerByName = {}; + await renderRouted(); + + expect(saveButton()).toBeInTheDocument(); + expect(cloneButtons()).toHaveLength(0); + expect(screen.queryByRole('note')).toBeNull(); + }); + + it('a published org set (the sys_metadata sentinel) is writable and shows no clone prompt', async () => { + codeLayerByName = { [BASE_SET]: { _packageId: 'sys_metadata', name: BASE_SET } }; + await renderRouted(); + + expect(saveButton()).toBeInTheDocument(); + expect(cloneButtons()).toHaveLength(0); + }); + + it('a type with no runtime write channel is locked by the TYPE tier: nothing to clone into', async () => { + typeFlags = { allowOrgOverride: false, allowRuntimeCreate: false }; + await renderRouted(); + + expect(screen.getByText(/no runtime write channel/)).toBeInTheDocument(); + expect(cloneButtons()).toHaveLength(0); + expect(screen.queryByRole('note')).toBeNull(); + }); + + it('a host-locked package (readOnly) shows the package lock, not a clone prompt', async () => { + await renderPlain({ readOnly: true, packageId: 'com.example.showcase' }); + + expect(screen.getByText('Read-only', { exact: true })).toBeInTheDocument(); + expect(cloneButtons()).toHaveLength(0); + }); +}); + +describe('PermissionMatrixEditPage — a save the server refuses as a packaged set shows the same guidance (objectui#5987 item 3)', () => { + const SERVER_TEXT = + "Metadata item 'permission/showcase_contributor' is provided by a code package and the type has not " + + 'opted into per-org overlay writes (allowOrgOverride=false). Edit the source artifact and redeploy, ' + + 'or set OS_METADATA_WRITABLE to grant a runtime escape hatch.'; + + it('403 NOT_OVERRIDABLE at the environment door: guidance names cloning, the primary action is offered, the server sentence is kept', async () => { + // The client reads this set as writable (no code layer) — the server's + // answer is the truth the editor must surface. + codeLayerByName = {}; + saveImpl = async () => { + throw Object.assign(new Error(SERVER_TEXT), { status: 403, code: 'NOT_OVERRIDABLE' }); + }; + await renderRouted(); + fireEvent.click(saveButton()!); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/Save refused \(403 not_overridable\)/); + expect(alert).toHaveTextContent(/Clone to customize instead/); + expect(within(alert).getByRole('button', { name: CLONE_EN })).toBeEnabled(); + // The server's own sentence is kept for diagnosis, not shown alone. + expect(within(alert).getByText(SERVER_TEXT)).toBeInTheDocument(); + // …and it is not ALSO rendered as the generic red strip. + expect(screen.getAllByText(SERVER_TEXT)).toHaveLength(1); + + // The offered action is the same dispatch as the lock's. + fireEvent.click(within(alert).getByRole('button', { name: CLONE_EN })); + await waitFor(() => expect(apiHandler).toHaveBeenCalledTimes(1)); + expect(dispatched[0].name).toBe('clone_permission_set'); + expect(dispatched[0].params._rowRecord).toEqual(ROW); + }); + + it('the refusal is keyed on the CODE: the lowercase spelling an older server emits is the same refusal', async () => { + codeLayerByName = {}; + saveImpl = async () => { + throw Object.assign(new Error(SERVER_TEXT), { status: 403, code: 'not_overridable' }); + }; + await renderRouted(); + fireEvent.click(saveButton()!); + + const alert = await screen.findByRole('alert'); + expect(within(alert).getByRole('button', { name: CLONE_EN })).toBeInTheDocument(); + }); + + it('control: any other refusal stays the generic strip, with no clone prompt', async () => { + codeLayerByName = {}; + saveImpl = async () => { + throw Object.assign(new Error('[Security] Access denied'), { status: 403, code: 'PERMISSION_DENIED' }); + }; + await renderRouted(); + fireEvent.click(saveButton()!); + + await screen.findByText('[Security] Access denied'); + expect(screen.queryByRole('alert')).toBeNull(); + expect(cloneButtons()).toHaveLength(0); + }); + + it('control: under the package door the same code is not the artifact lock, so no clone prompt', async () => { + codeLayerByName = {}; + saveImpl = async () => { + throw Object.assign(new Error(SERVER_TEXT), { status: 403, code: 'NOT_OVERRIDABLE' }); + }; + await renderPlain({ packageId: 'com.example.showcase' }); + fireEvent.click(saveButton()!); + + await screen.findByText(SERVER_TEXT); + expect(screen.queryByRole('alert')).toBeNull(); + expect(cloneButtons()).toHaveLength(0); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index 7718307acf..08fad83743 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -47,6 +47,8 @@ import { AlertTriangle, ChevronDown, ChevronRight, + Copy, + Lock, } from 'lucide-react'; import { Button } from '@object-ui/components'; import { Badge } from '@object-ui/components'; @@ -69,14 +71,16 @@ import { SheetHeader, SheetTitle, } from '@object-ui/components'; -import { useAdapter } from '@object-ui/react'; +import { useAdapter, useAction, useMetadata, useActionTextLocalizer } from '@object-ui/react'; +import type { ActionDef } from '@object-ui/core'; import { CapabilityMultiSelectField, parseCapabilityNames } from '@object-ui/fields'; import { PageShell } from './PageShell.js'; import { HistoryPanel } from './ResourceHistoryPage.js'; import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry } from './useMetadata.js'; -import { t as translate, useMetadataLocale } from './i18n.js'; +import { t as translate, tFormat, useMetadataLocale } from './i18n.js'; import { PermissionAdvancedFacets } from './PermissionAdvancedFacets.js'; import { errorCodeIs } from '@object-ui/types'; +import type { ConsoleActionDispatch } from '../../consoleActionDispatch.js'; import { mergePermissionSlice, scopePermissionSet, @@ -175,6 +179,22 @@ function isArtifactBackedLayer( ); } +/** + * "Clone to customize" (objectui#5987) — the record object a permission set is + * projected onto, and the record action the server PUBLISHES on it. The + * server's own `403 not_overridable` refusal names that action as the remedy + * (maintainer ruling on objectstack#11513: lock the base, clone to customize), + * so the editor runs THAT action — resolved by name off the object definition + * the console already holds, dispatched through the console's shared action + * runner exactly as a `record_header` button would be — and never hand-rolls a + * copy out of create/update calls: the action's `params` list IS the payload + * (which facets a clone carries is decided where the action is declared), and + * a second spelling of it here would be the silent-grant-loss shape + * objectstack#11703 closed. + */ +const PERMISSION_SET_OBJECT = 'sys_permission_set'; +const CLONE_PERMISSION_SET_ACTION = 'clone_permission_set'; + /** Localized short label for an OWD value; falls back to the raw value. */ function owdLabel(t: (k: string) => string, value: string): string { const key: Record = { @@ -277,6 +297,17 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, // registry (ADR-0056 P2). The metadata `client` handles the draft; capability // rows are data, fetched like AssignedUsersSection does. const adapter = useAdapter(); + // objectui#5987 — the three seams "Clone to customize" dispatches through: + // the console's shared action runner (`useAction()` under + // `GlobalActionRuntimeProvider` is the fully wired console runner — api + // handler, param-collection dialog, toasts), the console's metadata store + // (where the `sys_permission_set` object definition, and with it the + // published `clone_permission_set` action, is read from), and the + // `_actions.` bundle localizer every declared-action surface uses for + // the dialog title. See {@link PERMISSION_SET_OBJECT}. + const { execute: executeAction } = useAction(); + const metadataStore = useMetadata(); + const localizeActionTexts = useActionTextLocalizer(); const { entries } = useMetadataTypes(client); const entry: RichMetadataTypeEntry | undefined = entries.find((t) => t.type === type); // Does a code package SHIP this set? Read off the layered envelope the load @@ -429,6 +460,17 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, // does NOT live on `error`: that strip means "your save failed", which is the // opposite of the truth here. const [postSaveRereadFailed, setPostSaveRereadFailed] = React.useState(false); + // objectui#5987 — "Clone to customize" in flight; the clone's name when the + // host is embedded (no route to open it on); and the env-door save the + // server refused as a PACKAGED set (403 `NOT_OVERRIDABLE`), holding the + // server's message. That last one is NOT `error`: the red strip renders the + // transport's text, which for this refusal says "edit the source artifact + // and redeploy" — the pre-ruling remedy — so a save that reached the server + // used to end in a generic refusal while the lock's own guidance says + // "clone". Keyed on the refusal's CODE, never on its prose. + const [cloning, setCloning] = React.useState(false); + const [cloneNotice, setCloneNotice] = React.useState(null); + const [saveRefusedAsLocked, setSaveRefusedAsLocked] = React.useState(null); const [destructive, setDestructive] = React.useState< null | { issues: Array<{ kind?: string; path?: string; message?: string }>; pending: PermissionSetDraft } >(null); @@ -472,6 +514,10 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, // Same reason: a notice raised by the previous set's save must not survive // into a set that is about to be read fresh (objectui#9484). setPostSaveRereadFailed(false); + // Same reason again (objectui#5987): a refusal or a clone notice belongs + // to the set that raised it. + setSaveRefusedAsLocked(null); + setCloneNotice(null); (async () => { try { const [lay, objList, pendingDraft] = await Promise.all([ @@ -778,6 +824,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, setSaving(true); setError(null); setPostSaveRereadFailed(false); + setSaveRefusedAsLocked(null); try { // Package scope: merge only this package's slice back onto a fresh read // of the record so rows contributed by other packages survive byte-for- @@ -864,6 +911,16 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, if (err?.status === 409 && errorCodeIs(err, 'DESTRUCTIVE_CHANGE')) { const issues = err?.body?.issues ?? []; setDestructive({ issues: Array.isArray(issues) ? issues : [], pending: payload }); + } else if (!packageId && err?.status === 403 && errorCodeIs(err, 'NOT_OVERRIDABLE')) { + // objectui#5987 (the card's item 3) — the environment door refused the + // write because a code package ships this set: the same condition the + // artifact tier above locks on, reached by a save that got past it + // (the lock is derived from a read; the server's answer is the + // truth). Surface the lock's own guidance — clone first — beside the + // refusal, instead of the transport's message alone. Env door only: + // under a `packageId` the write is a package draft and this code has a + // different meaning there (the package is not writable). + setSaveRefusedAsLocked(err?.message ?? String(err)); } else { setError(err?.message ?? String(err)); } @@ -872,6 +929,87 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, } } + /* ── Clone to customize (objectui#5987) ──────────────────────── */ + /** + * Run the server-published `clone_permission_set` record action on THIS set. + * + * Every step is the same one a `record_header` "Clone" button on the + * `sys_permission_set` record page takes — resolve the action off the object + * definition, stash the row under `params._rowRecord` (what the runner's + * param dialog seeds `defaultFromRow` params from and what the api handler + * reads for `{id}` / record-id injection), surface the declared `params` + * ARRAY as `actionParams`, and `execute` — so the clone's payload, dialog, + * refusal toasts and success toast are the published action's, not this + * editor's. What this editor adds is only where the clone OPENS: on the + * routed metadata admin it navigates to the clone, which loads with no code + * layer and is therefore writable; an embedded host has no route, so the + * clone is announced by name instead. + * + * Two refusals, no fallbacks (AGENTS.md #0.1): an object that publishes no + * such action, or a set with no `sys_permission_set` row, ends here with a + * message naming what is missing — never a clone assembled from create / + * update calls. + */ + async function cloneToCustomize() { + if (cloning) return; + setCloning(true); + setError(null); + setCloneNotice(null); + try { + const objectDefs = await metadataStore.ensureType('object'); + const setObject = (Array.isArray(objectDefs) ? objectDefs : []).find( + (o: { name?: unknown }) => o?.name === PERMISSION_SET_OBJECT, + ) as { actions?: unknown } | undefined; + const declaredActions = Array.isArray(setObject?.actions) ? setObject.actions : []; + const cloneAction = declaredActions.find( + (a: { name?: unknown }) => a?.name === CLONE_PERMISSION_SET_ACTION, + ) as (ActionDef & { params?: unknown }) | undefined; + if (!cloneAction) throw new Error(t('perm.clone.actionMissing')); + + // The row the action runs against — `AssignedUsersSection` resolves the + // same set's record the same way. `data` is the adapter's `QueryResult` + // contract; a bare array is not read (#0.1 — fix the producer). + const found = adapter ? await adapter.find(PERMISSION_SET_OBJECT, { $filter: { name }, $top: 1 }) : null; + const row = (found?.data ?? [])[0] as Record | undefined; + if (!row) throw new Error(tFormat('perm.clone.rowMissing', locale, { name })); + + const { params: declaredParams, ...rest } = cloneAction; + const dispatch: ConsoleActionDispatch = { + ...localizeActionTexts(PERMISSION_SET_OBJECT, rest as Record), + objectName: PERMISSION_SET_OBJECT, + params: { _rowRecord: row }, + }; + if (Array.isArray(declaredParams) && declaredParams.length > 0) { + dispatch.actionParams = declaredParams as ConsoleActionDispatch['actionParams']; + } + const result = await executeAction(dispatch); + // A cancelled dialog and a refused POST both come back `success: false`; + // the runner has already toasted a refusal, and a cancel needs nothing. + if (!result.success) return; + // The data door answers the spec's `CreateDataResponse` — `{ object, id, + // record }` — which the console api handler unwraps to `result.data`. + const created = result.data as { record?: { name?: unknown } } | null | undefined; + const cloneName = typeof created?.record?.name === 'string' ? created.record.name : ''; + if (!cloneName) { + setError(t('perm.clone.noName')); + return; + } + if (embedded) { + setCloneNotice(cloneName); + } else { + // Same spelling as ResourceEditPage's post-create hop: the route is + // `metadata/:type/:name`, so `../` opens the clone under the + // same type. It loads with `code: null`, so the artifact tier does not + // engage and Save is offered. + navigate(`../${encodeURIComponent(cloneName)}`, { relative: 'path' }); + } + } catch (err: any) { + setError(err?.message ?? String(err)); + } finally { + setCloning(false); + } + } + /* ── Render helpers ──────────────────────────────────────── */ const filteredObjects = React.useMemo(() => { const q = filter.trim().toLowerCase(); @@ -899,6 +1037,25 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, }, ]; + // objectui#5987 — one element, two seats: the header slot Save would occupy + // when the artifact tier locks the surface, and the refused-save strip when a + // save reached the server anyway. Same label, same hint, same dispatch. + const cloneButton = ( + + ); + if (loading) { return ( @@ -956,6 +1113,13 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, {t('engine.edit.save')} )} + {/* objectui#5987 — the locked editor's PRIMARY action, in the slot Save + would occupy: a code package ships this set, so the ruled path is + to clone it and edit the clone. Only where the artifact tier is + the deciding gate — a host-locked package or a type with no + runtime write channel at all has no clone to offer (the clone is + itself a runtime-created set). */} + {lockedByArtifactTier && cloneButton} } > @@ -967,6 +1131,52 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, )} + {/* objectui#5987 — the locked-state guidance, visible rather than + tooltip-only, naming the ruled path first. The badge in the identity + strip below still carries the full reasoning in its hint. */} + {lockedByArtifactTier && ( +
+ + {t('perm.clone.guidance')} +
+ )} + + {/* objectui#5987 (item 3) — a save that reached the server and was + refused as a PACKAGED set: the same guidance as the lock, with the + same primary action, and the server's own sentence kept underneath + for diagnosis. `role="alert"`: this IS a failed save. */} + {saveRefusedAsLocked !== null && ( +
+
+ + {t('perm.save.locked')} +
+
+ {cloneButton} + {saveRefusedAsLocked} +
+
+ )} + + {/* objectui#5987 — an embedded host has no route to open the clone on, + so the clone is announced by name (advisory, like the re-read + notice below). */} + {cloneNotice !== null && ( +
+ + {tFormat('perm.clone.done', locale, { name: cloneNotice })} +
+ )} + {/* objectui#9484 — the save LANDED and only its confirming re-read did not, so this is advisory (amber, `role="status"`), not the destructive-toned error strip above. Rendered separately rather than diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 05ab136bd6..34efce7643 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1651,8 +1651,36 @@ const ENGINE_STRINGS_EN: Record = { // because a code package ships it and overwriting a packaged item is an // overlay — which `permission` has not opted into. 'perm.readOnly.artifact': 'Read-only (this set is provided by a code package)', + // objectui#5987 — the ruled path comes FIRST (maintainer ruling on + // objectstack#11513: lock the base, clone to customize). The remedies that + // used to lead ("edit the source artifact and redeploy", a new runtime set, + // the operator hatch) stay, as the secondary routes they are. 'perm.readOnly.artifact.hint': - 'A code package ships this permission set, so an environment-scope edit would overlay a packaged item — and the metadata-type registry declares allowOrgOverride false for permission (ADR-0005 forbids per-org overlay of a packaged set: silent privilege drift). The server refuses the write with 403 not_overridable. Edit the source artifact and redeploy, create a new runtime set instead, or ask an operator for the documented OS_METADATA_WRITABLE escape hatch.', + 'A code package ships this permission set, so it is locked here: an environment-scope edit would overlay a packaged item, and the metadata-type registry declares allowOrgOverride false for permission (ADR-0005 forbids per-org overlay of a packaged set: silent privilege drift). The server refuses the write with 403 not_overridable. Clone to customize: the clone is an ordinary set owned by your organization that you edit here, and upgrades keep flowing to this base. Other routes: edit the source artifact and redeploy, create a new runtime set, or ask an operator for the documented OS_METADATA_WRITABLE escape hatch.', + // objectui#5987 — "Clone to customize", the locked editor's primary action. + // It runs the `clone_permission_set` record action the server publishes on + // `sys_permission_set` (the action the 403 refusal itself names as the + // remedy) through the console's own action runner, never a hand-rolled copy. + 'perm.clone.action': 'Clone to customize', + 'perm.clone.hint': + 'Creates an organization-owned copy of this set through the published clone_permission_set action; the copy is editable here, and upgrades keep flowing to the package-provided base.', + 'perm.clone.guidance': + 'Locked: a code package provides this permission set, so it cannot be edited in place here (upgrades keep flowing to it). Clone to customize — the clone is an ordinary set owned by your organization, editable here.', + // Refusals, not fallbacks: when the published action or the record is not + // there, the editor says so instead of cloning by other means. + 'perm.clone.actionMissing': + 'Clone is unavailable: the sys_permission_set object on this server publishes no clone_permission_set action.', + 'perm.clone.rowMissing': + 'Clone is unavailable: no sys_permission_set record named {name} was found.', + 'perm.clone.noName': + 'The clone was created, but the server\'s answer did not name it. Open it from the permission-set list.', + // Embedded hosts have no route to navigate to, so the clone is announced. + 'perm.clone.done': 'Cloned as {name}. Open it from the permission-set list to edit.', + // A save that still reached the server and was refused as a packaged set: + // the same guidance as the lock, keyed on the refusal's code (never its + // prose), instead of the transport's generic message. + 'perm.save.locked': + 'Save refused (403 not_overridable): a code package provides this permission set, so the server does not accept an in-place edit. Clone to customize instead — the clone is your organization\'s own set, and upgrades keep flowing to this base.', // Designer wrapper 'designer.unsavedChanges': 'Unsaved changes', 'designer.editingOverlay': 'Editing overlay', @@ -3922,8 +3950,22 @@ const ENGINE_STRINGS_ZH: Record = { // 与上面的「类型没有写入通道」是两回事:该类型有运行时写入通道,被锁的是「这一个」 // 由代码包提供的权限集。 'perm.readOnly.artifact': '只读(该权限集由代码包提供)', + // objectui#5987 — 见 EN 表同键注释:裁定的路径(克隆后自定义)放在最前。 'perm.readOnly.artifact.hint': - '该权限集由代码包提供,因此在环境作用域下编辑它属于覆盖(overlay)已打包的条目;而元数据类型注册表对 permission 声明 allowOrgOverride 为 false(ADR-0005 禁止对已打包权限集做按组织覆盖:会造成权限静默漂移)。服务端会以 403 not_overridable 拒绝该写入。请修改源工件后重新部署,或改为新建一个运行时权限集,或由运维启用有文档记载的 OS_METADATA_WRITABLE 逃生阀。', + '该权限集由代码包提供,因此在此处被锁定:在环境作用域下编辑它属于覆盖(overlay)已打包的条目,而元数据类型注册表对 permission 声明 allowOrgOverride 为 false(ADR-0005 禁止对已打包权限集做按组织覆盖:会造成权限静默漂移)。服务端会以 403 not_overridable 拒绝该写入。请「克隆后自定义」:克隆出的权限集归本组织所有、可在此编辑,升级会继续流向这个基线。其他途径:修改源工件后重新部署、新建一个运行时权限集,或由运维启用有文档记载的 OS_METADATA_WRITABLE 逃生阀。', + // objectui#5987 — 见 EN 表同键注释。 + 'perm.clone.action': '克隆后自定义', + 'perm.clone.hint': + '通过已发布的 clone_permission_set 动作,创建一份归本组织所有的副本;副本可在此编辑,升级会继续流向由包提供的基线。', + 'perm.clone.guidance': + '已锁定:该权限集由代码包提供,不能在此处原地编辑(升级会继续流向它)。请「克隆后自定义」—— 克隆出的权限集是归本组织所有的普通权限集,可在此编辑。', + 'perm.clone.actionMissing': + '无法克隆:当前服务端的 sys_permission_set 对象未发布 clone_permission_set 动作。', + 'perm.clone.rowMissing': '无法克隆:未找到名为 {name} 的 sys_permission_set 记录。', + 'perm.clone.noName': '克隆已创建,但服务端的响应未给出其名称。请从权限集列表中打开它。', + 'perm.clone.done': '已克隆为 {name}。请从权限集列表中打开它进行编辑。', + 'perm.save.locked': + '保存被拒绝(403 not_overridable):该权限集由代码包提供,服务端不接受原地编辑。请改为「克隆后自定义」—— 克隆出的权限集归本组织所有,升级会继续流向这个基线。', // Designer wrapper 'designer.unsavedChanges': '未保存的修改', 'designer.editingOverlay': '编辑覆盖层', From 48aaf0f06807bada2fdad685171dfda4f2b66969 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:31:49 +0000 Subject: [PATCH 2/3] fix(app-shell): move the clone dispatch shaping out of the scanned editor surface (objectui#5987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI Test (shard 1/8) failed `permission-slice.authoredKeys.test.ts` on the first commit: `expected [ 'objectName', 'params' ] to deeply equal []`. The scanner reads `{ ...spread, key }` literals inside `setDraft(` regions of `PermissionMatrixEditor.tsx` as facets written into the permission-set draft. Its `setDraft(` regex also matches the prose `funnels through setDraft (matrix checkboxes, …` in a `//` comment, and the `)` that would close that region is on the next comment line, which the walker skips wholesale — so that region runs to end of file (on main too) and every leading-spread literal below it is counted. The clone dispatch literal (`{ ...localized, objectName, params }`) is the action runner's call shape, not a permission-set facet, and it landed inside that region. The construction now lives in `permission-set-clone-dispatch.ts` (`findCloneAction`, `buildCloneDispatch`), which the scan does not read. No behaviour change; the slice's carried-key list is untouched. The scanner's comment-matching is reported separately, not changed here. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../metadata-admin/PermissionMatrixEditor.tsx | 63 ++++--------- .../permission-set-clone-dispatch.ts | 92 +++++++++++++++++++ 2 files changed, 109 insertions(+), 46 deletions(-) create mode 100644 packages/app-shell/src/views/metadata-admin/permission-set-clone-dispatch.ts diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index 08fad83743..9d9039cc05 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -72,7 +72,6 @@ import { SheetTitle, } from '@object-ui/components'; import { useAdapter, useAction, useMetadata, useActionTextLocalizer } from '@object-ui/react'; -import type { ActionDef } from '@object-ui/core'; import { CapabilityMultiSelectField, parseCapabilityNames } from '@object-ui/fields'; import { PageShell } from './PageShell.js'; import { HistoryPanel } from './ResourceHistoryPage.js'; @@ -80,7 +79,11 @@ import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry } from import { t as translate, tFormat, useMetadataLocale } from './i18n.js'; import { PermissionAdvancedFacets } from './PermissionAdvancedFacets.js'; import { errorCodeIs } from '@object-ui/types'; -import type { ConsoleActionDispatch } from '../../consoleActionDispatch.js'; +import { + PERMISSION_SET_OBJECT, + buildCloneDispatch, + findCloneAction, +} from './permission-set-clone-dispatch.js'; import { mergePermissionSlice, scopePermissionSet, @@ -179,22 +182,6 @@ function isArtifactBackedLayer( ); } -/** - * "Clone to customize" (objectui#5987) — the record object a permission set is - * projected onto, and the record action the server PUBLISHES on it. The - * server's own `403 not_overridable` refusal names that action as the remedy - * (maintainer ruling on objectstack#11513: lock the base, clone to customize), - * so the editor runs THAT action — resolved by name off the object definition - * the console already holds, dispatched through the console's shared action - * runner exactly as a `record_header` button would be — and never hand-rolls a - * copy out of create/update calls: the action's `params` list IS the payload - * (which facets a clone carries is decided where the action is declared), and - * a second spelling of it here would be the silent-grant-loss shape - * objectstack#11703 closed. - */ -const PERMISSION_SET_OBJECT = 'sys_permission_set'; -const CLONE_PERMISSION_SET_ACTION = 'clone_permission_set'; - /** Localized short label for an OWD value; falls back to the raw value. */ function owdLabel(t: (k: string) => string, value: string): string { const key: Record = { @@ -304,7 +291,8 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, // (where the `sys_permission_set` object definition, and with it the // published `clone_permission_set` action, is read from), and the // `_actions.` bundle localizer every declared-action surface uses for - // the dialog title. See {@link PERMISSION_SET_OBJECT}. + // the dialog title. Resolution and dispatch shape live in + // `permission-set-clone-dispatch.ts` — see its header for why. const { execute: executeAction } = useAction(); const metadataStore = useMetadata(); const localizeActionTexts = useActionTextLocalizer(); @@ -935,15 +923,14 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, * * Every step is the same one a `record_header` "Clone" button on the * `sys_permission_set` record page takes — resolve the action off the object - * definition, stash the row under `params._rowRecord` (what the runner's - * param dialog seeds `defaultFromRow` params from and what the api handler - * reads for `{id}` / record-id injection), surface the declared `params` - * ARRAY as `actionParams`, and `execute` — so the clone's payload, dialog, - * refusal toasts and success toast are the published action's, not this - * editor's. What this editor adds is only where the clone OPENS: on the - * routed metadata admin it navigates to the clone, which loads with no code - * layer and is therefore writable; an embedded host has no route, so the - * clone is announced by name instead. + * definition (`findCloneAction`), shape the dispatch (`buildCloneDispatch`: + * the row under `params._rowRecord`, the declared `params` ARRAY as + * `actionParams`), and `execute` — so the clone's payload, dialog, refusal + * toasts and success toast are the published action's, not this editor's. + * What this editor adds is only where the clone OPENS: on the routed + * metadata admin it navigates to the clone, which loads with no code layer + * and is therefore writable; an embedded host has no route, so the clone is + * announced by name instead. * * Two refusals, no fallbacks (AGENTS.md #0.1): an object that publishes no * such action, or a set with no `sys_permission_set` row, ends here with a @@ -956,14 +943,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, setError(null); setCloneNotice(null); try { - const objectDefs = await metadataStore.ensureType('object'); - const setObject = (Array.isArray(objectDefs) ? objectDefs : []).find( - (o: { name?: unknown }) => o?.name === PERMISSION_SET_OBJECT, - ) as { actions?: unknown } | undefined; - const declaredActions = Array.isArray(setObject?.actions) ? setObject.actions : []; - const cloneAction = declaredActions.find( - (a: { name?: unknown }) => a?.name === CLONE_PERMISSION_SET_ACTION, - ) as (ActionDef & { params?: unknown }) | undefined; + const cloneAction = findCloneAction(await metadataStore.ensureType('object')); if (!cloneAction) throw new Error(t('perm.clone.actionMissing')); // The row the action runs against — `AssignedUsersSection` resolves the @@ -973,16 +953,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, const row = (found?.data ?? [])[0] as Record | undefined; if (!row) throw new Error(tFormat('perm.clone.rowMissing', locale, { name })); - const { params: declaredParams, ...rest } = cloneAction; - const dispatch: ConsoleActionDispatch = { - ...localizeActionTexts(PERMISSION_SET_OBJECT, rest as Record), - objectName: PERMISSION_SET_OBJECT, - params: { _rowRecord: row }, - }; - if (Array.isArray(declaredParams) && declaredParams.length > 0) { - dispatch.actionParams = declaredParams as ConsoleActionDispatch['actionParams']; - } - const result = await executeAction(dispatch); + const result = await executeAction(buildCloneDispatch(cloneAction, row, localizeActionTexts)); // A cancelled dialog and a refused POST both come back `success: false`; // the runner has already toasted a refusal, and a cancel needs nothing. if (!result.success) return; diff --git a/packages/app-shell/src/views/metadata-admin/permission-set-clone-dispatch.ts b/packages/app-shell/src/views/metadata-admin/permission-set-clone-dispatch.ts new file mode 100644 index 0000000000..79d19b2217 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/permission-set-clone-dispatch.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * "Clone to customize" (objectui#5987) — resolving the server-published + * `clone_permission_set` record action and shaping its dispatch. + * + * The permission matrix locks a set a code package ships (the artifact tier). + * The maintainer's ruling on objectstack#11513 — lock the base, clone to + * customize — makes cloning the sanctioned edit path, and the server's own + * `403 not_overridable` refusal names the `clone_permission_set` action as the + * remedy. The editor therefore runs THAT action, resolved by name off the + * `sys_permission_set` object definition the console already holds, and never + * hand-rolls a copy out of create/update calls: the action's `params` list IS + * the payload (which facets a clone carries is decided where the action is + * declared), and a second spelling of it here would be the silent-grant-loss + * shape objectstack#11703 closed. + * + * ## Why this lives beside the editor rather than in it + * + * The dispatch is the same shape `DeclaredActionsBar` and `ObjectGrid` hand + * the console action runner for a `record_header` / `list_item` action: the + * declaration spread first, `objectName`, and the row under + * `params._rowRecord`. That is an object literal that opens with a spread — + * the very shape `permission-slice.authoredKeys.test.ts` reads inside + * `PermissionMatrixEditor.tsx` as "a facet written into the permission-set + * draft". These keys are the action call's arguments, not facets of a + * permission set, so the construction sits in this module, which that scan + * does not read, instead of being reshaped to dodge it. + */ + +import type { ActionDef } from '@object-ui/core'; +import type { ConsoleActionDispatch } from '../../consoleActionDispatch.js'; + +/** The record object a permission set is projected onto. */ +export const PERMISSION_SET_OBJECT = 'sys_permission_set'; +/** The record action the server publishes on it, and the 403 names as the remedy. */ +export const CLONE_PERMISSION_SET_ACTION = 'clone_permission_set'; + +/** The published action, as an object definition declares it. */ +export type CloneAction = ActionDef & { params?: unknown }; + +/** + * Find `clone_permission_set` on the `sys_permission_set` object among the + * object definitions the console metadata store holds. `undefined` when the + * server publishes no such object or no such action — the caller refuses, + * it does not fall back. + */ +export function findCloneAction(objectDefs: unknown): CloneAction | undefined { + const defs = Array.isArray(objectDefs) ? objectDefs : []; + const setObject = defs.find( + (o: { name?: unknown }) => o?.name === PERMISSION_SET_OBJECT, + ) as { actions?: unknown } | undefined; + const declared = Array.isArray(setObject?.actions) ? setObject.actions : []; + return declared.find( + (a: { name?: unknown }) => a?.name === CLONE_PERMISSION_SET_ACTION, + ) as CloneAction | undefined; +} + +/** + * `localizeActionTexts` from `@object-ui/react`, by its call shape: label, + * confirmText and successMessage resolved through the `_actions.NAME` bundle. + */ +export type ActionTextLocalize = ( + objectName: string, + action: Record, +) => Record; + +/** + * Shape the runner dispatch for cloning `row` through `cloneAction`. + * + * Same dispatch shape as `DeclaredActionsBar`: the (localized) declaration + * forwarded whole, `objectName` set, the declared `params` ARRAY surfaced as + * `actionParams` (the runner's param-dialog input, seeded `defaultFromRow` + * from the row), and `params` reserved for the `_rowRecord` stash the api + * handler reads for `{id}` interpolation and record-id injection. + */ +export function buildCloneDispatch( + cloneAction: CloneAction, + row: Record, + localize: ActionTextLocalize, +): ConsoleActionDispatch { + const { params: declaredParams, ...rest } = cloneAction; + const dispatch: ConsoleActionDispatch = { + ...localize(PERMISSION_SET_OBJECT, rest as Record), + objectName: PERMISSION_SET_OBJECT, + params: { _rowRecord: row }, + }; + if (Array.isArray(declaredParams) && declaredParams.length > 0) { + dispatch.actionParams = declaredParams as ConsoleActionDispatch['actionParams']; + } + return dispatch; +} From 2f47d4042245efd838847738bcb9cc5560032cbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 06:12:17 +0000 Subject: [PATCH 3/3] docs(changeset): grade Clone to customize minor and name the door whose refusal lacks the clone remedy (objectui#5987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review at 48aaf0f068 (published text only): - `'@object-ui/app-shell': minor` — a new user-facing affordance is a feature under .changeset/README.md, matching the `feat(` headline; the dispatch order's `patch` was the seat's own correction. - The CHANGELOG-bound sentence no longer says the server's 403 refusal names the clone: only the data door's PackagedPermissionSetLockedError does; the metadata-door refusal a Studio save receives names the pre-ruling remedies. Prose and frontmatter only; no source change. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/5987-permset-clone-to-customize.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/5987-permset-clone-to-customize.md b/.changeset/5987-permset-clone-to-customize.md index ba296bc838..74c094e872 100644 --- a/.changeset/5987-permset-clone-to-customize.md +++ b/.changeset/5987-permset-clone-to-customize.md @@ -1,5 +1,5 @@ --- -'@object-ui/app-shell': patch +'@object-ui/app-shell': minor --- feat(app-shell): a package-provided permission set offers "Clone to customize" as its primary action, and names it first (objectui#5987) @@ -10,7 +10,9 @@ tier: `isArtifactBackedLayer`, the client mirror of the server's edit the source artifact and redeploy, a new runtime set, the `OS_METADATA_WRITABLE` hatch. The ruled path (objectstack#11513, 「同意 第一步 (创业阶段,Salesforce 式)」: lock the base, clone to customize) was never on the -screen, although the server's own `403 not_overridable` refusal names it. +screen, and the metadata-door refusal a Studio save receives names only the +pre-ruling remedies; the data door's refusal is the one that names the Clone +action. The locked editor now renders **Clone to customize** in the slot Save would occupy. It runs the `clone_permission_set` record action the server publishes