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
5Hide 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 @@
1import { Effect } from "effect";
2import { scenario } from "../src/scenario";
3import { Target } from "../src/services";
4import { integrationCreationPermissions } from "../src/integration-creation-permissions";
5import { forBrowser, joinOrg } from "./support/session";
6
7scenario(
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 @@
1import { Effect } from "effect";
2import { scenario } from "../src/scenario";
3import { Target } from "../src/services";
4import { integrationCreationPermissions } from "../src/integration-creation-permissions";
5import { createInvitedIdentity } from "../targets/selfhost";
6
7scenario(
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 @@
1import { randomBytes } from "node:crypto";
2import { expect } from "@effect/vitest";
3import { Effect } from "effect";
4import { composePluginApi } from "@executor-js/api/server";
5import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
6import { IntegrationSlug } from "@executor-js/sdk/shared";
7
8import { Api, Browser } from "./services";
9import type { Identity } from "./target";
10import { visit } from "./surfaces/browser";
11
12const api = composePluginApi([openApiHttpPlugin()] as const);
13
14/** Exercise integration creation and member restrictions through the shared console. */
15export 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
9import { PresetIcon } from "./preset-icon";9import { PresetIcon } from "./preset-icon";
10import { integrationsOptimisticAtom } from "../api/atoms";10import { integrationsOptimisticAtom } from "../api/atoms";
11import { useIntegrationPlugins } from "@executor-js/sdk/client";11import { useIntegrationPlugins } from "@executor-js/sdk/client";
12import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
12import {13import {
13 CommandDialog,14 CommandDialog,
14 CommandEmpty,15 CommandEmpty,
@@ -34,6 +35,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 19 unmodified lines
34 const integrationPlugins = useIntegrationPlugins();35 const integrationPlugins = useIntegrationPlugins();
35 const navigate = useNavigate();36 const navigate = useNavigate();
36 const integrationsResult = useAtomValue(integrationsOptimisticAtom);37 const integrationsResult = useAtomValue(integrationsOptimisticAtom);
38 const canCreateIntegration = useCanCreateWorkspaceConnections();
37 39
38 // Toggle with ⌘K / Ctrl+K40 // Toggle with ⌘K / Ctrl+K
39 useEffect(() => {41 useEffect(() => {
@@ -139,7 +141,11 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 99 unmodified lines
139 141
140 return (142 return (
141 <CommandDialog open={open} onOpenChange={onOpenChange}>143 <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 />
143 <CommandList>149 <CommandList>
144 <CommandEmpty>No results found.</CommandEmpty>150 <CommandEmpty>No results found.</CommandEmpty>
145 151
@@ -169,9 +175,11 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 23 unmodified lines
169 </CommandGroup>175 </CommandGroup>
170 )}176 )}
171 177
172 {connectedIntegrations.length > 0 && integrationPlugins.length > 0 && <CommandSeparator />}178 {canCreateIntegration &&
179 connectedIntegrations.length > 0 &&
180 integrationPlugins.length > 0 && <CommandSeparator />}
173 181
174 {integrationPlugins.length > 0 && (182 {canCreateIntegration && integrationPlugins.length > 0 && (
175 <CommandGroup heading="Add integration">183 <CommandGroup heading="Add integration">
176 {integrationPlugins.map((plugin) => (184 {integrationPlugins.map((plugin) => (
177 <CommandItem185 <CommandItem
@@ -186,9 +194,9 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 8 unmodified lines
186 </CommandGroup>194 </CommandGroup>
187 )}195 )}
188 196
189 {presetEntries.length > 0 && <CommandSeparator />}197 {canCreateIntegration && presetEntries.length > 0 && <CommandSeparator />}
190 198
191 {presetEntries.length > 0 && (199 {canCreateIntegration && presetEntries.length > 0 && (
192 <CommandGroup heading="Popular integrations">200 <CommandGroup heading="Popular integrations">
193 {presetEntries.map((e) => (201 {presetEntries.map((e) => (
194 <CommandItem202 <CommandItem
packages/react/src/components/integration-creation-gate.tsx+36−0
@@ -0,0 +1,36 @@
1import type { ReactNode } from "react";
2import { Link } from "@tanstack/react-router";
3import { useAtomValue } from "@effect/atom-react";
4
5import { orgMembersAtom } from "../api/account-atoms";
6import { isAsyncResultLoading } from "../lib/async-result";
7import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
8import { Button } from "./button";
9import { PageContainer, PageHeader } from "./page";
10import { Skeleton } from "./skeleton";
11
12/** Keep integration creation flows behind the same role gate as edit and delete. */
13export 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
25import { Wordmark } from "../components/wordmark";25import { Wordmark } from "../components/wordmark";
26import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";26import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
27import { useAuth } from "./auth-context";27import { useAuth } from "./auth-context";
28import { useCanCreateWorkspaceConnections } from "./use-admin-nav";
28 29
29// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
30// Shared multiplayer shell (cloud + self-host).31// Shared multiplayer shell (cloud + self-host).
@@ -351,6 +352,7 @@ function SidebarContent( · 320 unmodified lines
351 },352 },
352) {353) {
353 const plugins = useClientPlugins();354 const plugins = useClientPlugins();
355 const canCreateIntegration = useCanCreateWorkspaceConnections();
354 const pluginNavItems = plugins.flatMap((plugin) =>356 const pluginNavItems = plugins.flatMap((plugin) =>
355 (plugin.pages ?? []).flatMap((page) =>357 (plugin.pages ?? []).flatMap((page) =>
356 page.nav358 page.nav
@@ -385,17 +387,19 @@ function SidebarContent( · 28 unmodified lines
385 387
386 <div className="mt-5 mb-1 flex items-center justify-between px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">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">
387 <span>Integrations</span>389 <span>Integrations</span>
388 <Button390 {canCreateIntegration && (
389 type="button"391 <Button
390 variant="ghost"392 type="button"
391 size="icon-xs"393 variant="ghost"
392 aria-label="Browse integrations"394 size="icon-xs"
393 title="Browse integrations"395 aria-label="Browse integrations"
394 onClick={props.onOpenIntegrationConnect}396 title="Browse integrations"
395 className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"397 onClick={props.onOpenIntegrationConnect}
396 >398 className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"
397 <PlusIcon className="size-3.5" />399 >
398 </Button>400 <PlusIcon className="size-3.5" />
401 </Button>
402 )}
399 </div>403 </div>
400 404
401 <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />405 <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />
packages/react/src/pages/integration-add.tsx+13−3
@@ -1,16 +1,27 @@
1import { Suspense } from "react";1import { Suspense, type ComponentProps } from "react";
2import { useAtomRefresh } from "@effect/atom-react";2import { useAtomRefresh } from "@effect/atom-react";
3import { Link, useNavigate } from "@tanstack/react-router";3import { Link, useNavigate } from "@tanstack/react-router";
4import { useIntegrationPlugins } from "@executor-js/sdk/client";4import { useIntegrationPlugins } from "@executor-js/sdk/client";
5import { integrationsOptimisticAtom } from "../api/atoms";5import { integrationsOptimisticAtom } from "../api/atoms";
6import { trackEvent } from "../api/analytics";6import { trackEvent } from "../api/analytics";
7import { useExecutorDocumentTitle } from "../lib/document-title";7import { useExecutorDocumentTitle } from "../lib/document-title";
8import { IntegrationCreationGate } from "../components/integration-creation-gate";
8 9
9// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
10// Page11// Page
11// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
12 13
13export function AddIntegrationPage(props: {14/** Render an integration setup flow only when the workspace role permits creation. */
15export function AddIntegrationPage(props: ComponentProps<typeof AddIntegrationContent>) {
16 useExecutorDocumentTitle("Add integration");
17 return (
18 <IntegrationCreationGate>
19 <AddIntegrationContent {...props} />
20 </IntegrationCreationGate>
21 );
22}
23
24function AddIntegrationContent(props: {
14 pluginKey: string;25 pluginKey: string;
15 url?: string;26 url?: string;
16 preset?: string;27 preset?: string;
@@ -20,7 +31,6 @@ export function AddIntegrationPage(props: { · 3 unmodified lines
20 authKind?: string;31 authKind?: string;
21 specOverrides?: string;32 specOverrides?: string;
22}) {33}) {
23 useExecutorDocumentTitle("Add integration");
24 const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } =34 const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } =
25 props;35 props;
26 const navigate = useNavigate();36 const navigate = useNavigate();
packages/react/src/pages/integration-browse.tsx+10−0
@@ -27,6 +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 {31import {
31 availableCatalogKinds,32 availableCatalogKinds,
32 catalogLogoUrl,33 catalogLogoUrl,
@@ -362,8 +363,17 @@ function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { · 329 unmodified lines
362// Page363// Page
363// ---------------------------------------------------------------------------364// ---------------------------------------------------------------------------
364 365
366/** Render the integration catalog only when the workspace role permits creation. */
365export function IntegrationBrowsePage() {367export function IntegrationBrowsePage() {
366 useExecutorDocumentTitle("Add an integration");368 useExecutorDocumentTitle("Add an integration");
369 return (
370 <IntegrationCreationGate>
371 <IntegrationBrowseContent />
372 </IntegrationCreationGate>
373 );
374}
375
376function IntegrationBrowseContent() {
367 const navigate = useNavigate();377 const navigate = useNavigate();
368 const integrationPlugins = useIntegrationPlugins();378 const integrationPlugins = useIntegrationPlugins();
369 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });379 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
32import { useExecutorDocumentTitle } from "../lib/document-title";32import { useExecutorDocumentTitle } from "../lib/document-title";
33import { ErrorState } from "../components/error-state";33import { ErrorState } from "../components/error-state";
34import { isAsyncResultLoading } from "../lib/async-result";34import { isAsyncResultLoading } from "../lib/async-result";
35import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
35 36
36const KIND_TO_PLUGIN_KEY: Record<string, string> = {37const KIND_TO_PLUGIN_KEY: Record<string, string> = {
37 openapi: "openapi",38 openapi: "openapi",
@@ -48,6 +49,7 @@ export function IntegrationsPage() { · 10 unmodified lines
48 useExecutorDocumentTitle("Integrations");49 useExecutorDocumentTitle("Integrations");
49 const integrations = useAtomValue(integrationsOptimisticAtom);50 const integrations = useAtomValue(integrationsOptimisticAtom);
50 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);51 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
52 const canCreate = useCanCreateWorkspaceConnections();
51 53
52 return (54 return (
53 <PageContainer>55 <PageContainer>
@@ -55,15 +57,17 @@ export function IntegrationsPage() { · 1 unmodified line
55 title="Integrations"57 title="Integrations"
56 description="Tool providers available in this workspace."58 description="Tool providers available in this workspace."
57 actions={59 actions={
58 <Button asChild size="sm" className="gap-1.5">60 canCreate && (
59 <Link61 <Button asChild size="sm" className="gap-1.5">
60 to="/{-$orgSlug}/integrations/browse"62 <Link
61 onClick={() => trackEvent("integration_browse_opened", { via: "header" })}63 to="/{-$orgSlug}/integrations/browse"
62 >64 onClick={() => trackEvent("integration_browse_opened", { via: "header" })}
63 <PlusIcon className="size-4" />65 >
64 Add integration66 <PlusIcon className="size-4" />
65 </Link>67 Add integration
66 </Button>68 </Link>
69 </Button>
70 )
67 }71 }
68 />72 />
69 73
@@ -83,7 +87,7 @@ export function IntegrationsPage() { · 13 unmodified lines
83 ),87 ),
84 onSuccess: ({ value }) => {88 onSuccess: ({ value }) => {
85 if (value.length === 0) {89 if (value.length === 0) {
86 return <EmptyIntegrations />;90 return <EmptyIntegrations canCreate={canCreate} />;
87 }91 }
88 92
89 return (93 return (
@@ -102,7 +106,7 @@ export function IntegrationsPage() { · 12 unmodified lines
102// Empty state106// Empty state
103// ---------------------------------------------------------------------------107// ---------------------------------------------------------------------------
104 108
105function EmptyIntegrations() {109function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) {
106 return (110 return (
107 <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">111 <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">
108 <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">112 <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
110 </div>114 </div>
111 <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p>115 <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p>
112 <p className="mb-5 text-[13px] text-muted-foreground/60">116 <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."}
114 </p>120 </p>
115 <Button asChild size="sm" className="gap-1.5">121 {canCreate && (
116 <Link122 <Button asChild size="sm" className="gap-1.5">
117 to="/{-$orgSlug}/integrations/browse"123 <Link
118 onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })}124 to="/{-$orgSlug}/integrations/browse"
119 >125 onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })}
120 <PlusIcon className="size-4" />126 >
121 Add an integration127 <PlusIcon className="size-4" />
122 </Link>128 Add an integration
123 </Button>129 </Link>
130 </Button>
131 )}
124 </div>132 </div>
125 );133 );
126}134}