From b5bb340ba63a7e7d6e41be45d16efba7116c08f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 11:42:20 +0000 Subject: [PATCH] feat(studio): New similar on a read-only base's permission set in the Access pillar The Access pillar of a read-only package offers "New similar" on the open permission set. It calls the clone door with a name and a label only, and opens the copy in the package the door's answer names, with the copy selected. An answer naming no package or no copy opens nothing and says so. Walled posture and a caller without manage_metadata draw it disabled. The action reads "New similar" on Setup's catalog too, and AccessPillar is keyed by package like its siblings so the hop to the copy's package remounts. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUn8pWsnfJNsrCm68c --- .../28573-new-similar-permission-set.md | 17 + ...sionMatrixEditor.cloneToCustomize.test.tsx | 28 +- .../packagedItemWriteGate-12169.test.tsx | 2 +- .../metadata-admin/permission-set-clone.ts | 26 +- ...ignSurface.accessNewSimilar-28573.test.tsx | 304 ++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 167 +++++++++- ui/packages/i18n/src/namespaces/studio/en.ts | 27 +- ui/packages/i18n/src/namespaces/studio/zh.ts | 19 +- 8 files changed, 555 insertions(+), 35 deletions(-) create mode 100644 .changeset/28573-new-similar-permission-set.md create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessNewSimilar-28573.test.tsx 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 ec2be499d9f..ce1dc10a6dc 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -16,7 +16,7 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; -import { useParams, useNavigate, useSearchParams, Link, Navigate } from 'react-router-dom'; +import { useParams, useNavigate, useSearchParams, useLocation, Link, Navigate } from 'react-router-dom'; import { useAdapter, SchemaRendererProvider, useMetadata } from '@object-ui/react'; // The ONE draft-envelope reader (objectui#8181): unwrap AND strip the // framework's read decorations in one place. This file used to carry its own @@ -283,7 +283,11 @@ import { isStudioOrgScope, studioOrgScopePath, } from './studioScope.js'; -import { STUDIO_METADATA_AREA, studioMetadataAreaPath } from './metadataArea.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'; import { useHomePath } from '../../hooks/useHomePath.js'; @@ -2610,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; @@ -10217,6 +10256,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. @@ -10383,6 +10441,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 @@ -10508,6 +10596,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 @@ -10708,6 +10859,7 @@ export function AccessPillar({ chips: accessNoteEl, actions: ( <> + {accessNewSimilarEl} {accessDiscardEl} {accessExplainEl} @@ -10746,6 +10898,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', @@ -2551,6 +2555,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 b7a315d4036..dd62d04fdf2 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': '编辑覆盖层', @@ -2291,6 +2291,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/共享判定。',