Unified · Split
| @@ -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. | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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 | + }); | |
| @@ -9,6 +9,7 @@ import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favi · 8 unmodified lines | ||
| 9 | 9 | import { PresetIcon } from "./preset-icon"; |
| 10 | 10 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 11 | 11 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 12 | +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | |
| 12 | 13 | import { |
| 13 | 14 | CommandDialog, |
| 14 | 15 | CommandEmpty, |
| @@ -34,6 +35,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 19 unmodified lines | ||
| 34 | 35 | const integrationPlugins = useIntegrationPlugins(); |
| 35 | 36 | const navigate = useNavigate(); |
| 36 | 37 | const integrationsResult = useAtomValue(integrationsOptimisticAtom); |
| 38 | + const canCreateIntegration = useCanCreateWorkspaceConnections(); | |
| 37 | 39 | |
| 38 | 40 | // Toggle with ⌘K / Ctrl+K |
| 39 | 41 | useEffect(() => { |
| @@ -139,7 +141,11 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 99 unmodified lines | ||
| 139 | 141 | |
| 140 | 142 | return ( |
| 141 | 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 | 149 | <CommandList> |
| 144 | 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 | 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 | 183 | <CommandGroup heading="Add integration"> |
| 176 | 184 | {integrationPlugins.map((plugin) => ( |
| 177 | 185 | <CommandItem |
| @@ -186,9 +194,9 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 8 unmodified lines | ||
| 186 | 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 | 200 | <CommandGroup heading="Popular integrations"> |
| 193 | 201 | {presetEntries.map((e) => ( |
| 194 | 202 | <CommandItem |
| @@ -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 | +} | |
| @@ -25,6 +25,7 @@ import { CommandPalette } from "../components/command-palette"; · 24 unmodified lines | ||
| 25 | 25 | import { Wordmark } from "../components/wordmark"; |
| 26 | 26 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 27 | 27 | import { useAuth } from "./auth-context"; |
| 28 | +import { useCanCreateWorkspaceConnections } from "./use-admin-nav"; | |
| 28 | 29 | |
| 29 | 30 | // --------------------------------------------------------------------------- |
| 30 | 31 | // Shared multiplayer shell (cloud + self-host). |
| @@ -351,6 +352,7 @@ function SidebarContent( · 320 unmodified lines | ||
| 351 | 352 | }, |
| 352 | 353 | ) { |
| 353 | 354 | const plugins = useClientPlugins(); |
| 355 | + const canCreateIntegration = useCanCreateWorkspaceConnections(); | |
| 354 | 356 | const pluginNavItems = plugins.flatMap((plugin) => |
| 355 | 357 | (plugin.pages ?? []).flatMap((page) => |
| 356 | 358 | page.nav |
| @@ -385,17 +387,19 @@ function SidebarContent( · 28 unmodified lines | ||
| 385 | 387 | |
| 386 | 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 | 389 | <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 | + )} | |
| 399 | 403 | </div> |
| 400 | 404 | |
| 401 | 405 | <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} /> |
| @@ -1,16 +1,27 @@ | ||
| 1 | −import { Suspense } from "react"; | |
| 1 | +import { Suspense, type ComponentProps } from "react"; | |
| 2 | 2 | import { useAtomRefresh } from "@effect/atom-react"; |
| 3 | 3 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 4 | 4 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 5 | 5 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 6 | 6 | import { trackEvent } from "../api/analytics"; |
| 7 | 7 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 8 | +import { IntegrationCreationGate } from "../components/integration-creation-gate"; | |
| 8 | 9 | |
| 9 | 10 | // --------------------------------------------------------------------------- |
| 10 | 11 | // Page |
| 11 | 12 | // --------------------------------------------------------------------------- |
| 12 | 13 | |
| 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: { | |
| 14 | 25 | pluginKey: string; |
| 15 | 26 | url?: string; |
| 16 | 27 | preset?: string; |
| @@ -20,7 +31,6 @@ export function AddIntegrationPage(props: { · 3 unmodified lines | ||
| 20 | 31 | authKind?: string; |
| 21 | 32 | specOverrides?: string; |
| 22 | 33 | }) { |
| 23 | − useExecutorDocumentTitle("Add integration"); | |
| 24 | 34 | const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } = |
| 25 | 35 | props; |
| 26 | 36 | const navigate = useNavigate(); |
| @@ -27,6 +27,7 @@ import { · 26 unmodified lines | ||
| 27 | 27 | } from "../components/integration-favicon"; |
| 28 | 28 | import { Skeleton } from "../components/skeleton"; |
| 29 | 29 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 30 | +import { IntegrationCreationGate } from "../components/integration-creation-gate"; | |
| 30 | 31 | import { |
| 31 | 32 | availableCatalogKinds, |
| 32 | 33 | catalogLogoUrl, |
| @@ -362,8 +363,17 @@ function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { · 329 unmodified lines | ||
| 362 | 363 | // Page |
| 363 | 364 | // --------------------------------------------------------------------------- |
| 364 | 365 | |
| 366 | +/** Render the integration catalog only when the workspace role permits creation. */ | |
| 365 | 367 | export function IntegrationBrowsePage() { |
| 366 | 368 | useExecutorDocumentTitle("Add an integration"); |
| 369 | + return ( | |
| 370 | + <IntegrationCreationGate> | |
| 371 | + <IntegrationBrowseContent /> | |
| 372 | + </IntegrationCreationGate> | |
| 373 | + ); | |
| 374 | +} | |
| 375 | + | |
| 376 | +function IntegrationBrowseContent() { | |
| 367 | 377 | const navigate = useNavigate(); |
| 368 | 378 | const integrationPlugins = useIntegrationPlugins(); |
| 369 | 379 | const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); |
| @@ -32,6 +32,7 @@ import { Skeleton } from "../components/skeleton"; · 31 unmodified lines | ||
| 32 | 32 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 33 | 33 | import { ErrorState } from "../components/error-state"; |
| 34 | 34 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 35 | +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | |
| 35 | 36 | |
| 36 | 37 | const KIND_TO_PLUGIN_KEY: Record<string, string> = { |
| 37 | 38 | openapi: "openapi", |
| @@ -48,6 +49,7 @@ export function IntegrationsPage() { · 10 unmodified lines | ||
| 48 | 49 | useExecutorDocumentTitle("Integrations"); |
| 49 | 50 | const integrations = useAtomValue(integrationsOptimisticAtom); |
| 50 | 51 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); |
| 52 | + const canCreate = useCanCreateWorkspaceConnections(); | |
| 51 | 53 | |
| 52 | 54 | return ( |
| 53 | 55 | <PageContainer> |
| @@ -55,15 +57,17 @@ export function IntegrationsPage() { · 1 unmodified line | ||
| 55 | 57 | title="Integrations" |
| 56 | 58 | description="Tool providers available in this workspace." |
| 57 | 59 | 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 | + ) | |
| 67 | 71 | } |
| 68 | 72 | /> |
| 69 | 73 | |
| @@ -83,7 +87,7 @@ export function IntegrationsPage() { · 13 unmodified lines | ||
| 83 | 87 | ), |
| 84 | 88 | onSuccess: ({ value }) => { |
| 85 | 89 | if (value.length === 0) { |
| 86 | − return <EmptyIntegrations />; | |
| 90 | + return <EmptyIntegrations canCreate={canCreate} />; | |
| 87 | 91 | } |
| 88 | 92 | |
| 89 | 93 | return ( |
| @@ -102,7 +106,7 @@ export function IntegrationsPage() { · 12 unmodified lines | ||
| 102 | 106 | // Empty state |
| 103 | 107 | // --------------------------------------------------------------------------- |
| 104 | 108 | |
| 105 | −function EmptyIntegrations() { | |
| 109 | +function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) { | |
| 106 | 110 | return ( |
| 107 | 111 | <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16"> |
| 108 | 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 | 114 | </div> |
| 111 | 115 | <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p> |
| 112 | 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 | 120 | </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 | + )} | |
| 124 | 132 | </div> |
| 125 | 133 | ); |
| 126 | 134 | } |