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
2"@executor-js/react": patch2"@executor-js/react": patch
3---3---
4 4
5Hide integration creation controls from workspace members and explain the admin requirement on direct add links.5Show 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
61 });61 });
62 62
63 yield* browser.session(member, async ({ page, step }) => {63 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 });
83 await step(64 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",
85 async () => {91 async () => {
86 await page.getByTestId(`integration-entry-${slug}`).click();92 await page.keyboard.press("ControlOrMeta+k");
87 await page.getByRole("button", { name: "Add connection", exact: true }).waitFor();93 const palette = page.getByRole("dialog");
88 expect(await page.getByRole("button", { name: /^(Edit|Delete)$/ }).count()).toBe(0);94 await palette.getByRole("option", { name: new RegExp(title) }).waitFor();
89 await page.getByRole("button", { name: "Add connection", exact: true }).click();95 const addCommand = palette.getByRole("option", { name: /^Add OpenAPI/ });
90 const dialog = page.getByRole("dialog");96 await addCommand.waitFor();
91 await dialog.waitFor();97 expect(await addCommand.getAttribute("aria-disabled")).toBe("true");
92 expect(await dialog.getByText("Workspace", { exact: true }).count()).toBe(0);98 expect(await addCommand.textContent()).toContain("Admin only");
99 await page.keyboard.press("Escape");
93 },100 },
94 );101 );
95 for (const path of [102 await step("Member sees disabled Edit and Delete actions", async () => {
96 "/integrations/browse",103 await page.getByTestId(`integration-entry-${slug}`).click();
97 "/integrations/add/openapi",104 await page.getByRole("button", { name: "Add connection", exact: true }).waitFor();
98 "/integrations/add/mcp",105 for (const name of ["Edit", "Delete"]) {
99 ]) {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"]) {
100 await step(`Member follows ${path} and sees the admin explanation`, async () => {149 await step(`Member follows ${path} and sees the admin explanation`, async () => {
101 await visit(page, path);150 await visit(page, path);
102 await page.getByRole("heading", { name: "An admin must add integrations" }).waitFor();151 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
141 141
142 return (142 return (
143 <CommandDialog open={open} onOpenChange={onOpenChange}>143 <CommandDialog open={open} onOpenChange={onOpenChange}>
144 <CommandInput144 <CommandInput placeholder="Search integrations or jump to add…" />
145 placeholder={
146 canCreateIntegration ? "Search integrations or jump to add…" : "Search integrations…"
147 }
148 />
149 <CommandList>145 <CommandList>
150 <CommandEmpty>No results found.</CommandEmpty>146 <CommandEmpty>No results found.</CommandEmpty>
151 147
@@ -175,32 +171,33 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 23 unmodified lines
175 </CommandGroup>171 </CommandGroup>
176 )}172 )}
177 173
178 {canCreateIntegration &&174 {connectedIntegrations.length > 0 && integrationPlugins.length > 0 && <CommandSeparator />}
179 connectedIntegrations.length > 0 &&
180 integrationPlugins.length > 0 && <CommandSeparator />}
181 175
182 {canCreateIntegration && integrationPlugins.length > 0 && (176 {integrationPlugins.length > 0 && (
183 <CommandGroup heading="Add integration">177 <CommandGroup heading="Add integration">
184 {integrationPlugins.map((plugin) => (178 {integrationPlugins.map((plugin) => (
185 <CommandItem179 <CommandItem
186 key={`add-${plugin.key}`}180 key={`add-${plugin.key}`}
181 disabled={!canCreateIntegration}
187 value={`add ${plugin.label} ${plugin.key}`}182 value={`add ${plugin.label} ${plugin.key}`}
188 onSelect={() => goToAdd(plugin.key)}183 onSelect={() => goToAdd(plugin.key)}
189 >184 >
190 <PlusIcon />185 <PlusIcon />
191 <span className="flex-1 truncate">Add {plugin.label}</span>186 <span className="flex-1 truncate">Add {plugin.label}</span>
187 {!canCreateIntegration && <CommandShortcut>Admin only</CommandShortcut>}
192 </CommandItem>188 </CommandItem>
193 ))}189 ))}
194 </CommandGroup>190 </CommandGroup>
195 )}191 )}
196 192
197 {canCreateIntegration && presetEntries.length > 0 && <CommandSeparator />}193 {presetEntries.length > 0 && <CommandSeparator />}
198 194
199 {canCreateIntegration && presetEntries.length > 0 && (195 {presetEntries.length > 0 && (
200 <CommandGroup heading="Popular integrations">196 <CommandGroup heading="Popular integrations">
201 {presetEntries.map((e) => (197 {presetEntries.map((e) => (
202 <CommandItem198 <CommandItem
203 key={`preset-${e.pluginKey}-${e.presetId}`}199 key={`preset-${e.pluginKey}-${e.presetId}`}
200 disabled={!canCreateIntegration}
204 value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`}201 value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`}
205 onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)}202 onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)}
206 >203 >
@@ -216,7 +213,9 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 9 unmodified lines
216 }213 }
217 />214 />
218 <span className="flex-1 truncate">{e.presetName}</span>215 <span className="flex-1 truncate">{e.presetName}</span>
219 <CommandShortcut>{e.pluginLabel}</CommandShortcut>216 <CommandShortcut>
217 {canCreateIntegration ? e.pluginLabel : "Admin only"}
218 </CommandShortcut>
220 </CommandItem>219 </CommandItem>
221 ))}220 ))}
222 </CommandGroup>221 </CommandGroup>
packages/react/src/components/workspace-admin-hint.tsx+29−0
@@ -0,0 +1,29 @@
1import type { ReactNode } from "react";
2import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
3
4/** Explain a disabled workspace action on hover or keyboard focus. */
5export 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
6import type { Integration } from "@executor-js/sdk/shared";6import type { Integration } from "@executor-js/sdk/shared";
7import { integrationsOptimisticAtom } from "../api/atoms";7import { integrationsOptimisticAtom } from "../api/atoms";
8import { trackEvent } from "../api/analytics";8import { trackEvent } from "../api/analytics";
9import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
9import { Button } from "../components/button";10import { Button } from "../components/button";
10import { Skeleton } from "../components/skeleton";11import { Skeleton } from "../components/skeleton";
11import { SidebarUpdateCard } from "../components/update-card";12import { SidebarUpdateCard } from "../components/update-card";
@@ -387,11 +388,12 @@ function SidebarContent( · 375 unmodified lines
387 388
388 <div className="mt-5 mb-1 flex items-center justify-between px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">389 <div className="mt-5 mb-1 flex items-center justify-between px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
389 <span>Integrations</span>390 <span>Integrations</span>
390 {canCreateIntegration && (391 <WorkspaceAdminHint allowed={canCreateIntegration}>
391 <Button392 <Button
392 type="button"393 type="button"
393 variant="ghost"394 variant="ghost"
394 size="icon-xs"395 size="icon-xs"
396 disabled={!canCreateIntegration}
395 aria-label="Browse integrations"397 aria-label="Browse integrations"
396 title="Browse integrations"398 title="Browse integrations"
397 onClick={props.onOpenIntegrationConnect}399 onClick={props.onOpenIntegrationConnect}
@@ -399,7 +401,7 @@ function SidebarContent( · 1 unmodified line
399 >401 >
400 <PlusIcon className="size-3.5" />402 <PlusIcon className="size-3.5" />
401 </Button>403 </Button>
402 )}404 </WorkspaceAdminHint>
403 </div>405 </div>
404 406
405 <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />407 <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />
packages/react/src/pages/integration-browse.tsx+15−15
@@ -27,7 +27,7 @@ import { · 26 unmodified lines
27} from "../components/integration-favicon";27} from "../components/integration-favicon";
28import { Skeleton } from "../components/skeleton";28import { Skeleton } from "../components/skeleton";
29import { useExecutorDocumentTitle } from "../lib/document-title";29import { useExecutorDocumentTitle } from "../lib/document-title";
30import { IntegrationCreationGate } from "../components/integration-creation-gate";30import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
31import {31import {
32 availableCatalogKinds,32 availableCatalogKinds,
33 catalogLogoUrl,33 catalogLogoUrl,
@@ -245,7 +245,7 @@ function RowIcon(props: { readonly src?: string; readonly alt: string }) { · 211 unmodified lines
245 );245 );
246}246}
247 247
248function ResultCard(props: { readonly row: Row }) {248function ResultCard(props: { readonly row: Row; readonly canCreate: boolean }) {
249 const { row } = props;249 const { row } = props;
250 return (250 return (
251 <div251 <div
@@ -276,6 +276,7 @@ function ResultCard(props: { readonly row: Row }) { · 24 unmodified lines
276 variant="outline"276 variant="outline"
277 size="sm"277 size="sm"
278 onClick={row.onSelect}278 onClick={row.onSelect}
279 disabled={!props.canCreate}
279 // Every card's button reads "Add", so the visible label alone is280 // Every card's button reads "Add", so the visible label alone is
280 // useless to a screen reader; the accessible name carries the card.281 // useless to a screen reader; the accessible name carries the card.
281 aria-label={`Add ${row.title}`}282 aria-label={`Add ${row.title}`}
@@ -363,17 +364,10 @@ function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { · 81 unmodified lines
363// Page364// Page
364// ---------------------------------------------------------------------------365// ---------------------------------------------------------------------------
365 366
366/** Render the integration catalog only when the workspace role permits creation. */367/** Let members browse integrations while reserving creation for workspace admins. */
367export function IntegrationBrowsePage() {368export function IntegrationBrowsePage() {
368 useExecutorDocumentTitle("Add an integration");369 useExecutorDocumentTitle("Add an integration");
369 return (370 const canCreate = useCanCreateWorkspaceConnections();
370 <IntegrationCreationGate>
371 <IntegrationBrowseContent />
372 </IntegrationCreationGate>
373 );
374}
375
376function IntegrationBrowseContent() {
377 const navigate = useNavigate();371 const navigate = useNavigate();
378 const integrationPlugins = useIntegrationPlugins();372 const integrationPlugins = useIntegrationPlugins();
379 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });373 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });
@@ -933,6 +927,11 @@ function IntegrationBrowseContent() { · 553 unmodified lines
933 description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint."927 description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint."
934 />928 />
935 929
930 {!canCreate && (
931 <p className="mb-4 text-sm text-muted-foreground">
932 Requires a workspace admin to add integrations.
933 </p>
934 )}
936 <div className="mb-4 flex gap-2">935 <div className="mb-4 flex gap-2">
937 <div className="relative min-w-0 flex-1">936 <div className="relative min-w-0 flex-1">
938 <SearchIcon937 <SearchIcon
@@ -947,7 +946,7 @@ function IntegrationBrowseContent() { · 8 unmodified lines
947 setError(null);946 setError(null);
948 }}947 }}
949 onKeyDown={(event) => {948 onKeyDown={(event) => {
950 if (event.key === "Enter" && isUrl) void handleDetect();949 if (event.key === "Enter" && isUrl && canCreate) void handleDetect();
951 }}950 }}
952 placeholder="Search integrations, or paste a URL…"951 placeholder="Search integrations, or paste a URL…"
953 aria-label="Search integrations, or paste a URL"952 aria-label="Search integrations, or paste a URL"
@@ -961,7 +960,7 @@ function IntegrationBrowseContent() { · 7 unmodified lines
961 <Button960 <Button
962 className="h-11 shrink-0"961 className="h-11 shrink-0"
963 onClick={() => void handleDetect()}962 onClick={() => void handleDetect()}
964 disabled={detecting || query.trim().length === 0}963 disabled={!canCreate || detecting || query.trim().length === 0}
965 loading={detecting}964 loading={detecting}
966 >965 >
967 Add this URL966 Add this URL
@@ -983,6 +982,7 @@ function IntegrationBrowseContent() { · 15 unmodified lines
983 key={plugin.key}982 key={plugin.key}
984 type="button"983 type="button"
985 aria-label={`New ${plugin.label} integration from scratch`}984 aria-label={`New ${plugin.label} integration from scratch`}
985 disabled={!canCreate}
986 onClick={() => {986 onClick={() => {
987 trackEvent("integration_add_started", {987 trackEvent("integration_add_started", {
988 plugin_key: plugin.key,988 plugin_key: plugin.key,
@@ -993,7 +993,7 @@ function IntegrationBrowseContent() { · 4 unmodified lines
993 params: { pluginKey: plugin.key },993 params: { pluginKey: plugin.key },
994 });994 });
995 }}995 }}
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"
997 >997 >
998 <PlusIcon className="size-3" aria-hidden />998 <PlusIcon className="size-3" aria-hidden />
999 {plugin.label}999 {plugin.label}
@@ -1030,7 +1030,7 @@ function IntegrationBrowseContent() { · 30 unmodified lines
1030 ) : (1030 ) : (
1031 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">1031 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
1032 {results.map((row) => (1032 {results.map((row) => (
1033 <ResultCard key={row.key} row={row} />1033 <ResultCard key={row.key} row={row} canCreate={canCreate} />
1034 ))}1034 ))}
1035 {catalog.loadingMore1035 {catalog.loadingMore
1036 ? Array.from({ length: 3 }, (_, index) => (1036 ? 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
37import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs";37import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs";
38import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements";38import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements";
39import { usePolicyActions } from "../hooks/use-policy-actions";39import { usePolicyActions } from "../hooks/use-policy-actions";
40import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
40import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";41import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
41import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";42import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";
42import { Button } from "../components/button";43import { Button } from "../components/button";
@@ -139,11 +140,11 @@ export function IntegrationDetailPage(props: { · 96 unmodified lines
139 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";140 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
140 const currentTab = isBuiltInIntegration ? "tools" : activeTab;141 const currentTab = isBuiltInIntegration ? "tools" : activeTab;
141 // Integrations are workspace-owned; the server refuses catalog mutations142 // 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.
143 const canMutateIntegration = useCanCreateWorkspaceConnections();144 const canMutateIntegration = useCanCreateWorkspaceConnections();
144 const canEdit = canMutateIntegration && !isBuiltInIntegration && integrationData !== null;145 const canEdit = !isBuiltInIntegration && integrationData !== null;
145 const canRefresh = integrationData?.canRefresh ?? false;146 const canRefresh = integrationData?.canRefresh ?? false;
146 const canRemove = canMutateIntegration && (integrationData?.canRemove ?? false);147 const canRemove = integrationData?.canRemove ?? false;
147 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {148 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {
148 const search = new URLSearchParams(locationSearch);149 const search = new URLSearchParams(locationSearch);
149 // The route-validated flag and the raw `addAccount=1` are the same request;150 // The route-validated flag and the raw `addAccount=1` are the same request;
@@ -499,9 +500,16 @@ export function IntegrationDetailPage(props: { · 349 unmodified lines
499 500
500 <div className="flex shrink-0 items-center gap-2">501 <div className="flex shrink-0 items-center gap-2">
501 {!confirmDelete && canEdit && (502 {!confirmDelete && canEdit && (
502 <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>503 <WorkspaceAdminHint allowed={canMutateIntegration}>
503 Edit504 <Button
504 </Button>505 disabled={!canMutateIntegration}
506 variant="outline"
507 size="sm"
508 onClick={() => setEditSheetOpen(true)}
509 >
510 Edit
511 </Button>
512 </WorkspaceAdminHint>
505 )}513 )}
506 514
507 {canRefresh && (515 {canRefresh && (
@@ -530,20 +538,23 @@ export function IntegrationDetailPage(props: { · 22 unmodified lines
530 variant="destructive"538 variant="destructive"
531 size="sm"539 size="sm"
532 onClick={() => void handleDelete()}540 onClick={() => void handleDelete()}
533 disabled={deleting}541 disabled={deleting || !canMutateIntegration}
534 >542 >
535 {deleting ? "Deleting..." : "Confirm Delete"}543 {deleting ? "Deleting..." : "Confirm Delete"}
536 </Button>544 </Button>
537 </div>545 </div>
538 ) : (546 ) : (
539 <Button547 <WorkspaceAdminHint allowed={canMutateIntegration}>
540 variant="outline"548 <Button
541 size="sm"549 disabled={!canMutateIntegration}
542 onClick={() => setConfirmDelete(true)}550 variant="outline"
543 className="border-destructive/30 text-destructive hover:bg-destructive/10"551 size="sm"
544 >552 onClick={() => setConfirmDelete(true)}
545 Delete553 className="border-destructive/30 text-destructive hover:bg-destructive/10"
546 </Button>554 >
555 Delete
556 </Button>
557 </WorkspaceAdminHint>
547 ))}558 ))}
548 </div>559 </div>
549 </div>560 </div>
packages/react/src/pages/integrations.tsx+17−2
@@ -8,6 +8,7 @@ import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/ · 7 unmodified lines
8import { integrationsOptimisticAtom } from "../api/atoms";8import { integrationsOptimisticAtom } from "../api/atoms";
9import { trackEvent } from "../api/analytics";9import { trackEvent } from "../api/analytics";
10import { McpInstallCard } from "../components/mcp-install-card";10import { McpInstallCard } from "../components/mcp-install-card";
11import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
11import { Button } from "../components/button";12import { Button } from "../components/button";
12import { PageContainer, PageHeader } from "../components/page";13import { PageContainer, PageHeader } from "../components/page";
13import {14import {
@@ -57,7 +58,7 @@ export function IntegrationsPage() { · 43 unmodified lines
57 title="Integrations"58 title="Integrations"
58 description="Tool providers available in this workspace."59 description="Tool providers available in this workspace."
59 actions={60 actions={
60 canCreate && (61 canCreate ? (
61 <Button asChild size="sm" className="gap-1.5">62 <Button asChild size="sm" className="gap-1.5">
62 <Link63 <Link
63 to="/{-$orgSlug}/integrations/browse"64 to="/{-$orgSlug}/integrations/browse"
@@ -67,6 +68,13 @@ export function IntegrationsPage() { · 3 unmodified lines
67 Add integration68 Add integration
68 </Link>69 </Link>
69 </Button>70 </Button>
71 ) : (
72 <WorkspaceAdminHint allowed={false}>
73 <Button disabled size="sm">
74 <PlusIcon className="size-4" />
75 Add integration
76 </Button>
77 </WorkspaceAdminHint>
70 )78 )
71 }79 }
72 />80 />
@@ -118,7 +126,7 @@ function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) { · 45 unmodified lines
118 ? "Connect an integration to start curating tools."126 ? "Connect an integration to start curating tools."
119 : "Ask a workspace admin to add an integration."}127 : "Ask a workspace admin to add an integration."}
120 </p>128 </p>
121 {canCreate && (129 {canCreate ? (
122 <Button asChild size="sm" className="gap-1.5">130 <Button asChild size="sm" className="gap-1.5">
123 <Link131 <Link
124 to="/{-$orgSlug}/integrations/browse"132 to="/{-$orgSlug}/integrations/browse"
@@ -128,6 +136,13 @@ function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) { · 3 unmodified lines
128 Add an integration136 Add an integration
129 </Link>137 </Link>
130 </Button>138 </Button>
139 ) : (
140 <WorkspaceAdminHint allowed={false}>
141 <Button disabled size="sm">
142 <PlusIcon className="size-4" />
143 Add an integration
144 </Button>
145 </WorkspaceAdminHint>
131 )}146 )}
132 </div>147 </div>
133 );148 );