fix(connections): distinguish local setup from provider handoff (#12947)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connections give those agents access to external services.
> - Connection setup first asks who may use the credential.
> - Some providers need a local method selection before OAuth starts.
> - The Access button said it would open GitHub even when it opened
another local step.
> - This pull request names the actual next action and shows the
external arrow only for a provider handoff.
> - Users can distinguish local setup from leaving Paperclip.

## Linked Issues or Issue Description

Refs: #12943

The browser audit found a second, separate clarity problem. GitHub
showed "Continue to GitHub" twice: once to open its local method
selection and once to start OAuth. The first label promised the wrong
action. This change fixes that label without adding or removing a setup
step.

## What Changed

- Use "Continue" when Access opens another local OAuth setup step.
- Keep "Continue to <provider>" and the external arrow when Access
starts OAuth directly.
- Test GitHub defaults, pre-enrollment setup, direct Notion OAuth, and
dialog behavior.
- Document the two GitHub button actions.
- Preserve the external-handoff arrow in the direct-OAuth Storybook
fixture.

## Verification

- Targeted connection tests: 114 passed.
- `pnpm --filter @paperclipai/ui typecheck`: passed.
- `pnpm --filter @paperclipai/ui build`: passed.
- `node scripts/check-token-gates.mjs`: passed.
- `git diff --check`: passed.
- After the Storybook review fix: 100 connection-flow tests, UI
typecheck, token gates, and Storybook build passed; visually confirmed
the Gmail handoff arrow and local GitHub step labels.
- Actual test-drive browser: confirmed default personal identity and Any
agent; clicked Continue to the local method screen; switched PAT and
OAuth; returned to Access with selections intact; cancelled without a
duplicate connection.
- Live OAuth, local MCP identity, and a real staging sandbox task passed
during the audit. This patch does not change those paths.
- Full local suite has known unrelated macOS path/runtime test failures
from the audit. All CI passed on head
`936ce09f4e1faaf09f9fb778c2cd4c65c2a41972`, including typecheck, build,
all test shards, browser tests, and canary dry run. Greptile: 5/5; no
unresolved comments.

## Risks

- Low risk. This changes button text and an icon cue only. There is no
new schema, permission, consent, or credential behavior.
- Providers with multiple methods now say Continue before their method
screen. Direct OAuth providers keep their existing wording.

## Model Used

- OpenAI Codex assisted with code, tests, terminal checks, and actual
browser verification. The runtime does not expose the exact model ID or
context-window size, so those details are unavailable.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub references)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (targeted tests; full-suite
caveats above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-06 17:43:48 -05:00 committed by GitHub
parent 83987210d6
commit 856813ba3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 53 additions and 11 deletions

View File

@ -7,6 +7,10 @@ only in its existing encrypted secret system.
## Self-hosted setup
The Access step uses **Continue** to open the local setup screen.
**Continue to GitHub** on that screen starts the provider handoff. The first
button does not imply that the browser is leaving Paperclip yet.
A self-hosted instance needs one Paperclip Cloud approval before its first
managed connection. After approval, setup returns to step 2 and continues to
GitHub without another instance approval or a service restart.

View File

@ -1868,14 +1868,12 @@ export function ConnectionSetupFlow({
: linkAuthMode === "oauth"
? "oauth"
: "api_key";
// The primary label names the next effect, so an OAuth handoff never arrives
// unannounced.
const accessMethodIsKnown = !entry
|| Boolean(connectionMethodKey)
|| credentialSourceMethods.length === 1;
const accessSubmitLabel = accessStepAuthKind === "oauth" && accessMethodIsKnown
// Name the actual next effect: multi-method apps and enrollment still have
// a local setup screen, even when OAuth is already the selected method.
const accessContinuesToProvider = Boolean(directOAuthEntry);
const accessSubmitLabel = accessContinuesToProvider
? `Continue to ${entry?.name ?? "sign-in"}`
: "Save and continue";
: accessStepAuthKind === "oauth" ? "Continue" : "Save and continue";
const stepIndex = (zapierSource || entry) && step !== "gallery" && step !== "success"
? SELECTED_APP_STEP_INDEX[step]
@ -2132,6 +2130,7 @@ export function ConnectionSetupFlow({
capabilities={galleryQuery.data?.capabilities}
githubIdentity={entry?.slug === "github"}
submitLabel={accessSubmitLabel}
continuesToProvider={accessContinuesToProvider}
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
pending={connectMutation.isPending || oauthStartMutation.isPending}
@ -3581,6 +3580,7 @@ export function AccessStep({
capabilities,
githubIdentity = false,
submitLabel,
continuesToProvider = false,
identityLoading = false,
preserveAgentAccess = false,
pending = false,
@ -3605,6 +3605,8 @@ export function AccessStep({
} | null;
githubIdentity?: boolean;
submitLabel: string;
/** Only show an external-handoff cue when this action starts provider OAuth. */
continuesToProvider?: boolean;
/** Wait for a durable OAuth connection before showing a reconnect identity. */
identityLoading?: boolean;
/** Reconnect changes credentials only; existing install reach stays intact. */
@ -3842,7 +3844,7 @@ export function AccessStep({
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
{submitLabel}
{!pending && authKind === "oauth" ? <ArrowUpRight className="h-4 w-4" aria-hidden="true" /> : null}
{!pending && continuesToProvider ? <ArrowUpRight className="h-4 w-4" aria-hidden="true" /> : null}
</Button>
</div>
</div>

View File

@ -157,6 +157,7 @@ async function passAccessStep() {
}
const submit = Array.from(document.body.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Save and continue"
|| b.textContent?.trim() === "Continue"
|| b.textContent?.trim().startsWith("Continue to"),
);
await act(async () => {
@ -774,6 +775,38 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
},
);
it("labels GitHub's local setup transition without promising a provider handoff", 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();
await passAccessStep();
expect(container.textContent).toContain("Step 2 of 2");
expect(container.textContent).toContain("How do you want to connect?");
expect(buttonByText("Continue to GitHub")).toBeDefined();
expect(startOAuthMock).not.toHaveBeenCalled();
expect(connectAppMock).not.toHaveBeenCalled();
});
it("keeps provider-specific wording and the handoff cue for direct OAuth", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValue({ apps: [NOTION] });
await render();
const continueButton = buttonByText("Continue to Notion");
expect(continueButton).toBeDefined();
expect(continueButton?.querySelector(".lucide-arrow-up-right")).not.toBeNull();
});
it("keeps GitHub's personal identity defaults while its managed method awaits enrollment", async () => {
mockParams.appKey = "github";
listGalleryMock.mockResolvedValue({
@ -798,6 +831,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
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 youre responsible?");
expect(buttonByText("Continue")).toBeDefined();
expect(buttonByText("Continue to GitHub")).toBeUndefined();
await passAccessStep();
@ -916,7 +951,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const accessContinue = buttonByText("Save and continue") ?? buttonByText("Continue to GitHub");
const accessContinue = buttonByText("Continue");
expect(accessContinue?.disabled).toBe(false);
await act(async () => {
accessContinue?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -1116,7 +1151,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
).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("Save and continue")?.disabled).toBe(true);
expect(buttonByText("Continue")?.disabled).toBe(true);
});
it("opens the selected app directly on its setup route", async () => {
@ -1329,7 +1364,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
));
expect(container.textContent).toContain("This task grants access only to Ada");
const continueButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Save and continue",
(button) => button.textContent?.trim() === "Continue",
);
expect(continueButton).toBeTruthy();
expect(continueButton?.disabled).toBe(false);

View File

@ -307,6 +307,7 @@ function SeededAccessStep({
setInstallAgentIds={setIds}
capabilities={capabilities}
submitLabel={authKind === "oauth" ? "Continue to Gmail" : "Save and continue"}
continuesToProvider={authKind === "oauth"}
onBack={() => {}}
onContinue={() => {}}
/>