diff --git a/.changeset/28573-new-similar-permission-set.md b/.changeset/28573-new-similar-permission-set.md new file mode 100644 index 00000000000..b360085f962 --- /dev/null +++ b/.changeset/28573-new-similar-permission-set.md @@ -0,0 +1,17 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(studio): "New similar" on a read-only base's permission set in the Access pillar, opening the copy where the clone door landed it + +Clause-②: no +Metadata-protocol: none + +A customer's own permission set is a new set in the package that holds their changes (ADR-0146 D6). Since the clone door lands a copy of a read-only package X's set in X's paired `X.custom`, Studio can offer that gesture where the base is designed. + +- **New similar in Studio.** In the Access pillar of a read-only package, the open permission set offers "New similar". It asks for a machine name and a label (the same dialog as Setup's catalog) and posts exactly `{ name, label }` to `POST /security/permission-sets/:name/clone`. The client sends no package and no facet: the server decides where the copy lands. +- **The copy opens where the answer says.** The door's answer names the package the copy was saved in. The pillar opens that package's Access pillar with the copy selected (`/studio//access?surface=permission:`), and shows the server's notice there as sent. An answer that names no package (absent or `null`) or no copy says so, and opens nothing. +- **Refusals and gates.** A refusal (400, 403, 404, 409) is shown in the server's words in the dialog. Under a walled posture, or for a caller without `manage_metadata`, the action is drawn disabled with the console's own sentence for why. The base's sets stay read-only, and no override gesture is drawn on a permission set. +- **One label.** The action reads "New similar" (zh: 新建类似) on Setup's catalog too, and the guidance strings that name it say the server saves the copy in the package that holds your changes. Setup's post-clone hop is unchanged. +- **Access pillar keyed by package.** Like its sibling pillars, it remounts when the package changes, so opening the copy in another package's pillar no longer keeps the base's open set. diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.cloneToCustomize.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.cloneToCustomize.test.tsx index d451e4fb838..3b26ba7d71f 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.cloneToCustomize.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.cloneToCustomize.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * "Clone to customize" — the locked permission matrix's primary action + * "New similar" (formerly "Clone to customize") — the locked permission matrix's primary action * (objectui#5987, the UI half of objectstack#11513), calling the server's * clone door (objectui#7611, PR-4). * @@ -15,7 +15,7 @@ * whole definition under a new name, "objectui calling it with a name and a * label". This suite pins that the editor: * - * 1. renders "Clone to customize" as the locked editor's PRIMARY action, in + * 1. renders "New similar" 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. asks for a new name and label, and POSTs exactly `{ name, label }` to @@ -250,8 +250,10 @@ async function renderPlain(props?: { packageId?: string; readOnly?: boolean; emb await screen.findByText('Account'); } -const CLONE_EN = /^Clone to customize$/; -const CLONE_ZH = /^克隆后自定义$/; +// objectstack#28573 (ADR-0146 D6) — the action reads "New similar" on Setup's +// catalog as in Studio's Access pillar. +const CLONE_EN = /^New similar$/; +const CLONE_ZH = /^新建类似$/; const cloneButtons = () => screen.queryAllByRole('button', { name: CLONE_EN }); const saveButton = () => screen.queryByRole('button', { name: /^Save$/ }); const createCopy = () => screen.getByRole('button', { name: /^Create copy$/ }); @@ -284,8 +286,8 @@ afterEach(() => { /* ────────────────────────────────────────────────────────────────────────── */ -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 () => { +describe('PermissionMatrixEditPage — the locked editor offers New similar (objectui#5987)', () => { + it('renders "New similar" as the primary action where Save would be, and no Save', async () => { await renderRouted(); expect(saveButton()).toBeNull(); @@ -293,7 +295,7 @@ describe('PermissionMatrixEditPage — the locked editor offers Clone to customi expect(clone).toBeInTheDocument(); expect(clone).toBeEnabled(); // The visible locked-state guidance names cloning. - expect(screen.getByRole('note')).toHaveTextContent(/Clone to customize/); + expect(screen.getByRole('note')).toHaveTextContent(/New similar/); }); it('the artifact badge hint names cloning FIRST; the former remedies follow as secondary routes', async () => { @@ -301,7 +303,7 @@ describe('PermissionMatrixEditPage — the locked editor offers Clone to customi const badge = screen.getByText(/provided by a code package/); const hint = badge.getAttribute('title') ?? ''; - const clone = hint.indexOf('Clone to customize'); + const clone = hint.indexOf('New similar'); expect(clone).toBeGreaterThan(-1); for (const secondary of ['redeploy', 'new runtime set']) { const at = hint.indexOf(secondary); @@ -325,16 +327,16 @@ describe('PermissionMatrixEditPage — the locked editor offers Clone to customi expect(screen.getByRole('button', { name: CLONE_ZH })).toBeInTheDocument(); expect(cloneButtons()).toHaveLength(0); - expect(screen.getByRole('note')).toHaveTextContent(/克隆后自定义/); + 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('重新部署')); + expect(hint.indexOf('新建类似')).toBeGreaterThan(-1); + expect(hint.indexOf('新建类似')).toBeLessThan(hint.indexOf('重新部署')); }); }); -describe('PermissionMatrixEditPage — Clone to customize calls the clone door with a name and a label (objectui#7611)', () => { +describe('PermissionMatrixEditPage — New similar calls the clone door with a name and a label (objectui#7611)', () => { it('POSTs exactly { name, label } to the door for THIS set, and nothing else', async () => { await renderRouted(); fireEvent.click(cloneButtons()[0]); @@ -504,7 +506,7 @@ describe('PermissionMatrixEditPage — a save the server refuses as a packaged s const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent(/Save refused \(403 not_overridable\)/); - expect(alert).toHaveTextContent(/Clone to customize instead/); + expect(alert).toHaveTextContent(/New similar 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(); diff --git a/ui/packages/app-shell/src/views/metadata-admin/packagedItemWriteGate-12169.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/packagedItemWriteGate-12169.test.tsx index 98fc0e50d18..bdfeecb82dc 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/packagedItemWriteGate-12169.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/packagedItemWriteGate-12169.test.tsx @@ -244,7 +244,7 @@ describe('a hatch-elevated type: its PACKAGED item is read-only on every gate (o expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull(); for (const box of screen.getAllByRole('checkbox')) expect(box).toBeDisabled(); expect(screen.getByText(ARTIFACT_CAPTION)).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: /Clone to customize/ }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('button', { name: /New similar/ }).length).toBeGreaterThan(0); }); it('the badge claims no more: its tooltip says the hatch never opens an item a package ships', async () => { diff --git a/ui/packages/app-shell/src/views/metadata-admin/permission-set-clone.ts b/ui/packages/app-shell/src/views/metadata-admin/permission-set-clone.ts index eef16311c94..7113bd1ea2c 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/permission-set-clone.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/permission-set-clone.ts @@ -11,9 +11,17 @@ * (objectstack#22881, ADR-0126 §7.1). The server reads the source set from the * security catalog, copies its WHOLE definition under the new name, drops the * keys it declares (the provenance envelope, `packageId`, `managedBy`, - * `protection`, `adminScope`, `isDefault`), and saves the copy as an ordinary - * permission set authored in this environment. It answers `{ permissionSet, - * notice }`: the saved copy, and a sentence naming what was not copied. + * `protection`, `adminScope`, `isDefault`), and saves the copy in the package + * it decides (ADR-0146 D6, "new similar"): a set a read-only package X ships + * lands in X's paired `.custom`, a set in a writable package in that + * package, an environment-authored set in no package. It answers + * `{ permissionSet, package, notice }`: the saved copy, the package it landed + * in (`null` for environment-wide), and a sentence saying where it went and + * what was not copied. + * + * ⛔ The client never names the package. The body's optional `package` key is + * the server's to default, and where the copy lands is read back off the + * answer, never derived here from the source's package. * * So this module sends a name and a label and nothing else (the seat's ruling * on objectui#7611, 6097763840: "objectui calling it with a name and a label"). @@ -44,13 +52,21 @@ import { envelopeData, envelopeRefused } from '../setup/packagedFlows.js'; export interface PermissionSetCloned { /** The copy's machine name, as the server saved it. */ name: string; + /** + * The package the server saved the copy in: its id, `null` when the answer + * says environment-wide, and `undefined` when the answer carries no + * `package` at all (a server older than the "new similar" door). Read as + * sent; a caller that opens the copy opens it only where this names. + */ + package?: string | null; /** The server's sentence on what was not copied; empty when it sent none. */ notice: string; } /** * Clone `source` through the door. Resolves to the saved copy's name (empty - * when the answer names none) and the server's notice. A refusal rejects with + * when the answer names none), the package it landed in, and the server's + * notice. A refusal rejects with * the server's own message; `fallback` words one only when the answer has none. */ export async function clonePermissionSet( @@ -70,8 +86,10 @@ export async function clonePermissionSet( if (!res.ok || envelopeRefused(json)) throw new Error(actionErrorDetail(json, fallback(res.status))); const data = envelopeData(json); const created = (data.permissionSet as { name?: unknown } | null | undefined)?.name; + const landed = data.package; return { name: typeof created === 'string' ? created : '', + package: typeof landed === 'string' && landed !== '' ? landed : landed === null ? null : undefined, notice: typeof data.notice === 'string' ? data.notice : '', }; } diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessNewSimilar-28573.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessNewSimilar-28573.test.tsx new file mode 100644 index 00000000000..f6848dbc8da --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessNewSimilar-28573.test.tsx @@ -0,0 +1,304 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28573 (ADR-0146 D6, N-20b) — "New similar" on a read-only base's + * permission set, in Studio's Access pillar. + * + * The clone door (`POST /security/permission-sets/:name/clone`) decides where + * the copy lands: since #28563 a set a read-only package X ships is copied into + * X's paired `X.custom`, and the answer names that package. This suite pins + * that the pillar: + * + * - offers "New similar" on a read-only base's open set, and posts exactly + * `{ name, label }` (no package, no facet); + * - opens the copy where the answer says it landed: that package's Access + * pillar with the copy selected, the server's notice shown as sent; + * - opens nothing on an answer that names no package (absent or `null`), and + * says so; + * - shows a refusal (409) in the server's words, staying on the base; + * - draws the action disabled, in the console's own sentence, under a walled + * posture and for a caller without `manage_metadata`; + * - draws no override gesture on a base set, and no "New similar" on a + * writable package's set. + * + * The REAL `AccessPillar`, `PermissionMatrixEditPage` and + * `PermissionSetCloneDialog`, routed the way `StudioDesignSurface` mounts the + * pillar (`/studio/:packageId/access`, keyed by package). The door's answer is + * the shape `packages/runtime/src/domains/permission-set-clone.ts` sends: + * `{ permissionSet, package, notice }`. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation, useParams } from 'react-router-dom'; + +const BASE_PKG = 'hotcrm'; +const CUSTOM_PKG = 'hotcrm.custom'; +const BASE_SET = 'hotcrm_sales_rep'; +const COPY_NAME = 'my_sales_rep'; +const COPY_LABEL = 'My sales rep'; +const DOOR = `/api/v1/security/permission-sets/${BASE_SET}/clone`; + +/** `permissionSetCloneNotice` for a copy that landed in a freshly ensured pair. */ +const NOTICE = + `The copy was saved in package '${CUSTOM_PKG}', created for it to hold your changes to the package the set came from, ` + + 'beside the original rather than in place of it, and travels with that package. Not copied: delegated-admin scope ' + + '(`adminScope`) and the everyone-baseline suggestion (`isDefault`) — grant either deliberately on the copy if it needs one.'; + +/** The door's 409 for a taken name. */ +const NAME_TAKEN = + `Permission set '${COPY_NAME}' already exists — a clone must take a NEW machine name. Retry with a name no permission set uses (for example '${COPY_NAME}_copy').`; + +type Row = { pkg: string; body: Record }; + +const BASE_BODY = { name: BASE_SET, label: 'Sales rep', objects: { crm_lead: { allowRead: true } }, fields: {} }; + +let clientImpl: ReturnType; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useMetadataClient: () => clientImpl, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: false, allowRuntimeCreate: true }], + }), + }; +}); +let canAuthor = true; +vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useCanAuthorMetadata: () => canAuthor, +})); +let walled = false; +vi.mock('../metadata-admin/catalog-scope', async (importOriginal) => ({ + ...(await importOriginal>()), + useWalledCatalogReadOnly: () => walled, +})); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); + +import { t, tFormat, translateMetadataType } from '../metadata-admin/i18n'; +import { AccessPillar } from './StudioDesignSurface'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +const en = (key: string) => t(key, 'en'); + +/** A metadata door double: published rows, each owned by a package. */ +function makeClient(published: Record) { + return { + published, + save: vi.fn(async (_t: string, _n: string, body: Record) => body), + reset: vi.fn(async () => ({})), + withPreviewDrafts() { return this; }, + list: vi.fn(async (type: string, opts: { packageId?: string } = {}) => { + if (type === 'object') return [{ name: 'crm_lead', label: 'Lead' }]; + if (type !== 'permission') return []; + return Object.values(published) + .filter((r) => !opts.packageId || r.pkg === opts.packageId) + .map((r) => ({ name: r.body.name, label: r.body.label })); + }), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async (_type: string, name: string) => ({ + effective: published[name]?.body ?? null, + code: null, + overlay: null, + overlayScope: null, + })), + getDraft: vi.fn(async () => null), + get: vi.fn(async () => ({ fields: [] })), + }; +} + +/* ── The door ────────────────────────────────────────────────────────────── */ + +let doorAnswer: { status: number; body: unknown }; +const calls: Array<{ url: string; method: string; body: unknown }> = []; + +/** The door's 200: it saved the copy in `pkg`, which the double then lists. */ +function landedIn(pkg: string | null | 'absent') { + const data: Record = { permissionSet: { ...BASE_BODY, name: COPY_NAME, label: COPY_LABEL }, notice: NOTICE }; + if (pkg !== 'absent') data.package = pkg; + return { status: 200, body: { success: true, data } }; +} + +function stubFetch() { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body; + calls.push({ url: String(url), method: init?.method ?? 'GET', body }); + const answer = doorAnswer.body as { success?: boolean; data?: { package?: unknown } } | null; + if (doorAnswer.status === 200 && answer?.success && typeof answer.data?.package === 'string') { + clientImpl.published[COPY_NAME] = { + pkg: answer.data.package, + body: { ...BASE_BODY, name: COPY_NAME, label: COPY_LABEL }, + }; + } + return new Response(JSON.stringify(doorAnswer.body), { + status: doorAnswer.status, + headers: { 'Content-Type': 'application/json' }, + }); + }), + ); +} + +/** `StudioDesignSurface`'s mount of the pillar: one route, keyed by package. */ +function StudioAccessRoute() { + const { packageId = '' } = useParams<{ packageId?: string }>(); + const { pathname, search } = useLocation(); + return ( + <> + {pathname + search} + + + ); +} + +function renderAccess(pkg = BASE_PKG) { + render( + + + } /> + + , + ); +} + +const newSimilar = () => screen.findByRole('button', { name: en('perm.clone.action') }, { timeout: 8000 }); +const route = () => screen.getByTestId('route').textContent ?? ''; + +async function fillAndCreate() { + fireEvent.click(await newSimilar()); + const dialog = await screen.findByRole('dialog'); + fireEvent.change(within(dialog).getByLabelText(en('perm.clone.name')), { target: { value: COPY_NAME } }); + fireEvent.change(within(dialog).getByLabelText(en('perm.clone.label')), { target: { value: COPY_LABEL } }); + fireEvent.click(within(dialog).getByRole('button', { name: en('perm.clone.confirm') })); + return dialog; +} + +beforeEach(() => { + clientImpl = makeClient({ [BASE_SET]: { pkg: BASE_PKG, body: BASE_BODY } }); + canAuthor = true; + walled = false; + doorAnswer = landedIn(CUSTOM_PKG); + calls.length = 0; + stubFetch(); + window.confirm = vi.fn(() => true) as unknown as typeof window.confirm; +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('objectstack#28573 — New similar on a read-only base permission set', () => { + it('reads "New similar" (en) and draws no override gesture on the base set', async () => { + renderAccess(); + expect(await newSimilar()).toBeEnabled(); + expect(en('perm.clone.action')).toBe('New similar'); + expect(screen.queryByRole('button', { name: /customi[sz]e|override/i })).toBeNull(); + }); + + it('posts exactly { name, label } and opens the copy in the package the answer names, selected', async () => { + renderAccess(); + await fillAndCreate(); + + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0]).toEqual({ url: DOOR, method: 'POST', body: { name: COPY_NAME, label: COPY_LABEL } }); + + // The pair's Access pillar, with the copy selected. + await waitFor(() => expect(new URL(route(), 'http://x').pathname).toBe(`/studio/${CUSTOM_PKG}/access`)); + expect(new URL(route(), 'http://x').searchParams.get('surface')).toBe(`permission:${COPY_NAME}`); + expect(await screen.findByRole('heading', { level: 1, name: COPY_LABEL }, { timeout: 8000 })).toBeInTheDocument(); + expect(clientImpl.list).toHaveBeenCalledWith('permission', { packageId: CUSTOM_PKG }); + // Writable there: the copy is no base, so no New similar is offered on it. + expect(screen.queryByRole('button', { name: en('perm.clone.action') })).toBeNull(); + // Where it went and what was not copied, in the server's words. + const strip = screen.getByTestId('access-new-similar-strip'); + expect(strip).toHaveTextContent( + tFormat('engine.studio.access.newSimilar.done', 'en', { name: COPY_NAME, package: CUSTOM_PKG }), + ); + expect(strip).toHaveTextContent(NOTICE); + }); + + for (const pkg of ['absent', null] as const) { + it(`an answer whose package is ${pkg === null ? 'null' : 'absent'} opens nothing and says so`, async () => { + doorAnswer = landedIn(pkg); + renderAccess(); + await fillAndCreate(); + + const strip = await screen.findByTestId('access-new-similar-strip'); + expect(strip).toHaveTextContent(tFormat('engine.studio.access.newSimilar.noPackage', 'en', { name: COPY_NAME })); + expect(strip).toHaveTextContent(NOTICE); + expect(route()).toBe(`/studio/${BASE_PKG}/access?surface=permission%3A${BASE_SET}`); + expect(clientImpl.layered.mock.calls.every((c: unknown[]) => c[1] === BASE_SET)).toBe(true); + }); + } + + it('an answer that names no copy opens nothing and says so', async () => { + doorAnswer = { status: 200, body: { success: true, data: { package: CUSTOM_PKG, notice: NOTICE } } }; + renderAccess(); + await fillAndCreate(); + + expect(await screen.findByTestId('access-new-similar-strip')).toHaveTextContent(en('perm.clone.noName')); + expect(new URL(route(), 'http://x').pathname).toBe(`/studio/${BASE_PKG}/access`); + }); + + it('409: the server\'s sentence in the dialog, which stays open on the base', async () => { + doorAnswer = { + status: 409, + body: { success: false, error: { code: 'RESOURCE_CONFLICT', message: NAME_TAKEN, httpStatus: 409 } }, + }; + renderAccess(); + const dialog = await fillAndCreate(); + + expect(await within(dialog).findByRole('alert')).toHaveTextContent(NAME_TAKEN); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(new URL(route(), 'http://x').pathname).toBe(`/studio/${BASE_PKG}/access`); + }); + + it('walled posture: disabled, in the console\'s own words, and nothing is sent', async () => { + walled = true; + renderAccess(); + const button = await newSimilar(); + expect(button).toBeDisabled(); + expect(button).toHaveAccessibleDescription( + tFormat('engine.catalog.readOnly.walled', 'en', { type: translateMetadataType('permission', 'en') }), + ); + fireEvent.click(button); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(calls).toHaveLength(0); + }); + + it('a caller without manage_metadata: disabled, with the capability sentence', async () => { + canAuthor = false; + renderAccess(); + const button = await newSimilar(); + expect(button).toBeDisabled(); + expect(button).toHaveAccessibleDescription(en('engine.edit.capabilityReadOnly')); + }); + + it('a writable package\'s set offers no New similar', async () => { + clientImpl = makeClient({ [COPY_NAME]: { pkg: CUSTOM_PKG, body: { ...BASE_BODY, name: COPY_NAME, label: COPY_LABEL } } }); + renderAccess(CUSTOM_PKG); + expect(await screen.findByRole('heading', { level: 1, name: COPY_LABEL }, { timeout: 8000 })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: en('perm.clone.action') })).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 2f38c57d5bc..c3e204f638a 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -284,6 +284,9 @@ import { studioOrgScopePath, } from './studioScope.js'; import { STUDIO_METADATA_AREA, studioMetadataAreaPath, studioPillarItemPath } from './metadataArea.js'; +import { PermissionSetCloneDialog } from '../metadata-admin/PermissionSetCloneDialog.js'; +import type { PermissionSetCloned } from '../metadata-admin/permission-set-clone.js'; +import { useWalledCatalogReadOnly } from '../metadata-admin/catalog-scope.js'; import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; import { StudioMetadataArea } from './StudioMetadataArea.js'; import { useMetadataRefreshNonce } from './useMetadataRefreshNonce.js'; @@ -2611,7 +2614,13 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): onHeldEditChange={setHeldEdit} /> ) : tab === 'access' ? ( + // objectstack#28573 — keyed by package like its siblings: "New + // similar" opens the copy in ANOTHER package's Access pillar + // (the base's `.custom`), and that route keeps this element + // mounted. Unkeyed, the open set, the rail and the `?surface=` + // capture stayed the base's. | null | undefined)?.[NEW_SIMILAR_ARRIVAL_STATE] as + | Partial> + | undefined; + if (!arrival || arrival.kind !== 'landed' || arrival.package !== packageId || typeof arrival.name !== 'string') { + return null; + } + return { kind: 'landed', name: arrival.name, package: packageId, notice: typeof arrival.notice === 'string' ? arrival.notice : '' }; +} + /** * Access pillar — the permission workbench (builder-ui §7, ADR-0084's fourth * content pillar). Left rail: the environment's permission sets / profiles; @@ -10335,6 +10373,25 @@ export function AccessPillar({ const [draftGoneNonce, setDraftGoneNonce] = React.useState(0); // [ADR-0090 D6] "why can this user access?" — right-side explain sheet. const [explainOpen, setExplainOpen] = React.useState(false); + // objectstack#28573 (ADR-0146 D6) — "New similar" on a read-only base's set: + // whether its dialog is open, and what the strip above the matrix says. A + // `landed` strip is the copy the clone door saved, shown on that copy; a + // `noPackage` / `noName` strip is an answer this pillar cannot open, shown + // on the base. The copy usually opens in ANOTHER package, which remounts + // this pillar (keyed by package), so the strip rides the navigation's state + // and is read once here. + const navigate = useNavigate(); + const location = useLocation(); + const [newSimilarOpen, setNewSimilarOpen] = React.useState(false); + const [newSimilarStrip, setNewSimilarStrip] = React.useState(() => + readNewSimilarArrival(location.state, packageId), + ); + // Who may press it: the clone door asks the metadata door's verdict + // (`manage_metadata`), and under a wall the catalog is the platform + // operator's (ADR-0131 D3), refused whatever the capability. Either way the + // action draws disabled, with the console's own sentence for why. + const canAuthorSets = useCanAuthorMetadata(); + const walledSets = useWalledCatalogReadOnly('permission'); // objectui#12456 — the open set's tab (epic objectui#12446, decision C): // Objects, System capabilities, Row-level security, Tab visibility, // Delegated admin. Objects is the landing; the choice holds across sets. @@ -10501,6 +10558,36 @@ export function AccessPillar({ setCurrent(name); }, []); + // objectstack#28573 — the clone door saved a copy of the open set. It opens + // only where the answer says it landed: that package's Access pillar with + // the copy selected. An answer that names no copy, or no package, is said + // so and opens nothing; the landing is never derived from the base. + const onNewSimilarSaved = React.useCallback( + (copy: PermissionSetCloned) => { + setNewSimilarOpen(false); + if (!copy.name) { + setNewSimilarStrip({ kind: 'noName', notice: copy.notice }); + return; + } + if (typeof copy.package !== 'string') { + setNewSimilarStrip({ kind: 'noPackage', name: copy.name, notice: copy.notice }); + return; + } + const landed: NewSimilarStrip = { kind: 'landed', name: copy.name, package: copy.package, notice: copy.notice }; + if (copy.package === packageId) { + setNewSimilarStrip(landed); + setOwdOpen(false); + setCurrent(copy.name); + void load(); + return; + } + navigate(studioPillarItemPath(copy.package, 'access', { type: 'permission', name: copy.name }), { + state: { [NEW_SIMILAR_ARRIVAL_STATE]: landed }, + }); + }, + [packageId, navigate, load], + ); + // Discard the open set's pending draft (`DELETE ?state=draft` at this // package's address, the door the metadata editor's "Discard draft" uses). // A published set goes back to its live body; a set that was never @@ -10626,6 +10713,68 @@ export function AccessPillar({ {t('engine.edit.discardDraft', locale)} ) : null; + // objectstack#28573 (ADR-0146 D6) — a read-only base's set offers "New + // similar", never an override: the copy is a NEW set, in the package the + // clone door chooses. Disabled, it says why in the console's words. + const newSimilarBlocked = walledSets + ? tFormat('engine.catalog.readOnly.walled', locale, { type: translateMetadataType('permission', locale) }) + : canAuthorSets + ? null + : t('engine.edit.capabilityReadOnly', locale); + const accessNewSimilarEl = + readOnly && current && !owdOpen ? ( + + + {newSimilarBlocked !== null && ( + + {newSimilarBlocked} + + )} + + ) : null; + const newSimilarStripEl = + newSimilarStrip && !owdOpen && (newSimilarStrip.kind !== 'landed' || newSimilarStrip.name === current) ? ( +
+ +
+

+ {newSimilarStrip.kind === 'landed' + ? tFormat('engine.studio.access.newSimilar.done', locale, { + name: newSimilarStrip.name, + package: newSimilarStrip.package, + }) + : newSimilarStrip.kind === 'noPackage' + ? tFormat('engine.studio.access.newSimilar.noPackage', locale, { name: newSimilarStrip.name }) + : t('perm.clone.noName', locale)} +

+ {newSimilarStrip.notice &&

{newSimilarStrip.notice}

} +
+ +
+ ) : null; const accessHeaderEl = ( {(owdOpen || !current) && accessHeaderEl} + {newSimilarStripEl}
{owdOpen ? ( /* Sibling surface: the package-wide OWD baseline overview @@ -10826,6 +10976,7 @@ export function AccessPillar({ chips: accessNoteEl, actions: ( <> + {accessNewSimilarEl} {accessDiscardEl} {accessExplainEl} @@ -10864,6 +11015,17 @@ export function AccessPillar({ + {/* objectstack#28573 — the clone door's dialog, shared with Setup's + catalog: a name and a label, nothing else, refusals verbatim. */} + {newSimilarOpen && current && ( + setNewSimilarOpen(false)} + onCloned={onNewSimilarSaved} + /> + )} + = { // set on it. The hint names the hatch only to say so, as the server's own // refusal sentence does. 'perm.readOnly.artifact.hint': - '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 allowEnvironmentOverlay false for permission (ADR-0131 D6 forbids an environment overlay of a packaged set: silent privilege drift). The server refuses the write with 403 not_overridable. Clone to customize: the copy is an ordinary permission set authored in this system, under a new name, that you edit here, and upgrades keep flowing to this base. Other routes: edit the source artifact and redeploy, or create a new runtime set. The OS_METADATA_WRITABLE escape hatch does not open a set a package ships (ADR-0131 D6).', - // objectui#5987 — "Clone to customize", the locked editor's primary action. + '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 allowEnvironmentOverlay false for permission (ADR-0131 D6 forbids an environment overlay of a packaged set: silent privilege drift). The server refuses the write with 403 not_overridable. New similar: the server copies this set under a new name into the package that holds your changes (for a read-only package, its paired .custom package) and says where it went; you edit the copy, and upgrades keep flowing to this base. Other routes: edit the source artifact and redeploy, or create a new runtime set. The OS_METADATA_WRITABLE escape hatch does not open a set a package ships (ADR-0131 D6).', + // objectui#5987 — the locked editor's primary action. objectstack ADR-0146 + // D6 (#28573) names it "New similar", on Setup's catalog and in Studio's + // Access pillar alike: the copy is a NEW set beside the base, never an + // override of it. // objectui#7611 — it asks for a new name and label (`PermissionSetCloneDialog.tsx`) // and calls the server's clone door (`permission-set-clone.ts`), which copies - // the whole definition; never a hand-rolled copy. - 'perm.clone.action': 'Clone to customize', + // the whole definition and decides the package the copy lands in; never a + // hand-rolled copy. + 'perm.clone.action': 'New similar', 'perm.clone.hint': - 'Copies this set under a new name you choose. The copy is an ordinary permission set authored in this system, editable here, and upgrades keep flowing to the package-provided base.', + 'Copies this set under a new name you choose. The server saves the copy beside this base, in the package that holds your changes, and says where; 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 copy is an ordinary permission set authored in this system, editable here.', - 'perm.clone.dialogTitle': 'Clone permission set', + 'Locked: a code package provides this permission set, so it cannot be edited in place here (upgrades keep flowing to it). New similar — the server saves a copy under a new name, in the package that holds your changes, and you edit the copy.', + 'perm.clone.dialogTitle': 'New similar permission set', 'perm.clone.dialogBody': 'Copies {name} under a new machine name and label. The server decides what the copy carries, and says what it left out.', 'perm.clone.name': 'New machine name', @@ -265,7 +269,7 @@ const STUDIO_EN: Record = { // 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 copy is an ordinary permission set authored in this system, and upgrades keep flowing to this base.', + 'Save refused (403 not_overridable): a code package provides this permission set, so the server does not accept an in-place edit. Use New similar instead — the server saves a copy under a new name, in the package that holds your changes, and upgrades keep flowing to this base.', // Designer wrapper 'designer.unsavedChanges': 'Unsaved changes', 'designer.editingOverlay': 'Editing overlay', @@ -2571,6 +2575,13 @@ const STUDIO_EN: Record = { 'engine.studio.access.bulkHint': 'Bulk actions (R · CRUD · All · None) appear on the row under the pointer or with keyboard focus.', // ── Access explain panel (ADR-0090 D6 — "why can this user access?") ──── 'engine.studio.access.explain.open': 'Explain access', + // objectstack#28573 (ADR-0146 D6) — "New similar" on a read-only base's + // permission set: the clone door saves the copy where it decides, and the + // pillar opens it only where the answer says it landed. + 'engine.studio.access.newSimilar.noPackage': + 'The server saved the copy {name}, but its answer does not name the package it landed in, so nothing was opened. Find it from the package that holds your changes.', + 'engine.studio.access.newSimilar.done': 'Saved the copy {name} in package {package}.', + 'engine.studio.access.newSimilar.dismiss': 'Dismiss', 'engine.studio.access.explain.title': 'Why can this user access?', 'engine.studio.access.explain.description': 'Runs the same evaluation pipeline as enforcement and reports what each layer contributed — permission set → position → binding chain, OWD/sharing verdicts.', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index a1e1a6eff2d..ce1115ddf07 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -186,16 +186,16 @@ const STUDIO_ZH: Record = { // objectui#13011 — 见 EN 表同键注释。 'perm.readOnly.capability': '只读(需要 `manage_metadata` 能力)', 'perm.readOnly.artifact': '只读(该权限集由代码包提供)', - // objectui#5987 — 见 EN 表同键注释:裁定的路径(克隆后自定义)放在最前。 + // objectui#5987 — 见 EN 表同键注释:裁定的路径(新建类似)放在最前。 'perm.readOnly.artifact.hint': - '该权限集由代码包提供,因此在此处被锁定:在环境作用域下编辑它属于覆盖(overlay)已打包的条目,而元数据类型注册表对 permission 声明 allowEnvironmentOverlay 为 false(ADR-0131 D6 禁止对已打包权限集做环境覆盖:会造成权限静默漂移)。服务端会以 403 not_overridable 拒绝该写入。请「克隆后自定义」:副本以新名称保存为本系统中创作的普通权限集、可在此编辑,升级会继续流向这个基线。其他途径:修改源工件后重新部署,或新建一个运行时权限集。OS_METADATA_WRITABLE 逃生阀不会开放由包提供的权限集(ADR-0131 D6)。', + '该权限集由代码包提供,因此在此处被锁定:在环境作用域下编辑它属于覆盖(overlay)已打包的条目,而元数据类型注册表对 permission 声明 allowEnvironmentOverlay 为 false(ADR-0131 D6 禁止对已打包权限集做环境覆盖:会造成权限静默漂移)。服务端会以 403 not_overridable 拒绝该写入。请「新建类似」:服务端以新名称把副本存入存放你的修改的软件包(只读包则为其配对的 .custom 包),并说明存在哪里;你编辑副本,升级会继续流向这个基线。其他途径:修改源工件后重新部署,或新建一个运行时权限集。OS_METADATA_WRITABLE 逃生阀不会开放由包提供的权限集(ADR-0131 D6)。', // objectui#5987 — 见 EN 表同键注释。 - 'perm.clone.action': '克隆后自定义', + 'perm.clone.action': '新建类似', 'perm.clone.hint': - '以你指定的新名称复制该权限集。副本是本系统中创作的普通权限集,可在此编辑,升级会继续流向由包提供的基线。', + '以你指定的新名称复制该权限集。服务端把副本存放在这个基线旁、存放你的修改的软件包中,并说明存在哪里;升级会继续流向由包提供的基线。', 'perm.clone.guidance': - '已锁定:该权限集由代码包提供,不能在此处原地编辑(升级会继续流向它)。请「克隆后自定义」—— 副本是本系统中创作的普通权限集,可在此编辑。', - 'perm.clone.dialogTitle': '克隆权限集', + '已锁定:该权限集由代码包提供,不能在此处原地编辑(升级会继续流向它)。请「新建类似」—— 服务端以新名称把副本存入存放你的修改的软件包,你编辑副本即可。', + 'perm.clone.dialogTitle': '新建类似权限集', 'perm.clone.dialogBody': '以新的机器名和显示名复制 {name}。副本携带哪些内容由服务端决定,未复制的部分会由服务端说明。', 'perm.clone.name': '新的机器名', 'perm.clone.label': '新的显示名', @@ -205,7 +205,7 @@ const STUDIO_ZH: Record = { 'perm.clone.noName': '克隆已创建,但服务端的响应未给出其名称。请从权限集列表中打开它。', 'perm.clone.done': '已克隆为 {name}。请从权限集列表中打开它进行编辑。', 'perm.save.locked': - '保存被拒绝(403 not_overridable):该权限集由代码包提供,服务端不接受原地编辑。请改为「克隆后自定义」—— 副本是本系统中创作的普通权限集,升级会继续流向这个基线。', + '保存被拒绝(403 not_overridable):该权限集由代码包提供,服务端不接受原地编辑。请改用「新建类似」—— 服务端以新名称把副本存入存放你的修改的软件包,升级会继续流向这个基线。', // Designer wrapper 'designer.unsavedChanges': '未保存的修改', 'designer.editingOverlay': '编辑覆盖层', @@ -2309,6 +2309,11 @@ const STUDIO_ZH: Record = { 'engine.studio.access.bulkHint': '批量操作(R · CRUD · All · None)显示在指针所在或获得键盘焦点的行上。', // ── 访问解释面板(ADR-0090 D6 —「为什么该用户可以访问?」)──────────── 'engine.studio.access.explain.open': '解释访问权限', + // objectstack#28573 — 见 EN 表同键注释。 + 'engine.studio.access.newSimilar.noPackage': + '服务端已保存副本 {name},但其响应未说明副本所在的软件包,因此没有打开任何内容。请到存放你的修改的软件包中查找它。', + 'engine.studio.access.newSimilar.done': '已将副本 {name} 保存到软件包 {package}。', + 'engine.studio.access.newSimilar.dismiss': '关闭', 'engine.studio.access.explain.title': '为什么该用户可以访问?', 'engine.studio.access.explain.description': '与运行时强制执行走同一条评估管线,逐层报告各层的贡献 —— 权限集 → 岗位 → 绑定链,以及 OWD/共享判定。',