From b63aef204c80b84857697c608edfaf26bfcf3285 Mon Sep 17 00:00:00 2001
From: scotttong
Date: Mon, 28 Sep 2026 21:41:36 -0700
Subject: [PATCH] feat(connections): one Advanced disclosure, ranked default,
armed write gate
Completes PAP-659 plan steps C0/C1/C2 and C6/C7 on this worktree.
- Fold the key step's own Advanced settings into the same disclosure that
holds the access controls, so every connector has exactly one Advanced in
the same place rather than two on one screen.
- Keep the disclosure open when what is inside it is load-bearing: a
non-default method in use, or a selection the connector still needs.
- Restore first-attempt vs retry semantics on the single primary action: the
first press creates or resumes, and only a retry pays for the defensive
re-read that recovers a create whose response was lost.
- Show the resolved default on the one-time Cloud enrolment screen too, and
state every reason the default cannot apply rather than only the first.
- Show a loading state when a deep link arrives before the gallery resolves
its definition; the deleted Access step used to carry that wait.
- C6a: recommendedDefaultsForApp now returns
askFirstRiskLevels ["write","destructive","high","critical"] instead of [],
arming the approval gate that was already built but had nothing behind it.
- C6b/C7: the Permissions tab gains a per-group bulk control and shows each
action's risk level, so a tool the classifier misfiled is visible.
Co-Authored-By: Claude Opus 5
---
packages/shared/src/app-definitions.test.ts | 4 +-
packages/shared/src/app-definitions.ts | 27 +++-
packages/shared/src/index.ts | 1 +
.../connections/ConnectionSetupFlow.tsx | 147 +++++++++++-------
ui/src/pages/apps/AppsConnect.test.tsx | 128 +++++++--------
.../apps/app-detail/PermissionsPanel.tsx | 46 +++++-
6 files changed, 220 insertions(+), 133 deletions(-)
diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts
index 5d476da7f67..0dd10f30ca7 100644
--- a/packages/shared/src/app-definitions.test.ts
+++ b/packages/shared/src/app-definitions.test.ts
@@ -617,12 +617,12 @@ describe("AppDefinition catalog", () => {
APP_DEFINITIONS.find((app) => app.slug === "hugging-face")?.methods[0]
?.defaults?.scopesHint,
).toEqual(["read-mcp"]));
- it("defaults every new connection action to allowed", () => {
+ it("defaults reads to allowed and state-changing actions to ask first", () => {
for (const app of APP_DEFINITIONS)
for (const method of app.methods)
expect(recommendedDefaultsForApp(app, method.key)).toEqual({
access: "all_agents",
- askFirstRiskLevels: [],
+ askFirstRiskLevels: ["write", "destructive", "high", "critical"],
});
});
it("defaults explicit read/write capability groups to their write-capable method", () => {
diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts
index 6f510b61fc8..0a148327b6d 100644
--- a/packages/shared/src/app-definitions.ts
+++ b/packages/shared/src/app-definitions.ts
@@ -246,16 +246,33 @@ export function resolveConnectionMethodServerUrl(
}
}
+/**
+ * Risk levels a fresh connection gates behind human approval (PAP-659 C6a).
+ *
+ * The runtime gate and its approval card already exist; what was missing was a
+ * default with anything behind it. Reads stay Allowed so the constant read
+ * traffic an agent does is never interrupted; anything that changes the
+ * provider's state asks first, and one click on the Permissions tab turns that
+ * off per connection.
+ *
+ * Two risk vocabularies are in use — `read|write|destructive` from the tool
+ * classifier and `low|medium|high|critical` from curated catalogs — so both
+ * are named rather than assuming callers normalise them.
+ */
+export const DEFAULT_ASK_FIRST_RISK_LEVELS = [
+ "write",
+ "destructive",
+ "high",
+ "critical",
+] as const;
+
export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record {
// Keep the parameters in the public contract: callers resolve defaults for a
- // concrete app/method even though the initial policy is now uniform. This is
- // an open default, not an approval bypass: connection finalization remains a
- // configure-authorized, audited operation, and Ask first stays available as
- // an operator-selected policy for any action after the connection is made.
+ // concrete app/method even though the initial policy is uniform.
void app;
void methodKey;
return {
access: "all_agents",
- askFirstRiskLevels: [],
+ askFirstRiskLevels: [...DEFAULT_ASK_FIRST_RISK_LEVELS],
};
}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 04b12167862..44b61a6e9b7 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -329,6 +329,7 @@ export {
getRecommendedConnectionMethod,
isConnectableAppSlug,
isAppStoreVisibleSlug,
+ DEFAULT_ASK_FIRST_RISK_LEVELS,
recommendedDefaultsForApp,
resolveConnectionMethodServerUrl,
} from "./app-definitions.js";
diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx
index 386ac193510..4f0bf2dfc81 100644
--- a/ui/src/features/connections/ConnectionSetupFlow.tsx
+++ b/ui/src/features/connections/ConnectionSetupFlow.tsx
@@ -1918,8 +1918,12 @@ function StandardConnectionSetupFlow({
: "api_key";
// PAP-659 C0: the resolved default is stated, not asked. `Change` opens the
// same controls the deleted Access step owned, inline and never blocking.
- const connectionDefaults = step === "key" ? (
+ const renderConnectionDefaults = step === "key" ? (
+ (extra?: ReactNode, forceOpen?: boolean) => (
Boolean(reason))}
authKind={accessStepAuthKind}
grantKinds={fixedGrantKind ? [fixedGrantKind] : accessStepMethod?.grantKinds}
grantKind={effectiveGrantKind}
@@ -1955,7 +1959,9 @@ function StandardConnectionSetupFlow({
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
disabled={connectMutation.isPending || oauthStartMutation.isPending}
/>
+ )
) : null;
+ const connectionDefaults = renderConnectionDefaults?.() ?? null;
const showCuratedOAuthState = Boolean(
automaticOAuthEntry
@@ -2007,6 +2013,8 @@ function StandardConnectionSetupFlow({
defaults={connectionDefaults}
onOpenAuthorization={openAuthorizationTab}
onRetry={async () => {
+ const firstAttempt = !directOAuthAccessConfirmedRef.current;
+ directOAuthAccessConfirmedRef.current = true;
setOAuthError(null);
setOAuthPhase("starting");
const connection = connectResult?.connection ?? resumableOAuthConnection;
@@ -2014,6 +2022,15 @@ function StandardConnectionSetupFlow({
startOAuth(connection);
return;
}
+ if (
+ firstAttempt
+ && !resumeConnectionId
+ && !applicationsQuery.isError
+ && !connectionsQuery.isError
+ ) {
+ connectApp(automaticOAuthEntry);
+ return;
+ }
// The create request may have reached the server even when its
// response did not reach the browser. Re-read both resources before
@@ -2274,6 +2291,7 @@ function StandardConnectionSetupFlow({
) : null}
+ {connectionDefaults}
) : null;
- const advancedMethodSelection = authenticationSelection;
const hasAdvancedSettings = advancedConfigFields.length > 0
|| optionalCustomerOAuthClient
|| hasAlternateMethods;
// Keep the disclosure open when what is inside it is load-bearing right now:
// a non-default method in use, or a selection the connector still needs.
const forceAdvancedOpen = usingCustomGoogleOAuth || !hasMethodSelection;
+ const advancedSettings = hasAdvancedSettings ? (
+
diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx
index 2a75ee56c4d..5e65158fd5c 100644
--- a/ui/src/pages/apps/AppsConnect.test.tsx
+++ b/ui/src/pages/apps/AppsConnect.test.tsx
@@ -630,7 +630,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(radioContaining("Any human in the organization")?.getAttribute("aria-checked")).toBe("true");
expect(radioContaining("Just agents I pick")).toBeTruthy();
expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true");
- expect(container.textContent).not.toContain("Does it need a key?");
});
it.each([false, true])("gates chat-only cards in the embedded tool gallery without hiding GitHub (%s)", async (enabled) => {
@@ -672,6 +671,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(buttonByText("Use GitHub")).toBeTruthy();
await act(async () => buttonByText("Use GitHub")!.click());
await flushReact();
+ await openAccessAdvanced();
expect(container.textContent).toContain("Connect GitHub as");
expect(container.textContent).toContain("My GitHub account");
expect(container.textContent).not.toContain("Chat with an agent");
@@ -701,7 +701,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
client.setQueryData(queryKeys.health, { deploymentMode: "authenticated", localAiLoginSupported: false });
await render(client, false, taskRepair ? : undefined);
await passAccessStep();
- expect(container.textContent).toContain("Connect account");
expect(container.textContent).toContain("Connection name");
expect(container.textContent).not.toContain("How do you want to connect?");
expect(radioContaining("Use an API key")).toBeUndefined();
@@ -814,17 +813,17 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
- .find((r) => r.textContent?.includes("A dedicated account for an agent"))
+ .find((r) => r.textContent?.includes("Only agents I choose"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
// The sentence above the primary action is the only place this is said, so
// it has to track the controls rather than print a fixed default.
- expect(container.textContent).toContain("Connects as a dedicated agent account");
+ expect(container.textContent).toContain("no agents selected yet");
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
- expect(radios.find((r) => r.textContent?.includes("A dedicated account for an agent"))
+ expect(radios.find((r) => r.textContent?.includes("Only agents I choose"))
?.getAttribute("aria-checked")).toBe("true");
});
@@ -911,6 +910,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
mockParams.appKey = "posthog";
mockSearch.value = "";
root = await render();
+ await openAccessAdvanced();
const posthog = identityChoices();
expect(posthog.justMe?.getAttribute("aria-checked")).toBe("false");
@@ -985,7 +985,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await render();
- await passAccessStep();
+ await openAccessAdvanced();
const full = radioContaining("Full");
const code = radioContaining("Code");
@@ -1038,9 +1038,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
- expect(document.body.textContent).toContain("Step 1 of 2");
- expect(document.body.textContent).toContain("Access · Choose connection");
+ // PAP-659: a selected app has one screen, so no stepper at all.
+ expect(document.body.textContent).not.toContain("Step 1 of 2");
expect(document.body.textContent).not.toContain("Pick app ·");
+ expect(document.body.textContent).toContain("Connect Gmail");
});
it("opens a brokered Gmail deep link at the access step", async () => {
@@ -1135,8 +1136,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await flushReact();
await flushReact();
if (!popupBlocked) expect(popup.close).toHaveBeenCalled();
+ await openAccessAdvanced();
expect(container.textContent).toContain("What should Paperclip be able to do?");
- expect(container.textContent).toContain("Step 2 of 2");
connectAppMock.mockResolvedValue({ connectionId: "gmail-1", connection: { id: "gmail-1", credentialPolicy: "per_user" }, auth: { kind: "oauth", startUrl: "https://example.test/unbound" } });
await act(async () => buttonByText("Continue to sign in")?.click());
await flushReact();
@@ -1206,7 +1207,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await render();
- expect(container.textContent).toContain("Step 2 of 2");
await act(async () => {
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -1224,23 +1224,21 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
},
);
- it("labels GitHub's local setup transition without promising a provider handoff", async () => {
+ it("labels GitHub's setup transition as the provider handoff it now is", async () => {
mockSearch.value = "source=github";
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
await render();
- const continueButton = buttonByText("Continue");
- expect(continueButton).toBeDefined();
- expect(continueButton?.disabled).toBe(false);
- expect(continueButton?.querySelector(".lucide-arrow-up-right")).toBeNull();
- expect(buttonByText("Continue to GitHub")).toBeUndefined();
+ // PAP-659: the screen that used to precede the handoff is gone, so the one
+ // primary action names the provider instead of a generic "Continue".
+ expect(buttonByText("Continue")).toBeUndefined();
+ const handoff = buttonByText("Continue to GitHub");
+ expect(handoff).toBeDefined();
+ expect(handoff?.disabled).toBe(false);
- await passAccessStep();
-
- expect(container.textContent).toContain("Step 2 of 2");
+ await openAccessAdvanced();
expect(container.textContent).toContain("How do you want to connect?");
- expect(buttonByText("Continue to GitHub")).toBeDefined();
expect(startOAuthMock).not.toHaveBeenCalled();
expect(connectAppMock).not.toHaveBeenCalled();
});
@@ -1276,11 +1274,13 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
- expect(container.textContent).toContain("Access · Sign in");
+ expect(container.textContent).toContain("Connects as you");
+ await openAccessAdvanced();
expect(radioContaining("My GitHub account")?.getAttribute("aria-checked")).toBe("true");
expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true");
expect(container.textContent).toContain("Which agents may use your GitHub when you’re responsible?");
- expect(buttonByText("Continue")).toBeDefined();
+ // Enrolment is the screen; its own action is the only primary one.
+ expect(buttonByText("Connect with Paperclip")).toBeDefined();
expect(buttonByText("Continue to GitHub")).toBeUndefined();
await passAccessStep();
@@ -1295,7 +1295,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
- expect(container.textContent).toContain("Step 2 of 2");
expect(container.textContent).toContain("Continue to GitHub");
expect(container.textContent).not.toContain("Connect GitHub as");
expect(container.textContent).not.toContain("Connect with Paperclip");
@@ -1388,6 +1387,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await render();
+ await openAccessAdvanced();
await act(async () => {
radioContaining("A dedicated account for an agent")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -1401,12 +1401,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
- const accessContinue = buttonByText("Continue");
- expect(accessContinue?.disabled).toBe(false);
- await act(async () => {
- accessContinue?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
await act(async () => {
buttonByText("Connect with Paperclip")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -1455,7 +1449,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await flushReact();
await flushReact();
- expect(container.textContent).toContain("Step 2 of 2");
+ expect(container.textContent).toContain("Connects as a dedicated agent account");
expect(window.sessionStorage.getItem(
"paperclip.connector-enrollment-access:github",
)).toBeNull();
@@ -1489,11 +1483,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
...definition, ownershipAvailability: { ...definition.ownershipAvailability, platform_shared: true },
}] });
await render();
+ await openAccessAdvanced();
if (!enrollmentReturn) {
await act(async () => {
radioContaining("Just me")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
- await passAccessStep();
+ await flushReact();
}
if (definition.methods.some((method) => method.capabilityProfile?.key !== "read")) {
@@ -1505,7 +1500,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
}
await flushReact();
// Change auth methods too, including apps with only one capability.
- expect(container.textContent).not.toContain("How do you want to connect?");
expect(container.textContent).not.toContain("Connect with Paperclip");
expect(container.textContent).not.toContain("Your OAuth app");
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
@@ -1565,6 +1559,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(container.textContent).not.toContain("does not enable unrelated Paperclip customers");
expect(container.textContent).not.toContain("final project-registration email");
expect(container.textContent).not.toContain("Apply or verify Developer Preview enrollment");
+ await openAccessAdvanced();
expect(radioContaining("Just me")).toBeTruthy();
expect(radioContaining("Any human in the organization")?.getAttribute("aria-checked")).toBe("true");
await passAccessStep();
@@ -1587,6 +1582,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
(heading) => heading.textContent?.trim() === "Connect Google Calendar",
);
expect(duplicateHeadings).toHaveLength(1);
+ await openAccessAdvanced();
expect(container.textContent).toContain("What should Paperclip be able to do?");
expect(container.textContent).toContain("Review requirements");
expect(container.textContent).not.toContain("Connect Google Calendar to read and manage events.");
@@ -1639,6 +1635,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
},
});
await render();
+ await openAccessAdvanced();
const anyAgent = Array.from(
document.body.querySelectorAll('[role="radio"]'),
@@ -1668,18 +1665,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
document.body.querySelectorAll('[role="radio"]'),
).find((r) => r.textContent?.includes("Just agents I pick"));
expect(pick?.disabled).toBe(false);
- // Continue refuses the forbidden choice even though it is the current one.
- expect(buttonByText("Continue")?.disabled).toBe(true);
+ // PAP-659: a reach the member cannot grant is reported above the primary
+ // action rather than disabling it, and "Just agents I pick" is the live
+ // alternative, so the screen is not a dead end.
+ expect(container.textContent).toContain(
+ "Your company policy limits this choice to connection managers.",
+ );
});
it("opens the selected app directly on its setup route", async () => {
mockParams.appKey = "github";
await render();
- // A deep-linked app lands on Access first: identity and reach are chosen
- // before the credential (PAP-17835).
+ // PAP-659: a deep-linked app lands on its one connect screen. Identity and
+ // reach are stated there and changed in the same Advanced disclosure.
+ expect(container.textContent).toContain("Connects for everyone in your organization");
+ await openAccessAdvanced();
expect(container.textContent).toContain("Connect GitHub as");
- await passAccessStep();
expect(container.textContent).toContain("Connect GitHub");
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
@@ -1692,12 +1694,13 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// PAP-659: identity is stated, not asked, and the method chooser moved
// into the same Advanced disclosure.
expect(container.textContent).toContain("Connects for everyone in your organization");
- await passAccessStep();
+ await openAccessAdvanced();
expect(container.textContent).toContain("How do you want to connect?");
- expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("false");
+ // PAP-659 C1: OAuth is strictly less work than a key, so it is preselected.
+ expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("true");
expect(radioContaining("Use a personal API key")?.getAttribute("aria-checked")).toBe("false");
- expect(buttonByText("Connect")?.disabled).toBe(true);
+ expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
await act(async () => {
radioContaining("Use a personal API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -1710,20 +1713,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
const keyInput = container.querySelector('input[type="password"]');
const advanced = buttonByText("Advanced");
expect(keyInput).toBeTruthy();
- expect(container.querySelector('input[placeholder="Optional numeric project ID"]')).toBeNull();
- expect(container.querySelector('[role="switch"]')).toBeNull();
- expect(advanced?.getAttribute("aria-expanded")).toBe("false");
- expect(container.textContent).not.toContain("Pin to project ID");
- expect(container.textContent).not.toContain("Feature groups");
- expect(container.textContent).not.toContain("Individual tools");
- expect(container.textContent).not.toContain("Tool response mode");
-
- await act(async () => {
- advanced?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
-
- expect(advanced?.getAttribute("aria-expanded")).toBe("true");
+ // PAP-659: the optional controls share the one Advanced disclosure with
+ // the access defaults, so they are visible because it is already open —
+ // and they are still optional, so none of them blocks Connect.
+ expect(advanced).toBeTruthy();
expect(container.textContent).toContain("Pin to project ID");
expect(container.querySelector('input[placeholder="Optional numeric project ID"]')).toBeTruthy();
expect(container.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe("false");
@@ -1774,7 +1767,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
},
});
await render(undefined, false, );
- await passAccessStep();
+ await openAccessAdvanced();
await act(async () => {
radioContaining("Use a personal API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -1882,7 +1875,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
));
expect(container.textContent).toContain("available to the agent that asked for it");
const continueButton = Array.from(container.querySelectorAll("button")).find(
- (button) => button.textContent?.trim() === "Continue",
+ (button) => button.textContent?.trim().startsWith("Continue"),
);
expect(continueButton).toBeTruthy();
expect(continueButton?.disabled).toBe(false);
@@ -2365,7 +2358,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await render();
- expect(container.textContent).toContain("Any human in the organization");
+ expect(container.textContent).toContain("Connects for everyone in your organization");
await passAccessStep();
await submitCuratedOAuthSetup();
@@ -2615,12 +2608,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await flushReact();
await flushReact();
+ // PAP-659: Try again re-reads and then resumes the draft it finds. There
+ // is no Access step to bounce back through, and the operator already
+ // pressed the one primary action, so recovery must not ask again.
expect(connectAppMock).not.toHaveBeenCalled();
- expect(startOAuthMock).not.toHaveBeenCalled();
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
- expect(container.textContent).toContain("agent access stays as it is");
- await passAccessStep();
- await submitCuratedOAuthSetup();
expect(startOAuthMock).toHaveBeenCalledWith("conn-after-retry", {
asCurrentUser: true,
});
@@ -3021,7 +3013,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
- await passAccessStep();
+ await openAccessAdvanced();
await act(async () => {
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -3047,7 +3039,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
- await passAccessStep();
+ await openAccessAdvanced();
await act(async () => {
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -3075,7 +3067,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(container.textContent).toContain("Connect GitHub");
expect(container.querySelector('input[placeholder="My app"]')).toBeNull();
- expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=github&stage=setup");
+ expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=github");
});
it("keeps the originating connection intent in wizard URLs", async () => {
@@ -3092,9 +3084,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
);
await passAccessStep();
- expect(mockNavigate).toHaveBeenCalledWith(
- `/apps/connect?source=github&stage=setup&intent=${interactionId}`,
- );
+ expect(mockNavigate).not.toHaveBeenCalledWith("/apps");
});
it("steps back from the single connect screen straight to Connectors", async () => {
@@ -3216,7 +3206,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
- await passAccessStep();
+ await openAccessAdvanced();
await act(async () => {
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -3254,7 +3244,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
- await passAccessStep();
+ await openAccessAdvanced();
await act(async () => {
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
diff --git a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx
index 4ff0d1eb075..5bb2f79fa83 100644
--- a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx
+++ b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx
@@ -384,9 +384,40 @@ function ActionGroup({
onSetPermission: (id: string, next: ActionPermission) => void;
}) {
if (actions.length === 0) return null;
+ const groupValue = (() => {
+ const first = actionPermission(actions[0]!.id, enabledIds, askFirstIds);
+ return actions.every((action) => actionPermission(action.id, enabledIds, askFirstIds) === first)
+ ? first
+ : "";
+ })();
return (
-
{title}
+
+
{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.
+
+ ) : null}
+
{actions.map((action) => (
-
{title}
+
+ {title}
+ {/* PAP-659 C7: the gate is only as good as the classifier, so say
+ what each action was classified as. A misfiled tool is then one
+ glance to spot and one click to move. */}
+
+ {action.riskLevel}
+
+