Unified · Split
| @@ -0,0 +1,5 @@ | ||
| 1 | +--- | |
| 2 | +"@executor-js/react": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Tool policies set from an account section of the integration Tools tab now apply to that connection only, and each account header gets a menu to set a policy for the whole connection. Members no longer see policy controls they cannot use, and a refused policy write shows the server's reason. | |
| @@ -4,18 +4,23 @@ · 3 unmodified lines | ||
| 4 | 4 | // the category (group) row menu writes a subtree rule. The product promises |
| 5 | 5 | // under test: |
| 6 | 6 | // |
| 7 | −// 1. Both menus surface the REAL stored pattern (connection-wildcarded | |
| 8 | −// `integration.*.*.tool`) before anything is written. | |
| 7 | +// 1. Both menus surface the REAL stored pattern (pinned to the account the | |
| 8 | +// row sits under, `integration.<owner>.<connection>.tool`) before | |
| 9 | +// anything is written. | |
| 9 | 10 | // 2. A leaf rule and a category rule coexist: the more specific leaf rule |
| 10 | 11 | // keeps precedence over the later category rule, which covers the rest |
| 11 | 12 | // of its group. |
| 12 | −// 3. Rules are connection-agnostic: set from one account's section, they | |
| 13 | −// govern the other account's rows too, and the menu there shows the | |
| 14 | −// active rule with a Clear option. | |
| 15 | −// 4. The tool detail header's policy badge is the same authoring surface: | |
| 13 | +// 3. Rules are account-scoped: set from one account's section, they leave | |
| 14 | +// the other account's rows untouched. Two connections of one | |
| 15 | +// integration are different credentials (a bot token and a user token), | |
| 16 | +// so blocking a tool on one must not block it on the other. | |
| 17 | +// 4. The account header has its own menu that rules the whole connection | |
| 18 | +// (`integration.<owner>.<connection>.*`), recognizes its rule, and | |
| 19 | +// clears it. | |
| 20 | +// 5. The tool detail header's policy badge is the same authoring surface: | |
| 16 | 21 | // it writes the same stored pattern, recognizes its own rule afterward |
| 17 | 22 | // (the Clear affordance), and Clear really removes the rule. |
| 18 | −// 5. The rules materialize as manageable rows on /policies and persist | |
| 23 | +// 6. The rules materialize as manageable rows on /policies and persist | |
| 19 | 24 | // server-side with exactly the owner/pattern/action the UI promised. |
| 20 | 25 | import { randomBytes } from "node:crypto"; |
| 21 | 26 | |
| @@ -83,11 +88,12 @@ scenario( · 61 unmodified lines | ||
| 83 | 88 | const beta = ConnectionName.make(`beta${suffix}`); |
| 84 | 89 | const accounts = [alpha, beta] as const; |
| 85 | 90 | |
| 86 | − // The UI hides owner/connection segments; a rule authored on a node is | |
| 87 | − // stored connection-wildcarded so it spans every account. | |
| 88 | − const leafPattern = `${integration}.*.*.records.create`; | |
| 89 | − const categoryPattern = `${integration}.*.*.records.*`; | |
| 90 | − const listLeafPattern = `${integration}.*.*.records.list`; | |
| 91 | + // The UI hides owner/connection segments in the row labels, but a rule | |
| 92 | + // authored under an account section is stored pinned to that account. | |
| 93 | + const leafPattern = `${integration}.org.${alpha}.records.create`; | |
| 94 | + const categoryPattern = `${integration}.org.${alpha}.records.*`; | |
| 95 | + const listLeafPattern = `${integration}.org.${alpha}.records.list`; | |
| 96 | + const betaAccountPattern = `${integration}.org.${beta}.*`; | |
| 91 | 97 | |
| 92 | 98 | // Selfhost scenarios share one workspace — remove everything this one |
| 93 | 99 | // made (policies, connections, the integration) even on failure. |
| @@ -159,6 +165,14 @@ scenario( · 65 unmodified lines | ||
| 159 | 165 | .getByRole("button") |
| 160 | 166 | .filter({ hasText: leaf }) |
| 161 | 167 | .getByLabel(label, { exact: true }); |
| 168 | + // Wait until a leaf's indicator with this label is gone (after a clear). | |
| 169 | + const expectNoIndicator = async (connection: string, leaf: string, label: string) => { | |
| 170 | + await expect | |
| 171 | + .poll(() => leafIndicator(connection, leaf, label).count(), { | |
| 172 | + message: `${connection} ${leaf} still shows "${label}"`, | |
| 173 | + }) | |
| 174 | + .toBe(0); | |
| 175 | + }; | |
| 162 | 176 | const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" }); |
| 163 | 177 | |
| 164 | 178 | await step("Open the integration's Tools tab", async () => { |
| @@ -254,25 +268,58 @@ scenario( · 89 unmodified lines | ||
| 254 | 268 | }, |
| 255 | 269 | ); |
| 256 | 270 | |
| 257 | − await step("The same rules govern the second account's rows", async () => { | |
| 271 | + await step("The second account's rows are untouched", async () => { | |
| 258 | 272 | await closedGroup(beta, integration).click(); |
| 259 | 273 | await closedGroup(beta, "records").click(); |
| 260 | − await leafIndicator(beta, "create", `Blocked (matched ${leafPattern})`).waitFor(); | |
| 261 | − await leafIndicator( | |
| 262 | − beta, | |
| 263 | − "list", | |
| 264 | − `Require approval (matched ${categoryPattern})`, | |
| 265 | − ).waitFor(); | |
| 274 | + await sectionFor(beta).getByRole("button").filter({ hasText: "create" }).waitFor(); | |
| 275 | + expect( | |
| 276 | + await leafIndicator(beta, "create", `Blocked (matched ${leafPattern})`).count(), | |
| 277 | + "a rule set under one account does not block the same tool on another", | |
| 278 | + ).toBe(0); | |
| 279 | + expect( | |
| 280 | + await leafIndicator( | |
| 281 | + beta, | |
| 282 | + "list", | |
| 283 | + `Require approval (matched ${categoryPattern})`, | |
| 284 | + ).count(), | |
| 285 | + "a category rule set under one account does not reach another account", | |
| 286 | + ).toBe(0); | |
| 266 | 287 | }); |
| 267 | 288 | |
| 268 | 289 | await step("Reopening the menu offers to clear the active rule", async () => { |
| 269 | − await policyMenuFor(beta, `${integration}.records.create`).click(); | |
| 290 | + await policyMenuFor(alpha, `${integration}.records.create`).click(); | |
| 270 | 291 | await page.getByRole("menuitem", { name: "Clear" }).waitFor(); |
| 271 | 292 | await page.keyboard.press("Escape"); |
| 272 | 293 | }); |
| 273 | 294 | |
| 295 | + await step("The account header blocks the whole second connection", async () => { | |
| 296 | + const headerMenu = sectionFor(beta).getByRole("button", { | |
| 297 | + name: `Set policy for ${integration} / ${beta}`, | |
| 298 | + exact: true, | |
| 299 | + }); | |
| 300 | + await headerMenu.click(); | |
| 301 | + // The header menu is headed by the whole-account pattern it will store. | |
| 302 | + await page.getByText(betaAccountPattern, { exact: true }).waitFor(); | |
| 303 | + await page.getByRole("menuitem", { name: "Block" }).click(); | |
| 304 | + await leafIndicator(beta, "create", `Blocked (matched ${betaAccountPattern})`).waitFor(); | |
| 305 | + await leafIndicator(beta, "list", `Blocked (matched ${betaAccountPattern})`).waitFor(); | |
| 306 | + // The first account is not affected by the second account's rule. | |
| 307 | + await leafIndicator(alpha, "create", `Blocked (matched ${leafPattern})`).waitFor(); | |
| 308 | + }); | |
| 309 | + | |
| 310 | + await step("The account header recognizes its rule and Clear removes it", async () => { | |
| 311 | + const headerMenu = sectionFor(beta).getByRole("button", { | |
| 312 | + name: `Set policy for ${integration} / ${beta}`, | |
| 313 | + exact: true, | |
| 314 | + }); | |
| 315 | + await headerMenu.click(); | |
| 316 | + await page.getByRole("menuitem", { name: "Clear" }).click(); | |
| 317 | + await sectionFor(beta).getByRole("button").filter({ hasText: "create" }).waitFor(); | |
| 318 | + await expectNoIndicator(beta, "create", `Blocked (matched ${betaAccountPattern})`); | |
| 319 | + }); | |
| 320 | + | |
| 274 | 321 | await step("Open the tool detail for records.list", async () => { |
| 275 | − await sectionFor(beta).getByRole("button").filter({ hasText: "list" }).click(); | |
| 322 | + await sectionFor(alpha).getByRole("button").filter({ hasText: "list" }).click(); | |
| 276 | 323 | // The header badge reflects the inherited category rule. |
| 277 | 324 | await page.getByRole("button", { name: `Matched policy: ${categoryPattern}` }).waitFor(); |
| 278 | 325 | }); |
| @@ -108,6 +108,18 @@ export const integrationCreationPermissions = (admin: Identity, member: Identity · 107 unmodified lines | ||
| 108 | 108 | expect(await action.isDisabled()).toBe(true); |
| 109 | 109 | } |
| 110 | 110 | }); |
| 111 | + await step("Member sees tool policies without a way to change them", async () => { | |
| 112 | + // Policies on the Tools page are workspace rules the server refuses | |
| 113 | + // for members, so the row menus and the detail badge menu stay off. | |
| 114 | + await visit(page, "/tools"); | |
| 115 | + await page.getByRole("button").filter({ hasText: "executor" }).first().waitFor(); | |
| 116 | + expect( | |
| 117 | + await page.getByRole("button", { name: /^Set policy/ }).count(), | |
| 118 | + "members get no policy menus on tool rows", | |
| 119 | + ).toBe(0); | |
| 120 | + await visit(page, `/integrations/${slug}`); | |
| 121 | + await page.getByRole("button", { name: "Add connection", exact: true }).waitFor(); | |
| 122 | + }); | |
| 111 | 123 | await step("Member can still add a personal connection", async () => { |
| 112 | 124 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); |
| 113 | 125 | const dialog = page.getByRole("dialog"); |
| @@ -53,6 +53,20 @@ describe("frontend error reporting", () => { · 52 unmodified lines | ||
| 53 | 53 | expect(messageFromExit(Exit.fail({ reason: "unknown" }), "Fallback")).toBe("Fallback"); |
| 54 | 54 | }); |
| 55 | 55 | |
| 56 | + it("reads a message the error exposes as a prototype getter", () => { | |
| 57 | + // Schema-tagged API errors (e.g. OrgWriteDeniedError) declare no `message` | |
| 58 | + // field; the sentence lives on a class getter, which a struct decode misses. | |
| 59 | + class GetterError extends Data.TaggedError("GetterError")<{}> { | |
| 60 | + override get message(): string { | |
| 61 | + return "Requires a workspace admin."; | |
| 62 | + } | |
| 63 | + } | |
| 64 | + const exit = Exit.fail(new GetterError()); | |
| 65 | + | |
| 66 | + expect(messageFromExit(exit, "Fallback")).toBe("Requires a workspace admin."); | |
| 67 | + expect(messageFromUnknown(new GetterError(), "Fallback")).toBe("Requires a workspace admin."); | |
| 68 | + }); | |
| 69 | + | |
| 56 | 70 | it("reports failed exits with the provided context", () => { |
| 57 | 71 | const exit = Exit.fail({ message: "Could not update integration" }); |
| 58 | 72 | const { calls, report } = captureReports(); |
| @@ -21,8 +21,17 @@ class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{ · 20 unmodified lines | ||
| 21 | 21 | readonly context: FrontendErrorContext; |
| 22 | 22 | }> {} |
| 23 | 23 | |
| 24 | −const ErrorMessage = Schema.Struct({ message: Schema.String }); | |
| 25 | −const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | |
| 24 | +// Effect's tagged error classes (`Schema.TaggedErrorClass`) often declare no | |
| 25 | +// `message` field and expose it as a prototype getter instead, so a struct | |
| 26 | +// decode — which only sees own properties — would miss the very sentence the | |
| 27 | +// server wrote for the user. Read the property directly. | |
| 28 | +const decodeErrorMessage = (value: unknown): Option.Option<{ readonly message: string }> => { | |
| 29 | + if (typeof value !== "object" || value === null) return Option.none(); | |
| 30 | + const message: unknown = Reflect.get(value, "message"); | |
| 31 | + return typeof message === "string" && message.length > 0 | |
| 32 | + ? Option.some({ message }) | |
| 33 | + : Option.none(); | |
| 34 | +}; | |
| 26 | 35 | |
| 27 | 36 | const TaggedValue = Schema.Struct({ _tag: Schema.String }); |
| 28 | 37 | const decodeTaggedValue = Schema.decodeUnknownOption(TaggedValue); |
| @@ -3,7 +3,7 @@ import { ChevronRightIcon, MoreHorizontalIcon, SearchIcon, XIcon } from "lucide- · 2 unmodified lines | ||
| 3 | 3 | import type { EffectivePolicy, Owner, ToolPolicyAction } from "@executor-js/sdk/shared"; |
| 4 | 4 | import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; |
| 5 | 5 | import { trackEvent } from "../api/analytics"; |
| 6 | −import { toPolicyPattern } from "../lib/policy-pattern"; | |
| 6 | +import { accountPolicyPattern, toPolicyPattern } from "../lib/policy-pattern"; | |
| 7 | 7 | import { Badge } from "./badge"; |
| 8 | 8 | import { Button } from "./button"; |
| 9 | 9 | import { Input } from "./input"; |
| @@ -297,7 +297,9 @@ export function ToolTree(props: { · 287 unmodified lines | ||
| 297 | 297 | * emit the tool's full dotted id; group rows emit `prefix.*`. */ |
| 298 | 298 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 299 | 299 | onClearPolicy?: (pattern: string) => void; |
| 300 | − /** Maps the displayed row path into the persisted policy pattern. */ | |
| 300 | + /** Maps the displayed row path into the persisted policy pattern for the | |
| 301 | + * flat tree. Ignored in the account-grouped view, where every row writes a | |
| 302 | + * pattern pinned to its own account (`integration.<owner>.<conn>.<tool>`). */ | |
| 301 | 303 | patternForDisplay?: (displayPattern: string) => string; |
| 302 | 304 | /** Sorted user-authored policies (most-precedent first). Used to |
| 303 | 305 | * decide whether a node has its own exact-pattern user rule today |
| @@ -429,26 +431,55 @@ export function ToolTree(props: { · 125 unmodified lines | ||
| 429 | 431 | : (props.emptyLabel ?? "No tools available")} |
| 430 | 432 | </div> |
| 431 | 433 | ) : groupByConnection ? ( |
| 432 | − accountGroups.map((group) => ( | |
| 433 | − <section key={group.key}> | |
| 434 | − <header className="sticky top-0 z-10 flex items-center gap-2 border-b border-border/30 bg-muted/40 px-3 py-1.5 backdrop-blur-sm"> | |
| 435 | − {ownerDisplay.showOwnerLabels ? ( | |
| 436 | − <Badge variant="outline" className="shrink-0 text-[10px]"> | |
| 437 | − {ownerLabel(group.owner)} | |
| 438 | − </Badge> | |
| 439 | − ) : null} | |
| 440 | − <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground"> | |
| 441 | − {group.integration && group.connection | |
| 442 | − ? `${group.integration} / ${group.connection}` | |
| 443 | − : group.connection || ownerDisplay.label(group.owner)} | |
| 444 | − </span> | |
| 445 | − <span className="shrink-0 tabular-nums text-xs text-muted-foreground"> | |
| 446 | − {group.tools.length} | |
| 447 | − </span> | |
| 448 | − </header> | |
| 449 | − <ToolTreeBody key={group.key} tools={group.tools} {...bodyProps} /> | |
| 450 | − </section> | |
| 451 | − )) | |
| 434 | + accountGroups.map((group) => { | |
| 435 | + // Rows inside an account section author rules for THAT account | |
| 436 | + // only: two connections of one integration are different | |
| 437 | + // credentials, so a rule set under one must not govern the other. | |
| 438 | + const accountPattern = accountPolicyPattern(group.owner, group.connection); | |
| 439 | + const wholeAccountPattern = accountPattern(`${group.integration}.*`); | |
| 440 | + const wholeAccountRule = exactPatterns.get(wholeAccountPattern); | |
| 441 | + const accountLabel = | |
| 442 | + group.integration && group.connection | |
| 443 | + ? `${group.integration} / ${group.connection}` | |
| 444 | + : group.connection || ownerDisplay.label(group.owner); | |
| 445 | + return ( | |
| 446 | + <section key={group.key}> | |
| 447 | + <header className="group/tt-row sticky top-0 z-10 flex items-center gap-2 border-b border-border/30 bg-muted/40 px-3 py-1.5 backdrop-blur-sm"> | |
| 448 | + {ownerDisplay.showOwnerLabels ? ( | |
| 449 | + <Badge variant="outline" className="shrink-0 text-[10px]"> | |
| 450 | + {ownerLabel(group.owner)} | |
| 451 | + </Badge> | |
| 452 | + ) : null} | |
| 453 | + <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground"> | |
| 454 | + {accountLabel} | |
| 455 | + </span> | |
| 456 | + <span className="shrink-0 tabular-nums text-xs text-muted-foreground"> | |
| 457 | + {group.tools.length} | |
| 458 | + </span> | |
| 459 | + {onSetPolicy && group.integration && group.connection ? ( | |
| 460 | + <PolicyActionMenu | |
| 461 | + pattern={wholeAccountPattern} | |
| 462 | + current={wholeAccountRule} | |
| 463 | + onSet={onSetPolicy} | |
| 464 | + onClear={onClearPolicy} | |
| 465 | + triggerLabel={`Set policy for ${accountLabel}`} | |
| 466 | + triggerClassName={ | |
| 467 | + wholeAccountRule | |
| 468 | + ? undefined | |
| 469 | + : "opacity-0 group-hover/tt-row:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100" | |
| 470 | + } | |
| 471 | + /> | |
| 472 | + ) : null} | |
| 473 | + </header> | |
| 474 | + <ToolTreeBody | |
| 475 | + key={group.key} | |
| 476 | + tools={group.tools} | |
| 477 | + {...bodyProps} | |
| 478 | + patternForDisplay={accountPattern} | |
| 479 | + /> | |
| 480 | + </section> | |
| 481 | + ); | |
| 482 | + }) | |
| 452 | 483 | ) : ( |
| 453 | 484 | <ToolTreeBody tools={filteredTools} {...bodyProps} /> |
| 454 | 485 | )} |
| @@ -11,20 +11,14 @@ import { Link } from "@tanstack/react-router"; · 10 unmodified lines | ||
| 11 | 11 | import * as Exit from "effect/Exit"; |
| 12 | 12 | import * as Option from "effect/Option"; |
| 13 | 13 | import * as Predicate from "effect/Predicate"; |
| 14 | −import * as Schema from "effect/Schema"; | |
| 15 | 14 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 16 | 15 | |
| 17 | 16 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 18 | − | |
| 19 | −const ErrorMessage = Schema.Struct({ message: Schema.String }); | |
| 20 | −const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | |
| 17 | +import { messageFromExit } from "../api/error-reporting"; | |
| 21 | 18 | |
| 22 | 19 | /** The failed Exit's `message`, or `fallback` when the error carries none. */ |
| 23 | −export const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | |
| 24 | − Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { | |
| 25 | − onNone: () => fallback, | |
| 26 | − onSome: ({ message }) => message, | |
| 27 | − }); | |
| 20 | +export const errorMessageFromExit: (exit: Exit.Exit<unknown, unknown>, fallback: string) => string = | |
| 21 | + messageFromExit; | |
| 28 | 22 | |
| 29 | 23 | export const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean => |
| 30 | 24 | Option.match(Exit.findErrorOption(exit), { |
| @@ -0,0 +1,21 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { accountPolicyPattern, toPolicyPattern } from "./policy-pattern"; | |
| 4 | + | |
| 5 | +describe("policy pattern bridges", () => { | |
| 6 | + it("wildcards owner and connection for the connection-agnostic tree", () => { | |
| 7 | + expect(toPolicyPattern("slack.conversations.history")).toBe("slack.*.*.conversations.history"); | |
| 8 | + expect(toPolicyPattern("slack.conversations.*")).toBe("slack.*.*.conversations.*"); | |
| 9 | + expect(toPolicyPattern("slack.*")).toBe("slack.*"); | |
| 10 | + expect(toPolicyPattern("*")).toBe("*"); | |
| 11 | + }); | |
| 12 | + | |
| 13 | + it("pins owner and connection for a row inside an account section", () => { | |
| 14 | + const forBot = accountPolicyPattern("org", "bot"); | |
| 15 | + expect(forBot("slack.conversations.history")).toBe("slack.org.bot.conversations.history"); | |
| 16 | + expect(forBot("slack.conversations.*")).toBe("slack.org.bot.conversations.*"); | |
| 17 | + expect(forBot("slack.*")).toBe("slack.org.bot.*"); | |
| 18 | + expect(forBot("slack")).toBe("slack.org.bot.*"); | |
| 19 | + expect(forBot("*")).toBe("*"); | |
| 20 | + }); | |
| 21 | +}); | |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { matchPattern } from "@executor-js/sdk/shared"; | |
| 1 | +import { matchPattern, type Owner } from "@executor-js/sdk/shared"; | |
| 2 | 2 | |
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | // Policy pattern bridge. |
| @@ -29,3 +29,29 @@ export const toPolicyPattern = (displayPattern: string): string => { · 24 unmodified lines | ||
| 29 | 29 | }; |
| 30 | 30 | |
| 31 | 31 | export { matchPattern }; |
| 32 | + | |
| 33 | +// --------------------------------------------------------------------------- | |
| 34 | +// Account-scoped bridge. | |
| 35 | +// | |
| 36 | +// The account-grouped Tools tab shows the same tool once per connection. A | |
| 37 | +// rule authored from a row inside one account's section must govern THAT | |
| 38 | +// account only — a Slack bot connection and a Slack user connection are | |
| 39 | +// different credentials with different capabilities, and blocking a tool on | |
| 40 | +// one must not silently block it on the other. So instead of wildcarding the | |
| 41 | +// owner + connection segments, fill them in: `integration.<owner>.<conn>.<tool>`. | |
| 42 | +// `integration.*` becomes the whole-account subtree | |
| 43 | +// `integration.<owner>.<conn>.*`. Apply the returned mapper at both the site | |
| 44 | +// that BUILDS a pattern and the site that LOOKS UP the exact rule, exactly as | |
| 45 | +// with `toPolicyPattern`. | |
| 46 | +// --------------------------------------------------------------------------- | |
| 47 | + | |
| 48 | +export const accountPolicyPattern = | |
| 49 | + (owner: Owner, connection: string) => | |
| 50 | + (displayPattern: string): string => { | |
| 51 | + if (displayPattern === "*") return "*"; | |
| 52 | + const firstDot = displayPattern.indexOf("."); | |
| 53 | + if (firstDot === -1) return `${displayPattern}.${owner}.${connection}.*`; | |
| 54 | + const integration = displayPattern.slice(0, firstDot); | |
| 55 | + const rest = displayPattern.slice(firstDot + 1); | |
| 56 | + return `${integration}.${owner}.${connection}.${rest}`; | |
| 57 | + }; | |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | effectivePolicyFromSorted, |
| 14 | 14 | type Connection, |
| 15 | 15 | type Owner, |
| 16 | + type ToolPolicyAction, | |
| 16 | 17 | } from "@executor-js/sdk/shared"; |
| 17 | 18 | import { |
| 18 | 19 | checkConnectionHealth, |
| @@ -46,6 +47,7 @@ import { useExecutorDocumentTitle } from "../lib/document-title"; · 27 unmodified lines | ||
| 46 | 47 | import { ErrorState } from "../components/error-state"; |
| 47 | 48 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 48 | 49 | import { useConnectionsHealth } from "../lib/use-connection-health"; |
| 50 | +import { accountPolicyPattern } from "../lib/policy-pattern"; | |
| 49 | 51 | import { |
| 50 | 52 | integrationDetailInternalTabFromSearch, |
| 51 | 53 | type IntegrationDetailInternalTab, |
| @@ -142,6 +144,16 @@ export function IntegrationDetailPage(props: { · 90 unmodified lines | ||
| 142 | 144 | // Integrations are workspace-owned; the server refuses catalog mutations |
| 143 | 145 | // (update/remove) from non-admin members, so disable the controls for them. |
| 144 | 146 | const canMutateIntegration = useCanCreateWorkspaceConnections(); |
| 147 | + // Tool policies on this tab are workspace rules (`usePolicyActions("org")`), | |
| 148 | + // which the server refuses for non-admin members. Offer the menus only to | |
| 149 | + // those who can actually write them. | |
| 150 | + const canSetPolicy = canMutateIntegration; | |
| 151 | + const onSetPolicy = canSetPolicy | |
| 152 | + ? (pattern: string, action: ToolPolicyAction) => void policyActions.set(pattern, action) | |
| 153 | + : undefined; | |
| 154 | + const onClearPolicy = canSetPolicy | |
| 155 | + ? (pattern: string, policyId?: string) => void policyActions.clear(pattern, policyId) | |
| 156 | + : undefined; | |
| 145 | 157 | const canEdit = !isBuiltInIntegration && integrationData !== null; |
| 146 | 158 | const canRefresh = integrationData?.canRefresh ?? false; |
| 147 | 159 | const canRemove = integrationData?.canRemove ?? false; |
| @@ -301,6 +313,7 @@ export function IntegrationDetailPage(props: { · 153 unmodified lines | ||
| 301 | 313 | policy: effectivePolicyFromSorted(matchId, policyList, t.requiresApproval), |
| 302 | 314 | owner: t.owner, |
| 303 | 315 | connection: t.connection, |
| 316 | + integration: t.integration, | |
| 304 | 317 | }; |
| 305 | 318 | }); |
| 306 | 319 | }, [tools, policyList]); |
| @@ -620,8 +633,8 @@ export function IntegrationDetailPage(props: { · 313 unmodified lines | ||
| 620 | 633 | tools={integrationTools} |
| 621 | 634 | selectedToolId={selectedToolId} |
| 622 | 635 | onSelect={setSelectedToolId} |
| 623 | − onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | |
| 624 | − onClearPolicy={(pattern) => void policyActions.clear(pattern)} | |
| 636 | + onSetPolicy={onSetPolicy} | |
| 637 | + onClearPolicy={onClearPolicy} | |
| 625 | 638 | policies={sortedPolicies} |
| 626 | 639 | groupByConnection={!isBuiltInIntegration} |
| 627 | 640 | emptyLabel={hasToolSyncIssue ? emptyToolsTitle : undefined} |
| @@ -636,9 +649,15 @@ export function IntegrationDetailPage(props: { · 8 unmodified lines | ||
| 636 | 649 | toolName={selectedTool.name} |
| 637 | 650 | staticTool={selection?.static} |
| 638 | 651 | policy={selectedTool.policy} |
| 639 | − onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | |
| 640 | − onClearPolicy={(pattern, policyId) => | |
| 641 | − void policyActions.clear(pattern, policyId) | |
| 652 | + onSetPolicy={onSetPolicy} | |
| 653 | + onClearPolicy={onClearPolicy} | |
| 654 | + // The header badge must write and look up the SAME | |
| 655 | + // account-pinned pattern the tree row under this | |
| 656 | + // account uses, or it cannot recognize its own rule. | |
| 657 | + patternForDisplay={ | |
| 658 | + selection && !selection.static | |
| 659 | + ? accountPolicyPattern(selection.owner, selection.connection) | |
| 660 | + : undefined | |
| 642 | 661 | } |
| 643 | 662 | {...(!selection?.static && selectedBareName |
| 644 | 663 | ? { |
| @@ -2,7 +2,11 @@ import { useMemo, useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { Link } from "@tanstack/react-router"; |
| 3 | 3 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | −import { ToolAddress, effectivePolicyFromSorted } from "@executor-js/sdk/shared"; | |
| 5 | +import { | |
| 6 | + ToolAddress, | |
| 7 | + effectivePolicyFromSorted, | |
| 8 | + type ToolPolicyAction, | |
| 9 | +} from "@executor-js/sdk/shared"; | |
| 6 | 10 | |
| 7 | 11 | import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms"; |
| 8 | 12 | import { usePolicyActions } from "../hooks/use-policy-actions"; |
| @@ -13,6 +17,7 @@ import { Skeleton } from "../components/skeleton"; · 4 unmodified lines | ||
| 13 | 17 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 14 | 18 | import { ErrorState } from "../components/error-state"; |
| 15 | 19 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 20 | +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | |
| 16 | 21 | |
| 17 | 22 | // Dynamic tool policy patterns are derived from the connection-aware address. |
| 18 | 23 | // Static tools (for example Executor's own tools) use their address directly. |
| @@ -38,6 +43,15 @@ export function ToolsPage() { · 19 unmodified lines | ||
| 38 | 43 | const refreshTools = useAtomRefresh(toolsAllAtom); |
| 39 | 44 | const policies = useAtomValue(policiesOptimisticAtom); |
| 40 | 45 | const policyActions = usePolicyActions("org"); |
| 46 | + // Policies here are workspace rules, which the server refuses for non-admin | |
| 47 | + // members. Offer the menus only to those who can actually write them. | |
| 48 | + const canSetPolicy = useCanCreateWorkspaceConnections(); | |
| 49 | + const onSetPolicy = canSetPolicy | |
| 50 | + ? (pattern: string, action: ToolPolicyAction) => void policyActions.set(pattern, action) | |
| 51 | + : undefined; | |
| 52 | + const onClearPolicy = canSetPolicy | |
| 53 | + ? (pattern: string, policyId?: string) => void policyActions.clear(pattern, policyId) | |
| 54 | + : undefined; | |
| 41 | 55 | |
| 42 | 56 | const [selectedToolId, setSelectedToolId] = useState<string | null>(null); |
| 43 | 57 | |
| @@ -144,8 +158,8 @@ export function ToolsPage() { · 100 unmodified lines | ||
| 144 | 158 | tools={summaries} |
| 145 | 159 | selectedToolId={selectedToolId} |
| 146 | 160 | onSelect={setSelectedToolId} |
| 147 | − onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | |
| 148 | − onClearPolicy={(pattern) => void policyActions.clear(pattern)} | |
| 161 | + onSetPolicy={onSetPolicy} | |
| 162 | + onClearPolicy={onClearPolicy} | |
| 149 | 163 | policies={sortedPolicies} |
| 150 | 164 | /> |
| 151 | 165 | </div> |
| @@ -156,10 +170,8 @@ export function ToolsPage() { · 4 unmodified lines | ||
| 156 | 170 | toolName={selectedTool.name} |
| 157 | 171 | staticTool={selection?.static} |
| 158 | 172 | policy={selectedTool.policy} |
| 159 | − onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | |
| 160 | − onClearPolicy={(pattern, policyId) => | |
| 161 | − void policyActions.clear(pattern, policyId) | |
| 162 | − } | |
| 173 | + onSetPolicy={onSetPolicy} | |
| 174 | + onClearPolicy={onClearPolicy} | |
| 163 | 175 | /> |
| 164 | 176 | ) : ( |
| 165 | 177 | <ToolDetailEmpty hasTools={summaries.length > 0} /> |