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 | import { PresetIcon } from "./preset-icon"; | 9 | import { PresetIcon } from "./preset-icon"; |
| 10 | import { integrationsOptimisticAtom } from "../api/atoms"; | 10 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 11 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; | 11 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 12 | import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | ||
| 12 | import { | 13 | import { |
| 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+K | 40 | // 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 | <CommandItem | 185 | <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 | <CommandItem | 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 | import { Wordmark } from "../components/wordmark"; | 25 | import { Wordmark } from "../components/wordmark"; |
| 26 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; | 26 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 27 | import { useAuth } from "./auth-context"; | 27 | import { useAuth } from "./auth-context"; |
| 28 | import { 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.nav | 358 | 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 | <Button | 390 | {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} /> |
| @@ -1,16 +1,27 @@ | |||
| 1 | import { Suspense } from "react"; | 1 | import { Suspense, type ComponentProps } from "react"; |
| 2 | import { useAtomRefresh } from "@effect/atom-react"; | 2 | import { useAtomRefresh } from "@effect/atom-react"; |
| 3 | import { Link, useNavigate } from "@tanstack/react-router"; | 3 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 4 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; | 4 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 5 | import { integrationsOptimisticAtom } from "../api/atoms"; | 5 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 6 | import { trackEvent } from "../api/analytics"; | 6 | import { trackEvent } from "../api/analytics"; |
| 7 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 7 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 8 | import { IntegrationCreationGate } from "../components/integration-creation-gate"; | ||
| 8 | 9 | ||
| 9 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 10 | // Page | 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 | 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(); |
| @@ -27,6 +27,7 @@ import { · 26 unmodified lines | |||
| 27 | } from "../components/integration-favicon"; | 27 | } from "../components/integration-favicon"; |
| 28 | import { Skeleton } from "../components/skeleton"; | 28 | import { Skeleton } from "../components/skeleton"; |
| 29 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 29 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 30 | import { IntegrationCreationGate } from "../components/integration-creation-gate"; | ||
| 30 | import { | 31 | import { |
| 31 | availableCatalogKinds, | 32 | availableCatalogKinds, |
| 32 | catalogLogoUrl, | 33 | catalogLogoUrl, |
| @@ -362,8 +363,17 @@ function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { · 329 unmodified lines | |||
| 362 | // Page | 363 | // Page |
| 363 | // --------------------------------------------------------------------------- | 364 | // --------------------------------------------------------------------------- |
| 364 | 365 | ||
| 366 | /** Render the integration catalog only when the workspace role permits creation. */ | ||
| 365 | export function IntegrationBrowsePage() { | 367 | export function IntegrationBrowsePage() { |
| 366 | useExecutorDocumentTitle("Add an integration"); | 368 | useExecutorDocumentTitle("Add an integration"); |
| 369 | return ( | ||
| 370 | <IntegrationCreationGate> | ||
| 371 | <IntegrationBrowseContent /> | ||
| 372 | </IntegrationCreationGate> | ||
| 373 | ); | ||
| 374 | } | ||
| 375 | |||
| 376 | function 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" }); |
| @@ -32,6 +32,7 @@ import { Skeleton } from "../components/skeleton"; · 31 unmodified lines | |||
| 32 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 32 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 33 | import { ErrorState } from "../components/error-state"; | 33 | import { ErrorState } from "../components/error-state"; |
| 34 | import { isAsyncResultLoading } from "../lib/async-result"; | 34 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 35 | import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | ||
| 35 | 36 | ||
| 36 | const KIND_TO_PLUGIN_KEY: Record<string, string> = { | 37 | const 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 | <Link | 61 | <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 integration | 66 | <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 state | 106 | // Empty state |
| 103 | // --------------------------------------------------------------------------- | 107 | // --------------------------------------------------------------------------- |
| 104 | 108 | ||
| 105 | function EmptyIntegrations() { | 109 | function 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 | <Link | 122 | <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 integration | 127 | <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 | } |