Unified · Split
| @@ -0,0 +1,5 @@ | ||
| 1 | +--- | |
| 2 | +"@executor-js/react": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Show restricted integration actions as disabled controls with an admin explanation. Members can browse the catalog and add personal connections to existing integrations. | |
| @@ -0,0 +1,5 @@ | ||
| 1 | +--- | |
| 2 | +"@executor-js/sdk": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Request 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". | |
| @@ -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,164 @@ | ||
| 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( | |
| 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 | + }); | |
| @@ -3605,7 +3605,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3604 unmodified lines | ||
| 3605 | 3605 | ? { tools_synced_at: Date.now() } |
| 3606 | 3606 | : { |
| 3607 | 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 | 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 | 5146 | ): Effect.Effect<void> => |
| 5139 | 5147 | findConnectionRow(ref).pipe( |
| 5140 | 5148 | Effect.flatMap((fresh) => |
| 5141 | − fresh === null || oauthReauthRequiredFromProviderState(fresh.provider_state) !== null | |
| 5149 | + 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 | 5154 | ? Effect.void |
| 5143 | 5155 | : persistHealthResult(ref, fresh, result), |
| 5144 | 5156 | ), |
| @@ -5241,6 +5253,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 96 unmodified lines | ||
| 5241 | 5253 | // failure is the one real signal this path can produce, and it |
| 5242 | 5254 | // must not hide inside a green span. |
| 5243 | 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 | 5267 | Effect.tap((result) => persistProbeHealthResult(ref, result)), |
| 5245 | 5268 | Effect.map((result) => ({ |
| 5246 | 5269 | source: "credential_only" as const, |
| @@ -145,6 +145,7 @@ export type HealthCheckResult = typeof HealthCheckResult.Type; · 144 unmodified lines | ||
| 145 | 145 | export const toolSyncHealthDetailPrefix = "Tool sync failing"; |
| 146 | 146 | |
| 147 | 147 | export const isToolSyncHealth = (result: HealthCheckResult | null | undefined): boolean => |
| 148 | + result?.reason === "tool_sync_failed" || | |
| 148 | 149 | result?.detail?.startsWith(toolSyncHealthDetailPrefix) === true; |
| 149 | 150 | |
| 150 | 151 | // --------------------------------------------------------------------------- |
| @@ -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 | 2103 | it.effect("records missing authorization-code scopes without blocking the connection", () => |
| 2037 | 2104 | Effect.scoped( |
| 2038 | 2105 | Effect.gen(function* () { |
| @@ -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 | 717 | Effect.scoped( |
| 718 | 718 | Effect.gen(function* () { |
| 719 | − // A hostile/buggy server advertises far more scopes than any real | |
| 720 | − // template. Discovery caps the request at 100 so the authorize URL | |
| 721 | − // cannot be blown up. | |
| 722 | − const manyScopes = Array.from({ length: 200 }, (_, i) => `scope:${i}`); | |
| 719 | + // A fine-grained resource can legitimately advertise well over a | |
| 720 | + // hundred scopes (PostHog lists 150). Dropping any of them mints a | |
| 721 | + // token the resource rejects, so the whole list must be requested. | |
| 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 | 752 | const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } }); |
| 724 | 753 | const executor = yield* setupMcpScopeClient(server); |
| 725 | 754 | |
| @@ -735,8 +764,10 @@ describe("oauth.start integration-driven scopes", () => { · 9 unmodified lines | ||
| 735 | 764 | if (started.status !== "redirect") return; |
| 736 | 765 | |
| 737 | 766 | const requested = scopesFromAuthorizeUrl(started.authorizationUrl); |
| 738 | − expect(requested.length).toBe(100); | |
| 739 | − expect(requested).toEqual(manyScopes.slice(0, 100)); | |
| 767 | + expect(requested.length).toBeLessThan(manyScopes.length); | |
| 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 | ); |
| @@ -782,9 +782,26 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 781 unmodified lines | ||
| 782 | 782 | // Caps on server-controlled discovery input — a hostile or buggy server must |
| 783 | 783 | // not be able to hang `oauth.start` or overflow the authorize URL. |
| 784 | 784 | const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice |
| 785 | − const MAX_DISCOVERED_SCOPES = 100; // far beyond any realistic authorization template | |
| 786 | − const capScopes = (scopes: readonly string[]): readonly string[] => | |
| 787 | − dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES); | |
| 785 | + // The cap is on the encoded `scope` parameter's length, not the scope | |
| 786 | + // count: the URL is what overflows, and a real resource can legitimately | |
| 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 | 806 | // Bound a whole discovery sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS |
| 790 | 807 | // fetches, each with its own request timeout). 30s is larger than a single |
| @@ -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(() => { |
| @@ -176,11 +178,13 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 136 unmodified lines | ||
| 176 | 178 | {integrationPlugins.map((plugin) => ( |
| 177 | 179 | <CommandItem |
| 178 | 180 | key={`add-${plugin.key}`} |
| 181 | + disabled={!canCreateIntegration} | |
| 179 | 182 | value={`add ${plugin.label} ${plugin.key}`} |
| 180 | 183 | onSelect={() => goToAdd(plugin.key)} |
| 181 | 184 | > |
| 182 | 185 | <PlusIcon /> |
| 183 | 186 | <span className="flex-1 truncate">Add {plugin.label}</span> |
| 187 | + {!canCreateIntegration && <CommandShortcut>Admin only</CommandShortcut>} | |
| 184 | 188 | </CommandItem> |
| 185 | 189 | ))} |
| 186 | 190 | </CommandGroup> |
| @@ -193,6 +197,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 6 unmodified lines | ||
| 193 | 197 | {presetEntries.map((e) => ( |
| 194 | 198 | <CommandItem |
| 195 | 199 | key={`preset-${e.pluginKey}-${e.presetId}`} |
| 200 | + disabled={!canCreateIntegration} | |
| 196 | 201 | value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`} |
| 197 | 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 | 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 | 219 | </CommandItem> |
| 213 | 220 | ))} |
| 214 | 221 | </CommandGroup> |
| @@ -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 | +} | |
| @@ -0,0 +1,29 @@ | ||
| 1 | +import type { ReactNode } from "react"; | |
| 2 | +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"; | |
| 3 | + | |
| 4 | +/** Explain a disabled workspace action on hover or keyboard focus. */ | |
| 5 | +export 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 | +} | |
| @@ -6,6 +6,7 @@ import { BookOpen, Command, ExternalLink, PlusIcon } from "lucide-react"; · 5 unmodified lines | ||
| 6 | 6 | import type { Integration } from "@executor-js/sdk/shared"; |
| 7 | 7 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 8 | 8 | import { trackEvent } from "../api/analytics"; |
| 9 | +import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; | |
| 9 | 10 | import { Button } from "../components/button"; |
| 10 | 11 | import { Skeleton } from "../components/skeleton"; |
| 11 | 12 | import { SidebarUpdateCard } from "../components/update-card"; |
| @@ -25,6 +26,7 @@ import { CommandPalette } from "../components/command-palette"; · 13 unmodified lines | ||
| 25 | 26 | import { Wordmark } from "../components/wordmark"; |
| 26 | 27 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 27 | 28 | import { useAuth } from "./auth-context"; |
| 29 | +import { useCanCreateWorkspaceConnections } from "./use-admin-nav"; | |
| 28 | 30 | |
| 29 | 31 | // --------------------------------------------------------------------------- |
| 30 | 32 | // Shared multiplayer shell (cloud + self-host). |
| @@ -351,6 +353,7 @@ function SidebarContent( · 320 unmodified lines | ||
| 351 | 353 | }, |
| 352 | 354 | ) { |
| 353 | 355 | const plugins = useClientPlugins(); |
| 356 | + const canCreateIntegration = useCanCreateWorkspaceConnections(); | |
| 354 | 357 | const pluginNavItems = plugins.flatMap((plugin) => |
| 355 | 358 | (plugin.pages ?? []).flatMap((page) => |
| 356 | 359 | page.nav |
| @@ -385,17 +388,20 @@ function SidebarContent( · 28 unmodified lines | ||
| 385 | 388 | |
| 386 | 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 | 390 | <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> | |
| 391 | + <WorkspaceAdminHint allowed={canCreateIntegration}> | |
| 392 | + <Button | |
| 393 | + type="button" | |
| 394 | + variant="ghost" | |
| 395 | + size="icon-xs" | |
| 396 | + disabled={!canCreateIntegration} | |
| 397 | + aria-label="Browse integrations" | |
| 398 | + title="Browse integrations" | |
| 399 | + onClick={props.onOpenIntegrationConnect} | |
| 400 | + className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground" | |
| 401 | + > | |
| 402 | + <PlusIcon className="size-3.5" /> | |
| 403 | + </Button> | |
| 404 | + </WorkspaceAdminHint> | |
| 399 | 405 | </div> |
| 400 | 406 | |
| 401 | 407 | <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 { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | |
| 30 | 31 | import { |
| 31 | 32 | availableCatalogKinds, |
| 32 | 33 | catalogLogoUrl, |
| @@ -244,7 +245,7 @@ function RowIcon(props: { readonly src?: string; readonly alt: string }) { · 211 unmodified lines | ||
| 244 | 245 | ); |
| 245 | 246 | } |
| 246 | 247 | |
| 247 | −function ResultCard(props: { readonly row: Row }) { | |
| 248 | +function ResultCard(props: { readonly row: Row; readonly canCreate: boolean }) { | |
| 248 | 249 | const { row } = props; |
| 249 | 250 | return ( |
| 250 | 251 | <div |
| @@ -275,6 +276,7 @@ function ResultCard(props: { readonly row: Row }) { · 24 unmodified lines | ||
| 275 | 276 | variant="outline" |
| 276 | 277 | size="sm" |
| 277 | 278 | onClick={row.onSelect} |
| 279 | + disabled={!props.canCreate} | |
| 278 | 280 | // Every card's button reads "Add", so the visible label alone is |
| 279 | 281 | // useless to a screen reader; the accessible name carries the card. |
| 280 | 282 | aria-label={`Add ${row.title}`} |
| @@ -362,8 +364,10 @@ function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { · 81 unmodified lines | ||
| 362 | 364 | // Page |
| 363 | 365 | // --------------------------------------------------------------------------- |
| 364 | 366 | |
| 367 | +/** Let members browse integrations while reserving creation for workspace admins. */ | |
| 365 | 368 | export function IntegrationBrowsePage() { |
| 366 | 369 | useExecutorDocumentTitle("Add an integration"); |
| 370 | + const canCreate = useCanCreateWorkspaceConnections(); | |
| 367 | 371 | const navigate = useNavigate(); |
| 368 | 372 | const integrationPlugins = useIntegrationPlugins(); |
| 369 | 373 | const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); |
| @@ -923,6 +927,11 @@ export function IntegrationBrowsePage() { · 553 unmodified lines | ||
| 923 | 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 | 935 | <div className="mb-4 flex gap-2"> |
| 927 | 936 | <div className="relative min-w-0 flex-1"> |
| 928 | 937 | <SearchIcon |
| @@ -937,7 +946,7 @@ export function IntegrationBrowsePage() { · 8 unmodified lines | ||
| 937 | 946 | setError(null); |
| 938 | 947 | }} |
| 939 | 948 | onKeyDown={(event) => { |
| 940 | − if (event.key === "Enter" && isUrl) void handleDetect(); | |
| 949 | + if (event.key === "Enter" && isUrl && canCreate) void handleDetect(); | |
| 941 | 950 | }} |
| 942 | 951 | placeholder="Search integrations, or paste a URL…" |
| 943 | 952 | aria-label="Search integrations, or paste a URL" |
| @@ -951,7 +960,7 @@ export function IntegrationBrowsePage() { · 7 unmodified lines | ||
| 951 | 960 | <Button |
| 952 | 961 | className="h-11 shrink-0" |
| 953 | 962 | onClick={() => void handleDetect()} |
| 954 | − disabled={detecting || query.trim().length === 0} | |
| 963 | + disabled={!canCreate || detecting || query.trim().length === 0} | |
| 955 | 964 | loading={detecting} |
| 956 | 965 | > |
| 957 | 966 | Add this URL |
| @@ -973,6 +982,7 @@ export function IntegrationBrowsePage() { · 15 unmodified lines | ||
| 973 | 982 | key={plugin.key} |
| 974 | 983 | type="button" |
| 975 | 984 | aria-label={`New ${plugin.label} integration from scratch`} |
| 985 | + disabled={!canCreate} | |
| 976 | 986 | onClick={() => { |
| 977 | 987 | trackEvent("integration_add_started", { |
| 978 | 988 | plugin_key: plugin.key, |
| @@ -983,7 +993,7 @@ export function IntegrationBrowsePage() { · 4 unmodified lines | ||
| 983 | 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 | 998 | <PlusIcon className="size-3" aria-hidden /> |
| 989 | 999 | {plugin.label} |
| @@ -1020,7 +1030,7 @@ export function IntegrationBrowsePage() { · 30 unmodified lines | ||
| 1020 | 1030 | ) : ( |
| 1021 | 1031 | <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> |
| 1022 | 1032 | {results.map((row) => ( |
| 1023 | − <ResultCard key={row.key} row={row} /> | |
| 1033 | + <ResultCard key={row.key} row={row} canCreate={canCreate} /> | |
| 1024 | 1034 | ))} |
| 1025 | 1035 | {catalog.loadingMore |
| 1026 | 1036 | ? Array.from({ length: 3 }, (_, index) => ( |
| @@ -37,6 +37,7 @@ import { IntegrationEditSheet } from "../components/metadata-edit-sheet"; · 36 unmodified lines | ||
| 37 | 37 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs"; |
| 38 | 38 | import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements"; |
| 39 | 39 | import { usePolicyActions } from "../hooks/use-policy-actions"; |
| 40 | +import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; | |
| 40 | 41 | import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; |
| 41 | 42 | import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client"; |
| 42 | 43 | import { Button } from "../components/button"; |
| @@ -139,11 +140,11 @@ export function IntegrationDetailPage(props: { · 96 unmodified lines | ||
| 139 | 140 | const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in"; |
| 140 | 141 | const currentTab = isBuiltInIntegration ? "tools" : activeTab; |
| 141 | 142 | // 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 | 144 | const canMutateIntegration = useCanCreateWorkspaceConnections(); |
| 144 | − const canEdit = canMutateIntegration && !isBuiltInIntegration && integrationData !== null; | |
| 145 | + const canEdit = !isBuiltInIntegration && integrationData !== null; | |
| 145 | 146 | const canRefresh = integrationData?.canRefresh ?? false; |
| 146 | − const canRemove = canMutateIntegration && (integrationData?.canRemove ?? false); | |
| 147 | + const canRemove = integrationData?.canRemove ?? false; | |
| 147 | 148 | const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => { |
| 148 | 149 | const search = new URLSearchParams(locationSearch); |
| 149 | 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 | 501 | <div className="flex shrink-0 items-center gap-2"> |
| 501 | 502 | {!confirmDelete && canEdit && ( |
| 502 | − <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}> | |
| 503 | − Edit | |
| 504 | − </Button> | |
| 503 | + <WorkspaceAdminHint allowed={canMutateIntegration}> | |
| 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 | 515 | {canRefresh && ( |
| @@ -530,20 +538,23 @@ export function IntegrationDetailPage(props: { · 22 unmodified lines | ||
| 530 | 538 | variant="destructive" |
| 531 | 539 | size="sm" |
| 532 | 540 | onClick={() => void handleDelete()} |
| 533 | − disabled={deleting} | |
| 541 | + disabled={deleting || !canMutateIntegration} | |
| 534 | 542 | > |
| 535 | 543 | {deleting ? "Deleting..." : "Confirm Delete"} |
| 536 | 544 | </Button> |
| 537 | 545 | </div> |
| 538 | 546 | ) : ( |
| 539 | − <Button | |
| 540 | − variant="outline" | |
| 541 | − size="sm" | |
| 542 | − onClick={() => setConfirmDelete(true)} | |
| 543 | − className="border-destructive/30 text-destructive hover:bg-destructive/10" | |
| 544 | − > | |
| 545 | − Delete | |
| 546 | − </Button> | |
| 547 | + <WorkspaceAdminHint allowed={canMutateIntegration}> | |
| 548 | + <Button | |
| 549 | + disabled={!canMutateIntegration} | |
| 550 | + variant="outline" | |
| 551 | + size="sm" | |
| 552 | + onClick={() => setConfirmDelete(true)} | |
| 553 | + className="border-destructive/30 text-destructive hover:bg-destructive/10" | |
| 554 | + > | |
| 555 | + Delete | |
| 556 | + </Button> | |
| 557 | + </WorkspaceAdminHint> | |
| 547 | 558 | ))} |
| 548 | 559 | </div> |
| 549 | 560 | </div> |
| @@ -8,6 +8,7 @@ import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/ · 7 unmodified lines | ||
| 8 | 8 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 9 | 9 | import { trackEvent } from "../api/analytics"; |
| 10 | 10 | import { McpInstallCard } from "../components/mcp-install-card"; |
| 11 | +import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; | |
| 11 | 12 | import { Button } from "../components/button"; |
| 12 | 13 | import { PageContainer, PageHeader } from "../components/page"; |
| 13 | 14 | import { |
| @@ -32,6 +33,7 @@ import { Skeleton } from "../components/skeleton"; · 18 unmodified lines | ||
| 32 | 33 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 33 | 34 | import { ErrorState } from "../components/error-state"; |
| 34 | 35 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 36 | +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | |
| 35 | 37 | |
| 36 | 38 | const KIND_TO_PLUGIN_KEY: Record<string, string> = { |
| 37 | 39 | openapi: "openapi", |
| @@ -48,6 +50,7 @@ export function IntegrationsPage() { · 10 unmodified lines | ||
| 48 | 50 | useExecutorDocumentTitle("Integrations"); |
| 49 | 51 | const integrations = useAtomValue(integrationsOptimisticAtom); |
| 50 | 52 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); |
| 53 | + const canCreate = useCanCreateWorkspaceConnections(); | |
| 51 | 54 | |
| 52 | 55 | return ( |
| 53 | 56 | <PageContainer> |
| @@ -55,15 +58,24 @@ export function IntegrationsPage() { · 1 unmodified line | ||
| 55 | 58 | title="Integrations" |
| 56 | 59 | description="Tool providers available in this workspace." |
| 57 | 60 | 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> | |
| 61 | + canCreate ? ( | |
| 62 | + <Button asChild size="sm" className="gap-1.5"> | |
| 63 | + <Link | |
| 64 | + to="/{-$orgSlug}/integrations/browse" | |
| 65 | + onClick={() => trackEvent("integration_browse_opened", { via: "header" })} | |
| 66 | + > | |
| 67 | + <PlusIcon className="size-4" /> | |
| 68 | + Add integration | |
| 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 | 96 | onSuccess: ({ value }) => { |
| 85 | 97 | if (value.length === 0) { |
| 86 | − return <EmptyIntegrations />; | |
| 98 | + return <EmptyIntegrations canCreate={canCreate} />; | |
| 87 | 99 | } |
| 88 | 100 | |
| 89 | 101 | return ( |
| @@ -102,7 +114,7 @@ export function IntegrationsPage() { · 12 unmodified lines | ||
| 102 | 114 | // Empty state |
| 103 | 115 | // --------------------------------------------------------------------------- |
| 104 | 116 | |
| 105 | −function EmptyIntegrations() { | |
| 117 | +function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) { | |
| 106 | 118 | return ( |
| 107 | 119 | <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16"> |
| 108 | 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 | 122 | </div> |
| 111 | 123 | <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p> |
| 112 | 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 | 128 | </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> | |
| 129 | + {canCreate ? ( | |
| 130 | + <Button asChild size="sm" className="gap-1.5"> | |
| 131 | + <Link | |
| 132 | + to="/{-$orgSlug}/integrations/browse" | |
| 133 | + onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })} | |
| 134 | + > | |
| 135 | + <PlusIcon className="size-4" /> | |
| 136 | + Add an integration | |
| 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 | 147 | </div> |
| 125 | 148 | ); |
| 126 | 149 | } |