History

Merge remote-tracking branch 'origin/main' into fix/database-connection-safeguards

Rhys Sullivan committed 1 month ago

Commit 66a82a12 parents 4fb6ddd 2e5aa1618 files · +551 −68Browse files

Unified · Split

18 files
  1. .changeset/fair-admin-integrations.md +5 −0
  2. .changeset/oauth-discovered-scope-budget.md +5 −0
  3. e2e/cloud/integration-creation-permissions.test.ts +17 −0
  4. e2e/selfhost/integration-creation-permissions.test.ts +21 −0
  5. e2e/src/integration-creation-permissions.ts +164 −0
  6. packages/core/sdk/src/executor.ts +25 −2
  7. packages/core/sdk/src/health-check.ts +1 −0
  8. packages/core/sdk/src/oauth-flow.test.ts +67 −0
  9. packages/core/sdk/src/oauth-scope-union.test.ts +38 −7
  10. packages/core/sdk/src/oauth-service.ts +20 −3
  11. packages/react/src/components/command-palette.tsx +8 −1
  12. packages/react/src/components/integration-creation-gate.tsx +36 −0
  13. packages/react/src/components/workspace-admin-hint.tsx +29 −0
  14. packages/react/src/multiplayer/shell.tsx +17 −11
  15. packages/react/src/pages/integration-add.tsx +13 −3
  16. packages/react/src/pages/integration-browse.tsx +15 −5
  17. packages/react/src/pages/integration-detail.tsx +26 −15
  18. packages/react/src/pages/integrations.tsx +44 −21
