diff --git a/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx b/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx index 2a79e2c90b4..a0cd9ece823 100644 --- a/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx +++ b/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx @@ -135,7 +135,7 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent enabledIds={new Set(s.tools.filter((entry) => s.permissions[entry.id] !== "off").map((entry) => entry.id))} askFirstIds={new Set(s.tools.filter((entry) => s.permissions[entry.id] === "ask_first").map((entry) => entry.id))} disabled={!s.connected} refreshPending={s.refreshing} canConfigure - onSetPermission={(id, next) => change({ permissions: { ...s.permissions, [id]: next === "ask" ? "ask_first" : next } })} + onSetPermission={(ids, next) => change({ permissions: { ...s.permissions, ...Object.fromEntries(ids.map((id) => [id, next === "ask" ? "ask_first" : next])) } })} onReviewQuarantined={() => {}} onRefreshActions={a.refresh} /> }
diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 743080ddd21..77b8b3fd5de 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -52,6 +52,7 @@ import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs import { ConnectionProvenanceChip } from "./ConnectionProvenanceChip"; import { IdentitiesSection } from "./app-detail/IdentitiesSection"; import { PermissionsPanel } from "./app-detail/PermissionsPanel"; +import { actionPermissionMutation } from "./app-detail/action-permissions"; import { RailwayAccessPanel } from "./app-detail/RailwayAccessPanel"; import { ReviewPanel } from "./app-detail/ReviewPanel"; import { @@ -691,7 +692,7 @@ export function AppDetail({ renderActions, onReconnect }: { } onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? next : accessIncludingInstalls(next, install) })} onRefreshActions={() => refreshTools.mutate()} - onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))} + onSetActionPermission={(ids, next) => apply(actionPermissionMutation(ids, next, enabledIds, askFirstIds))} onReviewQuarantined={reviewQuarantined} /> {managesRemoteMcpAccess && isRemoteMcpConnectorId(connection.config?.sourceTemplateKey) && , - askFirstIds: Set, -) { - const enabled = new Set(enabledIds); - const askFirst = new Set(askFirstIds); - if (next === "off") { - enabled.delete(id); - askFirst.delete(id); - } else { - enabled.add(id); - if (next === "ask") askFirst.add(id); - else askFirst.delete(id); - } - return { enabled, askFirst }; -} diff --git a/ui/src/pages/apps/app-detail/PermissionsPanel.group.test.tsx b/ui/src/pages/apps/app-detail/PermissionsPanel.group.test.tsx new file mode 100644 index 00000000000..fb8cf0f09e8 --- /dev/null +++ b/ui/src/pages/apps/app-detail/PermissionsPanel.group.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ToolCatalogEntry } from "@paperclipai/shared"; +import { ActionsSection } from "./PermissionsPanel"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", issuePrefix: "PAP" } }), +})); + +let container: HTMLDivElement | null = null; + +afterEach(() => { + container?.remove(); + container = null; +}); + +const entry = (id: string, riskLevel: string) => + ({ id, toolName: id, title: id, riskLevel, status: "active" }) as unknown as ToolCatalogEntry; + +describe("Permissions group control", () => { + it("sets every action in the group in one change", () => { + const onSetPermission = vi.fn(); + const writes = [entry("send", "write"), entry("delete", "destructive"), entry("label", "write")]; + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + flushSync(() => + root.render( + + + + + , + ), + ); + + const select = container.querySelector('select[aria-label="Set every action in Write (3)"]'); + expect(select).toBeTruthy(); + expect(select!.value).toBe(""); + flushSync(() => { + select!.value = "ask"; + select!.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onSetPermission).toHaveBeenCalledTimes(1); + const [ids, next] = onSetPermission.mock.calls[0]!; + expect([...ids].sort()).toEqual(["delete", "label", "send"]); + expect(next).toBe("ask"); + }); +}); diff --git a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx index 5bb2f79fa83..033a35f8814 100644 --- a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx +++ b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx @@ -53,7 +53,7 @@ export function PermissionsPanel({ connectionId: string; install: InstallState; onSaveAccess: (next: AccessDraft) => void; - onSetActionPermission: (id: string, next: ActionPermission) => void; + onSetActionPermission: (ids: string[], next: ActionPermission) => void; onReviewQuarantined: (enabledIds: string[]) => void; onRefreshActions: () => void; refreshPending: boolean; @@ -222,7 +222,7 @@ export function ActionsSection({ focusId?: string | null; canConfigure: boolean; permissionChangeWarning?: string; - onSetPermission: (id: string, next: ActionPermission) => void; + onSetPermission: (ids: string[], next: ActionPermission) => void; onReviewQuarantined: (enabledIds: string[]) => void; onRefreshActions: () => void; }) { @@ -316,9 +316,9 @@ export function ActionsSection({ disabled={disabled} focusId={focusId} canConfigure={canConfigure} - onSetPermission={(id, next) => { + onSetPermission={(ids, next) => { setShowPermissionChangeWarning(true); - onSetPermission(id, next); + onSetPermission(ids, next); }} /> { + onSetPermission={(ids, next) => { setShowPermissionChangeWarning(true); - onSetPermission(id, next); + onSetPermission(ids, next); }} />
@@ -381,7 +381,7 @@ function ActionGroup({ disabled: boolean; focusId?: string | null; canConfigure: boolean; - onSetPermission: (id: string, next: ActionPermission) => void; + onSetPermission: (ids: string[], next: ActionPermission) => void; }) { if (actions.length === 0) return null; const groupValue = (() => { @@ -395,9 +395,9 @@ function ActionGroup({

{title}

{canConfigure ? ( - // PAP-659 C6b: set the whole group at once. Setting seventeen write - // actions one row at a time is the reason the armed default would - // otherwise be hard to live with; per-row overrides stay underneath. + // PAP-659 C6b: set the whole group at once, so narrowing a fresh + // connection's writes is one choice rather than one per action; + // per-row overrides stay underneath.