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
5 changes: 5 additions & 0 deletions .changeset/fair-admin-integrations.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions e2e/cloud/integration-creation-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -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));
}),
);
21 changes: 21 additions & 0 deletions e2e/selfhost/integration-creation-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
);
164 changes: 164 additions & 0 deletions e2e/src/integration-creation-permissions.ts
Original file line number Diff line number Diff line change
@@ -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),
);
});
9 changes: 8 additions & 1 deletion packages/react/src/components/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(() => {
Expand Down Expand Up @@ -176,11 +178,13 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool
{integrationPlugins.map((plugin) => (
<CommandItem
key={`add-${plugin.key}`}
disabled={!canCreateIntegration}
value={`add ${plugin.label} ${plugin.key}`}
onSelect={() => goToAdd(plugin.key)}
>
<PlusIcon />
<span className="flex-1 truncate">Add {plugin.label}</span>
{!canCreateIntegration && <CommandShortcut>Admin only</CommandShortcut>}
</CommandItem>
))}
</CommandGroup>
Expand All @@ -193,6 +197,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool
{presetEntries.map((e) => (
<CommandItem
key={`preset-${e.pluginKey}-${e.presetId}`}
disabled={!canCreateIntegration}
value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`}
onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)}
>
Expand All @@ -208,7 +213,9 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool
}
/>
<span className="flex-1 truncate">{e.presetName}</span>
<CommandShortcut>{e.pluginLabel}</CommandShortcut>
<CommandShortcut>
{canCreateIntegration ? e.pluginLabel : "Admin only"}
</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
Expand Down
36 changes: 36 additions & 0 deletions packages/react/src/components/integration-creation-gate.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PageContainer>
<Skeleton className="h-8 w-64" />
</PageContainer>
);
}

return (
<PageContainer>
<PageHeader
title="An admin must add integrations"
description="Ask a workspace admin to add the integration. You can add personal connections to existing integrations."
/>
<Button asChild variant="outline">
<Link to="/{-$orgSlug}">Back to integrations</Link>
</Button>
</PageContainer>
);
}
29 changes: 29 additions & 0 deletions packages/react/src/components/workspace-admin-hint.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
role="group"
aria-label="Requires a workspace admin"
className="inline-flex"
>
{props.children}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Requires a workspace admin
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
28 changes: 17 additions & 11 deletions packages/react/src/multiplayer/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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).
Expand Down Expand Up @@ -351,6 +353,7 @@ function SidebarContent(
},
) {
const plugins = useClientPlugins();
const canCreateIntegration = useCanCreateWorkspaceConnections();
const pluginNavItems = plugins.flatMap((plugin) =>
(plugin.pages ?? []).flatMap((page) =>
page.nav
Expand Down Expand Up @@ -385,17 +388,20 @@ function SidebarContent(

<div className="mt-5 mb-1 flex items-center justify-between px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
<span>Integrations</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Browse integrations"
title="Browse integrations"
onClick={props.onOpenIntegrationConnect}
className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"
>
<PlusIcon className="size-3.5" />
</Button>
<WorkspaceAdminHint allowed={canCreateIntegration}>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={!canCreateIntegration}
aria-label="Browse integrations"
title="Browse integrations"
onClick={props.onOpenIntegrationConnect}
className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"
>
<PlusIcon className="size-3.5" />
</Button>
</WorkspaceAdminHint>
</div>

<IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />
Expand Down
Loading
Loading