From b074251ef0e5a684a6e9a3f97b4c4948e3d3f51d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 05:41:19 +0000 Subject: [PATCH] feat(access): install prompt + Access-pillar banner for suggested audience bindings (ADR-0090 D5/D9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumes the framework's new suggested-audience-binding surface (framework#2746): a package permission set shipped with isDefault: true asks the admin to bind it to the builtin everyone position — the server never auto-binds, so the UI now surfaces the "admin confirms" moment. - services/suggestedBindingsApi: client for GET/POST /api/v1/security/suggested-bindings[/:id/confirm|dismiss] (marketplaceApi raw-fetch pattern: Bearer + credentials include) - components/SuggestedBindingsPanel: pending suggestions with per-row Grant / Dismiss, amber pending-action styling, sonner toasts; the server-side anchor-gate denial message is surfaced verbatim; renders nothing when empty or for non-admins (403) - MarketplacePackagePage: panel shown after a successful install into THIS runtime (cloud install dialog + local install banner) - StudioDesignSurface Access pillar: package-scoped panel under the header so suggestions remain resolvable after the install flow - i18n: marketplace.suggestedBindings.* (en/zh) and engine.studio.access.suggest* (metadata-admin en/zh) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DP13MuGwFWLcVxVtuA7rgq --- .../src/components/SuggestedBindingsPanel.tsx | 119 ++++++++++++++++++ .../marketplace/MarketplacePackagePage.tsx | 36 +++++- .../src/services/suggestedBindingsApi.ts | 97 ++++++++++++++ .../src/views/metadata-admin/i18n.ts | 17 +++ .../studio-design/StudioDesignSurface.tsx | 32 +++++ packages/i18n/src/locales/en.ts | 12 ++ packages/i18n/src/locales/zh.ts | 11 ++ 7 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 packages/app-shell/src/components/SuggestedBindingsPanel.tsx create mode 100644 packages/app-shell/src/services/suggestedBindingsApi.ts diff --git a/packages/app-shell/src/components/SuggestedBindingsPanel.tsx b/packages/app-shell/src/components/SuggestedBindingsPanel.tsx new file mode 100644 index 0000000000..100f5ce5bb --- /dev/null +++ b/packages/app-shell/src/components/SuggestedBindingsPanel.tsx @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { useCallback, useEffect, useState } from 'react'; +import { ShieldQuestion } from 'lucide-react'; +import { Button, cn } from '@object-ui/components'; +import { toast } from 'sonner'; +import { + listSuggestedBindings, + confirmSuggestedBinding, + dismissSuggestedBinding, + type SuggestedBinding, +} from '../services/suggestedBindingsApi'; + +/** + * SuggestedBindingsPanel — pending audience-binding suggestions + * (ADR-0090 D5/D9) with per-row Confirm / Dismiss. + * + * 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. + * + * 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. + */ +export interface SuggestedBindingsStrings { + /** One-line prompt per 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; +} + +export function SuggestedBindingsPanel({ + packageId, + strings, + className, + onResolved, +}: { + /** Limit to one package's suggestions (install dialog); omit for all (Access pillar). */ + packageId?: string; + strings: SuggestedBindingsStrings; + className?: string; + /** Called after any suggestion is confirmed or dismissed. */ + onResolved?: (s: SuggestedBinding) => void; +}) { + const [pending, setPending] = useState([]); + const [busyId, setBusyId] = useState(null); + + const reload = useCallback(() => { + let cancelled = false; + listSuggestedBindings({ status: 'pending', ...(packageId ? { packageId } : {}) }) + .then((rows) => { if (!cancelled) setPending(rows); }) + .catch(() => { /* non-admin (403) or surface unavailable → render nothing */ }); + return () => { cancelled = true; }; + }, [packageId]); + + useEffect(() => reload(), [reload]); + + const resolve = async (s: SuggestedBinding, action: 'confirm' | 'dismiss') => { + 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); + } catch (err: any) { + // The gate's message is the explanation ("carries view-all…") — show it. + toast.error(err?.message ?? String(err)); + } finally { + setBusyId(null); + } + }; + + if (pending.length === 0) return null; + + return ( +
+ {pending.map((s) => ( +
+ +

+ {strings.describe(s)} +

+ + +
+ ))} +
+ ); +} diff --git a/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx b/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx index 04c90792a9..a7a2948278 100644 --- a/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx +++ b/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx @@ -63,12 +63,29 @@ import { import { getRuntimeConfig } from '../../runtime-config'; import { emitMetadataRefresh } from '../../assistant/assistantBus'; import { useMetadata } from '../../providers/MetadataProvider'; +import { SuggestedBindingsPanel, type SuggestedBindingsStrings } from '../../components/SuggestedBindingsPanel'; +import type { SuggestedBinding } from '../../services/suggestedBindingsApi'; export function MarketplacePackagePage() { const navigate = useNavigate(); const { packageId, appName } = useParams<{ packageId?: string; appName?: string }>(); const isAdmin = useIsWorkspaceAdmin(); const { t, language } = useObjectTranslation(); + // 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. + const suggestionStrings: SuggestedBindingsStrings = { + describe: (s: SuggestedBinding) => + t(s.anchor === 'guest' ? 'marketplace.suggestedBindings.promptGuest' : 'marketplace.suggestedBindings.promptEveryone', { set: s.permission_set_name }), + confirm: t('marketplace.suggestedBindings.confirm'), + confirming: t('marketplace.suggestedBindings.confirming'), + dismiss: t('marketplace.suggestedBindings.dismiss'), + confirmedToast: (s: SuggestedBinding) => + t('marketplace.suggestedBindings.confirmedToast', { set: s.permission_set_name, anchor: s.anchor }), + dismissedToast: (s: SuggestedBinding) => + t('marketplace.suggestedBindings.dismissedToast', { set: s.permission_set_name }), + }; const basePath = appName ? `/apps/${appName}` : ''; const { refresh: refreshMetadata } = useMetadata(); @@ -706,7 +723,14 @@ export function MarketplacePackagePage() { className={`flex items-start gap-2 rounded-md border p-3 text-sm whitespace-pre-wrap ${localResult.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`} > {localResult.ok ?