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:
parent
83987210d6
commit
856813ba3a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 you’re 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);
|
||||
|
|
|
|||
|
|
@ -307,6 +307,7 @@ function SeededAccessStep({
|
|||
setInstallAgentIds={setIds}
|
||||
capabilities={capabilities}
|
||||
submitLabel={authKind === "oauth" ? "Continue to Gmail" : "Save and continue"}
|
||||
continuesToProvider={authKind === "oauth"}
|
||||
onBack={() => {}}
|
||||
onContinue={() => {}}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue