Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/30199-suggested-binding-reconsider.md
Original file line number Diff line number Diff line change
@@ -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).
138 changes: 134 additions & 4 deletions ui/packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 };
Expand All @@ -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');
Expand All @@ -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' },
Expand Down Expand Up @@ -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<string, string | undefined> = {};
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 () => {
Expand Down Expand Up @@ -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(<SuggestedBindingsPanel packageId={PKG} strings={STRINGS} onResolved={onResolved} />);
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();
});
});
33 changes: 28 additions & 5 deletions ui/packages/app-shell/src/components/SuggestedBindingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
listSuggestedBindings,
confirmSuggestedBinding,
dismissSuggestedBinding,
reopenSuggestedBinding,
type SuggestedBinding,
} from '../services/suggestedBindingsApi.js';

Expand All @@ -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
Expand All @@ -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';
Expand All @@ -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<SuggestedBinding[]>([]);
Expand All @@ -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;
Expand All @@ -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));
Expand Down Expand Up @@ -223,6 +235,17 @@ export function SuggestedBindingsPanel({
{busy ? strings.revoking : strings.revoke}
</Button>
)}
{state === 'dismissed' && (
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => act(s, 'reopen')}
data-testid={`suggested-binding-reopen-${s.permission_set_name}`}
>
{busy ? strings.reopening : strings.reopen}
</Button>
)}
</div>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
28 changes: 24 additions & 4 deletions ui/packages/app-shell/src/services/suggestedBindingsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -129,3 +130,22 @@ export async function dismissSuggestedBinding(id: string): Promise<SuggestedBind
const data = await parseOrThrow(res);
return data?.suggestion as SuggestedBinding;
}

/**
* Re-open a dismissed suggestion — a declined one or a revoked acceptance —
* so the deployment can decide it again. The server puts the row back to
* `pending` and grants nothing: the set reaches `everyone` only through a
* later accept, under the same gates. A row that is not `dismissed`, or whose
* set its package no longer suggests, is refused with a 409; an unknown id
* with a 404; a security service without the door with a 501. Each is
* thrown with the server's message, as the other calls do.
*/
export async function reopenSuggestedBinding(id: string): Promise<SuggestedBinding> {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
}}
/>
)}
Expand Down
Loading
Loading