History

Show disabled integration actions for members

Rhys Sullivan committed 1 month ago

Commit 15e75a61 parent 695fd738 files · +184 −79Browse files

Unified · Split

8 files
  1. .changeset/fair-admin-integrations.md +1 −1
  2. e2e/src/integration-creation-permissions.ts +81 −32
  3. packages/react/src/components/command-palette.tsx +11 −12
  4. packages/react/src/components/workspace-admin-hint.tsx +29 −0
  5. packages/react/src/multiplayer/shell.tsx +4 −2
  6. packages/react/src/pages/integration-browse.tsx +15 −15
  7. packages/react/src/pages/integration-detail.tsx +26 −15
  8. packages/react/src/pages/integrations.tsx +17 −2
.changeset/fair-admin-integrations.md+1−1
@@ -2,4 +2,4 @@ · 1 unmodified line
22 "@executor-js/react": patch
33 ---
44
5−Hide integration creation controls from workspace members and explain the admin requirement on direct add links.
5+Show restricted integration actions as disabled controls with an admin explanation. Members can browse the catalog and add personal connections to existing integrations.
e2e/src/integration-creation-permissions.ts+81−32
@@ -61,42 +61,91 @@ export const integrationCreationPermissions = (admin: Identity, member: Identity · 60 unmodified lines
6161 });
6262
6363 yield* browser.session(member, async ({ page, step }) => {
64− await step("Member sees existing integrations without an Add action", async () => {
65− await visit(page, "/");
66− await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor();
67− await page.getByTestId(`integration-entry-${slug}`).waitFor();
68− expect(
69− await page.getByRole("button", { name: "Browse integrations", exact: true }).count(),
70− ).toBe(0);
71− await page.keyboard.press("ControlOrMeta+k");
72− const palette = page.getByRole("dialog");
73− await palette.getByRole("option", { name: new RegExp(title) }).waitFor();
74− expect(await palette.getByRole("option", { name: /^Add / }).count()).toBe(0);
75− expect(await palette.getByText("Popular integrations", { exact: true }).count()).toBe(
76− 0,
77− );
78− await page.keyboard.press("Escape");
79− expect(await page.getByRole("link", { name: /^Add (an? )?integration$/ }).count()).toBe(
80− 0,
81− );
82− });
8364 await step(
84− "Member opens an existing integration and can add a personal connection",
65+ "Member sees disabled creation controls with an admin explanation",
66+ async () => {
67+ await visit(page, "/");
68+ await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor();
69+ await page.getByTestId(`integration-entry-${slug}`).waitFor();
70+ const add = page.getByRole("button", { name: "Add integration", exact: true });
71+ await add.waitFor();
72+ expect(await add.isDisabled()).toBe(true);
73+ expect(
74+ await page
75+ .getByRole("button", { name: "Browse integrations", exact: true })
76+ .isDisabled(),
77+ ).toBe(true);
78+ const hint = page
79+ .getByRole("group", { name: "Requires a workspace admin" })
80+ .filter({ has: add });
81+ await hint.hover();
82+ await page.getByRole("tooltip", { name: "Requires a workspace admin" }).waitFor();
83+ await hint.focus();
84+ const before = page.url();
85+ await page.keyboard.press("Enter");
86+ expect(page.url()).toBe(before);
87+ },
88+ );
89+ await step(
90+ "Member sees disabled add commands and can still find existing integrations",
8591 async () => {
86− await page.getByTestId(`integration-entry-${slug}`).click();
87− await page.getByRole("button", { name: "Add connection", exact: true }).waitFor();
88− expect(await page.getByRole("button", { name: /^(Edit|Delete)$/ }).count()).toBe(0);
89− await page.getByRole("button", { name: "Add connection", exact: true }).click();
90− const dialog = page.getByRole("dialog");
91− await dialog.waitFor();
92− expect(await dialog.getByText("Workspace", { exact: true }).count()).toBe(0);
92+ await page.keyboard.press("ControlOrMeta+k");
93+ const palette = page.getByRole("dialog");
94+ await palette.getByRole("option", { name: new RegExp(title) }).waitFor();
95+ const addCommand = palette.getByRole("option", { name: /^Add OpenAPI/ });
96+ await addCommand.waitFor();
97+ expect(await addCommand.getAttribute("aria-disabled")).toBe("true");
98+ expect(await addCommand.textContent()).toContain("Admin only");
99+ await page.keyboard.press("Escape");
93100 },
94101 );
95− for (const path of [
96− "/integrations/browse",
97− "/integrations/add/openapi",
98− "/integrations/add/mcp",
99− ]) {
102+ await step("Member sees disabled Edit and Delete actions", async () => {
103+ await page.getByTestId(`integration-entry-${slug}`).click();
104+ await page.getByRole("button", { name: "Add connection", exact: true }).waitFor();
105+ for (const name of ["Edit", "Delete"]) {
106+ const action = page.getByRole("button", { name, exact: true });
107+ await action.waitFor();
108+ expect(await action.isDisabled()).toBe(true);
109+ }
110+ });
111+ await step("Member can still add a personal connection", async () => {
112+ await page.getByRole("button", { name: "Add connection", exact: true }).click();
113+ const dialog = page.getByRole("dialog");
114+ await dialog.waitFor();
115+ expect(await dialog.getByText("Workspace", { exact: true }).count()).toBe(0);
116+ });
117+ await step("Member browses the catalog with disabled Add buttons", async () => {
118+ await visit(page, "/integrations/browse");
119+ await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor();
120+ await page
121+ .getByText("Requires a workspace admin to add integrations.", { exact: true })
122+ .waitFor();
123+ const addButtons = page.getByRole("button", { name: /^Add / });
124+ await addButtons.first().waitFor();
125+ for (const button of await addButtons.all())
126+ expect(await button.isDisabled()).toBe(true);
127+ const scratch = page.getByRole("button", {
128+ name: "New OpenAPI integration from scratch",
129+ exact: true,
130+ });
131+ expect(await scratch.isDisabled()).toBe(true);
132+ const view = page.getByRole("link", { name: `View ${title}`, exact: true });
133+ await view.waitFor();
134+ expect(await view.isEnabled()).toBe(true);
135+ });
136+ await step("Member cannot add a URL with the button or Enter key", async () => {
137+ const input = page.getByRole("textbox", {
138+ name: "Search integrations, or paste a URL",
139+ });
140+ await input.fill("https://api.example.com/openapi.json");
141+ expect(
142+ await page.getByRole("button", { name: "Add this URL", exact: true }).isDisabled(),
143+ ).toBe(true);
144+ const before = page.url();
145+ await input.press("Enter");
146+ expect(page.url()).toBe(before);
147+ });
148+ for (const path of ["/integrations/add/openapi", "/integrations/add/mcp"]) {
100149 await step(`Member follows ${path} and sees the admin explanation`, async () => {
101150 await visit(page, path);
102151 await page.getByRole("heading", { name: "An admin must add integrations" }).waitFor();
packages/react/src/components/command-palette.tsx+11−12
@@ -141,11 +141,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 140 unmodified lines
141141
142142 return (
143143 <CommandDialog open={open} onOpenChange={onOpenChange}>
144− <CommandInput
145− placeholder={
146− canCreateIntegration ? "Search integrations or jump to add…" : "Search integrations…"
147− }
148− />
144+ <CommandInput placeholder="Search integrations or jump to add…" />
149145 <CommandList>
150146 <CommandEmpty>No results found.</CommandEmpty>
151147
@@ -175,32 +171,33 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 23 unmodified lines
175171 </CommandGroup>
176172 )}
177173
178− {canCreateIntegration &&
179− connectedIntegrations.length > 0 &&
180− integrationPlugins.length > 0 && <CommandSeparator />}
174+ {connectedIntegrations.length > 0 && integrationPlugins.length > 0 && <CommandSeparator />}
181175
182− {canCreateIntegration && integrationPlugins.length > 0 && (
176+ {integrationPlugins.length > 0 && (
183177 <CommandGroup heading="Add integration">
184178 {integrationPlugins.map((plugin) => (
185179 <CommandItem
186180 key={`add-${plugin.key}`}
181+ disabled={!canCreateIntegration}
187182 value={`add ${plugin.label} ${plugin.key}`}
188183 onSelect={() => goToAdd(plugin.key)}
189184 >
190185 <PlusIcon />
191186 <span className="flex-1 truncate">Add {plugin.label}</span>
187+ {!canCreateIntegration && <CommandShortcut>Admin only</CommandShortcut>}
192188 </CommandItem>
193189 ))}
194190 </CommandGroup>
195191 )}
196192
197− {canCreateIntegration && presetEntries.length > 0 && <CommandSeparator />}
193+ {presetEntries.length > 0 && <CommandSeparator />}
198194
199− {canCreateIntegration && presetEntries.length > 0 && (
195+ {presetEntries.length > 0 && (
200196 <CommandGroup heading="Popular integrations">
201197 {presetEntries.map((e) => (
202198 <CommandItem
203199 key={`preset-${e.pluginKey}-${e.presetId}`}
200+ disabled={!canCreateIntegration}
204201 value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`}
205202 onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)}
206203 >
@@ -216,7 +213,9 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 9 unmodified lines
216213 }
217214 />
218215 <span className="flex-1 truncate">{e.presetName}</span>
219− <CommandShortcut>{e.pluginLabel}</CommandShortcut>
216+ <CommandShortcut>
217+ {canCreateIntegration ? e.pluginLabel : "Admin only"}
218+ </CommandShortcut>
220219 </CommandItem>
221220 ))}
222221 </CommandGroup>
packages/react/src/components/workspace-admin-hint.tsx+29−0
@@ -0,0 +1,29 @@
1+import type { ReactNode } from "react";
2+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
3+
4+/** Explain a disabled workspace action on hover or keyboard focus. */
5+export function WorkspaceAdminHint(props: {
6+ readonly allowed: boolean;
7+ readonly children: ReactNode;
8+}) {
9+ if (props.allowed) return props.children;
10+ return (
11+ <TooltipProvider>
12+ <Tooltip>
13+ <TooltipTrigger asChild>
14+ <span
15+ tabIndex={0}
16+ role="group"
17+ aria-label="Requires a workspace admin"
18+ className="inline-flex"
19+ >
20+ {props.children}
21+ </span>
22+ </TooltipTrigger>
23+ <TooltipContent side="bottom" sideOffset={6}>
24+ Requires a workspace admin
25+ </TooltipContent>
26+ </Tooltip>
27+ </TooltipProvider>
28+ );
29+}
packages/react/src/multiplayer/shell.tsx+4−2
@@ -6,6 +6,7 @@ import { BookOpen, Command, ExternalLink, PlusIcon } from "lucide-react"; · 5 unmodified lines
66 import type { Integration } from "@executor-js/sdk/shared";
77 import { integrationsOptimisticAtom } from "../api/atoms";
88 import { trackEvent } from "../api/analytics";
9+import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
910 import { Button } from "../components/button";
1011 import { Skeleton } from "../components/skeleton";
1112 import { SidebarUpdateCard } from "../components/update-card";
@@ -387,11 +388,12 @@ function SidebarContent( · 375 unmodified lines
387388
388389 <div className="mt-5 mb-1 flex items-center justify-between px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
389390 <span>Integrations</span>
390− {canCreateIntegration && (
391+ <WorkspaceAdminHint allowed={canCreateIntegration}>
391392 <Button
392393 type="button"
393394 variant="ghost"
394395 size="icon-xs"
396+ disabled={!canCreateIntegration}
395397 aria-label="Browse integrations"
396398 title="Browse integrations"
397399 onClick={props.onOpenIntegrationConnect}
@@ -399,7 +401,7 @@ function SidebarContent( · 1 unmodified line
399401 >
400402 <PlusIcon className="size-3.5" />
401403 </Button>
402− )}
404+ </WorkspaceAdminHint>
403405 </div>
404406
405407 <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />
packages/react/src/pages/integration-browse.tsx+15−15
@@ -27,7 +27,7 @@ import { · 26 unmodified lines
2727 } from "../components/integration-favicon";
2828 import { Skeleton } from "../components/skeleton";
2929 import { useExecutorDocumentTitle } from "../lib/document-title";
30−import { IntegrationCreationGate } from "../components/integration-creation-gate";
30+import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
3131 import {
3232 availableCatalogKinds,
3333 catalogLogoUrl,
@@ -245,7 +245,7 @@ function RowIcon(props: { readonly src?: string; readonly alt: string }) { · 211 unmodified lines
245245 );
246246 }
247247
248−function ResultCard(props: { readonly row: Row }) {
248+function ResultCard(props: { readonly row: Row; readonly canCreate: boolean }) {
249249 const { row } = props;
250250 return (
251251 <div
@@ -276,6 +276,7 @@ function ResultCard(props: { readonly row: Row }) { · 24 unmodified lines
276276 variant="outline"
277277 size="sm"
278278 onClick={row.onSelect}
279+ disabled={!props.canCreate}
279280 // Every card's button reads "Add", so the visible label alone is
280281 // useless to a screen reader; the accessible name carries the card.
281282 aria-label={`Add ${row.title}`}
@@ -363,17 +364,10 @@ function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { · 81 unmodified lines
363364 // Page
364365 // ---------------------------------------------------------------------------
365366
366−/** Render the integration catalog only when the workspace role permits creation. */
367+/** Let members browse integrations while reserving creation for workspace admins. */
367368 export function IntegrationBrowsePage() {
368369 useExecutorDocumentTitle("Add an integration");
369− return (
370− <IntegrationCreationGate>
371− <IntegrationBrowseContent />
372− </IntegrationCreationGate>
373− );
374−}
375−
376−function IntegrationBrowseContent() {
370+ const canCreate = useCanCreateWorkspaceConnections();
377371 const navigate = useNavigate();
378372 const integrationPlugins = useIntegrationPlugins();
379373 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });
@@ -933,6 +927,11 @@ function IntegrationBrowseContent() { · 553 unmodified lines
933927 description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint."
934928 />
935929
930+ {!canCreate && (
931+ <p className="mb-4 text-sm text-muted-foreground">
932+ Requires a workspace admin to add integrations.
933+ </p>
934+ )}
936935 <div className="mb-4 flex gap-2">
937936 <div className="relative min-w-0 flex-1">
938937 <SearchIcon
@@ -947,7 +946,7 @@ function IntegrationBrowseContent() { · 8 unmodified lines
947946 setError(null);
948947 }}
949948 onKeyDown={(event) => {
950− if (event.key === "Enter" && isUrl) void handleDetect();
949+ if (event.key === "Enter" && isUrl && canCreate) void handleDetect();
951950 }}
952951 placeholder="Search integrations, or paste a URL…"
953952 aria-label="Search integrations, or paste a URL"
@@ -961,7 +960,7 @@ function IntegrationBrowseContent() { · 7 unmodified lines
961960 <Button
962961 className="h-11 shrink-0"
963962 onClick={() => void handleDetect()}
964− disabled={detecting || query.trim().length === 0}
963+ disabled={!canCreate || detecting || query.trim().length === 0}
965964 loading={detecting}
966965 >
967966 Add this URL
@@ -983,6 +982,7 @@ function IntegrationBrowseContent() { · 15 unmodified lines
983982 key={plugin.key}
984983 type="button"
985984 aria-label={`New ${plugin.label} integration from scratch`}
985+ disabled={!canCreate}
986986 onClick={() => {
987987 trackEvent("integration_add_started", {
988988 plugin_key: plugin.key,
@@ -993,7 +993,7 @@ function IntegrationBrowseContent() { · 4 unmodified lines
993993 params: { pluginKey: plugin.key },
994994 });
995995 }}
996− className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
996+ className="disabled:opacity-50 disabled:pointer-events-none inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
997997 >
998998 <PlusIcon className="size-3" aria-hidden />
999999 {plugin.label}
@@ -1030,7 +1030,7 @@ function IntegrationBrowseContent() { · 30 unmodified lines
10301030 ) : (
10311031 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
10321032 {results.map((row) => (
1033− <ResultCard key={row.key} row={row} />
1033+ <ResultCard key={row.key} row={row} canCreate={canCreate} />
10341034 ))}
10351035 {catalog.loadingMore
10361036 ? Array.from({ length: 3 }, (_, index) => (
packages/react/src/pages/integration-detail.tsx+26−15
@@ -37,6 +37,7 @@ import { IntegrationEditSheet } from "../components/metadata-edit-sheet"; · 36 unmodified lines
3737 import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs";
3838 import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements";
3939 import { usePolicyActions } from "../hooks/use-policy-actions";
40+import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
4041 import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
4142 import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";
4243 import { Button } from "../components/button";
@@ -139,11 +140,11 @@ export function IntegrationDetailPage(props: { · 96 unmodified lines
139140 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
140141 const currentTab = isBuiltInIntegration ? "tools" : activeTab;
141142 // Integrations are workspace-owned; the server refuses catalog mutations
142− // (update/remove) from non-admin members, so hide the controls for them.
143+ // (update/remove) from non-admin members, so disable the controls for them.
143144 const canMutateIntegration = useCanCreateWorkspaceConnections();
144− const canEdit = canMutateIntegration && !isBuiltInIntegration && integrationData !== null;
145+ const canEdit = !isBuiltInIntegration && integrationData !== null;
145146 const canRefresh = integrationData?.canRefresh ?? false;
146− const canRemove = canMutateIntegration && (integrationData?.canRemove ?? false);
147+ const canRemove = integrationData?.canRemove ?? false;
147148 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {
148149 const search = new URLSearchParams(locationSearch);
149150 // The route-validated flag and the raw `addAccount=1` are the same request;
@@ -499,9 +500,16 @@ export function IntegrationDetailPage(props: { · 349 unmodified lines
499500
500501 <div className="flex shrink-0 items-center gap-2">
501502 {!confirmDelete && canEdit && (
502− <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>
503− Edit
504− </Button>
503+ <WorkspaceAdminHint allowed={canMutateIntegration}>
504+ <Button
505+ disabled={!canMutateIntegration}
506+ variant="outline"
507+ size="sm"
508+ onClick={() => setEditSheetOpen(true)}
509+ >
510+ Edit
511+ </Button>
512+ </WorkspaceAdminHint>
505513 )}
506514
507515 {canRefresh && (
@@ -530,20 +538,23 @@ export function IntegrationDetailPage(props: { · 22 unmodified lines
530538 variant="destructive"
531539 size="sm"
532540 onClick={() => void handleDelete()}
533− disabled={deleting}
541+ disabled={deleting || !canMutateIntegration}
534542 >
535543 {deleting ? "Deleting..." : "Confirm Delete"}
536544 </Button>
537545 </div>
538546 ) : (
539− <Button
540− variant="outline"
541− size="sm"
542− onClick={() => setConfirmDelete(true)}
543− className="border-destructive/30 text-destructive hover:bg-destructive/10"
544− >
545− Delete
546− </Button>
547+ <WorkspaceAdminHint allowed={canMutateIntegration}>
548+ <Button
549+ disabled={!canMutateIntegration}
550+ variant="outline"
551+ size="sm"
552+ onClick={() => setConfirmDelete(true)}
553+ className="border-destructive/30 text-destructive hover:bg-destructive/10"
554+ >
555+ Delete
556+ </Button>
557+ </WorkspaceAdminHint>
547558 ))}
548559 </div>
549560 </div>
packages/react/src/pages/integrations.tsx+17−2
@@ -8,6 +8,7 @@ import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/ · 7 unmodified lines
88 import { integrationsOptimisticAtom } from "../api/atoms";
99 import { trackEvent } from "../api/analytics";
1010 import { McpInstallCard } from "../components/mcp-install-card";
11+import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
1112 import { Button } from "../components/button";
1213 import { PageContainer, PageHeader } from "../components/page";
1314 import {
@@ -57,7 +58,7 @@ export function IntegrationsPage() { · 43 unmodified lines
5758 title="Integrations"
5859 description="Tool providers available in this workspace."
5960 actions={
60− canCreate && (
61+ canCreate ? (
6162 <Button asChild size="sm" className="gap-1.5">
6263 <Link
6364 to="/{-$orgSlug}/integrations/browse"
@@ -67,6 +68,13 @@ export function IntegrationsPage() { · 3 unmodified lines
6768 Add integration
6869 </Link>
6970 </Button>
71+ ) : (
72+ <WorkspaceAdminHint allowed={false}>
73+ <Button disabled size="sm">
74+ <PlusIcon className="size-4" />
75+ Add integration
76+ </Button>
77+ </WorkspaceAdminHint>
7078 )
7179 }
7280 />
@@ -118,7 +126,7 @@ function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) { · 45 unmodified lines
118126 ? "Connect an integration to start curating tools."
119127 : "Ask a workspace admin to add an integration."}
120128 </p>
121− {canCreate && (
129+ {canCreate ? (
122130 <Button asChild size="sm" className="gap-1.5">
123131 <Link
124132 to="/{-$orgSlug}/integrations/browse"
@@ -128,6 +136,13 @@ function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) { · 3 unmodified lines
128136 Add an integration
129137 </Link>
130138 </Button>
139+ ) : (
140+ <WorkspaceAdminHint allowed={false}>
141+ <Button disabled size="sm">
142+ <PlusIcon className="size-4" />
143+ Add an integration
144+ </Button>
145+ </WorkspaceAdminHint>
131146 )}
132147 </div>
133148 );