diff --git a/doc/connections/GITHUB.md b/doc/connections/GITHUB.md index 2b7b4df0c6..f79febf07f 100644 --- a/doc/connections/GITHUB.md +++ b/doc/connections/GITHUB.md @@ -5,6 +5,17 @@ compatibility method. Cloud owns the fixed public OAuth callback and signed webhook inbox; provider tokens are sealed to the enrolled instance and stored only in its existing encrypted secret system. +## Self-hosted setup + +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. + +If an unapproved enrollment link expires, return to setup and select +**Continue**. Paperclip asks the server for a valid link. The server reuses a +live pending enrollment or replaces an expired one; this does not revoke or +repeat an existing instance approval. + ## Identity resolution Every MCP call, `gh` invocation, native Git operation, checkout, health check, diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 295de8a693..266dd58039 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -1973,9 +1973,9 @@ export function ConnectionSetupFlow({ onClick={() => { setConnectorEnrollmentError(null); preserveEnrollmentAccess(); - const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl; - if (verificationUrl) openConnectorEnrollment(verificationUrl); - else startConnectorEnrollment.mutate(); + // Let the server reuse a live enrollment or replace an expired + // one. A cached verification URL may expire while this page is open. + startConnectorEnrollment.mutate(); }} > {startConnectorEnrollment.isPending ? : null} diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 14ce1ae01a..5cd3d6a28d 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -733,6 +733,47 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { ); }); + it.each(["2020-01-01T00:00:00.000Z", "2099-01-01T00:00:00.000Z"])( + "revalidates a cached pending enrollment before continuing (expiry %s)", + async (expiresAt) => { + mockSearch.value = "source=github&stage=setup"; + listGalleryMock.mockResolvedValue({ + apps: [{ + ...GITHUB, + methods: GITHUB.methods.filter((method) => !method.oauthStrategy), + ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, + }], + }); + getCloudConnectorEnrollmentMock.mockResolvedValue({ + configured: false, + status: "pending", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: "inst-test", + environment: "staging", + origins: [], + verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=cached", + expiresAt, + }); + + await render(); + expect(container.textContent).toContain("Step 2 of 2"); + await act(async () => { + buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith( + "company-1", "Paperclip", "/apps/connect?source=github&stage=setup", + ); + expect(navigateTopLevelMock).toHaveBeenCalledWith( + "https://my-staging.paperclip.app/connections/enroll?id=enroll-test", + ); + expect(navigateTopLevelMock).not.toHaveBeenCalledWith( + "https://my-staging.paperclip.app/connections/enroll?id=cached", + ); + }, + ); + it("keeps GitHub's personal identity defaults while its managed method awaits enrollment", async () => { mockParams.appKey = "github"; listGalleryMock.mockResolvedValue({