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 | // the category (group) row menu writes a subtree rule. The product promises | 4 | // the category (group) row menu writes a subtree rule. The product promises |
| 5 | // under test: | 5 | // under test: |
| 6 | // | 6 | // |
| 7 | // 1. Both menus surface the REAL stored pattern (connection-wildcarded | 7 | // 1. Both menus surface the REAL stored pattern (pinned to the account the |
| 8 | // `integration.*.*.tool`) before anything is written. | 8 | // row sits under, `integration.<owner>.<connection>.tool`) before |
| 9 | // anything is written. | ||
| 9 | // 2. A leaf rule and a category rule coexist: the more specific leaf rule | 10 | // 2. A leaf rule and a category rule coexist: the more specific leaf rule |
| 10 | // keeps precedence over the later category rule, which covers the rest | 11 | // keeps precedence over the later category rule, which covers the rest |
| 11 | // of its group. | 12 | // of its group. |
| 12 | // 3. Rules are connection-agnostic: set from one account's section, they | 13 | // 3. Rules are account-scoped: set from one account's section, they leave |
| 13 | // govern the other account's rows too, and the menu there shows the | 14 | // the other account's rows untouched. Two connections of one |
| 14 | // active rule with a Clear option. | 15 | // integration are different credentials (a bot token and a user token), |
| 15 | // 4. The tool detail header's policy badge is the same authoring surface: | 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 | // it writes the same stored pattern, recognizes its own rule afterward | 21 | // it writes the same stored pattern, recognizes its own rule afterward |
| 17 | // (the Clear affordance), and Clear really removes the rule. | 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 | // server-side with exactly the owner/pattern/action the UI promised. | 24 | // server-side with exactly the owner/pattern/action the UI promised. |
| 20 | import { randomBytes } from "node:crypto"; | 25 | import { randomBytes } from "node:crypto"; |
| 21 | 26 | ||
| @@ -83,11 +88,12 @@ scenario( · 61 unmodified lines | |||
| 83 | const beta = ConnectionName.make(`beta${suffix}`); | 88 | const beta = ConnectionName.make(`beta${suffix}`); |
| 84 | const accounts = [alpha, beta] as const; | 89 | const accounts = [alpha, beta] as const; |
| 85 | 90 | ||
| 86 | // The UI hides owner/connection segments; a rule authored on a node is | 91 | // The UI hides owner/connection segments in the row labels, but a rule |
| 87 | // stored connection-wildcarded so it spans every account. | 92 | // authored under an account section is stored pinned to that account. |
| 88 | const leafPattern = `${integration}.*.*.records.create`; | 93 | const leafPattern = `${integration}.org.${alpha}.records.create`; |
| 89 | const categoryPattern = `${integration}.*.*.records.*`; | 94 | const categoryPattern = `${integration}.org.${alpha}.records.*`; |
| 90 | const listLeafPattern = `${integration}.*.*.records.list`; | 95 | const listLeafPattern = `${integration}.org.${alpha}.records.list`; |
| 96 | const betaAccountPattern = `${integration}.org.${beta}.*`; | ||
| 91 | 97 | ||
| 92 | // Selfhost scenarios share one workspace — remove everything this one | 98 | // Selfhost scenarios share one workspace — remove everything this one |
| 93 | // made (policies, connections, the integration) even on failure. | 99 | // made (policies, connections, the integration) even on failure. |
| @@ -159,6 +165,14 @@ scenario( · 65 unmodified lines | |||
| 159 | .getByRole("button") | 165 | .getByRole("button") |
| 160 | .filter({ hasText: leaf }) | 166 | .filter({ hasText: leaf }) |
| 161 | .getByLabel(label, { exact: true }); | 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 | const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" }); | 176 | const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" }); |
| 163 | 177 | ||
| 164 | await step("Open the integration's Tools tab", async () => { | 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 | await closedGroup(beta, integration).click(); | 272 | await closedGroup(beta, integration).click(); |
| 259 | await closedGroup(beta, "records").click(); | 273 | await closedGroup(beta, "records").click(); |
| 260 | await leafIndicator(beta, "create", `Blocked (matched ${leafPattern})`).waitFor(); | 274 | await sectionFor(beta).getByRole("button").filter({ hasText: "create" }).waitFor(); |
| 261 | await leafIndicator( | 275 | expect( |
| 262 | beta, | 276 | await leafIndicator(beta, "create", `Blocked (matched ${leafPattern})`).count(), |
| 263 | "list", | 277 | "a rule set under one account does not block the same tool on another", |
| 264 | `Require approval (matched ${categoryPattern})`, | 278 | ).toBe(0); |
| 265 | ).waitFor(); | 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 | await step("Reopening the menu offers to clear the active rule", async () => { | 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 | await page.getByRole("menuitem", { name: "Clear" }).waitFor(); | 291 | await page.getByRole("menuitem", { name: "Clear" }).waitFor(); |
| 271 | await page.keyboard.press("Escape"); | 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 | await step("Open the tool detail for records.list", async () => { | 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 | // The header badge reflects the inherited category rule. | 323 | // The header badge reflects the inherited category rule. |
| 277 | await page.getByRole("button", { name: `Matched policy: ${categoryPattern}` }).waitFor(); | 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 | expect(await action.isDisabled()).toBe(true); | 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 | await step("Member can still add a personal connection", async () => { | 123 | await step("Member can still add a personal connection", async () => { |
| 112 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); | 124 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); |
| 113 | const dialog = page.getByRole("dialog"); | 125 | const dialog = page.getByRole("dialog"); |
| @@ -53,6 +53,20 @@ describe("frontend error reporting", () => { · 52 unmodified lines | |||
| 53 | expect(messageFromExit(Exit.fail({ reason: "unknown" }), "Fallback")).toBe("Fallback"); | 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 | it("reports failed exits with the provided context", () => { | 70 | it("reports failed exits with the provided context", () => { |
| 57 | const exit = Exit.fail({ message: "Could not update integration" }); | 71 | const exit = Exit.fail({ message: "Could not update integration" }); |
| 58 | const { calls, report } = captureReports(); | 72 | const { calls, report } = captureReports(); |
| @@ -21,8 +21,17 @@ class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{ · 20 unmodified lines | |||
| 21 | readonly context: FrontendErrorContext; | 21 | readonly context: FrontendErrorContext; |
| 22 | }> {} | 22 | }> {} |
| 23 | 23 | ||
| 24 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | 24 | // Effect's tagged error classes (`Schema.TaggedErrorClass`) often declare no |
| 25 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | 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 | const TaggedValue = Schema.Struct({ _tag: Schema.String }); | 36 | const TaggedValue = Schema.Struct({ _tag: Schema.String }); |
| 28 | const decodeTaggedValue = Schema.decodeUnknownOption(TaggedValue); | 37 | const decodeTaggedValue = Schema.decodeUnknownOption(TaggedValue); |
| @@ -3,7 +3,7 @@ import { ChevronRightIcon, MoreHorizontalIcon, SearchIcon, XIcon } from "lucide- · 2 unmodified lines | |||
| 3 | import type { EffectivePolicy, Owner, ToolPolicyAction } from "@executor-js/sdk/shared"; | 3 | import type { EffectivePolicy, Owner, ToolPolicyAction } from "@executor-js/sdk/shared"; |
| 4 | import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; | 4 | import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; |
| 5 | import { trackEvent } from "../api/analytics"; | 5 | import { trackEvent } from "../api/analytics"; |
| 6 | import { toPolicyPattern } from "../lib/policy-pattern"; | 6 | import { accountPolicyPattern, toPolicyPattern } from "../lib/policy-pattern"; |
| 7 | import { Badge } from "./badge"; | 7 | import { Badge } from "./badge"; |
| 8 | import { Button } from "./button"; | 8 | import { Button } from "./button"; |
| 9 | import { Input } from "./input"; | 9 | import { Input } from "./input"; |
| @@ -297,7 +297,9 @@ export function ToolTree(props: { · 287 unmodified lines | |||
| 297 | * emit the tool's full dotted id; group rows emit `prefix.*`. */ | 297 | * emit the tool's full dotted id; group rows emit `prefix.*`. */ |
| 298 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; | 298 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 299 | onClearPolicy?: (pattern: string) => void; | 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 | patternForDisplay?: (displayPattern: string) => string; | 303 | patternForDisplay?: (displayPattern: string) => string; |
| 302 | /** Sorted user-authored policies (most-precedent first). Used to | 304 | /** Sorted user-authored policies (most-precedent first). Used to |
| 303 | * decide whether a node has its own exact-pattern user rule today | 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 | : (props.emptyLabel ?? "No tools available")} | 431 | : (props.emptyLabel ?? "No tools available")} |
| 430 | </div> | 432 | </div> |
| 431 | ) : groupByConnection ? ( | 433 | ) : groupByConnection ? ( |
| 432 | accountGroups.map((group) => ( | 434 | accountGroups.map((group) => { |
| 433 | <section key={group.key}> | 435 | // Rows inside an account section author rules for THAT account |
| 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"> | 436 | // only: two connections of one integration are different |
| 435 | {ownerDisplay.showOwnerLabels ? ( | 437 | // credentials, so a rule set under one must not govern the other. |
| 436 | <Badge variant="outline" className="shrink-0 text-[10px]"> | 438 | const accountPattern = accountPolicyPattern(group.owner, group.connection); |
| 437 | {ownerLabel(group.owner)} | 439 | const wholeAccountPattern = accountPattern(`${group.integration}.*`); |
| 438 | </Badge> | 440 | const wholeAccountRule = exactPatterns.get(wholeAccountPattern); |
| 439 | ) : null} | 441 | const accountLabel = |
| 440 | <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground"> | 442 | group.integration && group.connection |
| 441 | {group.integration && group.connection | 443 | ? `${group.integration} / ${group.connection}` |
| 442 | ? `${group.integration} / ${group.connection}` | 444 | : group.connection || ownerDisplay.label(group.owner); |
| 443 | : group.connection || ownerDisplay.label(group.owner)} | 445 | return ( |
| 444 | </span> | 446 | <section key={group.key}> |
| 445 | <span className="shrink-0 tabular-nums text-xs text-muted-foreground"> | 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"> |
| 446 | {group.tools.length} | 448 | {ownerDisplay.showOwnerLabels ? ( |
| 447 | </span> | 449 | <Badge variant="outline" className="shrink-0 text-[10px]"> |
| 448 | </header> | 450 | {ownerLabel(group.owner)} |
| 449 | <ToolTreeBody key={group.key} tools={group.tools} {...bodyProps} /> | 451 | </Badge> |
| 450 | </section> | 452 | ) : null} |
| 451 | )) | 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 | <ToolTreeBody tools={filteredTools} {...bodyProps} /> | 484 | <ToolTreeBody tools={filteredTools} {...bodyProps} /> |
| 454 | )} | 485 | )} |
| @@ -11,20 +11,14 @@ import { Link } from "@tanstack/react-router"; · 10 unmodified lines | |||
| 11 | import * as Exit from "effect/Exit"; | 11 | import * as Exit from "effect/Exit"; |
| 12 | import * as Option from "effect/Option"; | 12 | import * as Option from "effect/Option"; |
| 13 | import * as Predicate from "effect/Predicate"; | 13 | import * as Predicate from "effect/Predicate"; |
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 14 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 16 | 15 | ||
| 17 | import { integrationsOptimisticAtom } from "../api/atoms"; | 16 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 18 | 17 | import { messageFromExit } from "../api/error-reporting"; | |
| 19 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 20 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | ||
| 21 | 18 | ||
| 22 | /** The failed Exit's `message`, or `fallback` when the error carries none. */ | 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 => | 20 | export const errorMessageFromExit: (exit: Exit.Exit<unknown, unknown>, fallback: string) => string = |
| 24 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { | 21 | messageFromExit; |
| 25 | onNone: () => fallback, | ||
| 26 | onSome: ({ message }) => message, | ||
| 27 | }); | ||
| 28 | 22 | ||
| 29 | export const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean => | 23 | export const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean => |
| 30 | Option.match(Exit.findErrorOption(exit), { | 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 | // Policy pattern bridge. | 4 | // Policy pattern bridge. |
| @@ -29,3 +29,29 @@ export const toPolicyPattern = (displayPattern: string): string => { · 24 unmodified lines | |||
| 29 | }; | 29 | }; |
| 30 | 30 | ||
| 31 | export { matchPattern }; | 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 | effectivePolicyFromSorted, | 13 | effectivePolicyFromSorted, |
| 14 | type Connection, | 14 | type Connection, |
| 15 | type Owner, | 15 | type Owner, |
| 16 | type ToolPolicyAction, | ||
| 16 | } from "@executor-js/sdk/shared"; | 17 | } from "@executor-js/sdk/shared"; |
| 17 | import { | 18 | import { |
| 18 | checkConnectionHealth, | 19 | checkConnectionHealth, |
| @@ -46,6 +47,7 @@ import { useExecutorDocumentTitle } from "../lib/document-title"; · 27 unmodified lines | |||
| 46 | import { ErrorState } from "../components/error-state"; | 47 | import { ErrorState } from "../components/error-state"; |
| 47 | import { isAsyncResultLoading } from "../lib/async-result"; | 48 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 48 | import { useConnectionsHealth } from "../lib/use-connection-health"; | 49 | import { useConnectionsHealth } from "../lib/use-connection-health"; |
| 50 | import { accountPolicyPattern } from "../lib/policy-pattern"; | ||
| 49 | import { | 51 | import { |
| 50 | integrationDetailInternalTabFromSearch, | 52 | integrationDetailInternalTabFromSearch, |
| 51 | type IntegrationDetailInternalTab, | 53 | type IntegrationDetailInternalTab, |
| @@ -142,6 +144,16 @@ export function IntegrationDetailPage(props: { · 90 unmodified lines | |||
| 142 | // Integrations are workspace-owned; the server refuses catalog mutations | 144 | // Integrations are workspace-owned; the server refuses catalog mutations |
| 143 | // (update/remove) from non-admin members, so disable the controls for them. | 145 | // (update/remove) from non-admin members, so disable the controls for them. |
| 144 | const canMutateIntegration = useCanCreateWorkspaceConnections(); | 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 | const canEdit = !isBuiltInIntegration && integrationData !== null; | 157 | const canEdit = !isBuiltInIntegration && integrationData !== null; |
| 146 | const canRefresh = integrationData?.canRefresh ?? false; | 158 | const canRefresh = integrationData?.canRefresh ?? false; |
| 147 | const canRemove = integrationData?.canRemove ?? false; | 159 | const canRemove = integrationData?.canRemove ?? false; |
| @@ -301,6 +313,7 @@ export function IntegrationDetailPage(props: { · 153 unmodified lines | |||
| 301 | policy: effectivePolicyFromSorted(matchId, policyList, t.requiresApproval), | 313 | policy: effectivePolicyFromSorted(matchId, policyList, t.requiresApproval), |
| 302 | owner: t.owner, | 314 | owner: t.owner, |
| 303 | connection: t.connection, | 315 | connection: t.connection, |
| 316 | integration: t.integration, | ||
| 304 | }; | 317 | }; |
| 305 | }); | 318 | }); |
| 306 | }, [tools, policyList]); | 319 | }, [tools, policyList]); |
| @@ -620,8 +633,8 @@ export function IntegrationDetailPage(props: { · 313 unmodified lines | |||
| 620 | tools={integrationTools} | 633 | tools={integrationTools} |
| 621 | selectedToolId={selectedToolId} | 634 | selectedToolId={selectedToolId} |
| 622 | onSelect={setSelectedToolId} | 635 | onSelect={setSelectedToolId} |
| 623 | onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | 636 | onSetPolicy={onSetPolicy} |
| 624 | onClearPolicy={(pattern) => void policyActions.clear(pattern)} | 637 | onClearPolicy={onClearPolicy} |
| 625 | policies={sortedPolicies} | 638 | policies={sortedPolicies} |
| 626 | groupByConnection={!isBuiltInIntegration} | 639 | groupByConnection={!isBuiltInIntegration} |
| 627 | emptyLabel={hasToolSyncIssue ? emptyToolsTitle : undefined} | 640 | emptyLabel={hasToolSyncIssue ? emptyToolsTitle : undefined} |
| @@ -636,9 +649,15 @@ export function IntegrationDetailPage(props: { · 8 unmodified lines | |||
| 636 | toolName={selectedTool.name} | 649 | toolName={selectedTool.name} |
| 637 | staticTool={selection?.static} | 650 | staticTool={selection?.static} |
| 638 | policy={selectedTool.policy} | 651 | policy={selectedTool.policy} |
| 639 | onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | 652 | onSetPolicy={onSetPolicy} |
| 640 | onClearPolicy={(pattern, policyId) => | 653 | onClearPolicy={onClearPolicy} |
| 641 | void policyActions.clear(pattern, policyId) | 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 | {...(!selection?.static && selectedBareName | 662 | {...(!selection?.static && selectedBareName |
| 644 | ? { | 663 | ? { |
| @@ -2,7 +2,11 @@ import { useMemo, useState } from "react"; · 1 unmodified line | |||
| 2 | import { Link } from "@tanstack/react-router"; | 2 | import { Link } from "@tanstack/react-router"; |
| 3 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 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 | import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms"; | 11 | import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms"; |
| 8 | import { usePolicyActions } from "../hooks/use-policy-actions"; | 12 | import { usePolicyActions } from "../hooks/use-policy-actions"; |
| @@ -13,6 +17,7 @@ import { Skeleton } from "../components/skeleton"; · 4 unmodified lines | |||
| 13 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 17 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 14 | import { ErrorState } from "../components/error-state"; | 18 | import { ErrorState } from "../components/error-state"; |
| 15 | import { isAsyncResultLoading } from "../lib/async-result"; | 19 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 20 | import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; | ||
| 16 | 21 | ||
| 17 | // Dynamic tool policy patterns are derived from the connection-aware address. | 22 | // Dynamic tool policy patterns are derived from the connection-aware address. |
| 18 | // Static tools (for example Executor's own tools) use their address directly. | 23 | // Static tools (for example Executor's own tools) use their address directly. |
| @@ -38,6 +43,15 @@ export function ToolsPage() { · 19 unmodified lines | |||
| 38 | const refreshTools = useAtomRefresh(toolsAllAtom); | 43 | const refreshTools = useAtomRefresh(toolsAllAtom); |
| 39 | const policies = useAtomValue(policiesOptimisticAtom); | 44 | const policies = useAtomValue(policiesOptimisticAtom); |
| 40 | const policyActions = usePolicyActions("org"); | 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 | const [selectedToolId, setSelectedToolId] = useState<string | null>(null); | 56 | const [selectedToolId, setSelectedToolId] = useState<string | null>(null); |
| 43 | 57 | ||
| @@ -144,8 +158,8 @@ export function ToolsPage() { · 100 unmodified lines | |||
| 144 | tools={summaries} | 158 | tools={summaries} |
| 145 | selectedToolId={selectedToolId} | 159 | selectedToolId={selectedToolId} |
| 146 | onSelect={setSelectedToolId} | 160 | onSelect={setSelectedToolId} |
| 147 | onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | 161 | onSetPolicy={onSetPolicy} |
| 148 | onClearPolicy={(pattern) => void policyActions.clear(pattern)} | 162 | onClearPolicy={onClearPolicy} |
| 149 | policies={sortedPolicies} | 163 | policies={sortedPolicies} |
| 150 | /> | 164 | /> |
| 151 | </div> | 165 | </div> |
| @@ -156,10 +170,8 @@ export function ToolsPage() { · 4 unmodified lines | |||
| 156 | toolName={selectedTool.name} | 170 | toolName={selectedTool.name} |
| 157 | staticTool={selection?.static} | 171 | staticTool={selection?.static} |
| 158 | policy={selectedTool.policy} | 172 | policy={selectedTool.policy} |
| 159 | onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | 173 | onSetPolicy={onSetPolicy} |
| 160 | onClearPolicy={(pattern, policyId) => | 174 | onClearPolicy={onClearPolicy} |
| 161 | void policyActions.clear(pattern, policyId) | ||
| 162 | } | ||
| 163 | /> | 175 | /> |
| 164 | ) : ( | 176 | ) : ( |
| 165 | <ToolDetailEmpty hasTools={summaries.length > 0} /> | 177 | <ToolDetailEmpty hasTools={summaries.length > 0} /> |