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) => ( goToAdd(plugin.key)} > Add {plugin.label} + {!canCreateIntegration && Admin only} ))} @@ -193,6 +197,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool {presetEntries.map((e) => ( goToPreset(e.pluginKey, e.presetId, e.presetUrl)} > @@ -208,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/integration-creation-gate.tsx b/packages/react/src/components/integration-creation-gate.tsx new file mode 100644 index 0000000000..afcdee7025 --- /dev/null +++ b/packages/react/src/components/integration-creation-gate.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; + +import { orgMembersAtom } from "../api/account-atoms"; +import { isAsyncResultLoading } from "../lib/async-result"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; +import { Button } from "./button"; +import { PageContainer, PageHeader } from "./page"; +import { Skeleton } from "./skeleton"; + +/** Keep integration creation flows behind the same role gate as edit and delete. */ +export function IntegrationCreationGate({ children }: { readonly children: ReactNode }) { + const canCreate = useCanCreateWorkspaceConnections(); + const members = useAtomValue(orgMembersAtom); + if (canCreate) return children; + if (isAsyncResultLoading(members)) { + return ( + + + + ); + } + + return ( + + + + + ); +} 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 1bd109ece1..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"; @@ -25,6 +26,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 +353,7 @@ function SidebarContent( }, ) { const plugins = useClientPlugins(); + const canCreateIntegration = useCanCreateWorkspaceConnections(); const pluginNavItems = plugins.flatMap((plugin) => (plugin.pages ?? []).flatMap((page) => page.nav @@ -385,17 +388,20 @@ function SidebarContent(
Integrations - + + +
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..c63789d3ef 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 { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import { availableCatalogKinds, catalogLogoUrl, @@ -244,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 (
+ {!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" @@ -951,7 +960,7 @@ export function IntegrationBrowsePage() { + + + )} {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 a08faeaa67..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 { @@ -32,6 +33,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 +50,7 @@ export function IntegrationsPage() { useExecutorDocumentTitle("Integrations"); const integrations = useAtomValue(integrationsOptimisticAtom); const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); + const canCreate = useCanCreateWorkspaceConnections(); return ( @@ -55,15 +58,24 @@ export function IntegrationsPage() { title="Integrations" description="Tool providers available in this workspace." actions={ - + canCreate ? ( + + ) : ( + + + + ) } /> @@ -83,7 +95,7 @@ export function IntegrationsPage() { ), onSuccess: ({ value }) => { if (value.length === 0) { - return ; + return ; } return ( @@ -102,7 +114,7 @@ export function IntegrationsPage() { // Empty state // --------------------------------------------------------------------------- -function EmptyIntegrations() { +function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) { return (
@@ -110,17 +122,28 @@ 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 ? ( + + ) : ( + + + + )}
); }