Skip to content

Commit 1b19b37

Browse files
committed
Make OAuth sign-in easy to cancel and retry
1 parent c6d1083 commit 1b19b37

5 files changed

Lines changed: 321 additions & 58 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Show a clear, cancellable sign-in state and prevent cancelled OAuth requests from disrupting a retry.

‎e2e/scenarios/connection-setup-ux.test.ts‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,131 @@ scenario(
288288
}),
289289
),
290290
);
291+
292+
scenario(
293+
"Connection setup · an interrupted OAuth popup can be cancelled and retried",
294+
{},
295+
Effect.scoped(
296+
Effect.gen(function* () {
297+
const { browser, identity, slug, client, emulator } = yield* fixture;
298+
yield* browser.session(identity, async ({ page, step }) => {
299+
await step("Start provider sign-in without entering a name", async () => {
300+
await visit(page, `/integrations/${slug}?addAccount=1`);
301+
await page.getByRole("tab", { name: "OAuth2", exact: true }).click();
302+
const opened = page.waitForEvent("popup");
303+
await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();
304+
const popup = await opened;
305+
await popup.waitForURL(/oauth\/v2\/authorize/);
306+
await popup.close();
307+
});
308+
await step("Cancel the waiting sign-in and retry", async () => {
309+
const cancel = page.getByRole("button", { name: "Cancel sign-in", exact: true });
310+
expect(
311+
await cancel.isVisible(),
312+
"an interrupted provider window must not strand Connecting forever",
313+
).toBe(true);
314+
expect(await cancel.isEnabled()).toBe(true);
315+
await page.getByText("Continue in the sign-in window", { exact: true }).waitFor();
316+
expect(
317+
await page.getByRole("button", { name: "Connecting…", exact: true }).count(),
318+
"waiting for provider consent must not leave a dead Connecting button",
319+
).toBe(0);
320+
await cancel.click();
321+
expect(
322+
await page.getByRole("button", { name: "Connect with OAuth", exact: true }).isEnabled(),
323+
).toBe(true);
324+
const opened = page.waitForEvent("popup");
325+
await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();
326+
const popup = await opened;
327+
await popup.waitForURL(/oauth\/v2\/authorize/);
328+
await popup.route("https://emulators.dev/oauth/v2/authorize/callback", (route) =>
329+
route.continue({ url: `${emulator.baseUrl}/oauth/v2/authorize/callback` }),
330+
);
331+
await popup.getByRole("button", { name: /admin/ }).click();
332+
await page.getByRole("dialog").waitFor({ state: "hidden", timeout: 30_000 });
333+
});
334+
});
335+
const connections = yield* client.connections.list({ query: { integration: slug } });
336+
expect(connections, "retry finishes and persists exactly one connection").toHaveLength(1);
337+
}),
338+
),
339+
);
340+
341+
scenario(
342+
"Connection setup · cancelling a pending OAuth start ignores its late response",
343+
{},
344+
Effect.scoped(
345+
Effect.gen(function* () {
346+
const { browser, identity, slug, client, emulator } = yield* fixture;
347+
yield* browser.session(identity, async ({ page, step }) => {
348+
const released = Promise.withResolvers<void>();
349+
const started = Promise.withResolvers<string>();
350+
const completed = Promise.withResolvers<void>();
351+
await page.route(
352+
"**/api/oauth/start",
353+
async (route) => {
354+
const response = await route.fetch();
355+
const body: unknown = await response.json();
356+
if (
357+
typeof body !== "object" ||
358+
body === null ||
359+
!("state" in body) ||
360+
typeof body.state !== "string"
361+
) {
362+
expect.fail("the held start response must contain its OAuth session state");
363+
}
364+
started.resolve(body.state);
365+
await released.promise;
366+
await route.fulfill({ response });
367+
completed.resolve();
368+
},
369+
{ times: 1 },
370+
);
371+
try {
372+
await step("Cancel while the authorization response is still in flight", async () => {
373+
await visit(page, `/integrations/${slug}?addAccount=1`);
374+
const opened = page.waitForEvent("popup");
375+
await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();
376+
const popup = await opened;
377+
const oldState = await started.promise;
378+
await page.getByRole("button", { name: "Cancel sign-in", exact: true }).click();
379+
await expect.poll(() => popup.isClosed()).toBe(true);
380+
const cancelled = page.waitForResponse((response) =>
381+
response.url().endsWith("/api/oauth/cancel"),
382+
);
383+
const retryOpened = page.waitForEvent("popup");
384+
await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();
385+
const retryPopup = await retryOpened;
386+
await retryPopup.waitForURL(/oauth\/v2\/authorize/);
387+
released.resolve();
388+
await completed.promise;
389+
const cancellation = await cancelled;
390+
expect(
391+
cancellation.request().postDataJSON(),
392+
"only the old session is cancelled",
393+
).toEqual({ state: oldState });
394+
expect(
395+
retryPopup.isClosed(),
396+
"the old response cannot close the new sign-in window",
397+
).toBe(false);
398+
expect(
399+
page.context().pages(),
400+
"only the replacement sign-in window remains",
401+
).toHaveLength(2);
402+
await page.getByText("Continue in the sign-in window", { exact: true }).waitFor();
403+
await retryPopup.route("https://emulators.dev/oauth/v2/authorize/callback", (route) =>
404+
route.continue({ url: `${emulator.baseUrl}/oauth/v2/authorize/callback` }),
405+
);
406+
await retryPopup.getByRole("button", { name: /admin/ }).click();
407+
await page.getByRole("dialog").waitFor({ state: "hidden", timeout: 30_000 });
408+
});
409+
} finally {
410+
released.resolve();
411+
await page.unrouteAll({ behavior: "wait" });
412+
}
413+
});
414+
const connections = yield* client.connections.list({ query: { integration: slug } });
415+
expect(connections, "the newer attempt survives and completes consent").toHaveLength(1);
416+
}),
417+
),
418+
);