.changeset/fair-admin-integrations.md+5−0
@@ -0,0 +1,5 @@
1---
2"@executor-js/react": patch
3---
4
5Show restricted integration actions as disabled controls with an admin explanation. Members can browse the catalog and add personal connections to existing integrations.
.changeset/oauth-discovered-scope-budget.md+5−0
@@ -0,0 +1,5 @@
1---
2"@executor-js/sdk": patch
3---
4
5Request every scope a resource advertises during OAuth scope discovery, bounded by an 8 KiB scope-string budget instead of a 100-scope count. Resources with many fine-grained scopes previously received a token missing the ones it needed. Health checks without a probe no longer replace a tool-sync failure verdict with "healthy".
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+164−0
@@ -0,0 +1,164 @@
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(
65 "Member sees disabled creation controls with an admin explanation",
66 async () => {
67 await visit(page, "/");
68 await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor();
69 await page.getByTestId(`integration-entry-${slug}`).waitFor();
70 const add = page.getByRole("button", { name: "Add integration", exact: true });
71 await add.waitFor();
72 expect(await add.isDisabled()).toBe(true);
73 expect(
74 await page
75 .getByRole("button", { name: "Browse integrations", exact: true })
76 .isDisabled(),
77 ).toBe(true);
78 const hint = page
79 .getByRole("group", { name: "Requires a workspace admin" })
80 .filter({ has: add });
81 await hint.hover();
82 await page.getByRole("tooltip", { name: "Requires a workspace admin" }).waitFor();
83 await hint.focus();
84 const before = page.url();
85 await page.keyboard.press("Enter");
86 expect(page.url()).toBe(before);
87 },
88 );
89 await step(
90 "Member sees disabled add commands and can still find existing integrations",
91 async () => {
92 await page.keyboard.press("ControlOrMeta+k");
93 const palette = page.getByRole("dialog");
94 await palette.getByRole("option", { name: new RegExp(title) }).waitFor();
95 const addCommand = palette.getByRole("option", { name: /^Add OpenAPI/ });
96 await addCommand.waitFor();
97 expect(await addCommand.getAttribute("aria-disabled")).toBe("true");
98 expect(await addCommand.textContent()).toContain("Admin only");
99 await page.keyboard.press("Escape");
100 },
101 );
102 await step("Member sees disabled Edit and Delete actions", async () => {
103 await page.getByTestId(`integration-entry-${slug}`).click();
104 await page.getByRole("button", { name: "Add connection", exact: true }).waitFor();
105 for (const name of ["Edit", "Delete"]) {
106 const action = page.getByRole("button", { name, exact: true });
107 await action.waitFor();
108 expect(await action.isDisabled()).toBe(true);
109 }
110 });
111 await step("Member can still add a personal connection", async () => {
112 await page.getByRole("button", { name: "Add connection", exact: true }).click();
113 const dialog = page.getByRole("dialog");
114 await dialog.waitFor();
115 expect(await dialog.getByText("Workspace", { exact: true }).count()).toBe(0);
116 });
117 await step("Member browses the catalog with disabled Add buttons", async () => {
118 await visit(page, "/integrations/browse");
119 await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor();
120 await page
121 .getByText("Requires a workspace admin to add integrations.", { exact: true })
122 .waitFor();
123 const addButtons = page.getByRole("button", { name: /^Add / });
124 await addButtons.first().waitFor();
125 for (const button of await addButtons.all())
126 expect(await button.isDisabled()).toBe(true);
127 const scratch = page.getByRole("button", {
128 name: "New OpenAPI integration from scratch",
129 exact: true,
130 });
131 expect(await scratch.isDisabled()).toBe(true);
132 const view = page.getByRole("link", { name: `View ${title}`, exact: true });
133 await view.waitFor();
134 expect(await view.isEnabled()).toBe(true);
135 });
136 await step("Member cannot add a URL with the button or Enter key", async () => {
137 const input = page.getByRole("textbox", {
138 name: "Search integrations, or paste a URL",
139 });
140 await input.fill("https://api.example.com/openapi.json");
141 expect(
142 await page.getByRole("button", { name: "Add this URL", exact: true }).isDisabled(),
143 ).toBe(true);
144 const before = page.url();
145 await input.press("Enter");
146 expect(page.url()).toBe(before);
147 });
148 for (const path of ["/integrations/add/openapi", "/integrations/add/mcp"]) {
149 await step(`Member follows ${path} and sees the admin explanation`, async () => {
150 await visit(page, path);
151 await page.getByRole("heading", { name: "An admin must add integrations" }).waitFor();
152 expect(await page.getByRole("textbox").count()).toBe(0);
153 expect(await page.getByRole("button", { name: /^Add/ }).count()).toBe(0);
154 });
155 }
156 await step("Member returns to their existing integrations", async () => {
157 await page.getByRole("link", { name: "Back to integrations" }).click();
158 await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor();
159 });
160 });
161 }),
162 adminClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
163 );
164 });
packages/core/sdk/src/executor.ts+25−2
@@ -3605,7 +3605,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3604 unmodified lines
3605 ? { tools_synced_at: Date.now() }3605 ? { tools_synced_at: Date.now() }
3606 : {3606 : {
3607 tools_synced_at: Date.now(),3607 tools_synced_at: Date.now(),
3608 last_health: health ?? toolSyncHealth(reason),3608 // A plugin-supplied verdict (e.g. the MCP server
3609 // rejecting the token during discovery) is still
3610 // sync-stamped: mark it so credential-only health
3611 // checks cannot bury it under "healthy", and a
3612 // later successful sync clears it.
3613 last_health:
3614 health === undefined
3615 ? toolSyncHealth(reason)
3616 : { ...health, reason: health.reason ?? "tool_sync_failed" },
3609 updated_at: new Date(),3617 updated_at: new Date(),
3610 },3618 },
3611 })3619 })
@@ -5138,7 +5146,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1526 unmodified lines
5138 ): Effect.Effect<void> =>5146 ): Effect.Effect<void> =>
5139 findConnectionRow(ref).pipe(5147 findConnectionRow(ref).pipe(
5140 Effect.flatMap((fresh) =>5148 Effect.flatMap((fresh) =>
5141 fresh === null || oauthReauthRequiredFromProviderState(fresh.provider_state) !== null5149 fresh === null ||
5150 oauthReauthRequiredFromProviderState(fresh.provider_state) !== null ||
5151 // A credential verdict cannot refute a failed tool sync; only a
5152 // successful sync clears that record (see `isToolSyncHealth`).
5153 isToolSyncHealth(Option.getOrNull(decodeLastHealth(fresh.last_health)))
5142 ? Effect.void5154 ? Effect.void
5143 : persistHealthResult(ref, fresh, result),5155 : persistHealthResult(ref, fresh, result),
5144 ),5156 ),
@@ -5241,6 +5253,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 96 unmodified lines
5241 // failure is the one real signal this path can produce, and it5253 // failure is the one real signal this path can produce, and it
5242 // must not hide inside a green span.5254 // must not hide inside a green span.
5243 oauthCredentialHealthWithoutProbe(connectionRow).pipe(5255 oauthCredentialHealthWithoutProbe(connectionRow).pipe(
5256 // A resolvable token says nothing about whether the
5257 // upstream accepts it. When tool sync has already recorded
5258 // that it does not (a rejected discovery handshake, an
5259 // unreachable server), that verdict stands until a sync
5260 // succeeds — serving "healthy" here would hide a connection
5261 // that has no tools behind a green badge.
5262 Effect.map((result) =>
5263 result.status === "healthy" && previous !== null && isToolSyncHealth(previous)
5264 ? previous
5265 : result,
5266 ),
5244 Effect.tap((result) => persistProbeHealthResult(ref, result)),5267 Effect.tap((result) => persistProbeHealthResult(ref, result)),
5245 Effect.map((result) => ({5268 Effect.map((result) => ({
5246 source: "credential_only" as const,5269 source: "credential_only" as const,
packages/core/sdk/src/health-check.ts+1−0
@@ -145,6 +145,7 @@ export type HealthCheckResult = typeof HealthCheckResult.Type; · 144 unmodified lines
145export const toolSyncHealthDetailPrefix = "Tool sync failing";145export const toolSyncHealthDetailPrefix = "Tool sync failing";
146 146
147export const isToolSyncHealth = (result: HealthCheckResult | null | undefined): boolean =>147export const isToolSyncHealth = (result: HealthCheckResult | null | undefined): boolean =>
148 result?.reason === "tool_sync_failed" ||
148 result?.detail?.startsWith(toolSyncHealthDetailPrefix) === true;149 result?.detail?.startsWith(toolSyncHealthDetailPrefix) === true;
149 150
150// ---------------------------------------------------------------------------151// ---------------------------------------------------------------------------
packages/core/sdk/src/oauth-flow.test.ts+67−0
@@ -2033,6 +2033,73 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 2032 unmodified lines
2033 ),2033 ),
2034 );2034 );
2035 2035
2036 it.effect(
2037 "checkHealth without a probe serves a sync-stamped verdict instead of burying it under healthy",
2038 () =>
2039 Effect.scoped(
2040 Effect.gen(function* () {
2041 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2042 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
2043 yield* executor.acme.seed();
2044
2045 yield* executor.oauth.createClient({
2046 owner: "org",
2047 slug: CLIENT,
2048 authorizationUrl: server.authorizationEndpoint,
2049 tokenUrl: server.tokenEndpoint,
2050 grant: "authorization_code",
2051 clientId: "test-client",
2052 clientSecret: "test-secret",
2053 resource: server.mcpResourceUrl,
2054 });
2055
2056 const started = yield* executor.oauth.start({
2057 owner: "org",
2058 client: CLIENT,
2059 clientOwner: "org",
2060 name: ConnectionName.make("main"),
2061 integration: INTEG,
2062 template: TEMPLATE,
2063 });
2064 expect(started.status).toBe("redirect");
2065 if (started.status !== "redirect") return;
2066 const callback = yield* server.completeAuthorizationCodeFlow({
2067 authorizationUrl: started.authorizationUrl,
2068 });
2069 yield* executor.oauth.complete({ state: started.state, code: callback.code });
2070
2071 // Tool sync found the upstream rejecting the freshly minted token
2072 // (e.g. an MCP discovery handshake answering 401) and stamped it.
2073 // The token itself still resolves, so a credential-only check would
2074 // otherwise report healthy and hide a connection that has no tools.
2075 const stamped = {
2076 status: "expired",
2077 checkedAt: Date.now(),
2078 detail: "MCP OAuth reauthorization required",
2079 reason: "tool_sync_failed",
2080 };
2081 yield* Effect.promise(() =>
2082 config.db.updateMany("connection", {
2083 where: (b) => b("name", "=", "main"),
2084 set: { last_health: stamped },
2085 }),
2086 );
2087
2088 const result = yield* executor.connections.checkHealth({
2089 owner: "org",
2090 integration: INTEG,
2091 name: ConnectionName.make("main"),
2092 });
2093 expect(result).toMatchObject(stamped);
2094
2095 const row = yield* Effect.promise(() =>
2096 config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }),
2097 );
2098 expect(row?.last_health).toMatchObject(stamped);
2099 }),
2100 ),
2101 );
2102
2036 it.effect("records missing authorization-code scopes without blocking the connection", () =>2103 it.effect("records missing authorization-code scopes without blocking the connection", () =>
2037 Effect.scoped(2104 Effect.scoped(
2038 Effect.gen(function* () {2105 Effect.gen(function* () {
packages/core/sdk/src/oauth-scope-union.test.ts+38−7
@@ -713,13 +713,42 @@ describe("oauth.start integration-driven scopes", () => { · 712 unmodified lines
713 ),713 ),
714 );714 );
715 715
716 it.effect("(j) caps server-advertised resource scopes so the authorize URL stays bounded", () =>716 it.effect("(j) requests every advertised scope of a large but realistic resource list", () =>
717 Effect.scoped(717 Effect.scoped(
718 Effect.gen(function* () {718 Effect.gen(function* () {
719 // A hostile/buggy server advertises far more scopes than any real719 // A fine-grained resource can legitimately advertise well over a
720 // template. Discovery caps the request at 100 so the authorize URL720 // hundred scopes (PostHog lists 150). Dropping any of them mints a
721 // cannot be blown up.721 // token the resource rejects, so the whole list must be requested.
722 const manyScopes = Array.from({ length: 200 }, (_, i) => `scope:${i}`);722 const manyScopes = Array.from(
723 { length: 150 },
724 (_, i) => `resource_${i}:${i % 2 === 0 ? "read" : "write"}`,
725 );
726 const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } });
727 const executor = yield* setupMcpScopeClient(server);
728
729 const started = yield* executor.oauth.start({
730 owner: "org",
731 client: CLIENT,
732 clientOwner: "org",
733 name: ConnectionName.make("main"),
734 integration: INTEG,
735 template: TEMPLATE,
736 });
737 expect(started.status).toBe("redirect");
738 if (started.status !== "redirect") return;
739
740 expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(manyScopes);
741 }),
742 ),
743 );
744
745 it.effect("(j2) caps server-advertised resource scopes so the authorize URL stays bounded", () =>
746 Effect.scoped(
747 Effect.gen(function* () {
748 // A hostile/buggy server advertises an absurd list. Discovery keeps
749 // the longest leading prefix whose joined `scope` value fits the
750 // 8 KiB budget so the authorize URL cannot be blown up.
751 const manyScopes = Array.from({ length: 2000 }, (_, i) => `scope:${i}`);
723 const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } });752 const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } });
724 const executor = yield* setupMcpScopeClient(server);753 const executor = yield* setupMcpScopeClient(server);
725 754
@@ -735,8 +764,10 @@ describe("oauth.start integration-driven scopes", () => { · 9 unmodified lines
735 if (started.status !== "redirect") return;764 if (started.status !== "redirect") return;
736 765
737 const requested = scopesFromAuthorizeUrl(started.authorizationUrl);766 const requested = scopesFromAuthorizeUrl(started.authorizationUrl);
738 expect(requested.length).toBe(100);767 expect(requested.length).toBeLessThan(manyScopes.length);
739 expect(requested).toEqual(manyScopes.slice(0, 100));768 expect(requested).toEqual(manyScopes.slice(0, requested.length));
769 expect(requested.join(" ").length).toBeLessThanOrEqual(8192);
770 expect([...requested, manyScopes[requested.length]].join(" ").length).toBeGreaterThan(8192);
740 }),771 }),
741 ),772 ),
742 );773 );
packages/core/sdk/src/oauth-service.ts+20−3
@@ -782,9 +782,26 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 781 unmodified lines
782 // Caps on server-controlled discovery input — a hostile or buggy server must782 // Caps on server-controlled discovery input — a hostile or buggy server must
783 // not be able to hang `oauth.start` or overflow the authorize URL.783 // not be able to hang `oauth.start` or overflow the authorize URL.
784 const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice784 const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice
785 const MAX_DISCOVERED_SCOPES = 100; // far beyond any realistic authorization template785 // The cap is on the encoded `scope` parameter's length, not the scope
786 const capScopes = (scopes: readonly string[]): readonly string[] =>786 // count: the URL is what overflows, and a real resource can legitimately
787 dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES);787 // advertise well over a hundred fine-grained scopes (PostHog lists 150).
788 // Dropping any advertised scope silently mints a token the resource then
789 // rejects, so the budget is generous — 8 KiB leaves room for the rest of the
790 // authorize URL under the common 8-16 KiB request-line limits — and only an
791 // absurd list is truncated.
792 const MAX_DISCOVERED_SCOPE_CHARS = 8192;
793 const capScopes = (scopes: readonly string[]): readonly string[] => {
794 const unique = dedupeScopes(scopes);
795 let length = 0;
796 let count = 0;
797 for (const scope of unique) {
798 const next = length + scope.length + (count > 0 ? 1 : 0);
799 if (next > MAX_DISCOVERED_SCOPE_CHARS) break;
800 length = next;
801 count += 1;
802 }
803 return unique.slice(0, count);
804 };
788 805
789 // Bound a whole discovery sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS806 // Bound a whole discovery sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS
790 // fetches, each with its own request timeout). 30s is larger than a single807 // fetches, each with its own request timeout). 30s is larger than a single
packages/react/src/components/command-palette.tsx+8−1
@@ -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(() => {
@@ -176,11 +178,13 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 136 unmodified lines
176 {integrationPlugins.map((plugin) => (178 {integrationPlugins.map((plugin) => (
177 <CommandItem179 <CommandItem
178 key={`add-${plugin.key}`}180 key={`add-${plugin.key}`}
181 disabled={!canCreateIntegration}
179 value={`add ${plugin.label} ${plugin.key}`}182 value={`add ${plugin.label} ${plugin.key}`}
180 onSelect={() => goToAdd(plugin.key)}183 onSelect={() => goToAdd(plugin.key)}
181 >184 >
182 <PlusIcon />185 <PlusIcon />
183 <span className="flex-1 truncate">Add {plugin.label}</span>186 <span className="flex-1 truncate">Add {plugin.label}</span>
187 {!canCreateIntegration && <CommandShortcut>Admin only</CommandShortcut>}
184 </CommandItem>188 </CommandItem>
185 ))}189 ))}
186 </CommandGroup>190 </CommandGroup>
@@ -193,6 +197,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 6 unmodified lines
193 {presetEntries.map((e) => (197 {presetEntries.map((e) => (
194 <CommandItem198 <CommandItem
195 key={`preset-${e.pluginKey}-${e.presetId}`}199 key={`preset-${e.pluginKey}-${e.presetId}`}
200 disabled={!canCreateIntegration}
196 value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`}201 value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`}
197 onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)}202 onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)}
198 >203 >
@@ -208,7 +213,9 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 9 unmodified lines
208 }213 }
209 />214 />
210 <span className="flex-1 truncate">{e.presetName}</span>215 <span className="flex-1 truncate">{e.presetName}</span>
211 <CommandShortcut>{e.pluginLabel}</CommandShortcut>216 <CommandShortcut>
217 {canCreateIntegration ? e.pluginLabel : "Admin only"}
218 </CommandShortcut>
212 </CommandItem>219 </CommandItem>
213 ))}220 ))}
214 </CommandGroup>221 </CommandGroup>
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/components/workspace-admin-hint.tsx+29−0
@@ -0,0 +1,29 @@
1import type { ReactNode } from "react";
2import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
3
4/** Explain a disabled workspace action on hover or keyboard focus. */
5export function WorkspaceAdminHint(props: {
6 readonly allowed: boolean;
7 readonly children: ReactNode;
8}) {
9 if (props.allowed) return props.children;
10 return (
11 <TooltipProvider>
12 <Tooltip>
13 <TooltipTrigger asChild>
14 <span
15 tabIndex={0}
16 role="group"
17 aria-label="Requires a workspace admin"
18 className="inline-flex"
19 >
20 {props.children}
21 </span>
22 </TooltipTrigger>
23 <TooltipContent side="bottom" sideOffset={6}>
24 Requires a workspace admin
25 </TooltipContent>
26 </Tooltip>
27 </TooltipProvider>
28 );
29}
packages/react/src/multiplayer/shell.tsx+17−11
@@ -6,6 +6,7 @@ import { BookOpen, Command, ExternalLink, PlusIcon } from "lucide-react"; · 5 unmodified lines
6import type { Integration } from "@executor-js/sdk/shared";6import type { Integration } from "@executor-js/sdk/shared";
7import { integrationsOptimisticAtom } from "../api/atoms";7import { integrationsOptimisticAtom } from "../api/atoms";
8import { trackEvent } from "../api/analytics";8import { trackEvent } from "../api/analytics";
9import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
9import { Button } from "../components/button";10import { Button } from "../components/button";
10import { Skeleton } from "../components/skeleton";11import { Skeleton } from "../components/skeleton";
11import { SidebarUpdateCard } from "../components/update-card";12import { SidebarUpdateCard } from "../components/update-card";
@@ -25,6 +26,7 @@ import { CommandPalette } from "../components/command-palette"; · 13 unmodified lines
25import { Wordmark } from "../components/wordmark";26import { Wordmark } from "../components/wordmark";
26import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";27import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
27import { useAuth } from "./auth-context";28import { useAuth } from "./auth-context";
29import { useCanCreateWorkspaceConnections } from "./use-admin-nav";
28 30
29// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
30// Shared multiplayer shell (cloud + self-host).32// Shared multiplayer shell (cloud + self-host).
@@ -351,6 +353,7 @@ function SidebarContent( · 320 unmodified lines
351 },353 },
352) {354) {
353 const plugins = useClientPlugins();355 const plugins = useClientPlugins();
356 const canCreateIntegration = useCanCreateWorkspaceConnections();
354 const pluginNavItems = plugins.flatMap((plugin) =>357 const pluginNavItems = plugins.flatMap((plugin) =>
355 (plugin.pages ?? []).flatMap((page) =>358 (plugin.pages ?? []).flatMap((page) =>
356 page.nav359 page.nav
@@ -385,17 +388,20 @@ function SidebarContent( · 28 unmodified lines
385 388
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">389 <div className="mt-5 mb-1 flex items-center justify-between px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
387 <span>Integrations</span>390 <span>Integrations</span>
388 <Button391 <WorkspaceAdminHint allowed={canCreateIntegration}>
389 type="button"392 <Button
390 variant="ghost"393 type="button"
391 size="icon-xs"394 variant="ghost"
392 aria-label="Browse integrations"395 size="icon-xs"
393 title="Browse integrations"396 disabled={!canCreateIntegration}
394 onClick={props.onOpenIntegrationConnect}397 aria-label="Browse integrations"
395 className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"398 title="Browse integrations"
396 >399 onClick={props.onOpenIntegrationConnect}
397 <PlusIcon className="size-3.5" />400 className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"
398 </Button>401 >
402 <PlusIcon className="size-3.5" />
403 </Button>
404 </WorkspaceAdminHint>
399 </div>405 </div>
400 406
401 <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />407 <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+15−5
@@ -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 { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
30import {31import {
31 availableCatalogKinds,32 availableCatalogKinds,
32 catalogLogoUrl,33 catalogLogoUrl,
@@ -244,7 +245,7 @@ function RowIcon(props: { readonly src?: string; readonly alt: string }) { · 211 unmodified lines
244 );245 );
245}246}
246 247
247function ResultCard(props: { readonly row: Row }) {248function ResultCard(props: { readonly row: Row; readonly canCreate: boolean }) {
248 const { row } = props;249 const { row } = props;
249 return (250 return (
250 <div251 <div
@@ -275,6 +276,7 @@ function ResultCard(props: { readonly row: Row }) { · 24 unmodified lines
275 variant="outline"276 variant="outline"
276 size="sm"277 size="sm"
277 onClick={row.onSelect}278 onClick={row.onSelect}
279 disabled={!props.canCreate}
278 // Every card's button reads "Add", so the visible label alone is280 // Every card's button reads "Add", so the visible label alone is
279 // useless to a screen reader; the accessible name carries the card.281 // useless to a screen reader; the accessible name carries the card.
280 aria-label={`Add ${row.title}`}282 aria-label={`Add ${row.title}`}
@@ -362,8 +364,10 @@ function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { · 81 unmodified lines
362// Page364// Page
363// ---------------------------------------------------------------------------365// ---------------------------------------------------------------------------
364 366
367/** Let members browse integrations while reserving creation for workspace admins. */
365export function IntegrationBrowsePage() {368export function IntegrationBrowsePage() {
366 useExecutorDocumentTitle("Add an integration");369 useExecutorDocumentTitle("Add an integration");
370 const canCreate = useCanCreateWorkspaceConnections();
367 const navigate = useNavigate();371 const navigate = useNavigate();
368 const integrationPlugins = useIntegrationPlugins();372 const integrationPlugins = useIntegrationPlugins();
369 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });373 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });
@@ -923,6 +927,11 @@ export function IntegrationBrowsePage() { · 553 unmodified lines
923 description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint."927 description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint."
924 />928 />
925 929
930 {!canCreate && (
931 <p className="mb-4 text-sm text-muted-foreground">
932 Requires a workspace admin to add integrations.
933 </p>
934 )}
926 <div className="mb-4 flex gap-2">935 <div className="mb-4 flex gap-2">
927 <div className="relative min-w-0 flex-1">936 <div className="relative min-w-0 flex-1">
928 <SearchIcon937 <SearchIcon
@@ -937,7 +946,7 @@ export function IntegrationBrowsePage() { · 8 unmodified lines
937 setError(null);946 setError(null);
938 }}947 }}
939 onKeyDown={(event) => {948 onKeyDown={(event) => {
940 if (event.key === "Enter" && isUrl) void handleDetect();949 if (event.key === "Enter" && isUrl && canCreate) void handleDetect();
941 }}950 }}
942 placeholder="Search integrations, or paste a URL…"951 placeholder="Search integrations, or paste a URL…"
943 aria-label="Search integrations, or paste a URL"952 aria-label="Search integrations, or paste a URL"
@@ -951,7 +960,7 @@ export function IntegrationBrowsePage() { · 7 unmodified lines
951 <Button960 <Button
952 className="h-11 shrink-0"961 className="h-11 shrink-0"
953 onClick={() => void handleDetect()}962 onClick={() => void handleDetect()}
954 disabled={detecting || query.trim().length === 0}963 disabled={!canCreate || detecting || query.trim().length === 0}
955 loading={detecting}964 loading={detecting}
956 >965 >
957 Add this URL966 Add this URL
@@ -973,6 +982,7 @@ export function IntegrationBrowsePage() { · 15 unmodified lines
973 key={plugin.key}982 key={plugin.key}
974 type="button"983 type="button"
975 aria-label={`New ${plugin.label} integration from scratch`}984 aria-label={`New ${plugin.label} integration from scratch`}
985 disabled={!canCreate}
976 onClick={() => {986 onClick={() => {
977 trackEvent("integration_add_started", {987 trackEvent("integration_add_started", {
978 plugin_key: plugin.key,988 plugin_key: plugin.key,
@@ -983,7 +993,7 @@ export function IntegrationBrowsePage() { · 4 unmodified lines
983 params: { pluginKey: plugin.key },993 params: { pluginKey: plugin.key },
984 });994 });
985 }}995 }}
986 className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"996 className="disabled:opacity-50 disabled:pointer-events-none inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
987 >997 >
988 <PlusIcon className="size-3" aria-hidden />998 <PlusIcon className="size-3" aria-hidden />
989 {plugin.label}999 {plugin.label}
@@ -1020,7 +1030,7 @@ export function IntegrationBrowsePage() { · 30 unmodified lines
1020 ) : (1030 ) : (
1021 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">1031 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
1022 {results.map((row) => (1032 {results.map((row) => (
1023 <ResultCard key={row.key} row={row} />1033 <ResultCard key={row.key} row={row} canCreate={canCreate} />
1024 ))}1034 ))}
1025 {catalog.loadingMore1035 {catalog.loadingMore
1026 ? Array.from({ length: 3 }, (_, index) => (1036 ? Array.from({ length: 3 }, (_, index) => (
packages/react/src/pages/integration-detail.tsx+26−15
@@ -37,6 +37,7 @@ import { IntegrationEditSheet } from "../components/metadata-edit-sheet"; · 36 unmodified lines
37import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs";37import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs";
38import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements";38import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements";
39import { usePolicyActions } from "../hooks/use-policy-actions";39import { usePolicyActions } from "../hooks/use-policy-actions";
40import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
40import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";41import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
41import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";42import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";
42import { Button } from "../components/button";43import { Button } from "../components/button";
@@ -139,11 +140,11 @@ export function IntegrationDetailPage(props: { · 96 unmodified lines
139 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";140 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
140 const currentTab = isBuiltInIntegration ? "tools" : activeTab;141 const currentTab = isBuiltInIntegration ? "tools" : activeTab;
141 // Integrations are workspace-owned; the server refuses catalog mutations142 // Integrations are workspace-owned; the server refuses catalog mutations
142 // (update/remove) from non-admin members, so hide the controls for them.143 // (update/remove) from non-admin members, so disable the controls for them.
143 const canMutateIntegration = useCanCreateWorkspaceConnections();144 const canMutateIntegration = useCanCreateWorkspaceConnections();
144 const canEdit = canMutateIntegration && !isBuiltInIntegration && integrationData !== null;145 const canEdit = !isBuiltInIntegration && integrationData !== null;
145 const canRefresh = integrationData?.canRefresh ?? false;146 const canRefresh = integrationData?.canRefresh ?? false;
146 const canRemove = canMutateIntegration && (integrationData?.canRemove ?? false);147 const canRemove = integrationData?.canRemove ?? false;
147 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {148 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {
148 const search = new URLSearchParams(locationSearch);149 const search = new URLSearchParams(locationSearch);
149 // The route-validated flag and the raw `addAccount=1` are the same request;150 // The route-validated flag and the raw `addAccount=1` are the same request;
@@ -499,9 +500,16 @@ export function IntegrationDetailPage(props: { · 349 unmodified lines
499 500
500 <div className="flex shrink-0 items-center gap-2">501 <div className="flex shrink-0 items-center gap-2">
501 {!confirmDelete && canEdit && (502 {!confirmDelete && canEdit && (
502 <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>503 <WorkspaceAdminHint allowed={canMutateIntegration}>
503 Edit504 <Button
504 </Button>505 disabled={!canMutateIntegration}
506 variant="outline"
507 size="sm"
508 onClick={() => setEditSheetOpen(true)}
509 >
510 Edit
511 </Button>
512 </WorkspaceAdminHint>
505 )}513 )}
506 514
507 {canRefresh && (515 {canRefresh && (
@@ -530,20 +538,23 @@ export function IntegrationDetailPage(props: { · 22 unmodified lines
530 variant="destructive"538 variant="destructive"
531 size="sm"539 size="sm"
532 onClick={() => void handleDelete()}540 onClick={() => void handleDelete()}
533 disabled={deleting}541 disabled={deleting || !canMutateIntegration}
534 >542 >
535 {deleting ? "Deleting..." : "Confirm Delete"}543 {deleting ? "Deleting..." : "Confirm Delete"}
536 </Button>544 </Button>
537 </div>545 </div>
538 ) : (546 ) : (
539 <Button547 <WorkspaceAdminHint allowed={canMutateIntegration}>
540 variant="outline"548 <Button
541 size="sm"549 disabled={!canMutateIntegration}
542 onClick={() => setConfirmDelete(true)}550 variant="outline"
543 className="border-destructive/30 text-destructive hover:bg-destructive/10"551 size="sm"
544 >552 onClick={() => setConfirmDelete(true)}
545 Delete553 className="border-destructive/30 text-destructive hover:bg-destructive/10"
546 </Button>554 >
555 Delete
556 </Button>
557 </WorkspaceAdminHint>
547 ))}558 ))}
548 </div>559 </div>
549 </div>560 </div>
packages/react/src/pages/integrations.tsx+44−21
@@ -8,6 +8,7 @@ import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/ · 7 unmodified lines
8import { integrationsOptimisticAtom } from "../api/atoms";8import { integrationsOptimisticAtom } from "../api/atoms";
9import { trackEvent } from "../api/analytics";9import { trackEvent } from "../api/analytics";
10import { McpInstallCard } from "../components/mcp-install-card";10import { McpInstallCard } from "../components/mcp-install-card";
11import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
11import { Button } from "../components/button";12import { Button } from "../components/button";
12import { PageContainer, PageHeader } from "../components/page";13import { PageContainer, PageHeader } from "../components/page";
13import {14import {
@@ -32,6 +33,7 @@ import { Skeleton } from "../components/skeleton"; · 18 unmodified lines
32import { useExecutorDocumentTitle } from "../lib/document-title";33import { useExecutorDocumentTitle } from "../lib/document-title";
33import { ErrorState } from "../components/error-state";34import { ErrorState } from "../components/error-state";
34import { isAsyncResultLoading } from "../lib/async-result";35import { isAsyncResultLoading } from "../lib/async-result";
36import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
35 37
36const KIND_TO_PLUGIN_KEY: Record<string, string> = {38const KIND_TO_PLUGIN_KEY: Record<string, string> = {
37 openapi: "openapi",39 openapi: "openapi",
@@ -48,6 +50,7 @@ export function IntegrationsPage() { · 10 unmodified lines
48 useExecutorDocumentTitle("Integrations");50 useExecutorDocumentTitle("Integrations");
49 const integrations = useAtomValue(integrationsOptimisticAtom);51 const integrations = useAtomValue(integrationsOptimisticAtom);
50 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);52 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
53 const canCreate = useCanCreateWorkspaceConnections();
51 54
52 return (55 return (
53 <PageContainer>56 <PageContainer>
@@ -55,15 +58,24 @@ export function IntegrationsPage() { · 1 unmodified line
55 title="Integrations"58 title="Integrations"
56 description="Tool providers available in this workspace."59 description="Tool providers available in this workspace."
57 actions={60 actions={
58 <Button asChild size="sm" className="gap-1.5">61 canCreate ? (
59 <Link62 <Button asChild size="sm" className="gap-1.5">
60 to="/{-$orgSlug}/integrations/browse"63 <Link
61 onClick={() => trackEvent("integration_browse_opened", { via: "header" })}64 to="/{-$orgSlug}/integrations/browse"
62 >65 onClick={() => trackEvent("integration_browse_opened", { via: "header" })}
63 <PlusIcon className="size-4" />66 >
64 Add integration67 <PlusIcon className="size-4" />
65 </Link>68 Add integration
66 </Button>69 </Link>
70 </Button>
71 ) : (
72 <WorkspaceAdminHint allowed={false}>
73 <Button disabled size="sm">
74 <PlusIcon className="size-4" />
75 Add integration
76 </Button>
77 </WorkspaceAdminHint>
78 )
67 }79 }
68 />80 />
69 81
@@ -83,7 +95,7 @@ export function IntegrationsPage() { · 13 unmodified lines
83 ),95 ),
84 onSuccess: ({ value }) => {96 onSuccess: ({ value }) => {
85 if (value.length === 0) {97 if (value.length === 0) {
86 return <EmptyIntegrations />;98 return <EmptyIntegrations canCreate={canCreate} />;
87 }99 }
88 100
89 return (101 return (
@@ -102,7 +114,7 @@ export function IntegrationsPage() { · 12 unmodified lines
102// Empty state114// Empty state
103// ---------------------------------------------------------------------------115// ---------------------------------------------------------------------------
104 116
105function EmptyIntegrations() {117function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) {
106 return (118 return (
107 <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">119 <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">120 <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
@@ -110,17 +122,28 @@ function EmptyIntegrations() { · 1 unmodified line
110 </div>122 </div>
111 <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p>123 <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">124 <p className="mb-5 text-[13px] text-muted-foreground/60">
113 Connect an integration to start curating tools.125 {canCreate
126 ? "Connect an integration to start curating tools."
127 : "Ask a workspace admin to add an integration."}
114 </p>128 </p>
115 <Button asChild size="sm" className="gap-1.5">129 {canCreate ? (
116 <Link130 <Button asChild size="sm" className="gap-1.5">
117 to="/{-$orgSlug}/integrations/browse"131 <Link
118 onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })}132 to="/{-$orgSlug}/integrations/browse"
119 >133 onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })}
120 <PlusIcon className="size-4" />134 >
121 Add an integration135 <PlusIcon className="size-4" />
122 </Link>136 Add an integration
123 </Button>137 </Link>
138 </Button>
139 ) : (
140 <WorkspaceAdminHint allowed={false}>
141 <Button disabled size="sm">
142 <PlusIcon className="size-4" />
143 Add an integration
144 </Button>
145 </WorkspaceAdminHint>
146 )}
124 </div>147 </div>
125 );148 );
126}149}