From ca1740783b32c201f5316248fb677b83c9ee44f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:21:29 +0000 Subject: [PATCH] fix(app-shell): reconsider a dismissed or revoked suggested binding A dismissed (or revoked) row in SuggestedBindingsPanel drew only its state line. It now offers Reconsider, which calls the existing POST /security/suggested-bindings/:id/reopen door through a new reopenSuggestedBinding(id) client call; the row redraws as pending with Accept and Dismiss. Refusals (404 / 409 / 501) surface the server's message and re-read the list, as the other actions do. Strings: marketplace.suggestedBindings.reopen/.reopening/.reopenedToast in all ten locale packs; engine.studio.access.suggestReopen/.suggestReopening/ .suggestReopenedToast in en and zh. The de quote-pairing ratchet moves to 103 for the new quoted toast. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzezVANVfXzi7zxbUT8 --- .../30199-suggested-binding-reconsider.md | 15 ++ .../SuggestedBindingsPanel.test.tsx | 138 +++++++++++++++++- .../src/components/SuggestedBindingsPanel.tsx | 33 ++++- .../marketplace/MarketplacePackagePage.tsx | 4 + .../src/services/suggestedBindingsApi.ts | 28 +++- .../studio-design/StudioDesignSurface.tsx | 4 + .../__tests__/de-quote-pairing-3876.test.ts | 12 +- ui/packages/i18n/src/locales/ar.ts | 3 + ui/packages/i18n/src/locales/de.ts | 3 + ui/packages/i18n/src/locales/en.ts | 3 + ui/packages/i18n/src/locales/es.ts | 3 + ui/packages/i18n/src/locales/fr.ts | 3 + ui/packages/i18n/src/locales/ja.ts | 3 + ui/packages/i18n/src/locales/ko.ts | 3 + ui/packages/i18n/src/locales/pt.ts | 3 + ui/packages/i18n/src/locales/ru.ts | 3 + ui/packages/i18n/src/locales/zh.ts | 3 + ui/packages/i18n/src/namespaces/studio/en.ts | 3 + ui/packages/i18n/src/namespaces/studio/zh.ts | 3 + 19 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 .changeset/30199-suggested-binding-reconsider.md diff --git a/.changeset/30199-suggested-binding-reconsider.md b/.changeset/30199-suggested-binding-reconsider.md new file mode 100644 index 00000000000..ab124ecd833 --- /dev/null +++ b/.changeset/30199-suggested-binding-reconsider.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(app-shell): a dismissed or revoked default permission set in the suggested-bindings panel can be reconsidered (#30199) + +Clause-②: no +Metadata-protocol: none + +The suggested-bindings panel (the install dialog and Marketplace package page, and Studio's Access pillar) drew a dismissed suggestion, or a revoked acceptance, as a state line with no action. An administrator who chose Dismiss could only take it back by calling `POST /api/v1/security/suggested-bindings/:id/reopen` by hand, although the server has offered that door all along. + +Such a row now offers "Reconsider". It calls the re-open door, and the row comes back as a pending suggestion with Accept and Dismiss (a Guest row with Dismiss only, as before). Re-opening grants nothing: the set reaches the Everyone position only when it is accepted again, under the same checks. The button is shown to the same callers who see Accept and Dismiss: a caller without `manage_metadata` is refused the list and sees no panel. A refusal (the row is gone, the set is no longer suggested, or the deployment's security service has no re-open door) is shown with the server's own message, and the panel re-reads the list. + +New client call: `reopenSuggestedBinding(id)`, beside `dismissSuggestedBinding`. New strings: `marketplace.suggestedBindings.reopen`, `.reopening` and `.reopenedToast` in all ten locale packs, and `engine.studio.access.suggestReopen`, `.suggestReopening` and `.suggestReopenedToast` (en, zh). diff --git a/ui/packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx b/ui/packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx index 00d815484da..aa5be4f6496 100644 --- a/ui/packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx +++ b/ui/packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx @@ -15,6 +15,10 @@ * - a `guest` row offers no Accept and says why, and keeps Dismiss; * - Accept, Dismiss and Revoke each call their route, and the row takes the * state the server answered; + * - a dismissed or revoked row offers Reconsider, which calls the re-open + * route once and redraws the row as pending, with Accept and Dismiss; no + * other state offers it, and its 404 / 409 / 501 are surfaced like the + * other refusals (objectstack#30199); * - a `403` or `409` on an action shows the server's own message, never a * silent no-op, and the panel then re-reads the list; * - a `403` on the list renders nothing. @@ -72,6 +76,9 @@ const STRINGS: SuggestedBindingsStrings = { revoke: 'Revoke', revoking: 'Revoking', revokedToast: (s) => `REVOKED ${s.permission_set_name}`, + reopen: 'Reconsider', + reopening: 'Reopening', + reopenedToast: (s) => `REOPENED ${s.permission_set_name}`, }; type Answer = { status: number; body: unknown }; @@ -86,7 +93,7 @@ const listed = (suggestions: SuggestedBinding[]): Answer => * A `fetch` that answers each route from a queue (the last answer repeats), * and records every call as `METHOD path`. */ -function serve(routes: { list: Answer[]; confirm?: Answer[]; dismiss?: Answer[] }) { +function serve(routes: { list: Answer[]; confirm?: Answer[]; dismiss?: Answer[]; reopen?: Answer[] }) { const calls: string[] = []; const next = (q: Answer[] | undefined): Answer => { if (!q || q.length === 0) throw new Error('unexpected route'); @@ -100,7 +107,9 @@ function serve(routes: { list: Answer[]; confirm?: Answer[]; dismiss?: Answer[] ? next(routes.confirm) : url.pathname.endsWith('/dismiss') ? next(routes.dismiss) - : next(routes.list); + : url.pathname.endsWith('/reopen') + ? next(routes.reopen) + : next(routes.list); return new Response(JSON.stringify(answer.body), { status: answer.status, headers: { 'Content-Type': 'application/json' }, @@ -146,13 +155,46 @@ describe('SuggestedBindingsPanel — the state of each row (objectui#7611)', () expect(screen.queryByTestId('suggested-binding-dismiss-crm_reader')).toBeNull(); }); - it('a dismissed or revoked row says it is not granted and offers no action', async () => { + it('a dismissed or revoked row says it is not granted and offers Reconsider only', async () => { serve({ list: [listed([row({ permission_set_name: 'crm_reader', status: 'dismissed', deployment_decision: true, resolved_by: 'u_admin' })])] }); panel(); const el = await rowEl('crm_reader'); expect(el.dataset.state).toBe('dismissed'); expect(el.textContent).toContain('NOT GRANTED crm_reader'); - expect(el.querySelectorAll('button')).toHaveLength(0); + expect(el.querySelectorAll('button')).toHaveLength(1); + expect(screen.getByTestId('suggested-binding-reopen-crm_reader')).toBeTruthy(); + expect(screen.queryByTestId('suggested-binding-confirm-crm_reader')).toBeNull(); + expect(screen.queryByTestId('suggested-binding-dismiss-crm_reader')).toBeNull(); + expect(screen.queryByTestId('suggested-binding-revoke-crm_reader')).toBeNull(); + }); + + it('CONTROL: no pending, guest, accepted or not-in-force row offers Reconsider', async () => { + const pending = row({ permission_set_name: 'crm_pending' }); + const guest = row({ permission_set_name: 'site_visitor', anchor: 'guest' }); + const accepted = row({ permission_set_name: 'crm_accepted', status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' }); + const toNotInForce = row({ permission_set_name: 'crm_nif' }); + serve({ + list: [listed([pending, guest, accepted, toNotInForce])], + confirm: [ok({ suggestion: { ...toNotInForce, status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' }, bindingCreated: false })], + }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-confirm-crm_nif')); + await waitFor(async () => expect((await rowEl('crm_nif')).dataset.state).toBe('notInForce')); + const states: Record = {}; + for (const set of ['crm_pending', 'site_visitor', 'crm_accepted', 'crm_nif']) { + states[set] = (await rowEl(set)).dataset.state; + expect(screen.queryByTestId(`suggested-binding-reopen-${set}`), set).toBeNull(); + } + expect(states).toEqual({ crm_pending: 'pending', site_visitor: 'guest', crm_accepted: 'accepted', crm_nif: 'notInForce' }); + }); + + it('a caller who may not decide (the list answers 403) is offered no Reconsider', async () => { + const calls = serve({ list: [refused(403, 'PERMISSION_DENIED', '[Security] Access denied: listing audience-binding suggestions requires the `manage_metadata` capability.')] }); + const { container } = panel(); + await waitFor(() => expect(calls).toHaveLength(1)); + await Promise.resolve(); + expect(container.querySelector('[data-testid^="suggested-binding-reopen-"]')).toBeNull(); + expect(container.innerHTML).toBe(''); }); it('a baseline row (confirmed, no deployment decision) is not drawn — this surface does not decide the baseline', async () => { @@ -286,3 +328,91 @@ describe('SuggestedBindingsPanel — the actions (objectui#7611)', () => { await waitFor(() => expect(container.innerHTML).toBe('')); }); }); + +describe('SuggestedBindingsPanel — Reconsider a dismissed or revoked row (objectstack#30199)', () => { + const dismissed = row({ permission_set_name: 'crm_reader', status: 'dismissed', deployment_decision: true, resolved_by: 'u_admin', resolved_at: '2026-11-05T00:00:00.000Z' }); + // What the re-open door answers: pending again, the decision cleared. + const reopened = { ...dismissed, status: 'pending' as const, deployment_decision: false, resolved_by: null, resolved_at: null }; + + it('one click calls the re-open route once; the row redraws as pending with Accept and Dismiss', async () => { + const calls = serve({ list: [listed([dismissed])], reopen: [ok({ suggestion: reopened })] }); + const onResolved = vi.fn(); + render(); + fireEvent.click(await screen.findByTestId('suggested-binding-reopen-crm_reader')); + await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('pending')); + expect(calls.filter((c) => c.startsWith('POST '))).toEqual([ + 'POST /api/v1/security/suggested-bindings/sug_crm_reader/reopen', + ]); + expect(screen.getByTestId('suggested-binding-confirm-crm_reader')).toBeTruthy(); + expect(screen.getByTestId('suggested-binding-dismiss-crm_reader')).toBeTruthy(); + expect(screen.queryByTestId('suggested-binding-reopen-crm_reader')).toBeNull(); + expect(toast.success).toHaveBeenCalledWith('REOPENED crm_reader'); + expect(onResolved).toHaveBeenCalledWith(reopened); + // A re-open grants nothing: no accept is sent on its behalf. + expect(calls.some((c) => c.endsWith('/confirm'))).toBe(false); + }); + + it('dismiss, reconsider, then accept: each step calls its own route once, in order', async () => { + const pending = row({ permission_set_name: 'crm_reader' }); + const accepted = { ...pending, status: 'confirmed' as const, deployment_decision: true, resolved_by: 'u_admin' }; + const calls = serve({ + list: [listed([pending])], + dismiss: [ok({ suggestion: dismissed })], + reopen: [ok({ suggestion: reopened })], + confirm: [ok({ suggestion: accepted, bindingCreated: true })], + }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-dismiss-crm_reader')); + fireEvent.click(await screen.findByTestId('suggested-binding-reopen-crm_reader')); + fireEvent.click(await screen.findByTestId('suggested-binding-confirm-crm_reader')); + await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('accepted')); + expect(calls.filter((c) => c.startsWith('POST '))).toEqual([ + 'POST /api/v1/security/suggested-bindings/sug_crm_reader/dismiss', + 'POST /api/v1/security/suggested-bindings/sug_crm_reader/reopen', + 'POST /api/v1/security/suggested-bindings/sug_crm_reader/confirm', + ]); + expect(toast.success).toHaveBeenCalledWith('GRANTED crm_reader'); + }); + + it('a re-opened guest row redraws as guest: Dismiss, and still no Accept', async () => { + const guestDismissed = row({ permission_set_name: 'site_visitor', anchor: 'guest', status: 'dismissed', deployment_decision: true, resolved_by: 'u_admin' }); + serve({ list: [listed([guestDismissed])], reopen: [ok({ suggestion: { ...guestDismissed, status: 'pending', deployment_decision: false, resolved_by: null } })] }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-reopen-site_visitor')); + await waitFor(async () => expect((await rowEl('site_visitor')).dataset.state).toBe('guest')); + expect(screen.queryByTestId('suggested-binding-confirm-site_visitor')).toBeNull(); + expect(screen.getByTestId('suggested-binding-dismiss-site_visitor')).toBeTruthy(); + }); + + it('re-open refused 409 (the set is no longer suggested) → the server message is shown, and the panel re-reads', async () => { + const message = "Permission set 'crm_reader' no longer declares isDefault: true — nothing is suggested."; + const calls = serve({ list: [listed([dismissed]), listed([dismissed])], reopen: [refused(409, 'SUGGESTION_STATE', message)] }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-reopen-crm_reader')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message)); + await waitFor(() => expect(calls.filter((c) => c.startsWith('GET '))).toHaveLength(2)); + expect((await rowEl('crm_reader')).dataset.state).toBe('dismissed'); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('re-open refused 404 (the row is gone) → the server message is shown, and the re-read drops the row', async () => { + const message = "Audience-binding suggestion 'sug_crm_reader' not found"; + const other = row({ permission_set_name: 'crm_other' }); + serve({ list: [listed([dismissed, other]), listed([other])], reopen: [refused(404, 'SUGGESTION_NOT_FOUND', message)] }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-reopen-crm_reader')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message)); + await waitFor(() => expect(screen.queryByTestId('suggested-binding-crm_reader')).toBeNull()); + expect((await rowEl('crm_other')).dataset.state).toBe('pending'); + }); + + it('re-open answered 501 (a security service without the door) → its message is shown and the dismissal stands', async () => { + const message = "This deployment's security service does not implement re-opening a dismissed audience-binding suggestion, so the dismissal stands."; + serve({ list: [listed([dismissed])], reopen: [refused(501, 'NOT_IMPLEMENTED', message)] }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-reopen-crm_reader')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message)); + expect((await rowEl('crm_reader')).dataset.state).toBe('dismissed'); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/app-shell/src/components/SuggestedBindingsPanel.tsx b/ui/packages/app-shell/src/components/SuggestedBindingsPanel.tsx index e13967711e3..85a24a3bc2f 100644 --- a/ui/packages/app-shell/src/components/SuggestedBindingsPanel.tsx +++ b/ui/packages/app-shell/src/components/SuggestedBindingsPanel.tsx @@ -8,6 +8,7 @@ import { listSuggestedBindings, confirmSuggestedBinding, dismissSuggestedBinding, + reopenSuggestedBinding, type SuggestedBinding, } from '../services/suggestedBindingsApi.js'; @@ -33,14 +34,17 @@ import { * change. The list does not carry this, so it lasts until the panel * re-reads; after that the row reads as accepted, which claims the * decision only, never that the set is in force; - * - dismissed or revoked — the server records both as `dismissed`. + * - dismissed or revoked — the server records both as `dismissed` — offers + * Reconsider, which re-opens the row: it is `pending` again, with Accept + * and Dismiss, and grants nothing until it is accepted. * * A `confirmed` row WITHOUT a deployment decision is one the baseline already * names; this surface does not decide the baseline (the server refuses its * dismiss `409`), so it is not drawn. * - * Every refusal (403 / 409) is surfaced with the server's own message, and - * the panel then re-reads the list so it shows what the server holds. + * Every refusal (403 / 404 / 409, and a 501 from a service without the + * re-open door) is surfaced with the server's own message, and the panel + * then re-reads the list so it shows what the server holds. * * Renders nothing while loading, when the package has no suggestion, or when * the list is refused — a caller without `manage_metadata` gets 403 — so it @@ -66,6 +70,11 @@ export interface SuggestedBindingsStrings { revoke: string; revoking: string; revokedToast: (s: SuggestedBinding) => string; + /** Action on a dismissed or revoked row: re-open it for a decision. */ + reopen: string; + reopening: string; + /** Toast after a re-open. Says the row is pending again, never that the set is granted. */ + reopenedToast: (s: SuggestedBinding) => string; } type RowState = 'pending' | 'guest' | 'accepted' | 'notInForce' | 'dismissed'; @@ -90,7 +99,7 @@ export function SuggestedBindingsPanel({ packageId?: string; strings: SuggestedBindingsStrings; className?: string; - /** Called after any suggestion is accepted, dismissed or revoked. */ + /** Called after any suggestion is accepted, dismissed, revoked or re-opened. */ onResolved?: (s: SuggestedBinding) => void; }) { const [rows, setRows] = useState([]); @@ -117,7 +126,7 @@ export function SuggestedBindingsPanel({ const put = (next: SuggestedBinding) => setRows((current) => current.map((r) => (r.id === next.id ? next : r))); - const act = async (s: SuggestedBinding, action: 'confirm' | 'dismiss' | 'revoke') => { + const act = async (s: SuggestedBinding, action: 'confirm' | 'dismiss' | 'revoke' | 'reopen') => { setBusyId(s.id); try { let resolved: SuggestedBinding; @@ -132,6 +141,9 @@ export function SuggestedBindingsPanel({ }); if (bindingCreated) toast.success(strings.confirmedToast(s)); else toast.warning(strings.notInForceState(s)); + } else if (action === 'reopen') { + resolved = await reopenSuggestedBinding(s.id); + toast.success(strings.reopenedToast(s)); } else { resolved = await dismissSuggestedBinding(s.id); toast.success(action === 'revoke' ? strings.revokedToast(s) : strings.dismissedToast(s)); @@ -223,6 +235,17 @@ export function SuggestedBindingsPanel({ {busy ? strings.revoking : strings.revoke} )} + {state === 'dismissed' && ( + + )} ); })} diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx index c996435c136..58118ccda27 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx @@ -176,6 +176,10 @@ export function MarketplacePackagePage() { revoking: t('marketplace.suggestedBindings.revoking'), revokedToast: (s: SuggestedBinding) => t('marketplace.suggestedBindings.revokedToast', { set: s.permission_set_name }), + reopen: t('marketplace.suggestedBindings.reopen'), + reopening: t('marketplace.suggestedBindings.reopening'), + reopenedToast: (s: SuggestedBinding) => + t('marketplace.suggestedBindings.reopenedToast', { set: s.permission_set_name }), }; const basePath = appName ? `/apps/${appName}` : ''; const { refresh: refreshMetadata } = useMetadata(); diff --git a/ui/packages/app-shell/src/services/suggestedBindingsApi.ts b/ui/packages/app-shell/src/services/suggestedBindingsApi.ts index 751faf772fd..f27d0408722 100644 --- a/ui/packages/app-shell/src/services/suggestedBindingsApi.ts +++ b/ui/packages/app-shell/src/services/suggestedBindingsApi.ts @@ -11,10 +11,11 @@ * dismisses each suggestion ONCE for the whole deployment, and an accept takes * effect — the server re-derives `everyone`'s definition with the set * (objectstack#22801 → A, built by objectstack#22854). A caller without that - * authority gets `403 PERMISSION_DENIED` on all three routes, the list - * included. These calls are ordinary same-origin admin API calls (not object - * CRUD), so they follow the marketplaceApi raw-fetch pattern: Bearer token + - * `credentials: 'include'`. + * authority gets `403 PERMISSION_DENIED` on every route, the list included. + * A dismissed (or revoked) suggestion can be re-opened, which makes it + * pending again and grants nothing until it is accepted. These calls are + * ordinary same-origin admin API calls (not object CRUD), so they follow the + * marketplaceApi raw-fetch pattern: Bearer token + `credentials: 'include'`. */ import { TokenStorage } from '@object-ui/auth'; @@ -129,3 +130,22 @@ export async function dismissSuggestedBinding(id: string): Promise { + const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}/reopen`, { + method: 'POST', + credentials: 'include', + headers: authHeaders({ 'Content-Type': 'application/json' }), + }); + const data = await parseOrThrow(res); + return data?.suggestion as SuggestedBinding; +} 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 f1d1c431fd7..3d6616ee34b 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -10013,6 +10013,10 @@ export function AccessPillar({ revoking: t('engine.studio.access.suggestRevoking', locale), revokedToast: (s) => tFormat('engine.studio.access.suggestRevokedToast', locale, { set: s.permission_set_name }), + reopen: t('engine.studio.access.suggestReopen', locale), + reopening: t('engine.studio.access.suggestReopening', locale), + reopenedToast: (s) => + tFormat('engine.studio.access.suggestReopenedToast', locale, { set: s.permission_set_name }), }} /> )} diff --git a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 4e36b913ab4..c6afbfbf5d6 100644 --- a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -381,7 +381,11 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // it named a clone on — one interpolated span fewer. // 102 once the Connect an Agent page dropped its VS Code card and with it // `connectAgent.vscode.body`, which quoted the „MCP: Add Server“ command. - expect(okSpans, 'correctly paired spans').toBe(102); + // 103 once objectstack-ai/objectstack#30199 added + // `marketplace.suggestedBindings.reopenedToast`, the toast after a dismissed + // suggestion is re-opened, which quotes the set („{{set}}“) — one + // interpolated span, runtime data. + expect(okSpans, 'correctly paired spans').toBe(103); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -460,8 +464,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // `form.requiredForAction`, one more matched pair. 103 / 103 / 0 after // objectstack-ai/objectstack#28789 retired `packagedAutomation.cloneCreated`, // one matched pair fewer. 102 / 102 / 0 after the Connect an Agent page - // dropped `connectAgent.vscode.body`, one matched pair fewer. - expect({ open, close, rdq }).toEqual({ open: 102, close: 102, rdq: 0 }); + // dropped `connectAgent.vscode.body`, one matched pair fewer. 103 / 103 / 0 + // after objectstack-ai/objectstack#30199 added + // `marketplace.suggestedBindings.reopenedToast`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 103, close: 103, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index a8da87215d3..f7f66efbae5 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -4432,6 +4432,9 @@ const ar = { revoke: "إلغاء", revoking: "جارٍ الإلغاء…", revokedToast: "تم إلغاء قبول «{{set}}».", + reopen: "إعادة النظر", + reopening: "جارٍ إعادة الفتح…", + reopenedToast: "أُعيد فتح اقتراح «{{set}}»: لا يُمنح شيء حتى يُقبل.", }, title: "سوق التطبيقات", subtitle: "استعرض التطبيقات المعتمدة المنشورة في كتالوج ObjectStack. انقر على تطبيق لرؤية التفاصيل وتثبيته في أحد بيئاتك.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 78cec490b95..decad288cde 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4095,6 +4095,9 @@ const de = { revoke: "Widerrufen", revoking: "Wird widerrufen…", revokedToast: "Annahme von „{{set}}“ widerrufen.", + reopen: "Neu bewerten", + reopening: "Wird wieder geöffnet…", + reopenedToast: "Vorschlag für „{{set}}“ wieder geöffnet: Nichts wird gewährt, bis er angenommen wird.", }, title: "App-Marktplatz", subtitle: "Durchsuchen Sie genehmigte Apps, die im ObjectStack-Katalog veröffentlicht wurden. Klicken Sie auf eine App, um Details zu sehen und sie in eine Ihrer Umgebungen zu installieren.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 39190c93b69..9ba9c6ae612 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -5075,6 +5075,9 @@ const en = { revoke: 'Revoke', revoking: 'Revoking…', revokedToast: 'Acceptance of "{{set}}" revoked.', + reopen: 'Reconsider', + reopening: 'Reopening…', + reopenedToast: 'Suggestion for "{{set}}" reopened: nothing is granted until it is accepted.', }, uninstall: { confirm: 'Uninstall {{manifestId}} v{{version}} from this runtime?\n\nThe app will leave the running kernel at once, together with its permission sets and their grants, and its cached manifest will be removed. Records stored in its objects stay in the database.', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index fb33b8a878b..f22af5ce0c2 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4180,6 +4180,9 @@ const es = { revoke: "Revocar", revoking: "Revocando…", revokedToast: "Aceptación de «{{set}}» revocada.", + reopen: "Reconsiderar", + reopening: "Reabriendo…", + reopenedToast: "Sugerencia para «{{set}}» reabierta: no se concede nada hasta que se acepte.", }, title: "Marketplace de aplicaciones", subtitle: "Explore aplicaciones aprobadas publicadas en el catálogo de ObjectStack. Haga clic en una aplicación para ver detalles e instalarla en uno de sus entornos.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index c8f7cdfd318..c216cdab8b9 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4176,6 +4176,9 @@ const fr = { revoke: "Révoquer", revoking: "Révocation…", revokedToast: "Acceptation de « {{set}} » révoquée.", + reopen: "Reconsidérer", + reopening: "Réouverture…", + reopenedToast: "Suggestion pour « {{set}} » rouverte : rien n'est accordé tant qu'elle n'est pas acceptée.", }, title: "Marketplace d'applications", subtitle: "Parcourez les applications approuvées publiées dans le catalogue ObjectStack. Cliquez sur une application pour voir les détails et l'installer dans l'un de vos environnements.", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 55f474f6d6d..2c2f9ba76a8 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4097,6 +4097,9 @@ const ja = { revoke: "取り消す", revoking: "取り消しています…", revokedToast: "「{{set}}」の承認を取り消しました。", + reopen: "再検討", + reopening: "再オープン中…", + reopenedToast: "「{{set}}」の提案を再オープンしました。承認されるまで何も付与されません。", }, title: "アプリマーケットプレイス", subtitle: "ObjectStackカタログに公開された承認済みアプリを参照します。アプリをクリックして詳細を表示し、環境にインストールしてください。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 563f97cf8bb..530c8bb2d76 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4094,6 +4094,9 @@ const ko = { revoke: "취소", revoking: "취소하는 중…", revokedToast: "\"{{set}}\" 승인을 취소했습니다.", + reopen: "다시 검토", + reopening: "다시 여는 중…", + reopenedToast: "\"{{set}}\" 제안을 다시 열었습니다. 승인될 때까지 아무것도 부여되지 않습니다.", }, title: "앱 마켓플레이스", subtitle: "ObjectStack 카탈로그에 게시된 승인된 앱을 탐색하세요. 앱을 클릭하여 세부 정보를 보고 환경 중 하나에 설치하세요.", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 926e9dcc3fa..c9c734753f3 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4175,6 +4175,9 @@ const pt = { revoke: "Revogar", revoking: "Revogando…", revokedToast: "Aceitação de \"{{set}}\" revogada.", + reopen: "Reconsiderar", + reopening: "Reabrindo…", + reopenedToast: "Sugestão para \"{{set}}\" reaberta: nada é concedido até que seja aceita.", }, title: "Marketplace de aplicativos", subtitle: "Explore aplicativos aprovados publicados no catálogo ObjectStack. Clique em um aplicativo para ver os detalhes e instalá-lo em um de seus ambientes.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 515738a869a..f251e5e8654 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -4274,6 +4274,9 @@ const ru = { revoke: "Отозвать", revoking: "Отзыв…", revokedToast: "Принятие «{{set}}» отозвано.", + reopen: "Пересмотреть", + reopening: "Открывается снова…", + reopenedToast: "Предложение для «{{set}}» снова открыто: ничего не предоставляется, пока оно не принято.", }, title: "Маркетплейс приложений", subtitle: "Просматривайте одобренные приложения из каталога ObjectStack. Нажмите на приложение, чтобы увидеть подробности и установить его.", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index bd67f5a6d8a..c7f54ab82ad 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4466,6 +4466,9 @@ const zh = { revoke: '撤销', revoking: '撤销中…', revokedToast: '已撤销对「{{set}}」的接受。', + reopen: '重新考虑', + reopening: '重新打开中…', + reopenedToast: '已重新打开「{{set}}」的建议:接受之前不授予任何权限。', }, uninstall: { confirm: '从本运行时卸载 {{manifestId}} v{{version}}?\n\n该应用将立即从运行中的内核中移除,其权限集及相应授权也一并移除,缓存的清单也将被删除。其对象中已存储的记录仍保留在数据库中。', diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 83e58b5a572..3c409eb37c1 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -2628,6 +2628,9 @@ const STUDIO_EN: Record = { 'engine.studio.access.suggestRevoke': 'Revoke', 'engine.studio.access.suggestRevoking': 'Revoking…', 'engine.studio.access.suggestRevokedToast': 'Acceptance of "{set}" revoked.', + 'engine.studio.access.suggestReopen': 'Reconsider', + 'engine.studio.access.suggestReopening': 'Reopening…', + 'engine.studio.access.suggestReopenedToast': 'Suggestion for "{set}" reopened: nothing is granted until it is accepted.', 'engine.studio.access.heading': 'Permission sets', 'engine.studio.access.search': 'Search permissions…', 'engine.studio.access.none': 'No permission sets yet — click “+” above to create one', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 0df6acd7f41..1a3f4c53d04 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2344,6 +2344,9 @@ const STUDIO_ZH: Record = { 'engine.studio.access.suggestRevoke': '撤销', 'engine.studio.access.suggestRevoking': '撤销中…', 'engine.studio.access.suggestRevokedToast': '已撤销对「{set}」的接受。', + 'engine.studio.access.suggestReopen': '重新考虑', + 'engine.studio.access.suggestReopening': '重新打开中…', + 'engine.studio.access.suggestReopenedToast': '已重新打开「{set}」的建议:接受之前不授予任何权限。', 'engine.studio.access.heading': '权限集', 'engine.studio.access.search': '搜索权限…', 'engine.studio.access.none': '还没有权限集 — 点上方的「+」新建',