diff --git a/.changeset/fair-admin-integrations.md b/.changeset/fair-admin-integrations.md
new file mode 100644
index 0000000000..a8e83972b8
--- /dev/null
+++ b/.changeset/fair-admin-integrations.md
@@ -0,0 +1,5 @@
+---
+"@executor-js/react": patch
+---
+
+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/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..c54309a569
--- /dev/null
+++ b/e2e/src/integration-creation-permissions.ts
@@ -0,0 +1,164 @@
+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 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.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");
+ },
+ );
+ 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();
+ 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..ecd148dcb2 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(() => {
@@ -176,11 +178,13 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool
{integrationPlugins.map((plugin) => (
+ Requires a workspace admin to add integrations. +
+ )}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."}
-