History

Scope tool policies to the account they are set under (#2062)

Rhys Sullivan committed 1 month ago

Commit 29413d81 parent 4a08d8d11 files · +257 −67Browse files

Unified · Split

11 files
  1. .changeset/account-scoped-tool-policies.md +5 −0
  2. e2e/scenarios/policies-ui.test.ts +68 −21
  3. e2e/src/integration-creation-permissions.ts +12 −0
  4. packages/react/src/api/error-reporting.test.ts +14 −0
  5. packages/react/src/api/error-reporting.tsx +11 −2
  6. packages/react/src/components/tool-tree.tsx +53 −22
  7. packages/react/src/lib/integration-add.tsx +3 −9
  8. packages/react/src/lib/policy-pattern.test.ts +21 −0
  9. packages/react/src/lib/policy-pattern.ts +27 −1
  10. packages/react/src/pages/integration-detail.tsx +24 −5
  11. packages/react/src/pages/tools.tsx +19 −7
.changeset/account-scoped-tool-policies.md+5−0
@@ -0,0 +1,5 @@
1---
2"@executor-js/react": patch
3---
4
5Tool 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.
e2e/scenarios/policies-ui.test.ts+68−21
@@ -4,18 +4,23 @@ · 3 unmodified lines
4// the category (group) row menu writes a subtree rule. The product promises4// 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-wildcarded7// 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 rule10// 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 rest11// 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, they13// 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 the14// 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 afterward21// 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 persist23// 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.
20import { randomBytes } from "node:crypto";25import { 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 is91 // 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 one98 // 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 });
e2e/src/integration-creation-permissions.ts+12−0
@@ -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");
packages/react/src/api/error-reporting.test.ts+14−0
@@ -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();
packages/react/src/api/error-reporting.tsx+11−2
@@ -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
24const ErrorMessage = Schema.Struct({ message: Schema.String });24// Effect's tagged error classes (`Schema.TaggedErrorClass`) often declare no
25const 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.
28const 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
27const TaggedValue = Schema.Struct({ _tag: Schema.String });36const TaggedValue = Schema.Struct({ _tag: Schema.String });
28const decodeTaggedValue = Schema.decodeUnknownOption(TaggedValue);37const decodeTaggedValue = Schema.decodeUnknownOption(TaggedValue);
packages/react/src/components/tool-tree.tsx+53−22
@@ -3,7 +3,7 @@ import { ChevronRightIcon, MoreHorizontalIcon, SearchIcon, XIcon } from "lucide- · 2 unmodified lines
3import type { EffectivePolicy, Owner, ToolPolicyAction } from "@executor-js/sdk/shared";3import type { EffectivePolicy, Owner, ToolPolicyAction } from "@executor-js/sdk/shared";
4import { ownerLabel, useOwnerDisplay } from "../api/owner-display";4import { ownerLabel, useOwnerDisplay } from "../api/owner-display";
5import { trackEvent } from "../api/analytics";5import { trackEvent } from "../api/analytics";
6import { toPolicyPattern } from "../lib/policy-pattern";6import { accountPolicyPattern, toPolicyPattern } from "../lib/policy-pattern";
7import { Badge } from "./badge";7import { Badge } from "./badge";
8import { Button } from "./button";8import { Button } from "./button";
9import { Input } from "./input";9import { 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 to304 /** Sorted user-authored policies (most-precedent first). Used to
303 * decide whether a node has its own exact-pattern user rule today305 * 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.connection443 ? `${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 )}
packages/react/src/lib/integration-add.tsx+3−9
@@ -11,20 +11,14 @@ import { Link } from "@tanstack/react-router"; · 10 unmodified lines
11import * as Exit from "effect/Exit";11import * as Exit from "effect/Exit";
12import * as Option from "effect/Option";12import * as Option from "effect/Option";
13import * as Predicate from "effect/Predicate";13import * as Predicate from "effect/Predicate";
14import * as Schema from "effect/Schema";
15import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";14import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
16 15
17import { integrationsOptimisticAtom } from "../api/atoms";16import { integrationsOptimisticAtom } from "../api/atoms";
18 17import { messageFromExit } from "../api/error-reporting";
19const ErrorMessage = Schema.Struct({ message: Schema.String });
20const 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. */
23export const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>20export 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
29export const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean =>23export const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean =>
30 Option.match(Exit.findErrorOption(exit), {24 Option.match(Exit.findErrorOption(exit), {
packages/react/src/lib/policy-pattern.test.ts+21−0
@@ -0,0 +1,21 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { accountPolicyPattern, toPolicyPattern } from "./policy-pattern";
4
5describe("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});
packages/react/src/lib/policy-pattern.ts+27−1
@@ -1,4 +1,4 @@
1import { matchPattern } from "@executor-js/sdk/shared";1import { 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
31export { matchPattern };31export { 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
48export 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 };
packages/react/src/pages/integration-detail.tsx+24−5
@@ -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";
17import {18import {
18 checkConnectionHealth,19 checkConnectionHealth,
@@ -46,6 +47,7 @@ import { useExecutorDocumentTitle } from "../lib/document-title"; · 27 unmodified lines
46import { ErrorState } from "../components/error-state";47import { ErrorState } from "../components/error-state";
47import { isAsyncResultLoading } from "../lib/async-result";48import { isAsyncResultLoading } from "../lib/async-result";
48import { useConnectionsHealth } from "../lib/use-connection-health";49import { useConnectionsHealth } from "../lib/use-connection-health";
50import { accountPolicyPattern } from "../lib/policy-pattern";
49import {51import {
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 mutations144 // 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 && selectedBareName662 {...(!selection?.static && selectedBareName
644 ? {663 ? {
packages/react/src/pages/tools.tsx+19−7
@@ -2,7 +2,11 @@ import { useMemo, useState } from "react"; · 1 unmodified line
2import { Link } from "@tanstack/react-router";2import { Link } from "@tanstack/react-router";
3import { useAtomRefresh, useAtomValue } from "@effect/atom-react";3import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { ToolAddress, effectivePolicyFromSorted } from "@executor-js/sdk/shared";5import {
6 ToolAddress,
7 effectivePolicyFromSorted,
8 type ToolPolicyAction,
9} from "@executor-js/sdk/shared";
6 10
7import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms";11import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms";
8import { usePolicyActions } from "../hooks/use-policy-actions";12import { usePolicyActions } from "../hooks/use-policy-actions";
@@ -13,6 +17,7 @@ import { Skeleton } from "../components/skeleton"; · 4 unmodified lines
13import { useExecutorDocumentTitle } from "../lib/document-title";17import { useExecutorDocumentTitle } from "../lib/document-title";
14import { ErrorState } from "../components/error-state";18import { ErrorState } from "../components/error-state";
15import { isAsyncResultLoading } from "../lib/async-result";19import { isAsyncResultLoading } from "../lib/async-result";
20import { 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} />

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t