From 695fd73ec53a179890a4b451a471fb357bcd7d3d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:20:36 -0700 Subject: [PATCH 1/2] Align integration creation UI with admin permissions --- .changeset/fair-admin-integrations.md | 5 + .../integration-creation-permissions.test.ts | 17 +++ .../integration-creation-permissions.test.ts | 21 ++++ e2e/src/integration-creation-permissions.ts | 115 ++++++++++++++++++ .../react/src/components/command-palette.tsx | 18 ++- .../components/integration-creation-gate.tsx | 36 ++++++ packages/react/src/multiplayer/shell.tsx | 26 ++-- packages/react/src/pages/integration-add.tsx | 16 ++- .../react/src/pages/integration-browse.tsx | 10 ++ packages/react/src/pages/integrations.tsx | 50 ++++---- 10 files changed, 274 insertions(+), 40 deletions(-) create mode 100644 .changeset/fair-admin-integrations.md create mode 100644 e2e/cloud/integration-creation-permissions.test.ts create mode 100644 e2e/selfhost/integration-creation-permissions.test.ts create mode 100644 e2e/src/integration-creation-permissions.ts create mode 100644 packages/react/src/components/integration-creation-gate.tsx diff --git a/.changeset/fair-admin-integrations.md b/.changeset/fair-admin-integrations.md new file mode 100644 index 0000000000..6ea6729688 --- /dev/null +++ b/.changeset/fair-admin-integrations.md @@ -0,0 +1,5 @@ +--- +"@executor-js/react": patch +--- + +Hide integration creation controls from workspace members and explain the admin requirement on direct add links. diff --git a/e2e/cloud/integration-creation-permissions.test.ts b/e2e/cloud/integration-creation-permissions.test.ts new file mode 100644 index 0000000000..44f7b91bb5 --- /dev/null +++ b/e2e/cloud/integration-creation-permissions.test.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect"; +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; +import { integrationCreationPermissions } from "../src/integration-creation-permissions"; +import { forBrowser, joinOrg } from "./support/session"; + +scenario( + "Integration creation · cloud members see admin guidance and admins can add", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + yield* integrationCreationPermissions(forBrowser(admin), forBrowser(member)); + }), +); diff --git a/e2e/selfhost/integration-creation-permissions.test.ts b/e2e/selfhost/integration-creation-permissions.test.ts new file mode 100644 index 0000000000..0ddd87ebe8 --- /dev/null +++ b/e2e/selfhost/integration-creation-permissions.test.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect"; +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; +import { integrationCreationPermissions } from "../src/integration-creation-permissions"; +import { createInvitedIdentity } from "../targets/selfhost"; + +scenario( + "Integration creation · self-host members see admin guidance and owners can add", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const admin = yield* target.newIdentity(); + const member = yield* Effect.promise(() => + createInvitedIdentity(target.baseUrl, admin, { + role: "member", + emailPrefix: "integration-permissions", + }), + ); + yield* integrationCreationPermissions(admin, member); + }), +); diff --git a/e2e/src/integration-creation-permissions.ts b/e2e/src/integration-creation-permissions.ts new file mode 100644 index 0000000000..8a32937f28 --- /dev/null +++ b/e2e/src/integration-creation-permissions.ts @@ -0,0 +1,115 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { Api, Browser } from "./services"; +import type { Identity } from "./target"; +import { visit } from "./surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +/** Exercise integration creation and member restrictions through the shared console. */ +export const integrationCreationPermissions = (admin: Identity, member: Identity) => + Effect.gen(function* () { + const browser = yield* Browser; + const { client } = yield* Api; + const adminClient = yield* client(api, admin); + const title = `Permissions API ${randomBytes(4).toString("hex")}`; + const slug = IntegrationSlug.make(title.toLowerCase().replaceAll(" ", "_")); + const spec = JSON.stringify({ + openapi: "3.0.3", + info: { title, version: "1.0.0" }, + servers: [{ url: "https://api.example.com" }], + paths: {}, + components: { + securitySchemes: { apiKey: { type: "apiKey", in: "header", name: "X-API-Key" } }, + }, + security: [{ apiKey: [] }], + }); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* browser.session(admin, async ({ page, step }) => { + await step("Admin opens the integration catalog", async () => { + await visit(page, "/"); + await page.getByRole("button", { name: "Browse integrations", exact: true }).waitFor(); + await page.keyboard.press("ControlOrMeta+k"); + await page.getByRole("option", { name: /^Add OpenAPI/ }).waitFor(); + await page.keyboard.press("Escape"); + await page.getByRole("link", { name: "Add integration", exact: true }).click(); + await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor(); + await page + .getByRole("textbox", { name: "Search integrations, or paste a URL" }) + .waitFor(); + }); + await step("Admin creates an integration from the setup form", async () => { + await visit(page, "/integrations/add/openapi"); + await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec); + await page.getByRole("button", { name: "Add integration", exact: true }).click(); + await page.waitForURL((url) => url.pathname.endsWith(`/integrations/${slug}`), { + timeout: 30_000, + }); + await page.getByRole("button", { name: "Edit", exact: true }).waitFor(); + await page.getByRole("button", { name: "Delete", exact: true }).waitFor(); + }); + }); + expect(yield* adminClient.integrations.get({ params: { slug } })).toMatchObject({ + name: title, + }); + + yield* browser.session(member, async ({ page, step }) => { + await step("Member sees existing integrations without an Add action", async () => { + await visit(page, "/"); + await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor(); + await page.getByTestId(`integration-entry-${slug}`).waitFor(); + expect( + await page.getByRole("button", { name: "Browse integrations", exact: true }).count(), + ).toBe(0); + await page.keyboard.press("ControlOrMeta+k"); + const palette = page.getByRole("dialog"); + await palette.getByRole("option", { name: new RegExp(title) }).waitFor(); + expect(await palette.getByRole("option", { name: /^Add / }).count()).toBe(0); + expect(await palette.getByText("Popular integrations", { exact: true }).count()).toBe( + 0, + ); + await page.keyboard.press("Escape"); + expect(await page.getByRole("link", { name: /^Add (an? )?integration$/ }).count()).toBe( + 0, + ); + }); + await step( + "Member opens an existing integration and can add a personal connection", + async () => { + await page.getByTestId(`integration-entry-${slug}`).click(); + await page.getByRole("button", { name: "Add connection", exact: true }).waitFor(); + expect(await page.getByRole("button", { name: /^(Edit|Delete)$/ }).count()).toBe(0); + await page.getByRole("button", { name: "Add connection", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await dialog.waitFor(); + expect(await dialog.getByText("Workspace", { exact: true }).count()).toBe(0); + }, + ); + for (const path of [ + "/integrations/browse", + "/integrations/add/openapi", + "/integrations/add/mcp", + ]) { + await step(`Member follows ${path} and sees the admin explanation`, async () => { + await visit(page, path); + await page.getByRole("heading", { name: "An admin must add integrations" }).waitFor(); + expect(await page.getByRole("textbox").count()).toBe(0); + expect(await page.getByRole("button", { name: /^Add/ }).count()).toBe(0); + }); + } + await step("Member returns to their existing integrations", async () => { + await page.getByRole("link", { name: "Back to integrations" }).click(); + await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor(); + }); + }); + }), + adminClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ); + }); diff --git a/packages/react/src/components/command-palette.tsx b/packages/react/src/components/command-palette.tsx index 585ceed1f2..cf3a3cfe52 100644 --- a/packages/react/src/components/command-palette.tsx +++ b/packages/react/src/components/command-palette.tsx @@ -9,6 +9,7 @@ import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favi import { PresetIcon } from "./preset-icon"; import { integrationsOptimisticAtom } from "../api/atoms"; import { useIntegrationPlugins } from "@executor-js/sdk/client"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import { CommandDialog, CommandEmpty, @@ -34,6 +35,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool const integrationPlugins = useIntegrationPlugins(); const navigate = useNavigate(); const integrationsResult = useAtomValue(integrationsOptimisticAtom); + const canCreateIntegration = useCanCreateWorkspaceConnections(); // Toggle with ⌘K / Ctrl+K useEffect(() => { @@ -139,7 +141,11 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool return ( - + No results found. @@ -169,9 +175,11 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool )} - {connectedIntegrations.length > 0 && integrationPlugins.length > 0 && } + {canCreateIntegration && + connectedIntegrations.length > 0 && + integrationPlugins.length > 0 && } - {integrationPlugins.length > 0 && ( + {canCreateIntegration && integrationPlugins.length > 0 && ( {integrationPlugins.map((plugin) => ( )} - {presetEntries.length > 0 && } + {canCreateIntegration && presetEntries.length > 0 && } - {presetEntries.length > 0 && ( + {canCreateIntegration && presetEntries.length > 0 && ( {presetEntries.map((e) => ( + + + ); + } + + return ( + + + + + ); +} diff --git a/packages/react/src/multiplayer/shell.tsx b/packages/react/src/multiplayer/shell.tsx index 1bd109ece1..bb0a84225f 100644 --- a/packages/react/src/multiplayer/shell.tsx +++ b/packages/react/src/multiplayer/shell.tsx @@ -25,6 +25,7 @@ import { CommandPalette } from "../components/command-palette"; import { Wordmark } from "../components/wordmark"; import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; import { useAuth } from "./auth-context"; +import { useCanCreateWorkspaceConnections } from "./use-admin-nav"; // --------------------------------------------------------------------------- // Shared multiplayer shell (cloud + self-host). @@ -351,6 +352,7 @@ function SidebarContent( }, ) { const plugins = useClientPlugins(); + const canCreateIntegration = useCanCreateWorkspaceConnections(); const pluginNavItems = plugins.flatMap((plugin) => (plugin.pages ?? []).flatMap((page) => page.nav @@ -385,17 +387,19 @@ function SidebarContent(
Integrations - + {canCreateIntegration && ( + + )}
diff --git a/packages/react/src/pages/integration-add.tsx b/packages/react/src/pages/integration-add.tsx index 9691ca4cd2..f6b1f32a72 100644 --- a/packages/react/src/pages/integration-add.tsx +++ b/packages/react/src/pages/integration-add.tsx @@ -1,16 +1,27 @@ -import { Suspense } from "react"; +import { Suspense, type ComponentProps } from "react"; import { useAtomRefresh } from "@effect/atom-react"; import { Link, useNavigate } from "@tanstack/react-router"; import { useIntegrationPlugins } from "@executor-js/sdk/client"; import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { useExecutorDocumentTitle } from "../lib/document-title"; +import { IntegrationCreationGate } from "../components/integration-creation-gate"; // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- -export function AddIntegrationPage(props: { +/** Render an integration setup flow only when the workspace role permits creation. */ +export function AddIntegrationPage(props: ComponentProps) { + useExecutorDocumentTitle("Add integration"); + return ( + + + + ); +} + +function AddIntegrationContent(props: { pluginKey: string; url?: string; preset?: string; @@ -20,7 +31,6 @@ export function AddIntegrationPage(props: { authKind?: string; specOverrides?: string; }) { - useExecutorDocumentTitle("Add integration"); const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } = props; const navigate = useNavigate(); diff --git a/packages/react/src/pages/integration-browse.tsx b/packages/react/src/pages/integration-browse.tsx index 83b133e28f..a4745799a2 100644 --- a/packages/react/src/pages/integration-browse.tsx +++ b/packages/react/src/pages/integration-browse.tsx @@ -27,6 +27,7 @@ import { } from "../components/integration-favicon"; import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; +import { IntegrationCreationGate } from "../components/integration-creation-gate"; import { availableCatalogKinds, catalogLogoUrl, @@ -362,8 +363,17 @@ function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { // Page // --------------------------------------------------------------------------- +/** Render the integration catalog only when the workspace role permits creation. */ export function IntegrationBrowsePage() { useExecutorDocumentTitle("Add an integration"); + return ( + + + + ); +} + +function IntegrationBrowseContent() { const navigate = useNavigate(); const integrationPlugins = useIntegrationPlugins(); const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index a08faeaa67..968ece3bba 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -32,6 +32,7 @@ import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; const KIND_TO_PLUGIN_KEY: Record = { openapi: "openapi", @@ -48,6 +49,7 @@ export function IntegrationsPage() { useExecutorDocumentTitle("Integrations"); const integrations = useAtomValue(integrationsOptimisticAtom); const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); + const canCreate = useCanCreateWorkspaceConnections(); return ( @@ -55,15 +57,17 @@ export function IntegrationsPage() { title="Integrations" description="Tool providers available in this workspace." actions={ - + canCreate && ( + + ) } /> @@ -83,7 +87,7 @@ export function IntegrationsPage() { ), onSuccess: ({ value }) => { if (value.length === 0) { - return ; + return ; } return ( @@ -102,7 +106,7 @@ export function IntegrationsPage() { // Empty state // --------------------------------------------------------------------------- -function EmptyIntegrations() { +function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) { return (
@@ -110,17 +114,21 @@ function EmptyIntegrations() {

No integrations yet

- Connect an integration to start curating tools. + {canCreate + ? "Connect an integration to start curating tools." + : "Ask a workspace admin to add an integration."}

- + {canCreate && ( + + )}
); } From 15e75a6fa492a080b9d15bed7aba29baca6fcb3c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:37:00 -0700 Subject: [PATCH 2/2] Show disabled integration actions for members --- .changeset/fair-admin-integrations.md | 2 +- e2e/src/integration-creation-permissions.ts | 113 +++++++++++++----- .../react/src/components/command-palette.tsx | 23 ++-- .../src/components/workspace-admin-hint.tsx | 29 +++++ packages/react/src/multiplayer/shell.tsx | 6 +- .../react/src/pages/integration-browse.tsx | 30 ++--- .../react/src/pages/integration-detail.tsx | 41 ++++--- packages/react/src/pages/integrations.tsx | 19 ++- 8 files changed, 184 insertions(+), 79 deletions(-) create mode 100644 packages/react/src/components/workspace-admin-hint.tsx diff --git a/.changeset/fair-admin-integrations.md b/.changeset/fair-admin-integrations.md index 6ea6729688..a8e83972b8 100644 --- a/.changeset/fair-admin-integrations.md +++ b/.changeset/fair-admin-integrations.md @@ -2,4 +2,4 @@ "@executor-js/react": patch --- -Hide integration creation controls from workspace members and explain the admin requirement on direct add links. +Show restricted integration actions as disabled controls with an admin explanation. Members can browse the catalog and add personal connections to existing integrations. diff --git a/e2e/src/integration-creation-permissions.ts b/e2e/src/integration-creation-permissions.ts index 8a32937f28..c54309a569 100644 --- a/e2e/src/integration-creation-permissions.ts +++ b/e2e/src/integration-creation-permissions.ts @@ -61,42 +61,91 @@ export const integrationCreationPermissions = (admin: Identity, member: Identity }); yield* browser.session(member, async ({ page, step }) => { - await step("Member sees existing integrations without an Add action", async () => { - await visit(page, "/"); - await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor(); - await page.getByTestId(`integration-entry-${slug}`).waitFor(); - expect( - await page.getByRole("button", { name: "Browse integrations", exact: true }).count(), - ).toBe(0); - await page.keyboard.press("ControlOrMeta+k"); - const palette = page.getByRole("dialog"); - await palette.getByRole("option", { name: new RegExp(title) }).waitFor(); - expect(await palette.getByRole("option", { name: /^Add / }).count()).toBe(0); - expect(await palette.getByText("Popular integrations", { exact: true }).count()).toBe( - 0, - ); - await page.keyboard.press("Escape"); - expect(await page.getByRole("link", { name: /^Add (an? )?integration$/ }).count()).toBe( - 0, - ); - }); await step( - "Member opens an existing integration and can add a personal connection", + "Member sees disabled creation controls with an admin explanation", + async () => { + await visit(page, "/"); + await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor(); + await page.getByTestId(`integration-entry-${slug}`).waitFor(); + const add = page.getByRole("button", { name: "Add integration", exact: true }); + await add.waitFor(); + expect(await add.isDisabled()).toBe(true); + expect( + await page + .getByRole("button", { name: "Browse integrations", exact: true }) + .isDisabled(), + ).toBe(true); + const hint = page + .getByRole("group", { name: "Requires a workspace admin" }) + .filter({ has: add }); + await hint.hover(); + await page.getByRole("tooltip", { name: "Requires a workspace admin" }).waitFor(); + await hint.focus(); + const before = page.url(); + await page.keyboard.press("Enter"); + expect(page.url()).toBe(before); + }, + ); + await step( + "Member sees disabled add commands and can still find existing integrations", async () => { - await page.getByTestId(`integration-entry-${slug}`).click(); - await page.getByRole("button", { name: "Add connection", exact: true }).waitFor(); - expect(await page.getByRole("button", { name: /^(Edit|Delete)$/ }).count()).toBe(0); - await page.getByRole("button", { name: "Add connection", exact: true }).click(); - const dialog = page.getByRole("dialog"); - await dialog.waitFor(); - expect(await dialog.getByText("Workspace", { exact: true }).count()).toBe(0); + await page.keyboard.press("ControlOrMeta+k"); + const palette = page.getByRole("dialog"); + await palette.getByRole("option", { name: new RegExp(title) }).waitFor(); + const addCommand = palette.getByRole("option", { name: /^Add OpenAPI/ }); + await addCommand.waitFor(); + expect(await addCommand.getAttribute("aria-disabled")).toBe("true"); + expect(await addCommand.textContent()).toContain("Admin only"); + await page.keyboard.press("Escape"); }, ); - for (const path of [ - "/integrations/browse", - "/integrations/add/openapi", - "/integrations/add/mcp", - ]) { + await step("Member sees disabled Edit and Delete actions", async () => { + await page.getByTestId(`integration-entry-${slug}`).click(); + await page.getByRole("button", { name: "Add connection", exact: true }).waitFor(); + for (const name of ["Edit", "Delete"]) { + const action = page.getByRole("button", { name, exact: true }); + await action.waitFor(); + expect(await action.isDisabled()).toBe(true); + } + }); + await step("Member can still add a personal connection", async () => { + await page.getByRole("button", { name: "Add connection", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await dialog.waitFor(); + expect(await dialog.getByText("Workspace", { exact: true }).count()).toBe(0); + }); + await step("Member browses the catalog with disabled Add buttons", async () => { + await visit(page, "/integrations/browse"); + await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor(); + await page + .getByText("Requires a workspace admin to add integrations.", { exact: true }) + .waitFor(); + const addButtons = page.getByRole("button", { name: /^Add / }); + await addButtons.first().waitFor(); + for (const button of await addButtons.all()) + expect(await button.isDisabled()).toBe(true); + const scratch = page.getByRole("button", { + name: "New OpenAPI integration from scratch", + exact: true, + }); + expect(await scratch.isDisabled()).toBe(true); + const view = page.getByRole("link", { name: `View ${title}`, exact: true }); + await view.waitFor(); + expect(await view.isEnabled()).toBe(true); + }); + await step("Member cannot add a URL with the button or Enter key", async () => { + const input = page.getByRole("textbox", { + name: "Search integrations, or paste a URL", + }); + await input.fill("https://api.example.com/openapi.json"); + expect( + await page.getByRole("button", { name: "Add this URL", exact: true }).isDisabled(), + ).toBe(true); + const before = page.url(); + await input.press("Enter"); + expect(page.url()).toBe(before); + }); + for (const path of ["/integrations/add/openapi", "/integrations/add/mcp"]) { await step(`Member follows ${path} and sees the admin explanation`, async () => { await visit(page, path); await page.getByRole("heading", { name: "An admin must add integrations" }).waitFor(); diff --git a/packages/react/src/components/command-palette.tsx b/packages/react/src/components/command-palette.tsx index cf3a3cfe52..ecd148dcb2 100644 --- a/packages/react/src/components/command-palette.tsx +++ b/packages/react/src/components/command-palette.tsx @@ -141,11 +141,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool return ( - + No results found. @@ -175,32 +171,33 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool
)} - {canCreateIntegration && - connectedIntegrations.length > 0 && - integrationPlugins.length > 0 && } + {connectedIntegrations.length > 0 && integrationPlugins.length > 0 && } - {canCreateIntegration && integrationPlugins.length > 0 && ( + {integrationPlugins.length > 0 && ( {integrationPlugins.map((plugin) => ( goToAdd(plugin.key)} > Add {plugin.label} + {!canCreateIntegration && Admin only} ))} )} - {canCreateIntegration && presetEntries.length > 0 && } + {presetEntries.length > 0 && } - {canCreateIntegration && presetEntries.length > 0 && ( + {presetEntries.length > 0 && ( {presetEntries.map((e) => ( goToPreset(e.pluginKey, e.presetId, e.presetUrl)} > @@ -216,7 +213,9 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool } /> {e.presetName} - {e.pluginLabel} + + {canCreateIntegration ? e.pluginLabel : "Admin only"} + ))} diff --git a/packages/react/src/components/workspace-admin-hint.tsx b/packages/react/src/components/workspace-admin-hint.tsx new file mode 100644 index 0000000000..ea497615f0 --- /dev/null +++ b/packages/react/src/components/workspace-admin-hint.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"; + +/** Explain a disabled workspace action on hover or keyboard focus. */ +export function WorkspaceAdminHint(props: { + readonly allowed: boolean; + readonly children: ReactNode; +}) { + if (props.allowed) return props.children; + return ( + + + + + {props.children} + + + + Requires a workspace admin + + + + ); +} diff --git a/packages/react/src/multiplayer/shell.tsx b/packages/react/src/multiplayer/shell.tsx index bb0a84225f..2ee203b25e 100644 --- a/packages/react/src/multiplayer/shell.tsx +++ b/packages/react/src/multiplayer/shell.tsx @@ -6,6 +6,7 @@ import { BookOpen, Command, ExternalLink, PlusIcon } from "lucide-react"; import type { Integration } from "@executor-js/sdk/shared"; import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; +import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; import { Button } from "../components/button"; import { Skeleton } from "../components/skeleton"; import { SidebarUpdateCard } from "../components/update-card"; @@ -387,11 +388,12 @@ function SidebarContent(
Integrations - {canCreateIntegration && ( + - )} +
diff --git a/packages/react/src/pages/integration-browse.tsx b/packages/react/src/pages/integration-browse.tsx index a4745799a2..c63789d3ef 100644 --- a/packages/react/src/pages/integration-browse.tsx +++ b/packages/react/src/pages/integration-browse.tsx @@ -27,7 +27,7 @@ import { } from "../components/integration-favicon"; import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; -import { IntegrationCreationGate } from "../components/integration-creation-gate"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import { availableCatalogKinds, catalogLogoUrl, @@ -245,7 +245,7 @@ function RowIcon(props: { readonly src?: string; readonly alt: string }) { ); } -function ResultCard(props: { readonly row: Row }) { +function ResultCard(props: { readonly row: Row; readonly canCreate: boolean }) { const { row } = props; return (
- - - ); -} - -function IntegrationBrowseContent() { + const canCreate = useCanCreateWorkspaceConnections(); const navigate = useNavigate(); const integrationPlugins = useIntegrationPlugins(); const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); @@ -933,6 +927,11 @@ function IntegrationBrowseContent() { description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint." /> + {!canCreate && ( +

+ Requires a workspace admin to add integrations. +

+ )}
{ - if (event.key === "Enter" && isUrl) void handleDetect(); + if (event.key === "Enter" && isUrl && canCreate) void handleDetect(); }} placeholder="Search integrations, or paste a URL…" aria-label="Search integrations, or paste a URL" @@ -961,7 +960,7 @@ function IntegrationBrowseContent() { + + + )} {canRefresh && ( @@ -530,20 +538,23 @@ export function IntegrationDetailPage(props: { variant="destructive" size="sm" onClick={() => void handleDelete()} - disabled={deleting} + disabled={deleting || !canMutateIntegration} > {deleting ? "Deleting..." : "Confirm Delete"}
) : ( - + + + ))}
diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index 968ece3bba..b12c8fc558 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -8,6 +8,7 @@ import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/ import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { McpInstallCard } from "../components/mcp-install-card"; +import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; import { Button } from "../components/button"; import { PageContainer, PageHeader } from "../components/page"; import { @@ -57,7 +58,7 @@ export function IntegrationsPage() { title="Integrations" description="Tool providers available in this workspace." actions={ - canCreate && ( + canCreate ? ( + ) : ( + + + ) } /> @@ -118,7 +126,7 @@ function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) { ? "Connect an integration to start curating tools." : "Ask a workspace admin to add an integration."}

- {canCreate && ( + {canCreate ? ( + ) : ( + + + )} );