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.
{`Set every action in ${title}`}
{
- const next = event.target.value as ActionPermission;
- for (const action of actions) onSetPermission(action.id, next);
+ // One change for the whole group: each row's setter rebuilds the
+ // full permission set from the same render, so looping it lets
+ // the last call overwrite the others.
+ onSetPermission(actions.map((action) => action.id), event.target.value as ActionPermission);
}}
className="h-7 rounded-md border border-input bg-background px-2 text-xs text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
>
@@ -465,7 +467,7 @@ function ActionRow({
disabled: boolean;
focused: boolean;
canConfigure: boolean;
- onSetPermission: (id: string, next: ActionPermission) => void;
+ onSetPermission: (ids: string[], next: ActionPermission) => void;
}) {
const rowRef = useRef(null);
const [testOpen, setTestOpen] = useState(false);
@@ -522,7 +524,7 @@ function ActionRow({
aria-checked={selected}
aria-label={`${title}: ${option.label}`}
disabled={disabled}
- onClick={() => onSetPermission(action.id, option.value)}
+ onClick={() => onSetPermission([action.id], option.value)}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-sm text-muted-foreground outline-none transition-colors",
"hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
diff --git a/ui/src/pages/apps/app-detail/action-permissions.test.ts b/ui/src/pages/apps/app-detail/action-permissions.test.ts
new file mode 100644
index 00000000000..8e24bc9bec6
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/action-permissions.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { actionPermissionMutation } from "./action-permissions";
+
+describe("actionPermissionMutation", () => {
+ const enabled = new Set(["a", "b", "c"]);
+ const askFirst = new Set(["b"]);
+
+ it("sets every action in a group at once", () => {
+ expect(actionPermissionMutation(["a", "b", "c"], "ask", enabled, askFirst)).toEqual({
+ enabled: new Set(["a", "b", "c"]),
+ askFirst: new Set(["a", "b", "c"]),
+ });
+ expect(actionPermissionMutation(["a", "b", "c"], "off", enabled, askFirst)).toEqual({
+ enabled: new Set(),
+ askFirst: new Set(),
+ });
+ expect(actionPermissionMutation(["a", "b", "c"], "allowed", new Set(), askFirst)).toEqual({
+ enabled: new Set(["a", "b", "c"]),
+ askFirst: new Set(),
+ });
+ });
+
+ it("leaves actions outside the group alone", () => {
+ expect(actionPermissionMutation(["a"], "off", enabled, askFirst)).toEqual({
+ enabled: new Set(["b", "c"]),
+ askFirst: new Set(["b"]),
+ });
+ });
+});
diff --git a/ui/src/pages/apps/app-detail/action-permissions.ts b/ui/src/pages/apps/app-detail/action-permissions.ts
new file mode 100644
index 00000000000..b4f01cf3587
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/action-permissions.ts
@@ -0,0 +1,27 @@
+/**
+ * Apply one permission to a set of actions in a single change.
+ *
+ * Every save rewrites the connection's whole enabled/ask-first set, so a
+ * group control must not issue one save per action: each would start from the
+ * same render and the last would overwrite the rest.
+ */
+export function actionPermissionMutation(
+ ids: string[],
+ next: "off" | "allowed" | "ask",
+ enabledIds: Set,
+ askFirstIds: Set,
+) {
+ const enabled = new Set(enabledIds);
+ const askFirst = new Set(askFirstIds);
+ for (const id of ids) {
+ 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/storybook/stories/fireflies-pr.stories.tsx b/ui/storybook/stories/fireflies-pr.stories.tsx
index cd8c691c222..18878d8832f 100644
--- a/ui/storybook/stories/fireflies-pr.stories.tsx
+++ b/ui/storybook/stories/fireflies-pr.stories.tsx
@@ -70,7 +70,7 @@ function Permissions() {
const [enabled, setEnabled] = useState(new Set(["transcripts", "summary"]));
const [ask, setAsk] = useState(new Set(["share"]));
const entries = [["transcripts", "List meeting transcripts", false], ["summary", "Get meeting summary and action items", false], ["transcript", "Get full transcript", false], ["share", "Share a meeting", true], ["revoke", "Revoke meeting access", true]].map(([id, title, write]) => ({ id, toolName: id, title, description: title, connectionId: "storybook-fireflies", entryKind: "tool", status: "active", isReadOnly: !write, isWrite: write, isDestructive: id === "revoke", riskLevel: write ? "medium" : "low" } as ToolCatalogEntry));
- return e.isReadOnly)} canChange={entries.filter((e) => e.isWrite)} quarantined={[]} enabledIds={enabled} askFirstIds={ask} disabled={false} refreshPending={false} canConfigure onSetPermission={(id, next) => { setEnabled((s) => { const n = new Set(s); if (next === "allowed") n.add(id); else n.delete(id); return n; }); setAsk((s) => { const n = new Set(s); if (next === "ask") n.add(id); else n.delete(id); return n; }); }} onReviewQuarantined={noop} onRefreshActions={noop} />;
+ return e.isReadOnly)} canChange={entries.filter((e) => e.isWrite)} quarantined={[]} enabledIds={enabled} askFirstIds={ask} disabled={false} refreshPending={false} canConfigure onSetPermission={(ids, next) => { setEnabled((s) => { const n = new Set(s); for (const id of ids) { if (next === "allowed") n.add(id); else n.delete(id); } return n; }); setAsk((s) => { const n = new Set(s); for (const id of ids) { if (next === "ask") n.add(id); else n.delete(id); } return n; }); }} onReviewQuarantined={noop} onRefreshActions={noop} />;
}
export const RetainedPermissions: Story = { name: "08 · Permissions — retained Allowed, Ask first, Off", render: () => };