Skip to content

Commit ef2ad31

Browse files
fix(app-shell): reconsider a dismissed or revoked default permission set in the suggested-bindings panel (#30266)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBBxSPB3for8PaPgERfFkDCM)_ Fixes #30199 ## 维护者速读 - 改了什么:权限建议面板里,被「忽略」或被「撤销」的默认权限集现在多一个「重新考虑」按钮。点了以后这一行回到待决定状态,重新出现「授予」「忽略」;重新打开本身不授予任何权限,要再点「授予」才生效。10 种语言和 Studio 中英文案都已补齐。 - 为什么改:服务端早就有 `POST /security/suggested-bindings/:id/reopen`,但界面从没调用它,管理员点了「暂不」之后只能找开发者调接口才能反悔。 - 风险与回滚:只动面板、它的客户端函数、两处调用方的文案表和语言包;没有服务端、REST 或 spec 改动。按钮只出现在已忽略/已撤销的行上,其他状态的行一字不变(有对照测试钉住)。回滚即 revert。 - 先看:① `SuggestedBindingsPanel.tsx` 里 `state === 'dismissed'` 的新按钮和 `act()` 的 `reopen` 分支;② `suggestedBindingsApi.ts` 的 `reopenSuggestedBinding`;③ `de-quote-pairing-3876.test.ts` 的计数从 102 调到 103(新的德语提示引用了权限集名)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx`,预期 23 个用例全部通过。 - 需要你做的:无,席位落地。 Clause-②: no Metadata-protocol: none ## What changes - **`SuggestedBindingsPanel`**: a `dismissed` row (a declined suggestion or a revoked acceptance; the server records both as `dismissed`) drew only `strings.dismissedState(s)`. It now offers one action, Reconsider (`suggested-binding-reopen-SET`), which calls the re-open door. The server answers the row as `pending`, the panel puts that row in place, and it redraws with Accept and Dismiss (a re-opened `guest` row redraws as `guest`: Dismiss, still no Accept). A success toast says the suggestion is open again and that nothing is granted until it is accepted. A refusal goes through the same `catch` as Accept and Dismiss: the server's message as an error toast, then a re-read of the list. - **Who sees it.** The same callers as Accept and Dismiss. The panel has no client-side gate of its own: a caller without `manage_metadata` is refused the list (403) and the panel renders nothing. The re-open door enforces the same authority (`assertDeploymentAuthority`). - **`suggestedBindingsApi.reopenSuggestedBinding(id)`**, beside `dismissSuggestedBinding`: `POST …/security/suggested-bindings/:id/reopen`, through the same `parseOrThrow`, so a 404 (`SUGGESTION_NOT_FOUND`), 409 (`SUGGESTION_STATE`) or 501 (`NOT_IMPLEMENTED`, a security service without the optional member) is thrown with the server's code, status and message. - **Strings.** `SuggestedBindingsStrings` gains `reopen`, `reopening` and `reopenedToast`. The panel has two string tables, and each gets the keys in the locales it already carries: `marketplace.suggestedBindings.*` in all ten locale packs (ar, de, en, es, fr, ja, ko, pt, ru, zh), and `engine.studio.access.suggestReopen` / `suggestReopening` / `suggestReopenedToast` in the studio namespace (en, zh, the only two it has). zh: 重新考虑 / 重新打开中… . - **`de-quote-pairing-3876.test.ts`**: the German toast quotes the set („{{set}}“), one more matched pair, so the two counts move 102 → 103 with a comment line each, as earlier keys did. ## Why Issue #30199. The door has existed since the re-open work on the server side (`reopenAudienceBindingSuggestion`, mounted in `packages/rest/src/security-routes.ts` and the runtime dispatcher), and its contract says a dismissed or revoked row returns to `pending` and grants nothing until accepted. The Console never called it. ## Tests - `SuggestedBindingsPanel.test.tsx` (real panel, real client, stubbed `fetch` answering the server's envelopes): - a dismissed or revoked row shows Reconsider and nothing else; - one click sends exactly one `POST …/sug_crm_reader/reopen`, no confirm; the row redraws as `pending` with Accept and Dismiss, the toast shows, `onResolved` gets the re-opened row; - dismiss → reconsider → accept sends dismiss, reopen, confirm, in that order, and ends `accepted`; - a re-opened guest row redraws as `guest`; - 409 (the set no longer declares `isDefault`), 404 (row gone; the re-read drops it) and 501 (no door; the dismissal stands) each show the server's message; - a caller refused the list (403) gets no panel and no Reconsider; - **CONTROL**: pending, guest, accepted and not-in-force rows offer no Reconsider and keep their states. - **Ablation** (`scripts/ablation-replace.mjs`, `{state === 'dismissed' && (` → `{false && (`): 7 failed / 16 passed, the 7 being every Reconsider pin; the controls stay green. Restored: blob equals HEAD, `git diff HEAD` empty. ## Acceptance notes - **Revoked rows get the action too (measured).** `reopenAudienceBindingSuggestion` refuses only a row whose `status` is not `dismissed`, and `dismissAudienceBindingSuggestion` writes `status: 'dismissed'` for both a decline and a revoke. Live: a revoke answered `dismissed`, and re-opening it answered `pending`, `deployment_decision: false`. - **Fixture for the live check.** This repo ships no HotCRM. The one example with an `isDefault` set, `examples/app-showcase`'s `showcase_member_default`, boots as a baseline row (`confirmed`, `deployment_decision: false`), which this panel does not draw and the server will not dismiss. So the live leg used the closest fixture: on the showcase dev boot, a writable package `com.qa.reopen` made over the API, holding an object `reopen_note` (`public_read`) and an `isDefault` permission set `reopen_member_default` that grants read on it. Its suggestion lists as `pending`. - **Hosted environments** still depend on #30192 (the `/security` doors reading the environment kernel). That is outside this PR. ## Verification <details><summary>Live: dismiss, reconsider, accept in Studio's Access pillar; an invited member then reads the object</summary> Showcase dev boot (`pnpm dev -- --fresh`), Console via Vite with `DEV_PROXY_TARGET`, Playwright as admin in zh-CN at `/studio/com.qa.reopen/access`. The served panel source was checked to contain the new test id before the run. Screenshots: `/mnt/project-files/ui-orders/30199-shots/` (project files). ``` start: pending after dismiss: dismissed · buttons: 重新考虑 after reconsider: pending · buttons: 授予,忽略 after accept: accepted · buttons: 撤销 200 POST /api/v1/security/suggested-bindings/sug_mw301423d3iszr6v/dismiss 200 POST /api/v1/security/suggested-bindings/sug_mw301423d3iszr6v/reopen 200 POST /api/v1/security/suggested-bindings/sug_mw301423d3iszr6v/confirm ``` Invited member (`invite-member` + `sign-up/email`): `GET /data/reopen_note` 403 before the accept, 200 after it. `GET /security/suggested-bindings` as the member: 403, so the member sees no panel. API legs on the same boot: revoke → `dismissed`; reopen of the revoked row → `pending`, and the member still reads 403 (a re-open grants nothing); reopen of a pending row → 409 `SUGGESTION_STATE`; unknown id → 404 `SUGGESTION_NOT_FOUND`; the member calling reopen → 403 `PERMISSION_DENIED`. </details> <details><summary>Suites, type-check, lint, gates</summary> All at head `ca174078`, each under `os-verify-lock` where it builds or tests. - Panel + de quote-pairing: `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts` exit 0 (2 files, 32 passed). - `@object-ui/i18n` whole suite + the panel: exit 0 (96 files, 1,466 passed, 13 skipped). - Callers of the panel: the five `MarketplacePackagePage.*` test files that render it and `StudioDesignSurface.accessSegment-12452.test.tsx`: exit 0 (6 files, 36 passed). - `pnpm --dir ui --filter @object-ui/app-shell --filter @object-ui/i18n run type-check` (both scripts echoed, `tsc --noEmit` and `tsconfig.test.json`): exit 0. - `npx eslint` on the five touched app-shell files: exit 0. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 35 commands, each run and recorded; all 35 exit 0. `--ran` reconciliation: "35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED". The list is at `/mnt/project-files/ui-orders/30199-gates-ran.list` (project files). </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Touched files linted; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 block is the dispatch's required Chinese summary.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzezVANVfXzi7zxbUT8)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 119c2db commit ef2ad31

19 files changed

Lines changed: 254 additions & 16 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): a dismissed or revoked default permission set in the suggested-bindings panel can be reconsidered (#30199)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
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.
14+
15+
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).

