From 9dcc463d1284ef013656ae069c9be07d05f3bd36 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:41:19 +0000 Subject: [PATCH 1/3] feat(app-shell): the suggested-bindings panel shows each row's state and revokes an acceptance After objectstack#22854 (#22801 -> A) an accept by a manage_metadata holder takes effect for the whole deployment and a dismiss on an accepted row revokes it. The panel listed pending rows only and dropped bindingCreated. It now lists every row of the package and draws pending (Accept / Dismiss), a guest row (no Accept, the reason instead, Dismiss), accepted (Revoke, through the dismiss call), accepted but not yet in force (a 200 answered bindingCreated: false) and dismissed or revoked. A baseline row is not drawn. A 403 or 409 on an action shows the server's message and re-reads the list; a 403 on the list still renders nothing. Part of objectui#7611. Claude-Session: https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR Co-authored-by: Claude --- .../SuggestedBindingsPanel.test.tsx | 288 ++++++++++++++++++ .../src/components/SuggestedBindingsPanel.tsx | 238 +++++++++++---- .../marketplace/MarketplacePackagePage.tsx | 16 +- .../src/services/suggestedBindingsApi.ts | 62 +++- .../src/views/metadata-admin/i18n.ts | 17 ++ .../studio-design/StudioDesignSurface.tsx | 20 +- packages/i18n/src/locales/ar.ts | 7 + packages/i18n/src/locales/de.ts | 7 + packages/i18n/src/locales/en.ts | 7 + packages/i18n/src/locales/es.ts | 7 + packages/i18n/src/locales/fr.ts | 7 + packages/i18n/src/locales/ja.ts | 7 + packages/i18n/src/locales/ko.ts | 7 + packages/i18n/src/locales/pt.ts | 7 + packages/i18n/src/locales/ru.ts | 7 + packages/i18n/src/locales/zh.ts | 7 + 16 files changed, 636 insertions(+), 75 deletions(-) create mode 100644 packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx diff --git a/packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx b/packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx new file mode 100644 index 0000000000..00d815484d --- /dev/null +++ b/packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx @@ -0,0 +1,288 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `SuggestedBindingsPanel` after objectstack#22854 (#22801 → A): an accept by a + * `manage_metadata` holder takes effect for the whole deployment, a dismiss on + * an accepted row revokes it, a `guest` row cannot be accepted, and a caller + * without the authority is refused the list (objectui#7611, C9 part 2). + * + * Pinned here, one test per state and per action: + * + * - the states a row is drawn in — pending, accepted, accepted but not yet + * in force (a `200` answered `bindingCreated: false`), dismissed or + * revoked — and the one it is not drawn in (a baseline row, `confirmed` + * without a deployment decision, which this surface does not decide); + * - 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 `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. + * + * Real subjects: the panel and the real `suggestedBindingsApi` client over a + * stubbed `fetch` answering the server's envelopes (`{ data: { suggestions, + * synced } }`, `{ data: { suggestion, bindingCreated } }`, `{ data: { + * suggestion } }`, and ADR-0112's `{ error: { code, message } }`). Row shapes + * are the ones the list route answers at objectstack `main` (measured on the + * PR). `sonner` is mocked so the toasts can be read. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +// Imported at module scope: `@object-ui/components` is a heavy barrel, and +// resolving it mid-assertion races the RTL timeouts (AGENTS.md § 测试纪律). +import '@object-ui/components'; + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), warning: vi.fn(), error: vi.fn(), info: vi.fn(), + }), +})); + +import { toast } from 'sonner'; +import { SuggestedBindingsPanel, type SuggestedBindingsStrings } from './SuggestedBindingsPanel'; +import type { SuggestedBinding } from '../services/suggestedBindingsApi'; + +const PKG = 'com.acme.crm'; + +function row(overrides: Partial & { permission_set_name: string }): SuggestedBinding { + return { + id: `sug_${overrides.permission_set_name}`, + package_id: PKG, + anchor: 'everyone', + status: 'pending', + deployment_decision: false, + resolved_by: null, + resolved_at: null, + ...overrides, + }; +} + +const STRINGS: SuggestedBindingsStrings = { + describe: (s) => `PROMPT ${s.anchor} ${s.permission_set_name}`, + confirm: 'Accept', + confirming: 'Accepting', + dismiss: 'Dismiss', + confirmedToast: (s) => `GRANTED ${s.permission_set_name}`, + dismissedToast: (s) => `DISMISSED ${s.permission_set_name}`, + guestUnsupported: 'GUEST CANNOT BE ACCEPTED', + acceptedState: (s) => `ACCEPTED ${s.permission_set_name}`, + notInForceState: (s) => `NOT IN FORCE ${s.permission_set_name}`, + dismissedState: (s) => `NOT GRANTED ${s.permission_set_name}`, + revoke: 'Revoke', + revoking: 'Revoking', + revokedToast: (s) => `REVOKED ${s.permission_set_name}`, +}; + +type Answer = { status: number; body: unknown }; +const ok = (data: unknown): Answer => ({ status: 200, body: { data } }); +const refused = (status: number, code: string, message: string): Answer => ({ + status, body: { error: { code, message } }, +}); +const listed = (suggestions: SuggestedBinding[]): Answer => + ok({ suggestions, synced: { created: 0, confirmedObserved: 0, pruned: 0, reopened: 0, refused: 0 } }); + +/** + * 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[] }) { + const calls: string[] = []; + const next = (q: Answer[] | undefined): Answer => { + if (!q || q.length === 0) throw new Error('unexpected route'); + return q.length > 1 ? q.shift()! : q[0]; + }; + const fetchStub = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), 'http://localhost'); + const method = (init?.method ?? 'GET').toUpperCase(); + calls.push(`${method} ${url.pathname}${url.search}`); + const answer = url.pathname.endsWith('/confirm') + ? next(routes.confirm) + : url.pathname.endsWith('/dismiss') + ? next(routes.dismiss) + : next(routes.list); + return new Response(JSON.stringify(answer.body), { + status: answer.status, + headers: { 'Content-Type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchStub); + return calls; +} + +const panel = () => render(); +const rowEl = (set: string) => screen.findByTestId(`suggested-binding-${set}`); + +beforeEach(() => { vi.clearAllMocks(); }); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +describe('SuggestedBindingsPanel — the state of each row (objectui#7611)', () => { + it('lists every status for the package, not pending only', async () => { + const calls = serve({ list: [listed([row({ permission_set_name: 'crm_reader' })])] }); + panel(); + await rowEl('crm_reader'); + expect(calls).toEqual([`GET /api/v1/security/suggested-bindings?packageId=${encodeURIComponent(PKG)}`]); + }); + + it('a pending row offers Accept and Dismiss', async () => { + serve({ list: [listed([row({ permission_set_name: 'crm_reader' })])] }); + panel(); + const el = await rowEl('crm_reader'); + expect(el.dataset.state).toBe('pending'); + expect(el.textContent).toContain('PROMPT everyone crm_reader'); + expect(screen.getByTestId('suggested-binding-confirm-crm_reader')).toBeTruthy(); + expect(screen.getByTestId('suggested-binding-dismiss-crm_reader')).toBeTruthy(); + expect(screen.queryByTestId('suggested-binding-revoke-crm_reader')).toBeNull(); + }); + + it('an accepted row (confirmed by a deployment decision) offers Revoke and no Accept', async () => { + serve({ list: [listed([row({ permission_set_name: 'crm_reader', status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' })])] }); + panel(); + const el = await rowEl('crm_reader'); + expect(el.dataset.state).toBe('accepted'); + expect(el.textContent).toContain('ACCEPTED crm_reader'); + expect(screen.getByTestId('suggested-binding-revoke-crm_reader')).toBeTruthy(); + expect(screen.queryByTestId('suggested-binding-confirm-crm_reader')).toBeNull(); + 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 () => { + 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); + }); + + it('a baseline row (confirmed, no deployment decision) is not drawn — this surface does not decide the baseline', async () => { + serve({ + list: [listed([ + row({ permission_set_name: 'baseline_set', status: 'confirmed', deployment_decision: false }), + row({ permission_set_name: 'crm_reader' }), + ])], + }); + panel(); + await rowEl('crm_reader'); + expect(screen.queryByTestId('suggested-binding-baseline_set')).toBeNull(); + }); + + it('CONTROL for the above: a panel holding only a baseline row renders nothing', async () => { + const calls = serve({ list: [listed([row({ permission_set_name: 'baseline_set', status: 'confirmed', deployment_decision: false })])] }); + const { container } = panel(); + await waitFor(() => expect(calls).toHaveLength(1)); + await Promise.resolve(); + expect(container.innerHTML).toBe(''); + }); + + it('a guest row offers no Accept, says why, and keeps Dismiss', async () => { + serve({ list: [listed([row({ permission_set_name: 'site_visitor', anchor: 'guest' })])] }); + panel(); + const el = await rowEl('site_visitor'); + expect(el.dataset.state).toBe('guest'); + expect(el.textContent).toContain('PROMPT guest site_visitor'); + expect(el.textContent).toContain('GUEST CANNOT BE ACCEPTED'); + expect(screen.queryByTestId('suggested-binding-confirm-site_visitor')).toBeNull(); + expect(screen.getByTestId('suggested-binding-dismiss-site_visitor')).toBeTruthy(); + }); + + it('a 403 on the list renders nothing (a caller without manage_metadata)', 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.innerHTML).toBe(''); + }); +}); + +describe('SuggestedBindingsPanel — the actions (objectui#7611)', () => { + it('Accept answered bindingCreated: true → the row is accepted, with Revoke, and the success toast', 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])], confirm: [ok({ suggestion: accepted, bindingCreated: true })] }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-confirm-crm_reader')); + await screen.findByTestId('suggested-binding-revoke-crm_reader'); + expect((await rowEl('crm_reader')).dataset.state).toBe('accepted'); + expect(calls).toContain('POST /api/v1/security/suggested-bindings/sug_crm_reader/confirm'); + expect(toast.success).toHaveBeenCalledWith('GRANTED crm_reader'); + expect(toast.warning).not.toHaveBeenCalled(); + }); + + it('Accept answered 200 with bindingCreated: false → accepted but NOT in force, a warning and no success toast', async () => { + const pending = row({ permission_set_name: 'crm_reader' }); + const accepted = { ...pending, status: 'confirmed' as const, deployment_decision: true, resolved_by: 'u_admin' }; + serve({ list: [listed([pending])], confirm: [ok({ suggestion: accepted, bindingCreated: false })] }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-confirm-crm_reader')); + await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('notInForce')); + expect((await rowEl('crm_reader')).textContent).toContain('NOT IN FORCE crm_reader'); + expect(toast.warning).toHaveBeenCalledWith('NOT IN FORCE crm_reader'); + expect(toast.success).not.toHaveBeenCalled(); + // The acceptance is recorded, so it can be revoked. + expect(screen.getByTestId('suggested-binding-revoke-crm_reader')).toBeTruthy(); + }); + + it('Dismiss on a pending row calls dismiss, and the row says it is not granted', async () => { + const pending = row({ permission_set_name: 'crm_reader' }); + const dismissed = { ...pending, status: 'dismissed' as const, deployment_decision: true, resolved_by: 'u_admin' }; + const calls = serve({ list: [listed([pending])], dismiss: [ok({ suggestion: dismissed })] }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-dismiss-crm_reader')); + await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('dismissed')); + expect(calls).toContain('POST /api/v1/security/suggested-bindings/sug_crm_reader/dismiss'); + expect(toast.success).toHaveBeenCalledWith('DISMISSED crm_reader'); + }); + + it('Revoke on an accepted row is the dismiss call; the row says it is not granted and the revoke toast shows', async () => { + const accepted = row({ permission_set_name: 'crm_reader', status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' }); + const revoked = { ...accepted, status: 'dismissed' as const }; + const calls = serve({ list: [listed([accepted])], dismiss: [ok({ suggestion: revoked })] }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-revoke-crm_reader')); + await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('dismissed')); + expect(calls).toContain('POST /api/v1/security/suggested-bindings/sug_crm_reader/dismiss'); + expect(calls.some((c) => c.endsWith('/confirm'))).toBe(false); + expect(toast.success).toHaveBeenCalledWith('REVOKED crm_reader'); + }); + + it('Revoke refused 409 → the server message is shown, and the panel re-reads and shows what the server holds', async () => { + const accepted = row({ permission_set_name: 'crm_reader', status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' }); + const elsewhere = { ...accepted, status: 'dismissed' as const }; + const message = "Audience-binding suggestion 'sug_crm_reader' is already dismissed — only a pending or accepted suggestion can be dismissed."; + const calls = serve({ + list: [listed([accepted]), listed([elsewhere])], + dismiss: [refused(409, 'SUGGESTION_STATE', message)], + }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-revoke-crm_reader')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message)); + await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('dismissed')); + expect(calls.filter((c) => c.startsWith('GET '))).toHaveLength(2); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('Accept refused 403 (a bit the everyone anchor forbids) → the server message is shown and the row stays pending', async () => { + const pending = row({ permission_set_name: 'crm_risky' }); + const message = "[Security] Access denied: permission set 'crm_risky' cannot be given to the 'everyone' audience anchor — it carries viewAllRecords on crm_account (ADR-0090 D5/D7)."; + serve({ list: [listed([pending])], confirm: [refused(403, 'PERMISSION_DENIED', message)] }); + panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-confirm-crm_risky')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message)); + expect((await rowEl('crm_risky')).dataset.state).toBe('pending'); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('Revoke refused 403 and the re-read refused 403 → the message is shown, then the panel renders nothing', async () => { + const accepted = row({ permission_set_name: 'crm_reader', status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' }); + const message = '[Security] Access denied: dismissing an audience-binding suggestion requires the `manage_metadata` capability.'; + serve({ + list: [listed([accepted]), refused(403, 'PERMISSION_DENIED', message)], + dismiss: [refused(403, 'PERMISSION_DENIED', message)], + }); + const { container } = panel(); + fireEvent.click(await screen.findByTestId('suggested-binding-revoke-crm_reader')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message)); + await waitFor(() => expect(container.innerHTML).toBe('')); + }); +}); diff --git a/packages/app-shell/src/components/SuggestedBindingsPanel.tsx b/packages/app-shell/src/components/SuggestedBindingsPanel.tsx index b1f713932c..10eda1ea91 100644 --- a/packages/app-shell/src/components/SuggestedBindingsPanel.tsx +++ b/packages/app-shell/src/components/SuggestedBindingsPanel.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { useCallback, useEffect, useState } from 'react'; -import { ShieldQuestion } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { ShieldBan, ShieldCheck, ShieldAlert, ShieldOff, ShieldQuestion } from 'lucide-react'; import { Button, cn } from '@object-ui/components'; import { toast } from 'sonner'; import { @@ -12,28 +12,72 @@ import { } from '../services/suggestedBindingsApi.js'; /** - * SuggestedBindingsPanel — pending audience-binding suggestions - * (ADR-0090 D5/D9) with per-row Confirm / Dismiss. + * SuggestedBindingsPanel — a package's suggested audience bindings + * (ADR-0090 D5/D9) and the deployment's decision on each. * - * A package permission set shipped with `isDefault: true` asks the admin to - * bind it to the built-in `everyone` position (default grants for signed-in - * users). The server NEVER auto-binds; this panel is the "admin confirms" - * moment. Confirm runs under the server-side anchor gates — a 403 carries - * the human-readable reason (e.g. the set carries high-privilege bits) and - * is surfaced verbatim. + * A package permission set shipped with `isDefault: true` suggests giving the + * set to the built-in `everyone` position (default grants for signed-in + * users). The server NEVER auto-binds: a `manage_metadata` holder (under a + * wall, the platform operator) accepts it ONCE for the whole deployment, and + * the accept takes effect (objectstack#22801 → A). This panel shows each + * row's state as the server records it: * - * Renders nothing while loading, when there are no pending suggestions, or - * when the caller is not a tenant admin (403 on list) — safe to mount - * unconditionally in the install dialog and the Studio Access pillar. + * - pending — Accept / Dismiss; + * - pending on the `guest` anchor — no Accept (the server refuses it `409`: + * the `guest` anchor's sets are not derived from acceptances), the reason + * in its place, and Dismiss; + * - accepted (`confirmed` by a deployment decision) — Revoke, which is the + * dismiss call on an accepted row; + * - accepted but NOT in force — an accept that answered `200` with + * `bindingCreated: false`: recorded, but the `everyone` definition did not + * 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`. + * + * 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. + * + * 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 + * is safe to mount unconditionally in the install dialog and the Studio + * Access pillar. */ export interface SuggestedBindingsStrings { - /** One-line prompt per suggestion, e.g. `CRM suggests granting 'crm_readonly' to everyone`. */ + /** One-line prompt per pending suggestion, e.g. `CRM suggests granting 'crm_readonly' to everyone`. */ describe: (s: SuggestedBinding) => string; confirm: string; dismiss: string; confirming: string; confirmedToast: (s: SuggestedBinding) => string; dismissedToast: (s: SuggestedBinding) => string; + /** Why a pending `guest` row offers no accept. */ + guestUnsupported: string; + /** State line of an accepted row. Claims the decision, not that the set is in force. */ + acceptedState: (s: SuggestedBinding) => string; + /** State line (and warning toast) of an accept answered `bindingCreated: false`. */ + notInForceState: (s: SuggestedBinding) => string; + /** State line of a dismissed — or revoked — row. */ + dismissedState: (s: SuggestedBinding) => string; + revoke: string; + revoking: string; + revokedToast: (s: SuggestedBinding) => string; +} + +type RowState = 'pending' | 'guest' | 'accepted' | 'notInForce' | 'dismissed'; + +/** The state a row is drawn in, or `null` for one this surface does not decide. */ +function rowState(s: SuggestedBinding, notInForce: ReadonlySet): RowState | null { + if (s.status === 'pending') return s.anchor === 'guest' ? 'guest' : 'pending'; + if (s.status === 'dismissed') return 'dismissed'; + if (s.status === 'confirmed' && s.deployment_decision === true) { + return notInForce.has(s.id) ? 'notInForce' : 'accepted'; + } + return null; } export function SuggestedBindingsPanel({ @@ -46,74 +90,158 @@ export function SuggestedBindingsPanel({ packageId?: string; strings: SuggestedBindingsStrings; className?: string; - /** Called after any suggestion is confirmed or dismissed. */ + /** Called after any suggestion is accepted, dismissed or revoked. */ onResolved?: (s: SuggestedBinding) => void; }) { - const [pending, setPending] = useState([]); + const [rows, setRows] = useState([]); const [busyId, setBusyId] = useState(null); + // Accepts answered `bindingCreated: false` in this session, by row id. + const [notInForce, setNotInForce] = useState>(() => new Set()); - const reload = useCallback(() => { + // Keyed on the `packageId` primitive, never on a memoised function's + // identity (AGENTS.md #10). + useEffect(() => { let cancelled = false; - listSuggestedBindings({ status: 'pending', ...(packageId ? { packageId } : {}) }) - .then((rows) => { if (!cancelled) setPending(rows); }) - .catch(() => { /* non-admin (403) or surface unavailable → render nothing */ }); + listSuggestedBindings(packageId ? { packageId } : {}) + .then((next) => { if (!cancelled) setRows(next); }) + .catch(() => { /* refused (403) or surface unavailable → render nothing */ }); return () => { cancelled = true; }; }, [packageId]); - useEffect(() => reload(), [reload]); + const reread = () => { + listSuggestedBindings(packageId ? { packageId } : {}) + .then((next) => setRows(next)) + .catch(() => setRows([])); + }; + + const put = (next: SuggestedBinding) => + setRows((current) => current.map((r) => (r.id === next.id ? next : r))); - const resolve = async (s: SuggestedBinding, action: 'confirm' | 'dismiss') => { + const act = async (s: SuggestedBinding, action: 'confirm' | 'dismiss' | 'revoke') => { setBusyId(s.id); try { - const resolved = action === 'confirm' - ? await confirmSuggestedBinding(s.id) - : await dismissSuggestedBinding(s.id); - setPending((rows) => rows.filter((r) => r.id !== s.id)); - toast.success(action === 'confirm' ? strings.confirmedToast(s) : strings.dismissedToast(s)); - onResolved?.(resolved ?? s); + let resolved: SuggestedBinding; + if (action === 'confirm') { + const { suggestion, bindingCreated } = await confirmSuggestedBinding(s.id); + resolved = suggestion; + setNotInForce((ids) => { + const next = new Set(ids); + if (bindingCreated) next.delete(resolved.id); + else next.add(resolved.id); + return next; + }); + if (bindingCreated) toast.success(strings.confirmedToast(s)); + else toast.warning(strings.notInForceState(s)); + } else { + resolved = await dismissSuggestedBinding(s.id); + toast.success(action === 'revoke' ? strings.revokedToast(s) : strings.dismissedToast(s)); + } + put(resolved); + onResolved?.(resolved); } catch (err: any) { - // The gate's message is the explanation ("carries view-all…") — show it. + // The server's message is the explanation ("carries view-all…", "is + // observed, not accepted…") — show it, then show what the server holds. toast.error(err?.message ?? String(err)); + reread(); } finally { setBusyId(null); } }; - if (pending.length === 0) return null; + const drawn = rows + .map((s) => ({ s, state: rowState(s, notInForce) })) + .filter((r): r is { s: SuggestedBinding; state: RowState } => r.state !== null); + if (drawn.length === 0) return null; + const awaitsDecision = drawn.some((r) => r.state === 'pending' || r.state === 'guest'); return (
- {pending.map((s) => ( -
- -

- {strings.describe(s)} -

- - -
- ))} + +
+ {state === 'pending' || state === 'guest' ? ( +

{strings.describe(s)}

+ ) : ( +

+ {state === 'accepted' + ? strings.acceptedState(s) + : state === 'notInForce' + ? strings.notInForceState(s) + : strings.dismissedState(s)} +

+ )} + {state === 'guest' && ( +

{strings.guestUnsupported}

+ )} +
+ {state === 'pending' && ( + + )} + {(state === 'pending' || state === 'guest') && ( + + )} + {(state === 'accepted' || state === 'notInForce') && ( + + )} +
+ ); + })} ); } + +function StateIcon({ state }: { state: RowState }) { + const cls = 'h-4 w-4 shrink-0'; + switch (state) { + case 'pending': + return ; + case 'guest': + return ; + case 'accepted': + return ; + case 'notInForce': + return ; + case 'dismissed': + return ; + } +} diff --git a/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx b/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx index c204e3c8d9..6c7cf5b580 100644 --- a/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx +++ b/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx @@ -111,8 +111,9 @@ export function MarketplacePackagePage() { const displayLocale = useDisplayLocale(); // ADR-0090 D5 — install-time suggested audience bindings ("this app // suggests granting to Everyone"), surfaced right after a - // successful install into THIS runtime. Confirm/dismiss is admin-gated - // server-side; the panel renders nothing for non-admins. + // successful install into THIS runtime, with the deployment's decision on + // each. Accept/dismiss/revoke need `manage_metadata` server-side; the panel + // renders nothing for a caller without it (the list answers 403). const suggestionStrings: SuggestedBindingsStrings = { describe: (s: SuggestedBinding) => t(s.anchor === 'guest' ? 'marketplace.suggestedBindings.promptGuest' : 'marketplace.suggestedBindings.promptEveryone', { set: s.permission_set_name }), @@ -123,6 +124,17 @@ export function MarketplacePackagePage() { t('marketplace.suggestedBindings.confirmedToast', { set: s.permission_set_name, anchor: s.anchor }), dismissedToast: (s: SuggestedBinding) => t('marketplace.suggestedBindings.dismissedToast', { set: s.permission_set_name }), + guestUnsupported: t('marketplace.suggestedBindings.guestUnsupported'), + acceptedState: (s: SuggestedBinding) => + t('marketplace.suggestedBindings.acceptedState', { set: s.permission_set_name }), + notInForceState: (s: SuggestedBinding) => + t('marketplace.suggestedBindings.notInForceState', { set: s.permission_set_name }), + dismissedState: (s: SuggestedBinding) => + t('marketplace.suggestedBindings.dismissedState', { set: s.permission_set_name }), + revoke: t('marketplace.suggestedBindings.revoke'), + revoking: t('marketplace.suggestedBindings.revoking'), + revokedToast: (s: SuggestedBinding) => + t('marketplace.suggestedBindings.revokedToast', { set: s.permission_set_name }), }; const basePath = appName ? `/apps/${appName}` : ''; const { refresh: refreshMetadata } = useMetadata(); diff --git a/packages/app-shell/src/services/suggestedBindingsApi.ts b/packages/app-shell/src/services/suggestedBindingsApi.ts index e1dc96db49..751faf772f 100644 --- a/packages/app-shell/src/services/suggestedBindingsApi.ts +++ b/packages/app-shell/src/services/suggestedBindingsApi.ts @@ -5,11 +5,16 @@ * `/api/v1/security/suggested-bindings` surface. * * A package permission set declaring `isDefault: true` is an install-time - * SUGGESTION to bind the set to the built-in `everyone` position (default - * grants for authenticated users). The server never auto-binds: a tenant - * admin confirms or dismisses each suggestion. These calls are ordinary - * same-origin admin API calls (not object CRUD), so they follow the - * marketplaceApi raw-fetch pattern: Bearer token + `credentials: 'include'`. + * SUGGESTION to give the set to the built-in `everyone` position (default + * grants for authenticated users). The server never auto-binds: a caller + * holding `manage_metadata` (under a wall, the platform operator) accepts or + * 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'`. */ import { TokenStorage } from '@object-ui/auth'; @@ -23,11 +28,33 @@ export interface SuggestedBinding { permission_set_name: string; anchor: 'everyone' | 'guest'; status: 'pending' | 'confirmed' | 'dismissed'; + /** + * `true` when a `manage_metadata` holder's accept or dismiss resolved the + * row (`sys_audience_binding_suggestion.deployment_decision`). It is what + * the server itself branches on: a `confirmed` row WITH it is an + * acceptance, which a dismiss revokes; a `confirmed` row WITHOUT it is one + * the deployment's baseline already names, which the server observed and + * refuses to dismiss (`409`). + */ + deployment_decision?: boolean; resolved_by?: string | null; resolved_at?: string | null; created_at?: string; } +/** What an accept answers: the row as written, and whether it took effect. */ +export interface SuggestedBindingAcceptance { + suggestion: SuggestedBinding; + /** + * `true` when the `everyone` definition names the set now and did not + * before this request. `false` on a `200` means the acceptance was RECORDED + * but the definition did not change on this request — the set was already + * named, or the derivation withheld it and the server logged why — so the + * caller must not report it as granted. + */ + bindingCreated: boolean; +} + function authHeaders(extra: Record = {}): Record { const headers: Record = { Accept: 'application/json', ...extra }; const token = TokenStorage.get(); @@ -49,10 +76,10 @@ async function parseOrThrow(res: Response): Promise { } /** - * List suggestions. The server reconciles first (installed manifests → + * List suggestions. The server reconciles first (declared `isDefault` sets → * pending rows), so calling this right after an install already sees the - * new package's suggestions. Requires a tenant-level admin; non-admin - * callers get a 403 the UI should treat as "surface hidden". + * new package's suggestions. Requires `manage_metadata`; any other caller + * gets a 403 the UI treats as "surface hidden". */ export async function listSuggestedBindings(filter: { status?: 'pending' | 'confirmed' | 'dismissed'; @@ -71,21 +98,28 @@ export async function listSuggestedBindings(filter: { } /** - * Confirm a pending suggestion — the server creates the anchor binding under - * the ADR-0090 gates (a high-privilege set is refused with a 403 whose - * message explains the offending bits; surface it verbatim). + * Accept a pending suggestion for the deployment — the server re-derives the + * `everyone` definition with the set, under the ADR-0090 gates (a + * high-privilege set is refused with a 403 whose message explains the + * offending bits; a `guest` row is refused with a 409; surface either + * verbatim). `bindingCreated` is returned, never dropped: it is the only + * signal that tells an acceptance in force from one merely recorded. */ -export async function confirmSuggestedBinding(id: string): Promise { +export async function confirmSuggestedBinding(id: string): Promise { const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}/confirm`, { method: 'POST', credentials: 'include', headers: authHeaders({ 'Content-Type': 'application/json' }), }); const data = await parseOrThrow(res); - return data?.suggestion as SuggestedBinding; + return { suggestion: data?.suggestion as SuggestedBinding, bindingCreated: data?.bindingCreated === true }; } -/** Dismiss a pending suggestion (the set stays bindable by hand later). */ +/** + * Dismiss a pending suggestion, or REVOKE an accepted one — the same call: on + * a row a `manage_metadata` holder accepted, the server re-derives `everyone` + * without the set. A row the baseline names is refused with a 409. + */ export async function dismissSuggestedBinding(id: string): Promise { const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}/dismiss`, { method: 'POST', diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 001d79fa29..b536c413a5 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -3496,6 +3496,15 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.access.suggestDismiss': 'Dismiss', 'engine.studio.access.suggestConfirmedToast': '"{set}" is now granted to {anchor}.', 'engine.studio.access.suggestDismissedToast': 'Suggestion for "{set}" dismissed.', + // objectstack#22801 → A — an accept takes effect for the deployment; a + // dismiss on an accepted row revokes it; a `guest` row cannot be accepted. + 'engine.studio.access.suggestGuestUnsupported': 'Cannot be granted here: the Guest position takes no sets from accepted suggestions.', + 'engine.studio.access.suggestAcceptedState': '"{set}" was accepted for all signed-in users (Everyone).', + 'engine.studio.access.suggestNotInForceState': '"{set}" was accepted but is not in force yet: Everyone did not change on this request. The server log says why.', + 'engine.studio.access.suggestDismissedState': '"{set}" is not granted: the suggestion was dismissed or revoked.', + 'engine.studio.access.suggestRevoke': 'Revoke', + 'engine.studio.access.suggestRevoking': 'Revoking…', + 'engine.studio.access.suggestRevokedToast': 'Acceptance of "{set}" revoked.', 'engine.studio.access.heading': 'Permission sets', 'engine.studio.access.search': 'Search permissions…', 'engine.studio.access.none': 'No permission sets yet — create one below', @@ -6645,6 +6654,14 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.access.suggestDismiss': '忽略', 'engine.studio.access.suggestConfirmedToast': '「{set}」已授予 {anchor}。', 'engine.studio.access.suggestDismissedToast': '已忽略「{set}」的建议。', + // objectstack#22801 → A — accepted / not-in-force / dismissed states and revoke. + 'engine.studio.access.suggestGuestUnsupported': '此处无法授予:Guest 岗位的权限集不由已接受的建议派生。', + 'engine.studio.access.suggestAcceptedState': '「{set}」已为所有已登录用户(Everyone 岗位)接受。', + 'engine.studio.access.suggestNotInForceState': '「{set}」已接受,但尚未生效:本次请求未改变 Everyone 岗位。原因见服务器日志。', + 'engine.studio.access.suggestDismissedState': '「{set}」未授予:该建议已被忽略或撤销。', + 'engine.studio.access.suggestRevoke': '撤销', + 'engine.studio.access.suggestRevoking': '撤销中…', + 'engine.studio.access.suggestRevokedToast': '已撤销对「{set}」的接受。', 'engine.studio.access.heading': '权限集', 'engine.studio.access.search': '搜索权限…', 'engine.studio.access.none': '还没有权限集 — 在下方新建一个', diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 6e61ec3902..1b14e1f4b0 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -6856,10 +6856,11 @@ export function AccessPillar({ )} - {/* ADR-0090 D5/D9 — this package's pending suggested audience bindings - (isDefault sets shipped by the package, awaiting the admin's - confirm). Renders nothing when there are none or for non-admins; - confirm is enforced server-side by the anchor gates. */} + {/* ADR-0090 D5/D9 — this package's suggested audience bindings + (isDefault sets shipped by the package) and the deployment's + decision on each. Renders nothing when there are none or for a + caller without manage_metadata (the list answers 403); accept and + revoke are enforced server-side by the anchor gates. */} {!readOnly && ( tFormat('engine.studio.access.suggestDismissedToast', locale, { set: s.permission_set_name }), + guestUnsupported: t('engine.studio.access.suggestGuestUnsupported', locale), + acceptedState: (s) => + tFormat('engine.studio.access.suggestAcceptedState', locale, { set: s.permission_set_name }), + notInForceState: (s) => + tFormat('engine.studio.access.suggestNotInForceState', locale, { set: s.permission_set_name }), + dismissedState: (s) => + tFormat('engine.studio.access.suggestDismissedState', locale, { set: s.permission_set_name }), + revoke: t('engine.studio.access.suggestRevoke', locale), + revoking: t('engine.studio.access.suggestRevoking', locale), + revokedToast: (s) => + tFormat('engine.studio.access.suggestRevokedToast', locale, { set: s.permission_set_name }), }} /> )} diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 6ae3fa6aa9..04755992ea 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3679,6 +3679,13 @@ const ar = { dismiss: "تجاهل", confirmedToast: "تم الآن منح «{{set}}» إلى {{anchor}}.", dismissedToast: "تم تجاهل اقتراح «{{set}}».", + guestUnsupported: "لا يمكن المنح هنا: لا تُشتق مجموعات موضع Guest من الاقتراحات المقبولة.", + acceptedState: "تم قبول «{{set}}» لجميع المستخدمين المسجّلين (موضع Everyone).", + notInForceState: "تم قبول «{{set}}»، لكنه لم يسرِ بعد: لم يتغير موضع Everyone في هذا الطلب. يوضّح سجل الخادم السبب.", + dismissedState: "«{{set}}» غير ممنوح: تم تجاهل الاقتراح أو إلغاؤه.", + revoke: "إلغاء", + revoking: "جارٍ الإلغاء…", + revokedToast: "تم إلغاء قبول «{{set}}».", }, title: "سوق التطبيقات", subtitle: "استعرض التطبيقات المعتمدة المنشورة في كتالوج ObjectStack. انقر على تطبيق لرؤية التفاصيل وتثبيته في أحد بيئاتك.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 5b867452d6..28e65ab0f9 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3412,6 +3412,13 @@ const de = { dismiss: "Ausblenden", confirmedToast: "„{{set}}“ ist jetzt {{anchor}} gewährt.", dismissedToast: "Vorschlag für „{{set}}“ ausgeblendet.", + guestUnsupported: "Hier nicht gewährbar: Die Sets der Position „Guest“ werden nicht aus angenommenen Vorschlägen abgeleitet.", + acceptedState: "„{{set}}“ wurde für alle angemeldeten Benutzer angenommen (Position „Everyone“).", + notInForceState: "„{{set}}“ wurde angenommen, ist aber noch nicht wirksam: Die Position „Everyone“ hat sich bei dieser Anfrage nicht geändert. Der Grund steht im Serverprotokoll.", + dismissedState: "„{{set}}“ ist nicht gewährt: Der Vorschlag wurde ausgeblendet oder widerrufen.", + revoke: "Widerrufen", + revoking: "Wird widerrufen…", + revokedToast: "Annahme von „{{set}}“ widerrufen.", }, 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/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 4370eb5bb0..9264d91d2c 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -4197,6 +4197,13 @@ const en = { dismiss: 'Dismiss', confirmedToast: '"{{set}}" is now granted to {{anchor}}.', dismissedToast: 'Suggestion for "{{set}}" dismissed.', + guestUnsupported: 'Cannot be granted here: the Guest position takes no sets from accepted suggestions.', + acceptedState: '"{{set}}" was accepted for all signed-in users (the Everyone position).', + notInForceState: '"{{set}}" was accepted but is not in force yet: the Everyone position did not change on this request. The server log says why.', + dismissedState: '"{{set}}" is not granted: the suggestion was dismissed or revoked.', + revoke: 'Revoke', + revoking: 'Revoking…', + revokedToast: 'Acceptance of "{{set}}" revoked.', }, uninstall: { confirm: 'Uninstall {{manifestId}} v{{version}} from this runtime?\n\nThe cached manifest will be removed. The app will remain loaded in the running kernel until the next restart.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 0953746d83..148f1db29e 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3477,6 +3477,13 @@ const es = { dismiss: "Descartar", confirmedToast: "«{{set}}» ya está concedido a {{anchor}}.", dismissedToast: "Sugerencia para «{{set}}» descartada.", + guestUnsupported: "No se puede conceder aquí: los conjuntos de la posición Guest no se derivan de sugerencias aceptadas.", + acceptedState: "«{{set}}» se aceptó para todos los usuarios con sesión iniciada (la posición Everyone).", + notInForceState: "«{{set}}» se aceptó, pero aún no está en vigor: la posición Everyone no cambió con esta solicitud. El registro del servidor indica el motivo.", + dismissedState: "«{{set}}» no está concedido: la sugerencia se descartó o se revocó.", + revoke: "Revocar", + revoking: "Revocando…", + revokedToast: "Aceptación de «{{set}}» revocada.", }, 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/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 3b0242723e..79c6c51cf4 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3474,6 +3474,13 @@ const fr = { dismiss: "Ignorer", confirmedToast: "« {{set}} » est désormais accordé à {{anchor}}.", dismissedToast: "Suggestion pour « {{set}} » ignorée.", + guestUnsupported: "Impossible de l'accorder ici : les ensembles de la position Guest ne sont pas dérivés des suggestions acceptées.", + acceptedState: "« {{set}} » a été accepté pour tous les utilisateurs connectés (position Everyone).", + notInForceState: "« {{set}} » a été accepté, mais n'est pas encore en vigueur : la position Everyone n'a pas changé lors de cette requête. Le journal du serveur indique pourquoi.", + dismissedState: "« {{set}} » n'est pas accordé : la suggestion a été ignorée ou révoquée.", + revoke: "Révoquer", + revoking: "Révocation…", + revokedToast: "Acceptation de « {{set}} » révoqué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/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index f6c1ab4cf2..34d027dd73 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3414,6 +3414,13 @@ const ja = { dismiss: "閉じる", confirmedToast: "「{{set}}」を {{anchor}} に付与しました。", dismissedToast: "「{{set}}」の提案を閉じました。", + guestUnsupported: "ここでは付与できません。Guest ポジションのセットは承認された提案から導出されません。", + acceptedState: "「{{set}}」は、サインイン済みの全ユーザー(Everyone ポジション)向けに承認されています。", + notInForceState: "「{{set}}」は承認されましたが、まだ有効ではありません。このリクエストでは Everyone ポジションは変更されませんでした。理由はサーバーログに記録されています。", + dismissedState: "「{{set}}」は付与されていません。提案は閉じられたか、取り消されました。", + revoke: "取り消す", + revoking: "取り消しています…", + revokedToast: "「{{set}}」の承認を取り消しました。", }, title: "アプリマーケットプレイス", subtitle: "ObjectStackカタログに公開された承認済みアプリを参照します。アプリをクリックして詳細を表示し、環境にインストールしてください。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 790ec5fb3c..fad64af21e 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3411,6 +3411,13 @@ const ko = { dismiss: "닫기", confirmedToast: "\"{{set}}\"이(가) 이제 {{anchor}}에 부여되었습니다.", dismissedToast: "\"{{set}}\" 제안을 닫았습니다.", + guestUnsupported: "여기서는 부여할 수 없습니다. Guest 위치의 세트는 승인된 제안에서 파생되지 않습니다.", + acceptedState: "\"{{set}}\"이(가) 로그인한 모든 사용자(Everyone 위치)에 대해 승인되었습니다.", + notInForceState: "\"{{set}}\"이(가) 승인되었지만 아직 적용되지 않았습니다. 이 요청으로 Everyone 위치가 변경되지 않았습니다. 이유는 서버 로그에 기록되어 있습니다.", + dismissedState: "\"{{set}}\"은(는) 부여되지 않았습니다. 제안이 닫혔거나 취소되었습니다.", + revoke: "취소", + revoking: "취소하는 중…", + revokedToast: "\"{{set}}\" 승인을 취소했습니다.", }, title: "앱 마켓플레이스", subtitle: "ObjectStack 카탈로그에 게시된 승인된 앱을 탐색하세요. 앱을 클릭하여 세부 정보를 보고 환경 중 하나에 설치하세요.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 1ae02ffd3c..588218bc9d 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3472,6 +3472,13 @@ const pt = { dismiss: "Dispensar", confirmedToast: "\"{{set}}\" agora está concedido a {{anchor}}.", dismissedToast: "Sugestão de \"{{set}}\" dispensada.", + guestUnsupported: "Não é possível conceder aqui: os conjuntos da posição Guest não são derivados de sugestões aceitas.", + acceptedState: "\"{{set}}\" foi aceito para todos os usuários autenticados (a posição Everyone).", + notInForceState: "\"{{set}}\" foi aceito, mas ainda não está em vigor: a posição Everyone não mudou nesta solicitação. O log do servidor informa o motivo.", + dismissedState: "\"{{set}}\" não está concedido: a sugestão foi dispensada ou revogada.", + revoke: "Revogar", + revoking: "Revogando…", + revokedToast: "Aceitação de \"{{set}}\" revogada.", }, 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/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index bb5491c5f3..ecb1c0ac0f 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3558,6 +3558,13 @@ const ru = { dismiss: "Закрыть", confirmedToast: "«{{set}}» теперь предоставлен: {{anchor}}.", dismissedToast: "Предложение по «{{set}}» закрыто.", + guestUnsupported: "Здесь предоставить нельзя: наборы позиции Guest не выводятся из принятых предложений.", + acceptedState: "«{{set}}» принят для всех вошедших пользователей (позиция Everyone).", + notInForceState: "«{{set}}» принят, но ещё не действует: этот запрос не изменил позицию Everyone. Причина указана в журнале сервера.", + dismissedState: "«{{set}}» не предоставлен: предложение закрыто или отозвано.", + revoke: "Отозвать", + revoking: "Отзыв…", + revokedToast: "Принятие «{{set}}» отозвано.", }, title: "Маркетплейс приложений", subtitle: "Просматривайте одобренные приложения из каталога ObjectStack. Нажмите на приложение, чтобы увидеть подробности и установить его.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 65d9a78aca..acce08fe03 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3701,6 +3701,13 @@ const zh = { dismiss: '忽略', confirmedToast: '「{{set}}」已授予 {{anchor}}。', dismissedToast: '已忽略「{{set}}」的建议。', + guestUnsupported: '此处无法授予:Guest 岗位的权限集不由已接受的建议派生。', + acceptedState: '「{{set}}」已为所有已登录用户(Everyone 岗位)接受。', + notInForceState: '「{{set}}」已接受,但尚未生效:本次请求未改变 Everyone 岗位。原因见服务器日志。', + dismissedState: '「{{set}}」未授予:该建议已被忽略或撤销。', + revoke: '撤销', + revoking: '撤销中…', + revokedToast: '已撤销对「{{set}}」的接受。', }, uninstall: { confirm: '从本运行时卸载 {{manifestId}} v{{version}}?\n\n磁盘上的清单缓存将被移除。运行中的内核仍会保留该应用,直到下次重启运行时才会真正卸载。', From 37477770be9bfbca86598387589b3182f0c9f66c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:46:26 +0000 Subject: [PATCH 2/3] chore(changeset): declare the suggested-bindings panel states (app-shell, i18n minor) Also types the panel's catch as unknown and keys the not-in-force set on the accept response's id. Part of objectui#7611. Claude-Session: https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR Co-authored-by: Claude --- .changeset/7611-suggested-binding-states.md | 20 +++++++++++++++++++ .../src/components/SuggestedBindingsPanel.tsx | 8 ++++---- 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 .changeset/7611-suggested-binding-states.md diff --git a/.changeset/7611-suggested-binding-states.md b/.changeset/7611-suggested-binding-states.md new file mode 100644 index 0000000000..bbc3cc6294 --- /dev/null +++ b/.changeset/7611-suggested-binding-states.md @@ -0,0 +1,20 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/i18n': minor +--- + +The suggested-bindings panel shows each suggestion's real state and can revoke an acceptance (objectui#7611, after objectstack#22854). + +On a framework where a `manage_metadata` holder's accept takes effect for the whole deployment (objectstack#22801 → A), the panel on the marketplace package page and in the Studio Access pillar listed pending suggestions only, and an accept was reported as granted whatever the server answered. It now lists every suggestion of the package and draws each one in the state the server records: + +- **Pending.** Accept and Dismiss, as before. +- **Pending on the `guest` anchor.** No Accept: the server refuses it with `409`, because the Guest position takes no sets from accepted suggestions. The reason is shown in its place, and Dismiss stays. +- **Accepted.** A Revoke action, which is the existing dismiss call on an accepted row: the server re-derives Everyone without the set. The line says the set was accepted; it does not claim the set is in force, because the list does not say so. +- **Accepted, not yet in force.** An accept answered `200` with `bindingCreated: false` was recorded, but the Everyone position did not change on that request. The panel says so, with a warning toast in place of the success toast, and keeps Revoke. The list does not carry this, so after a reload the row reads as accepted. +- **Dismissed or revoked.** The server records both as `dismissed`. The row says the set is not granted, and offers no action. + +A suggestion the deployment's baseline already names (`confirmed` without a deployment decision) is not drawn: this surface does not decide the baseline. A `403` or `409` on Accept, Dismiss or Revoke shows the server's own message, and the panel then reads the list again so it shows what the server holds. A caller without `manage_metadata` is refused the list with `403`, and the panel still renders nothing for them. + +**Locale keys.** `marketplace.suggestedBindings.guestUnsupported`, `acceptedState`, `notInForceState`, `dismissedState`, `revoke`, `revoking` and `revokedToast`, in all ten packs. The Studio Access pillar's own table gains the matching `engine.studio.access.suggest…` keys in English and Chinese. + +**Clause-②: yes (widening)**: seven locale keys added to `@object-ui/i18n`'s published packs, which widens `TranslationKeys`. No export, prop or accept set moves. diff --git a/packages/app-shell/src/components/SuggestedBindingsPanel.tsx b/packages/app-shell/src/components/SuggestedBindingsPanel.tsx index 10eda1ea91..e13967711e 100644 --- a/packages/app-shell/src/components/SuggestedBindingsPanel.tsx +++ b/packages/app-shell/src/components/SuggestedBindingsPanel.tsx @@ -126,8 +126,8 @@ export function SuggestedBindingsPanel({ resolved = suggestion; setNotInForce((ids) => { const next = new Set(ids); - if (bindingCreated) next.delete(resolved.id); - else next.add(resolved.id); + if (bindingCreated) next.delete(suggestion.id); + else next.add(suggestion.id); return next; }); if (bindingCreated) toast.success(strings.confirmedToast(s)); @@ -138,10 +138,10 @@ export function SuggestedBindingsPanel({ } put(resolved); onResolved?.(resolved); - } catch (err: any) { + } catch (err: unknown) { // The server's message is the explanation ("carries view-all…", "is // observed, not accepted…") — show it, then show what the server holds. - toast.error(err?.message ?? String(err)); + toast.error(err instanceof Error ? err.message : String(err)); reread(); } finally { setBusyId(null); From 8d3b3a0eb6241743dacd421666900c8615033d53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:08:44 +0000 Subject: [PATCH 3/3] test(i18n): the de quote census counts the seven suggested-bindings spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five new de values quote the set and the position with matched „…“ pairs, seven spans between them; the census moves 77 -> 84 with rdq 0. Part of objectui#7611. Claude-Session: https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR Co-authored-by: Claude --- .../i18n/src/__tests__/de-quote-pairing-3876.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 213a71975a..36d7073e69 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -319,7 +319,12 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 77 once objectui#12098 added `marketplace.install.localLoadsAtRestart`, // the install-local answer that loads at the next restart, which quotes // the package name („{{name}}“) — one interpolated span, runtime data. - expect(okSpans, 'correctly paired spans').toBe(77); + // 84 once objectui#7611 added the suggested-bindings panel's states: + // `marketplace.suggestedBindings.acceptedState` and `.notInForceState` + // quote the set („{{set}}“) and the position („Everyone“) — two spans + // each; `.dismissedState` and `.revokedToast` quote the set — one each; + // `.guestUnsupported` quotes the position („Guest“) — one. + expect(okSpans, 'correctly paired spans').toBe(84); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -364,7 +369,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // after objectui#11913 added `grid.import.invalidTime`, one more matched pair. // 77 / 77 / 0 after objectui#12098 added // `marketplace.install.localLoadsAtRestart`, one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 77, close: 77, rdq: 0 }); + // 84 / 84 / 0 after objectui#7611 added five suggested-bindings values, + // seven matched pairs between them. + expect({ open, close, rdq }).toEqual({ open: 84, close: 84, 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);