History

Align integration creation UI with admin permissions

Rhys Sullivan committed 1 month ago

Commit 695fd731 parent 3890d6f10 files · +274 −40Browse files

Unified · Split

10 files
  1. .changeset/fair-admin-integrations.md +5 −0
  2. e2e/cloud/integration-creation-permissions.test.ts +17 −0
  3. e2e/selfhost/integration-creation-permissions.test.ts +21 −0
  4. e2e/src/integration-creation-permissions.ts +115 −0
  5. packages/react/src/components/command-palette.tsx +13 −5
  6. packages/react/src/components/integration-creation-gate.tsx +36 −0
  7. packages/react/src/multiplayer/shell.tsx +15 −11
  8. packages/react/src/pages/integration-add.tsx +13 −3
  9. packages/react/src/pages/integration-browse.tsx +10 −0
  10. packages/react/src/pages/integrations.tsx +29 −21
.changeset/fair-admin-integrations.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/react": patch
3+---
4+
5+Hide integration creation controls from workspace members and explain the admin requirement on direct add links.
e2e/cloud/integration-creation-permissions.test.ts+17−0
@@ -0,0 +1,17 @@
1+import { Effect } from "effect";
2+import { scenario } from "../src/scenario";
3+import { Target } from "../src/services";
4+import { integrationCreationPermissions } from "../src/integration-creation-permissions";
5+import { forBrowser, joinOrg } from "./support/session";
6+
7+scenario(
8+ "Integration creation · cloud members see admin guidance and admins can add",
9+ { timeout: 180_000 },
10+ Effect.gen(function* () {
11+ const target = yield* Target;
12+ const admin = yield* target.newIdentity();
13+ const invitee = yield* target.newIdentity({ org: false });
14+ const member = yield* joinOrg(target, admin, invitee);
15+ yield* integrationCreationPermissions(forBrowser(admin), forBrowser(member));
16+ }),
17+);
e2e/selfhost/integration-creation-permissions.test.ts+21−0
@@ -0,0 +1,21 @@
1+import { Effect } from "effect";
2+import { scenario } from "../src/scenario";
3+import { Target } from "../src/services";
4+import { integrationCreationPermissions } from "../src/integration-creation-permissions";
5+import { createInvitedIdentity } from "../targets/selfhost";
6+
7+scenario(
8+ "Integration creation · self-host members see admin guidance and owners can add",
9+ { timeout: 180_000 },
10+ Effect.gen(function* () {
11+ const target = yield* Target;
12+ const admin = yield* target.newIdentity();
13+ const member = yield* Effect.promise(() =>
14+ createInvitedIdentity(target.baseUrl, admin, {
15+ role: "member",
16+ emailPrefix: "integration-permissions",
17+ }),
18+ );
19+ yield* integrationCreationPermissions(admin, member);
20+ }),
21+);
e2e/src/integration-creation-permissions.ts+115−0
@@ -0,0 +1,115 @@
1+import { randomBytes } from "node:crypto";
2+import { expect } from "@effect/vitest";
3+import { Effect } from "effect";
4+import { composePluginApi } from "@executor-js/api/server";
5+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
6+import { IntegrationSlug } from "@executor-js/sdk/shared";
7+
8+import { Api, Browser } from "./services";
9+import type { Identity } from "./target";
10+import { visit } from "./surfaces/browser";
11+
12+const api = composePluginApi([openApiHttpPlugin()] as const);
13+
14+/** Exercise integration creation and member restrictions through the shared console. */
15+export const integrationCreationPermissions = (admin: Identity, member: Identity) =>
16+ Effect.gen(function* () {
17+ const browser = yield* Browser;
18+ const { client } = yield* Api;
19+ const adminClient = yield* client(api, admin);
20+ const title = `Permissions API ${randomBytes(4).toString("hex")}`;
21+ const slug = IntegrationSlug.make(title.toLowerCase().replaceAll(" ", "_"));
22+ const spec = JSON.stringify({
23+ openapi: "3.0.3",
24+ info: { title, version: "1.0.0" },
25+ servers: [{ url: "https://api.example.com" }],
26+ paths: {},
27+ components: {
28+ securitySchemes: { apiKey: { type: "apiKey", in: "header", name: "X-API-Key" } },
29+ },
30+ security: [{ apiKey: [] }],
31+ });
32+
33+ yield* Effect.ensuring(
34+ Effect.gen(function* () {
35+ yield* browser.session(admin, async ({ page, step }) => {
36+ await step("Admin opens the integration catalog", async () => {
37+ await visit(page, "/");
38+ await page.getByRole("button", { name: "Browse integrations", exact: true }).waitFor();
39+ await page.keyboard.press("ControlOrMeta+k");
40+ await page.getByRole("option", { name: /^Add OpenAPI/ }).waitFor();
41+ await page.keyboard.press("Escape");
42+ await page.getByRole("link", { name: "Add integration", exact: true }).click();
43+ await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor();
44+ await page
45+ .getByRole("textbox", { name: "Search integrations, or paste a URL" })
46+ .waitFor();
47+ });
48+ await step("Admin creates an integration from the setup form", async () => {
49+ await visit(page, "/integrations/add/openapi");
50+ await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec);
51+ await page.getByRole("button", { name: "Add integration", exact: true }).click();
52+ await page.waitForURL((url) => url.pathname.endsWith(`/integrations/${slug}`), {
53+ timeout: 30_000,
54+ });
55+ await page.getByRole("button", { name: "Edit", exact: true }).waitFor();
56+ await page.getByRole("button", { name: "Delete", exact: true }).waitFor();
57+ });
58+ });
59+ expect(yield* adminClient.integrations.get({ params: { slug } })).toMatchObject({
60+ name: title,
61+ });
62+
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(
84+ "Member opens an existing integration and can add a personal connection",
85+ 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);
93+ },
94+ );
95+ for (const path of [
96+ "/integrations/browse",
97+ "/integrations/add/openapi",
98+ "/integrations/add/mcp",
99+ ]) {
100+ await step(`Member follows ${path} and sees the admin explanation`, async () => {
101+ await visit(page, path);
102+ await page.getByRole("heading", { name: "An admin must add integrations" }).waitFor();
103+ expect(await page.getByRole("textbox").count()).toBe(0);
104+ expect(await page.getByRole("button", { name: /^Add/ }).count()).toBe(0);
105+ });
106+ }
107+ await step("Member returns to their existing integrations", async () => {
108+ await page.getByRole("link", { name: "Back to integrations" }).click();
109+ await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor();
110+ });
111+ });
112+ }),
113+ adminClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
114+ );
115+ });
packages/react/src/components/command-palette.tsx+13−5
@@ -9,6 +9,7 @@ import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favi · 8 unmodified lines
99 import { PresetIcon } from "./preset-icon";
1010 import { integrationsOptimisticAtom } from "../api/atoms";
1111 import { useIntegrationPlugins } from "@executor-js/sdk/client";
12+import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
1213 import {
1314 CommandDialog,
1415 CommandEmpty,
@@ -34,6 +35,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 19 unmodified lines
3435 const integrationPlugins = useIntegrationPlugins();
3536 const navigate = useNavigate();
3637 const integrationsResult = useAtomValue(integrationsOptimisticAtom);
38+ const canCreateIntegration = useCanCreateWorkspaceConnections();
3739
3840 // Toggle with ⌘K / Ctrl+K
3941 useEffect(() => {
@@ -139,7 +141,11 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 99 unmodified lines
139141
140142 return (
141143 <CommandDialog open={open} onOpenChange={onOpenChange}>
142− <CommandInput placeholder="Search integrations or jump to add…" />
144+ <CommandInput
145+ placeholder={
146+ canCreateIntegration ? "Search integrations or jump to add…" : "Search integrations…"
147+ }
148+ />
143149 <CommandList>
144150 <CommandEmpty>No results found.</CommandEmpty>
145151
@@ -169,9 +175,11 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 23 unmodified lines
169175 </CommandGroup>
170176 )}
171177
172− {connectedIntegrations.length > 0 && integrationPlugins.length > 0 && <CommandSeparator />}
178+ {canCreateIntegration &&
179+ connectedIntegrations.length > 0 &&
180+ integrationPlugins.length > 0 && <CommandSeparator />}
173181
174− {integrationPlugins.length > 0 && (
182+ {canCreateIntegration && integrationPlugins.length > 0 && (
175183 <CommandGroup heading="Add integration">
176184 {integrationPlugins.map((plugin) => (
177185 <CommandItem
@@ -186,9 +194,9 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 8 unmodified lines
186194 </CommandGroup>
187195 )}
188196
189− {presetEntries.length > 0 && <CommandSeparator />}
197+ {canCreateIntegration && presetEntries.length > 0 && <CommandSeparator />}
190198
191− {presetEntries.length > 0 && (
199+ {canCreateIntegration && presetEntries.length > 0 && (
192200 <CommandGroup heading="Popular integrations">
193201 {presetEntries.map((e) => (
194202 <CommandItem
packages/react/src/components/integration-creation-gate.tsx+36−0
@@ -0,0 +1,36 @@
1+import type { ReactNode } from "react";
2+import { Link } from "@tanstack/react-router";
3+import { useAtomValue } from "@effect/atom-react";
4+
5+import { orgMembersAtom } from "../api/account-atoms";
6+import { isAsyncResultLoading } from "../lib/async-result";
7+import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
8+import { Button } from "./button";
9+import { PageContainer, PageHeader } from "./page";
10+import { Skeleton } from "./skeleton";
11+
12+/** Keep integration creation flows behind the same role gate as edit and delete. */
13+export function IntegrationCreationGate({ children }: { readonly children: ReactNode }) {
14+ const canCreate = useCanCreateWorkspaceConnections();
15+ const members = useAtomValue(orgMembersAtom);
16+ if (canCreate) return children;
17+ if (isAsyncResultLoading(members)) {
18+ return (
19+ <PageContainer>
20+ <Skeleton className="h-8 w-64" />
21+ </PageContainer>
22+ );
23+ }
24+
25+ return (
26+ <PageContainer>
27+ <PageHeader
28+ title="An admin must add integrations"
29+ description="Ask a workspace admin to add the integration. You can add personal connections to existing integrations."
30+ />
31+ <Button asChild variant="outline">
32+ <Link to="/{-$orgSlug}">Back to integrations</Link>
33+ </Button>
34+ </PageContainer>
35+ );
36+}
packages/react/src/multiplayer/shell.tsx+15−11
@@ -25,6 +25,7 @@ import { CommandPalette } from "../components/command-palette"; · 24 unmodified lines
2525 import { Wordmark } from "../components/wordmark";
2626 import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
2727 import { useAuth } from "./auth-context";
28+import { useCanCreateWorkspaceConnections } from "./use-admin-nav";
2829
2930 // ---------------------------------------------------------------------------
3031 // Shared multiplayer shell (cloud + self-host).
@@ -351,6 +352,7 @@ function SidebarContent( · 320 unmodified lines
351352 },
352353 ) {
353354 const plugins = useClientPlugins();
355+ const canCreateIntegration = useCanCreateWorkspaceConnections();
354356 const pluginNavItems = plugins.flatMap((plugin) =>
355357 (plugin.pages ?? []).flatMap((page) =>
356358 page.nav
@@ -385,17 +387,19 @@ function SidebarContent( · 28 unmodified lines
385387
386388 <div className="mt-5 mb-1 flex items-center justify-between px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
387389 <span>Integrations</span>
388− <Button
389− type="button"
390− variant="ghost"
391− size="icon-xs"
392− aria-label="Browse integrations"
393− title="Browse integrations"
394− onClick={props.onOpenIntegrationConnect}
395− className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"
396− >
397− <PlusIcon className="size-3.5" />
398− </Button>
390+ {canCreateIntegration && (
391+ <Button
392+ type="button"
393+ variant="ghost"
394+ size="icon-xs"
395+ aria-label="Browse integrations"
396+ title="Browse integrations"
397+ onClick={props.onOpenIntegrationConnect}
398+ className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"
399+ >
400+ <PlusIcon className="size-3.5" />
401+ </Button>
402+ )}
399403 </div>
400404
401405 <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />
packages/react/src/pages/integration-add.tsx+13−3
@@ -1,16 +1,27 @@
1−import { Suspense } from "react";
1+import { Suspense, type ComponentProps } from "react";
22 import { useAtomRefresh } from "@effect/atom-react";
33 import { Link, useNavigate } from "@tanstack/react-router";
44 import { useIntegrationPlugins } from "@executor-js/sdk/client";
55 import { integrationsOptimisticAtom } from "../api/atoms";
66 import { trackEvent } from "../api/analytics";
77 import { useExecutorDocumentTitle } from "../lib/document-title";
8+import { IntegrationCreationGate } from "../components/integration-creation-gate";
89
910 // ---------------------------------------------------------------------------
1011 // Page
1112 // ---------------------------------------------------------------------------
1213
13−export function AddIntegrationPage(props: {
14+/** Render an integration setup flow only when the workspace role permits creation. */
15+export function AddIntegrationPage(props: ComponentProps<typeof AddIntegrationContent>) {
16+ useExecutorDocumentTitle("Add integration");
17+ return (
18+ <IntegrationCreationGate>
19+ <AddIntegrationContent {...props} />
20+ </IntegrationCreationGate>
21+ );
22+}
23+
24+function AddIntegrationContent(props: {
1425 pluginKey: string;
1526 url?: string;
1627 preset?: string;
@@ -20,7 +31,6 @@ export function AddIntegrationPage(props: { · 3 unmodified lines
2031 authKind?: string;
2132 specOverrides?: string;
2233 }) {
23− useExecutorDocumentTitle("Add integration");
2434 const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } =
2535 props;
2636 const navigate = useNavigate();
packages/react/src/pages/integration-browse.tsx+10−0
@@ -27,6 +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";
3031 import {
3132 availableCatalogKinds,
3233 catalogLogoUrl,
@@ -362,8 +363,17 @@ function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { · 329 unmodified lines
362363 // Page
363364 // ---------------------------------------------------------------------------
364365
366+/** Render the integration catalog only when the workspace role permits creation. */
365367 export function IntegrationBrowsePage() {
366368 useExecutorDocumentTitle("Add an integration");
369+ return (
370+ <IntegrationCreationGate>
371+ <IntegrationBrowseContent />
372+ </IntegrationCreationGate>
373+ );
374+}
375+
376+function IntegrationBrowseContent() {
367377 const navigate = useNavigate();
368378 const integrationPlugins = useIntegrationPlugins();
369379 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });
packages/react/src/pages/integrations.tsx+29−21
@@ -32,6 +32,7 @@ import { Skeleton } from "../components/skeleton"; · 31 unmodified lines
3232 import { useExecutorDocumentTitle } from "../lib/document-title";
3333 import { ErrorState } from "../components/error-state";
3434 import { isAsyncResultLoading } from "../lib/async-result";
35+import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
3536
3637 const KIND_TO_PLUGIN_KEY: Record<string, string> = {
3738 openapi: "openapi",
@@ -48,6 +49,7 @@ export function IntegrationsPage() { · 10 unmodified lines
4849 useExecutorDocumentTitle("Integrations");
4950 const integrations = useAtomValue(integrationsOptimisticAtom);
5051 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
52+ const canCreate = useCanCreateWorkspaceConnections();
5153
5254 return (
5355 <PageContainer>
@@ -55,15 +57,17 @@ export function IntegrationsPage() { · 1 unmodified line
5557 title="Integrations"
5658 description="Tool providers available in this workspace."
5759 actions={
58− <Button asChild size="sm" className="gap-1.5">
59− <Link
60− to="/{-$orgSlug}/integrations/browse"
61− onClick={() => trackEvent("integration_browse_opened", { via: "header" })}
62− >
63− <PlusIcon className="size-4" />
64− Add integration
65− </Link>
66− </Button>
60+ canCreate && (
61+ <Button asChild size="sm" className="gap-1.5">
62+ <Link
63+ to="/{-$orgSlug}/integrations/browse"
64+ onClick={() => trackEvent("integration_browse_opened", { via: "header" })}
65+ >
66+ <PlusIcon className="size-4" />
67+ Add integration
68+ </Link>
69+ </Button>
70+ )
6771 }
6872 />
6973
@@ -83,7 +87,7 @@ export function IntegrationsPage() { · 13 unmodified lines
8387 ),
8488 onSuccess: ({ value }) => {
8589 if (value.length === 0) {
86− return <EmptyIntegrations />;
90+ return <EmptyIntegrations canCreate={canCreate} />;
8791 }
8892
8993 return (
@@ -102,7 +106,7 @@ export function IntegrationsPage() { · 12 unmodified lines
102106 // Empty state
103107 // ---------------------------------------------------------------------------
104108
105−function EmptyIntegrations() {
109+function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) {
106110 return (
107111 <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">
108112 <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
@@ -110,17 +114,21 @@ function EmptyIntegrations() { · 1 unmodified line
110114 </div>
111115 <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p>
112116 <p className="mb-5 text-[13px] text-muted-foreground/60">
113− Connect an integration to start curating tools.
117+ {canCreate
118+ ? "Connect an integration to start curating tools."
119+ : "Ask a workspace admin to add an integration."}
114120 </p>
115− <Button asChild size="sm" className="gap-1.5">
116− <Link
117− to="/{-$orgSlug}/integrations/browse"
118− onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })}
119− >
120− <PlusIcon className="size-4" />
121− Add an integration
122− </Link>
123− </Button>
121+ {canCreate && (
122+ <Button asChild size="sm" className="gap-1.5">
123+ <Link
124+ to="/{-$orgSlug}/integrations/browse"
125+ onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })}
126+ >
127+ <PlusIcon className="size-4" />
128+ Add an integration
129+ </Link>
130+ </Button>
131+ )}
124132 </div>
125133 );
126134 }