‎ui/packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx‎

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
* - a `guest` row offers no Accept and says why, and keeps Dismiss;
1616
* - Accept, Dismiss and Revoke each call their route, and the row takes the
1717
* state the server answered;
18+
* - a dismissed or revoked row offers Reconsider, which calls the re-open
19+
* route once and redraws the row as pending, with Accept and Dismiss; no
20+
* other state offers it, and its 404 / 409 / 501 are surfaced like the
21+
* other refusals (objectstack#30199);
1822
* - a `403` or `409` on an action shows the server's own message, never a
1923
* silent no-op, and the panel then re-reads the list;
2024
* - a `403` on the list renders nothing.
@@ -72,6 +76,9 @@ const STRINGS: SuggestedBindingsStrings = {
7276
revoke: 'Revoke',
7377
revoking: 'Revoking',
7478
revokedToast: (s) => `REVOKED ${s.permission_set_name}`,
79+
reopen: 'Reconsider',
80+
reopening: 'Reopening',
81+
reopenedToast: (s) => `REOPENED ${s.permission_set_name}`,
7582
};
7683

7784
type Answer = { status: number; body: unknown };
@@ -86,7 +93,7 @@ const listed = (suggestions: SuggestedBinding[]): Answer =>
8693
* A `fetch` that answers each route from a queue (the last answer repeats),
8794
* and records every call as `METHOD path`.
8895
*/
89-
function serve(routes: { list: Answer[]; confirm?: Answer[]; dismiss?: Answer[] }) {
96+
function serve(routes: { list: Answer[]; confirm?: Answer[]; dismiss?: Answer[]; reopen?: Answer[] }) {
9097
const calls: string[] = [];
9198
const next = (q: Answer[] | undefined): Answer => {
9299
if (!q || q.length === 0) throw new Error('unexpected route');
@@ -100,7 +107,9 @@ function serve(routes: { list: Answer[]; confirm?: Answer[]; dismiss?: Answer[]
100107
? next(routes.confirm)
101108
: url.pathname.endsWith('/dismiss')
102109
? next(routes.dismiss)
103-
: next(routes.list);
110+
: url.pathname.endsWith('/reopen')
111+
? next(routes.reopen)
112+
: next(routes.list);
104113
return new Response(JSON.stringify(answer.body), {
105114
status: answer.status,
106115
headers: { 'Content-Type': 'application/json' },
@@ -146,13 +155,46 @@ describe('SuggestedBindingsPanel — the state of each row (objectui#7611)', ()
146155
expect(screen.queryByTestId('suggested-binding-dismiss-crm_reader')).toBeNull();
147156
});
148157

149-
it('a dismissed or revoked row says it is not granted and offers no action', async () => {
158+
it('a dismissed or revoked row says it is not granted and offers Reconsider only', async () => {
150159
serve({ list: [listed([row({ permission_set_name: 'crm_reader', status: 'dismissed', deployment_decision: true, resolved_by: 'u_admin' })])] });
151160
panel();
152161
const el = await rowEl('crm_reader');
153162
expect(el.dataset.state).toBe('dismissed');
154163
expect(el.textContent).toContain('NOT GRANTED crm_reader');
155-
expect(el.querySelectorAll('button')).toHaveLength(0);
164+
expect(el.querySelectorAll('button')).toHaveLength(1);
165+
expect(screen.getByTestId('suggested-binding-reopen-crm_reader')).toBeTruthy();
166+
expect(screen.queryByTestId('suggested-binding-confirm-crm_reader')).toBeNull();
167+
expect(screen.queryByTestId('suggested-binding-dismiss-crm_reader')).toBeNull();
168+
expect(screen.queryByTestId('suggested-binding-revoke-crm_reader')).toBeNull();
169+
});
170+
171+
it('CONTROL: no pending, guest, accepted or not-in-force row offers Reconsider', async () => {
172+
const pending = row({ permission_set_name: 'crm_pending' });
173+
const guest = row({ permission_set_name: 'site_visitor', anchor: 'guest' });
174+
const accepted = row({ permission_set_name: 'crm_accepted', status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' });
175+
const toNotInForce = row({ permission_set_name: 'crm_nif' });
176+
serve({
177+
list: [listed([pending, guest, accepted, toNotInForce])],
178+
confirm: [ok({ suggestion: { ...toNotInForce, status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' }, bindingCreated: false })],
179+
});
180+
panel();
181+
fireEvent.click(await screen.findByTestId('suggested-binding-confirm-crm_nif'));
182+
await waitFor(async () => expect((await rowEl('crm_nif')).dataset.state).toBe('notInForce'));
183+
const states: Record<string, string | undefined> = {};
184+
for (const set of ['crm_pending', 'site_visitor', 'crm_accepted', 'crm_nif']) {
185+
states[set] = (await rowEl(set)).dataset.state;
186+
expect(screen.queryByTestId(`suggested-binding-reopen-${set}`), set).toBeNull();
187+
}
188+
expect(states).toEqual({ crm_pending: 'pending', site_visitor: 'guest', crm_accepted: 'accepted', crm_nif: 'notInForce' });
189+
});
190+
191+
it('a caller who may not decide (the list answers 403) is offered no Reconsider', async () => {
192+
const calls = serve({ list: [refused(403, 'PERMISSION_DENIED', '[Security] Access denied: listing audience-binding suggestions requires the `manage_metadata` capability.')] });
193+
const { container } = panel();
194+
await waitFor(() => expect(calls).toHaveLength(1));
195+
await Promise.resolve();
196+
expect(container.querySelector('[data-testid^="suggested-binding-reopen-"]')).toBeNull();
197+
expect(container.innerHTML).toBe('');
156198
});
157199

158200
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)', () => {
286328
await waitFor(() => expect(container.innerHTML).toBe(''));
287329
});
288330
});
331+
332+
describe('SuggestedBindingsPanel — Reconsider a dismissed or revoked row (objectstack#30199)', () => {
333+
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' });
334+
// What the re-open door answers: pending again, the decision cleared.
335+
const reopened = { ...dismissed, status: 'pending' as const, deployment_decision: false, resolved_by: null, resolved_at: null };
336+
337+
it('one click calls the re-open route once; the row redraws as pending with Accept and Dismiss', async () => {
338+
const calls = serve({ list: [listed([dismissed])], reopen: [ok({ suggestion: reopened })] });
339+
const onResolved = vi.fn();
340+
render(<SuggestedBindingsPanel packageId={PKG} strings={STRINGS} onResolved={onResolved} />);
341+
fireEvent.click(await screen.findByTestId('suggested-binding-reopen-crm_reader'));
342+
await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('pending'));
343+
expect(calls.filter((c) => c.startsWith('POST '))).toEqual([
344+
'POST /api/v1/security/suggested-bindings/sug_crm_reader/reopen',
345+
]);
346+
expect(screen.getByTestId('suggested-binding-confirm-crm_reader')).toBeTruthy();
347+
expect(screen.getByTestId('suggested-binding-dismiss-crm_reader')).toBeTruthy();
348+
expect(screen.queryByTestId('suggested-binding-reopen-crm_reader')).toBeNull();
349+
expect(toast.success).toHaveBeenCalledWith('REOPENED crm_reader');
350+
expect(onResolved).toHaveBeenCalledWith(reopened);
351+
// A re-open grants nothing: no accept is sent on its behalf.
352+
expect(calls.some((c) => c.endsWith('/confirm'))).toBe(false);
353+
});
354+
355+
it('dismiss, reconsider, then accept: each step calls its own route once, in order', async () => {
356+
const pending = row({ permission_set_name: 'crm_reader' });
357+
const accepted = { ...pending, status: 'confirmed' as const, deployment_decision: true, resolved_by: 'u_admin' };
358+
const calls = serve({
359+
list: [listed([pending])],
360+
dismiss: [ok({ suggestion: dismissed })],
361+
reopen: [ok({ suggestion: reopened })],
362+
confirm: [ok({ suggestion: accepted, bindingCreated: true })],
363+
});
364+
panel();
365+
fireEvent.click(await screen.findByTestId('suggested-binding-dismiss-crm_reader'));
366+
fireEvent.click(await screen.findByTestId('suggested-binding-reopen-crm_reader'));
367+
fireEvent.click(await screen.findByTestId('suggested-binding-confirm-crm_reader'));
368+
await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('accepted'));
369+
expect(calls.filter((c) => c.startsWith('POST '))).toEqual([
370+
'POST /api/v1/security/suggested-bindings/sug_crm_reader/dismiss',
371+
'POST /api/v1/security/suggested-bindings/sug_crm_reader/reopen',
372+
'POST /api/v1/security/suggested-bindings/sug_crm_reader/confirm',
373+
]);
374+
expect(toast.success).toHaveBeenCalledWith('GRANTED crm_reader');
375+
});
376+
377+
it('a re-opened guest row redraws as guest: Dismiss, and still no Accept', async () => {
378+
const guestDismissed = row({ permission_set_name: 'site_visitor', anchor: 'guest', status: 'dismissed', deployment_decision: true, resolved_by: 'u_admin' });
379+
serve({ list: [listed([guestDismissed])], reopen: [ok({ suggestion: { ...guestDismissed, status: 'pending', deployment_decision: false, resolved_by: null } })] });
380+
panel();
381+
fireEvent.click(await screen.findByTestId('suggested-binding-reopen-site_visitor'));
382+
await waitFor(async () => expect((await rowEl('site_visitor')).dataset.state).toBe('guest'));
383+
expect(screen.queryByTestId('suggested-binding-confirm-site_visitor')).toBeNull();
384+
expect(screen.getByTestId('suggested-binding-dismiss-site_visitor')).toBeTruthy();
385+
});
386+
387+
it('re-open refused 409 (the set is no longer suggested) → the server message is shown, and the panel re-reads', async () => {
388+
const message = "Permission set 'crm_reader' no longer declares isDefault: true — nothing is suggested.";
389+
const calls = serve({ list: [listed([dismissed]), listed([dismissed])], reopen: [refused(409, 'SUGGESTION_STATE', message)] });
390+
panel();
391+
fireEvent.click(await screen.findByTestId('suggested-binding-reopen-crm_reader'));
392+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message));
393+
await waitFor(() => expect(calls.filter((c) => c.startsWith('GET '))).toHaveLength(2));
394+
expect((await rowEl('crm_reader')).dataset.state).toBe('dismissed');
395+
expect(toast.success).not.toHaveBeenCalled();
396+
});
397+
398+
it('re-open refused 404 (the row is gone) → the server message is shown, and the re-read drops the row', async () => {
399+
const message = "Audience-binding suggestion 'sug_crm_reader' not found";
400+
const other = row({ permission_set_name: 'crm_other' });
401+
serve({ list: [listed([dismissed, other]), listed([other])], reopen: [refused(404, 'SUGGESTION_NOT_FOUND', message)] });
402+
panel();
403+
fireEvent.click(await screen.findByTestId('suggested-binding-reopen-crm_reader'));
404+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message));
405+
await waitFor(() => expect(screen.queryByTestId('suggested-binding-crm_reader')).toBeNull());
406+
expect((await rowEl('crm_other')).dataset.state).toBe('pending');
407+
});
408+
409+
it('re-open answered 501 (a security service without the door) → its message is shown and the dismissal stands', async () => {
410+
const message = "This deployment's security service does not implement re-opening a dismissed audience-binding suggestion, so the dismissal stands.";
411+
serve({ list: [listed([dismissed])], reopen: [refused(501, 'NOT_IMPLEMENTED', message)] });
412+
panel();
413+
fireEvent.click(await screen.findByTestId('suggested-binding-reopen-crm_reader'));
414+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message));
415+
expect((await rowEl('crm_reader')).dataset.state).toBe('dismissed');
416+
expect(toast.success).not.toHaveBeenCalled();
417+
});
418+
});

‎ui/packages/app-shell/src/components/SuggestedBindingsPanel.tsx‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
listSuggestedBindings,
99
confirmSuggestedBinding,
1010
dismissSuggestedBinding,
11+
reopenSuggestedBinding,
1112
type SuggestedBinding,
1213
} from '../services/suggestedBindingsApi.js';
1314

@@ -33,14 +34,17 @@ import {
3334
* change. The list does not carry this, so it lasts until the panel
3435
* re-reads; after that the row reads as accepted, which claims the
3536
* decision only, never that the set is in force;
36-
* - dismissed or revoked — the server records both as `dismissed`.
37+
* - dismissed or revoked — the server records both as `dismissed` — offers
38+
* Reconsider, which re-opens the row: it is `pending` again, with Accept
39+
* and Dismiss, and grants nothing until it is accepted.
3740
*
3841
* A `confirmed` row WITHOUT a deployment decision is one the baseline already
3942
* names; this surface does not decide the baseline (the server refuses its
4043
* dismiss `409`), so it is not drawn.
4144
*
42-
* Every refusal (403 / 409) is surfaced with the server's own message, and
43-
* the panel then re-reads the list so it shows what the server holds.
45+
* Every refusal (403 / 404 / 409, and a 501 from a service without the
46+
* re-open door) is surfaced with the server's own message, and the panel
47+
* then re-reads the list so it shows what the server holds.
4448
*
4549
* Renders nothing while loading, when the package has no suggestion, or when
4650
* the list is refused — a caller without `manage_metadata` gets 403 — so it
@@ -66,6 +70,11 @@ export interface SuggestedBindingsStrings {
6670
revoke: string;
6771
revoking: string;
6872
revokedToast: (s: SuggestedBinding) => string;
73+
/** Action on a dismissed or revoked row: re-open it for a decision. */
74+
reopen: string;
75+
reopening: string;
76+
/** Toast after a re-open. Says the row is pending again, never that the set is granted. */
77+
reopenedToast: (s: SuggestedBinding) => string;
6978
}
7079

7180
type RowState = 'pending' | 'guest' | 'accepted' | 'notInForce' | 'dismissed';
@@ -90,7 +99,7 @@ export function SuggestedBindingsPanel({
9099
packageId?: string;
91100
strings: SuggestedBindingsStrings;
92101
className?: string;
93-
/** Called after any suggestion is accepted, dismissed or revoked. */
102+
/** Called after any suggestion is accepted, dismissed, revoked or re-opened. */
94103
onResolved?: (s: SuggestedBinding) => void;
95104
}) {
96105
const [rows, setRows] = useState<SuggestedBinding[]>([]);
@@ -117,7 +126,7 @@ export function SuggestedBindingsPanel({
117126
const put = (next: SuggestedBinding) =>
118127
setRows((current) => current.map((r) => (r.id === next.id ? next : r)));
119128

120-
const act = async (s: SuggestedBinding, action: 'confirm' | 'dismiss' | 'revoke') => {
129+
const act = async (s: SuggestedBinding, action: 'confirm' | 'dismiss' | 'revoke' | 'reopen') => {
121130
setBusyId(s.id);
122131
try {
123132
let resolved: SuggestedBinding;
@@ -132,6 +141,9 @@ export function SuggestedBindingsPanel({
132141
});
133142
if (bindingCreated) toast.success(strings.confirmedToast(s));
134143
else toast.warning(strings.notInForceState(s));
144+
} else if (action === 'reopen') {
145+
resolved = await reopenSuggestedBinding(s.id);
146+
toast.success(strings.reopenedToast(s));
135147
} else {
136148
resolved = await dismissSuggestedBinding(s.id);
137149
toast.success(action === 'revoke' ? strings.revokedToast(s) : strings.dismissedToast(s));
@@ -223,6 +235,17 @@ export function SuggestedBindingsPanel({
223235
{busy ? strings.revoking : strings.revoke}
224236
</Button>
225237
)}
238+
{state === 'dismissed' && (
239+
<Button
240+
size="sm"
241+
variant="outline"
242+
disabled={busy}
243+
onClick={() => act(s, 'reopen')}
244+
data-testid={`suggested-binding-reopen-${s.permission_set_name}`}
245+
>
246+
{busy ? strings.reopening : strings.reopen}
247+
</Button>
248+
)}
226249
</div>
227250
);
228251
})}

‎ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ export function MarketplacePackagePage() {
176176
revoking: t('marketplace.suggestedBindings.revoking'),
177177
revokedToast: (s: SuggestedBinding) =>
178178
t('marketplace.suggestedBindings.revokedToast', { set: s.permission_set_name }),
179+
reopen: t('marketplace.suggestedBindings.reopen'),
180+
reopening: t('marketplace.suggestedBindings.reopening'),
181+
reopenedToast: (s: SuggestedBinding) =>
182+
t('marketplace.suggestedBindings.reopenedToast', { set: s.permission_set_name }),
179183
};
180184
const basePath = appName ? `/apps/${appName}` : '';
181185
const { refresh: refreshMetadata } = useMetadata();

‎ui/packages/app-shell/src/services/suggestedBindingsApi.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
* dismisses each suggestion ONCE for the whole deployment, and an accept takes
1212
* effect — the server re-derives `everyone`'s definition with the set
1313
* (objectstack#22801 → A, built by objectstack#22854). A caller without that
14-
* authority gets `403 PERMISSION_DENIED` on all three routes, the list
15-
* included. These calls are ordinary same-origin admin API calls (not object
16-
* CRUD), so they follow the marketplaceApi raw-fetch pattern: Bearer token +
17-
* `credentials: 'include'`.
14+
* authority gets `403 PERMISSION_DENIED` on every route, the list included.
15+
* A dismissed (or revoked) suggestion can be re-opened, which makes it
16+
* pending again and grants nothing until it is accepted. These calls are
17+
* ordinary same-origin admin API calls (not object CRUD), so they follow the
18+
* marketplaceApi raw-fetch pattern: Bearer token + `credentials: 'include'`.
1819
*/
1920

2021
import { TokenStorage } from '@object-ui/auth';
@@ -129,3 +130,22 @@ export async function dismissSuggestedBinding(id: string): Promise<SuggestedBind
129130
const data = await parseOrThrow(res);
130131
return data?.suggestion as SuggestedBinding;
131132
}
133+
134+
/**
135+
* Re-open a dismissed suggestion — a declined one or a revoked acceptance —
136+
* so the deployment can decide it again. The server puts the row back to
137+
* `pending` and grants nothing: the set reaches `everyone` only through a
138+
* later accept, under the same gates. A row that is not `dismissed`, or whose
139+
* set its package no longer suggests, is refused with a 409; an unknown id
140+
* with a 404; a security service without the door with a 501. Each is
141+
* thrown with the server's message, as the other calls do.
142+
*/
143+
export async function reopenSuggestedBinding(id: string): Promise<SuggestedBinding> {
144+
const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}/reopen`, {
145+
method: 'POST',
146+
credentials: 'include',
147+
headers: authHeaders({ 'Content-Type': 'application/json' }),
148+
});
149+
const data = await parseOrThrow(res);
150+
return data?.suggestion as SuggestedBinding;
151+
}

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10013,6 +10013,10 @@ export function AccessPillar({
1001310013
revoking: t('engine.studio.access.suggestRevoking', locale),
1001410014
revokedToast: (s) =>
1001510015
tFormat('engine.studio.access.suggestRevokedToast', locale, { set: s.permission_set_name }),
10016+
reopen: t('engine.studio.access.suggestReopen', locale),
10017+
reopening: t('engine.studio.access.suggestReopening', locale),
10018+
reopenedToast: (s) =>
10019+
tFormat('engine.studio.access.suggestReopenedToast', locale, { set: s.permission_set_name }),
1001610020
}}
1001710021
/>
1001810022
)}

0 commit comments

Comments
 (0)