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 | ? { 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) !== 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 | ? Effect.void | 5154 | ? 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 it | 5253 | // 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, |
| @@ -145,6 +145,7 @@ export type HealthCheckResult = typeof HealthCheckResult.Type; · 144 unmodified lines | |||
| 145 | export const toolSyncHealthDetailPrefix = "Tool sync failing"; | 145 | export const toolSyncHealthDetailPrefix = "Tool sync failing"; |
| 146 | 146 | ||
| 147 | export const isToolSyncHealth = (result: HealthCheckResult | null | undefined): boolean => | 147 | export 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 | // --------------------------------------------------------------------------- |
| @@ -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* () { |
| @@ -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 real | 719 | // A fine-grained resource can legitimately advertise well over a |
| 720 | // template. Discovery caps the request at 100 so the authorize URL | 720 | // 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 | ); |
| @@ -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 must | 782 | // 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 practice | 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 | 785 | // 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 AS | 806 | // 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 single | 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 | import { PresetIcon } from "./preset-icon"; | 9 | import { PresetIcon } from "./preset-icon"; |
| 10 | import { integrationsOptimisticAtom } from "../api/atoms"; | 10 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 11 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; | 11 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 12 | import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | ||
| 12 | import { | 13 | import { |
| 13 | CommandDialog, | 14 | CommandDialog, |
| 14 | CommandEmpty, | 15 | CommandEmpty, |
| @@ -34,6 +35,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 19 unmodified lines | |||
| 34 | const integrationPlugins = useIntegrationPlugins(); | 35 | const integrationPlugins = useIntegrationPlugins(); |
| 35 | const navigate = useNavigate(); | 36 | const navigate = useNavigate(); |
| 36 | const integrationsResult = useAtomValue(integrationsOptimisticAtom); | 37 | const integrationsResult = useAtomValue(integrationsOptimisticAtom); |
| 38 | const canCreateIntegration = useCanCreateWorkspaceConnections(); | ||
| 37 | 39 | ||
| 38 | // Toggle with ⌘K / Ctrl+K | 40 | // Toggle with ⌘K / Ctrl+K |
| 39 | useEffect(() => { | 41 | useEffect(() => { |
| @@ -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 | <CommandItem | 179 | <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 | <CommandItem | 198 | <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> |
| @@ -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 | import type { Integration } from "@executor-js/sdk/shared"; | 6 | import type { Integration } from "@executor-js/sdk/shared"; |
| 7 | import { integrationsOptimisticAtom } from "../api/atoms"; | 7 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 8 | import { trackEvent } from "../api/analytics"; | 8 | import { trackEvent } from "../api/analytics"; |
| 9 | import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; | ||
| 9 | import { Button } from "../components/button"; | 10 | import { Button } from "../components/button"; |
| 10 | import { Skeleton } from "../components/skeleton"; | 11 | import { Skeleton } from "../components/skeleton"; |
| 11 | import { SidebarUpdateCard } from "../components/update-card"; | 12 | import { SidebarUpdateCard } from "../components/update-card"; |
| @@ -25,6 +26,7 @@ import { CommandPalette } from "../components/command-palette"; · 13 unmodified lines | |||
| 25 | import { Wordmark } from "../components/wordmark"; | 26 | import { Wordmark } from "../components/wordmark"; |
| 26 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; | 27 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 27 | import { useAuth } from "./auth-context"; | 28 | import { useAuth } from "./auth-context"; |
| 29 | import { 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.nav | 359 | 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 | <Button | 391 | <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} /> |
| @@ -1,16 +1,27 @@ | |||
| 1 | import { Suspense } from "react"; | 1 | import { Suspense, type ComponentProps } from "react"; |
| 2 | import { useAtomRefresh } from "@effect/atom-react"; | 2 | import { useAtomRefresh } from "@effect/atom-react"; |
| 3 | import { Link, useNavigate } from "@tanstack/react-router"; | 3 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 4 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; | 4 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 5 | import { integrationsOptimisticAtom } from "../api/atoms"; | 5 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 6 | import { trackEvent } from "../api/analytics"; | 6 | import { trackEvent } from "../api/analytics"; |
| 7 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 7 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 8 | import { IntegrationCreationGate } from "../components/integration-creation-gate"; | ||
| 8 | 9 | ||
| 9 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 10 | // Page | 11 | // Page |
| 11 | // --------------------------------------------------------------------------- | 12 | // --------------------------------------------------------------------------- |
| 12 | 13 | ||
| 13 | export function AddIntegrationPage(props: { | 14 | /** Render an integration setup flow only when the workspace role permits creation. */ |
| 15 | export function AddIntegrationPage(props: ComponentProps<typeof AddIntegrationContent>) { | ||
| 16 | useExecutorDocumentTitle("Add integration"); | ||
| 17 | return ( | ||
| 18 | <IntegrationCreationGate> | ||
| 19 | <AddIntegrationContent {...props} /> | ||
| 20 | </IntegrationCreationGate> | ||
| 21 | ); | ||
| 22 | } | ||
| 23 | |||
| 24 | function AddIntegrationContent(props: { | ||
| 14 | pluginKey: string; | 25 | pluginKey: string; |
| 15 | url?: string; | 26 | url?: string; |
| 16 | preset?: string; | 27 | preset?: string; |
| @@ -20,7 +31,6 @@ export function AddIntegrationPage(props: { · 3 unmodified lines | |||
| 20 | authKind?: string; | 31 | authKind?: string; |
| 21 | specOverrides?: string; | 32 | specOverrides?: string; |
| 22 | }) { | 33 | }) { |
| 23 | useExecutorDocumentTitle("Add integration"); | ||
| 24 | const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } = | 34 | const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } = |
| 25 | props; | 35 | props; |
| 26 | const navigate = useNavigate(); | 36 | const navigate = useNavigate(); |
| @@ -27,6 +27,7 @@ import { · 26 unmodified lines | |||
| 27 | } from "../components/integration-favicon"; | 27 | } from "../components/integration-favicon"; |
| 28 | import { Skeleton } from "../components/skeleton"; | 28 | import { Skeleton } from "../components/skeleton"; |
| 29 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 29 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 30 | import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | ||
| 30 | import { | 31 | import { |
| 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 | ||
| 247 | function ResultCard(props: { readonly row: Row }) { | 248 | function ResultCard(props: { readonly row: Row; readonly canCreate: boolean }) { |
| 248 | const { row } = props; | 249 | const { row } = props; |
| 249 | return ( | 250 | return ( |
| 250 | <div | 251 | <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 is | 280 | // 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 | // Page | 364 | // Page |
| 363 | // --------------------------------------------------------------------------- | 365 | // --------------------------------------------------------------------------- |
| 364 | 366 | ||
| 367 | /** Let members browse integrations while reserving creation for workspace admins. */ | ||
| 365 | export function IntegrationBrowsePage() { | 368 | export 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 | <SearchIcon | 937 | <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 | <Button | 960 | <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 URL | 966 | 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.loadingMore | 1035 | {catalog.loadingMore |
| 1026 | ? Array.from({ length: 3 }, (_, index) => ( | 1036 | ? Array.from({ length: 3 }, (_, index) => ( |
| @@ -37,6 +37,7 @@ import { IntegrationEditSheet } from "../components/metadata-edit-sheet"; · 36 unmodified lines | |||
| 37 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs"; | 37 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs"; |
| 38 | import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements"; | 38 | import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements"; |
| 39 | import { usePolicyActions } from "../hooks/use-policy-actions"; | 39 | import { usePolicyActions } from "../hooks/use-policy-actions"; |
| 40 | import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; | ||
| 40 | import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | 41 | import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; |
| 41 | import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client"; | 42 | import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client"; |
| 42 | import { Button } from "../components/button"; | 43 | import { 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 mutations | 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 | 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 | Edit | 504 | <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 | <Button | 547 | <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 | Delete | 553 | 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> |
| @@ -8,6 +8,7 @@ import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/ · 7 unmodified lines | |||
| 8 | import { integrationsOptimisticAtom } from "../api/atoms"; | 8 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 9 | import { trackEvent } from "../api/analytics"; | 9 | import { trackEvent } from "../api/analytics"; |
| 10 | import { McpInstallCard } from "../components/mcp-install-card"; | 10 | import { McpInstallCard } from "../components/mcp-install-card"; |
| 11 | import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; | ||
| 11 | import { Button } from "../components/button"; | 12 | import { Button } from "../components/button"; |
| 12 | import { PageContainer, PageHeader } from "../components/page"; | 13 | import { PageContainer, PageHeader } from "../components/page"; |
| 13 | import { | 14 | import { |
| @@ -32,6 +33,7 @@ import { Skeleton } from "../components/skeleton"; · 18 unmodified lines | |||
| 32 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 33 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 33 | import { ErrorState } from "../components/error-state"; | 34 | import { ErrorState } from "../components/error-state"; |
| 34 | import { isAsyncResultLoading } from "../lib/async-result"; | 35 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 36 | import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | ||
| 35 | 37 | ||
| 36 | const KIND_TO_PLUGIN_KEY: Record<string, string> = { | 38 | const 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 | <Link | 62 | <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 integration | 67 | <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 state | 114 | // Empty state |
| 103 | // --------------------------------------------------------------------------- | 115 | // --------------------------------------------------------------------------- |
| 104 | 116 | ||
| 105 | function EmptyIntegrations() { | 117 | function 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 | <Link | 130 | <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 integration | 135 | <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 | } |