‎packages/react/src/components/add-account-modal.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ describe("runCimdConnect", () => {
366366

367367
const outcome = await runCimdConnect(
368368
{
369+
isActive: () => true,
369370
reserve: (): OAuthPopupReservation => RESERVED,
370371
release: (): void => {},
371372
createClient: (args: CimdCreateArgs): Promise<OAuthClientSlug | null> => {
@@ -410,6 +411,7 @@ describe("runCimdConnect", () => {
410411

411412
const outcome = await runCimdConnect(
412413
{
414+
isActive: () => true,
413415
reserve: (): OAuthPopupReservation => RESERVED,
414416
release: (): void => {},
415417
createClient: (): Promise<OAuthClientSlug | null> => {
@@ -1076,6 +1078,7 @@ describe("runCimdConnect popup reservation", () => {
10761078
const popup = popupSpy();
10771079
const outcome = await runCimdConnect(
10781080
{
1081+
isActive: () => true,
10791082
...popup,
10801083
createClient: (args: CimdCreateArgs): Promise<OAuthClientSlug> => {
10811084
popup.calls.push("createClient");
@@ -1096,6 +1099,7 @@ describe("runCimdConnect popup reservation", () => {
10961099
const popup = popupSpy();
10971100
const outcome = await runCimdConnect(
10981101
{
1102+
isActive: () => true,
10991103
...popup,
11001104
createClient: (): Promise<OAuthClientSlug | null> => Promise.resolve(null),
11011105
start: (): void => {},
@@ -1107,10 +1111,32 @@ describe("runCimdConnect popup reservation", () => {
11071111
expect(popup.calls).toEqual(["reserve", "release"]);
11081112
});
11091113

1114+
it("does not start sign-in after cancellation during client setup", async () => {
1115+
const created = Promise.withResolvers<OAuthClientSlug | null>();
1116+
const popup = popupSpy();
1117+
let active = true;
1118+
const connecting = runCimdConnect(
1119+
{
1120+
...popup,
1121+
isActive: () => active,
1122+
createClient: () => created.promise,
1123+
start: () => {
1124+
popup.calls.push("start");
1125+
},
1126+
},
1127+
cimdInput,
1128+
);
1129+
active = false;
1130+
created.resolve(OAuthClientSlug.make("cancelled-client"));
1131+
expect(await connecting).toEqual({ kind: "aborted" });
1132+
expect(popup.calls).toEqual(["reserve", "release"]);
1133+
});
1134+
11101135
it("never claims a window when the method is missing its endpoints", async () => {
11111136
const popup = popupSpy();
11121137
const outcome = await runCimdConnect(
11131138
{
1139+
isActive: () => true,
11141140
...popup,
11151141
createClient: (): Promise<OAuthClientSlug | null> => Promise.resolve(null),
11161142
start: (): void => {},

‎packages/react/src/components/add-account-modal.tsx‎

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,15 @@ import {
100100
DropdownMenuItem,
101101
DropdownMenuTrigger,
102102
} from "./dropdown-menu";
103-
import { ChevronDown, EyeIcon, EyeOffIcon, PlusIcon, XIcon } from "lucide-react";
103+
import {
104+
ChevronDown,
105+
EyeIcon,
106+
EyeOffIcon,
107+
LoaderCircleIcon,
108+
PanelsTopLeftIcon,
109+
PlusIcon,
110+
XIcon,
111+
} from "lucide-react";
104112
import {
105113
Dialog,
106114
DialogContent,
@@ -741,6 +749,7 @@ type CimdCreateClientArgs = {
741749
};
742750

743751
type RunCimdConnectDeps = {
752+
readonly isActive: () => boolean;
744753
readonly createClient: (args: CimdCreateClientArgs) => Promise<OAuthClientSlug | null>;
745754
readonly start: (args: CimdStartArgs) => void;
746755
/** Claim the sign-in window before any await. See `useOAuthPopupFlow.reserve`. */
@@ -767,6 +776,7 @@ type RunCimdConnectInput = {
767776

768777
type CimdOutcome =
769778
| { readonly kind: "started"; readonly client: OAuthClientSlug; readonly reused: boolean }
779+
| { readonly kind: "aborted" }
770780
| { readonly kind: "popup-blocked" }
771781
| { readonly kind: "failed"; readonly reason: "missing-endpoints" | "create-failed" };
772782

@@ -830,6 +840,10 @@ export async function runCimdConnect(
830840
if (reservation.kind === "blocked") return { kind: "popup-blocked" };
831841

832842
const resolved = await resolveCimdClient(deps, input);
843+
if (!deps.isActive()) {
844+
deps.release();
845+
return { kind: "aborted" };
846+
}
833847
if (resolved.kind === "failed") {
834848
deps.release();
835849
return resolved;
@@ -1866,7 +1880,14 @@ function AddAccountModalView(props: AddAccountModalProps) {
18661880
const oauthBusy = ccBusy || oauthPopup.busy;
18671881
const cimdConnecting = cimdBusy || oauthPopup.busy;
18681882
const dcrConnecting = dcrBusy || oauthPopup.busy;
1869-
const automaticOAuthConnecting = cimdConnecting || dcrConnecting;
1883+
const signInPending = cimdBusy || dcrBusy || oauthPopup.busy;
1884+
const automaticAttemptRef = useRef(0);
1885+
const cancelSignIn = () => {
1886+
automaticAttemptRef.current += 1;
1887+
oauthPopup.cancel();
1888+
setCimdBusy(false);
1889+
setDcrBusy(false);
1890+
};
18701891

18711892
// "Connection saved to" for a PICKED BYO OAuth app. Cloud: a Workspace (`org`)
18721893
// app can mint Personal or Workspace connections; a Personal (`user`) app can
@@ -2359,11 +2380,16 @@ function AddAccountModalView(props: AddAccountModalProps) {
23592380
const cimdOwner = owner;
23602381
const connectionName = previewConnectionName(label, cimdOwner);
23612382
const identityLabel = typedIdentityLabel(label);
2383+
const attempt = ++automaticAttemptRef.current;
2384+
const isActive = () => viewMountedRef.current && attempt === automaticAttemptRef.current;
23622385
setCimdBusy(true);
23632386
const outcome = await runCimdConnect(
23642387
{
23652388
reserve: oauthPopup.reserve,
2366-
release: oauthPopup.releaseReservation,
2389+
release: () => {
2390+
if (attempt === automaticAttemptRef.current) oauthPopup.releaseReservation();
2391+
},
2392+
isActive,
23672393
createClient: createCimdClient,
23682394
start: (args: CimdStartArgs): void => {
23692395
void oauthPopup.start({
@@ -2398,6 +2424,7 @@ function AddAccountModalView(props: AddAccountModalProps) {
23982424
existingClients: clientSummaries,
23992425
},
24002426
);
2427+
if (!isActive()) return;
24012428
setCimdBusy(false);
24022429
trackEvent("connection_oauth_started", {
24032430
integration_slug: String(integration),
@@ -2442,16 +2469,18 @@ function AddAccountModalView(props: AddAccountModalProps) {
24422469
setDcrFailed(true);
24432470
return;
24442471
}
2472+
const attempt = ++automaticAttemptRef.current;
2473+
const isActive = () => viewMountedRef.current && attempt === automaticAttemptRef.current;
24452474
setDcrBusy(true);
24462475
const outcome = await runAutomaticOAuthConnect(
24472476
{
24482477
reserve: oauthPopup.reserve,
2449-
release: oauthPopup.releaseReservation,
2450-
// Closing the modal genuinely unmounts this view (see
2451-
// `AddAccountModal`), so "still mounted" is exactly "still open".
2452-
// The sequence checks it between round trips: a close mid-flight
2453-
// must not register a client or launch the popup afterwards.
2454-
isActive: () => viewMountedRef.current,
2478+
release: () => {
2479+
if (attempt === automaticAttemptRef.current) oauthPopup.releaseReservation();
2480+
},
2481+
// A closed modal or cancelled attempt cannot register a client or
2482+
// launch sign-in after the next round trip. A retry owns a new attempt.
2483+
isActive,
24552484
probe: async (url: string): Promise<OAuthProbeResult | null> => {
24562485
const exit = await doProbe({ payload: { url }, reactivityKeys: [] });
24572486
if (Exit.isFailure(exit)) return null;
@@ -2560,7 +2589,7 @@ function AddAccountModalView(props: AddAccountModalProps) {
25602589
// The modal closed mid-flight: this view is unmounted, so no state may
25612590
// be written at all — not the fallback below, and not even the busy
25622591
// flag, which belongs to the surface that is gone.
2563-
if (outcome.kind === "aborted") return;
2592+
if (!isActive() || outcome.kind === "aborted") return;
25642593
setDcrBusy(false);
25652594
// `connection_oauth_started` measures the connect funnel; a reconnect
25662595
// reports through `connection_reconnected` on the popup callbacks above,
@@ -2720,7 +2749,49 @@ function AddAccountModalView(props: AddAccountModalProps) {
27202749
: "sm:max-w-xl",
27212750
)}
27222751
>
2723-
{addingMethod && createCustomMethod ? (
2752+
{signInPending ? (
2753+
<>
2754+
<DialogHeader>
2755+
<DialogTitle>Add connection · {integrationName}</DialogTitle>
2756+
</DialogHeader>
2757+
<div role="status" className="flex flex-col items-center px-4 py-8 text-center">
2758+
<div className="mb-5 flex size-12 items-center justify-center rounded-xl border border-border/70 bg-muted/30 text-foreground shadow-sm">
2759+
{oauthPopup.phase === "authorizing" ? (
2760+
<PanelsTopLeftIcon aria-hidden="true" className="size-5" />
2761+
) : (
2762+
<LoaderCircleIcon
2763+
aria-hidden="true"
2764+
className="size-5 animate-spin motion-reduce:animate-none"
2765+
/>
2766+
)}
2767+
</div>
2768+
<p className="text-sm font-medium text-foreground">
2769+
{oauthPopup.phase === "authorizing"
2770+
? "Continue in the sign-in window"
2771+
: oauthPopup.phase === "saving"
2772+
? "Finishing connection"
2773+
: "Preparing sign-in"}
2774+
</p>
2775+
<DialogDescription className="mt-2 max-w-xs text-sm leading-relaxed">
2776+
{oauthPopup.phase === "authorizing"
2777+
? "If the window closed or sign-in stalled, cancel and try again."
2778+
: oauthPopup.phase === "saving"
2779+
? "Sign-in complete. Updating your connections."
2780+
: "The sign-in window will be ready shortly. You can cancel at any time."}
2781+
</DialogDescription>
2782+
</div>
2783+
<DialogFooter className="border-t border-border/60 pt-4 sm:justify-between">
2784+
<Button type="button" variant="ghost" onClick={close}>
2785+
Close
2786+
</Button>
2787+
{oauthPopup.phase !== "saving" ? (
2788+
<Button type="button" variant="outline" onClick={cancelSignIn}>
2789+
Cancel sign-in
2790+
</Button>
2791+
) : null}
2792+
</DialogFooter>
2793+
</>
2794+
) : addingMethod && createCustomMethod ? (
27242795
<>
27252796
<DialogHeader className="border-b border-border/60 px-5 py-4">
27262797
<DialogTitle className="text-base">Add authentication method</DialogTitle>
@@ -3371,12 +3442,7 @@ function AddAccountModalView(props: AddAccountModalProps) {
33713442
</p>
33723443
) : null}
33733444
<DialogFooter>
3374-
<Button
3375-
type="button"
3376-
variant="ghost"
3377-
onClick={close}
3378-
disabled={submitting || oauthBusy || automaticOAuthConnecting}
3379-
>
3445+
<Button type="button" variant="ghost" onClick={close} disabled={submitting || ccBusy}>
33803446
{isOAuth ? "Close" : "Cancel"}
33813447
</Button>
33823448
{/* Footer action, in precedence order:

0 commit comments

Comments
 (0)