History

Make OAuth sign-in easy to cancel and retry

Rhys Sullivan committed 1 month ago

Commit 1b19b371 parent c6d10835 files · +321 −58Browse files

Unified · Split

5 files
  1. .changeset/connection-oauth-cancel.md +5 −0
  2. e2e/scenarios/connection-setup-ux.test.ts +128 −0
  3. packages/react/src/components/add-account-modal.test.ts +26 −0
  4. packages/react/src/components/add-account-modal.tsx +83 −17
  5. packages/react/src/plugins/oauth-sign-in.tsx +79 −41
.changeset/connection-oauth-cancel.md+5−0
@@ -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+128−0
@@ -288,3 +288,131 @@ scenario( · 287 unmodified lines
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+26−0
@@ -366,6 +366,7 @@ describe("runCimdConnect", () => { · 365 unmodified lines
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", () => { · 38 unmodified lines
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", () => { · 660 unmodified lines
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", () => { · 14 unmodified lines
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", () => { · 5 unmodified lines
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+83−17
@@ -100,7 +100,15 @@ import { · 99 unmodified lines
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 = { · 634 unmodified lines
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 = { · 20 unmodified lines
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( · 57 unmodified lines
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) { · 1030 unmodified lines
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) { · 486 unmodified lines
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) { · 28 unmodified lines
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) { · 38 unmodified lines
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) { · 102 unmodified lines
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) { · 153 unmodified lines
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) { · 644 unmodified lines
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:
packages/react/src/plugins/oauth-sign-in.tsx+79−41
@@ -1,10 +1,12 @@
1−import { useCallback, useEffect, useRef, useState } from "react";
2−import { useAtomSet } from "@effect/atom-react";
1+import { useCallback, useContext, useEffect, useRef, useState } from "react";
2+import { RegistryContext, useAtomSet } from "@effect/atom-react";
33 import * as Data from "effect/Data";
44 import * as Effect from "effect/Effect";
55 import * as Exit from "effect/Exit";
6+import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
67
7−import { cancelOAuth, oauthConnectionCompleted, startOAuth } from "../api/atoms";
8+import { oauthConnectionCompleted } from "../api/atoms";
9+import { ExecutorApiClient } from "../api/client";
810 import { trackEvent } from "../api/analytics";
911 import { messageFromExit, messageFromUnknown, useReportHandledError } from "../api/error-reporting";
1012 import {
@@ -186,12 +188,11 @@ export function useOAuthPopupFlow< · 175 unmodified lines
186188 popupName,
187189 startErrorMessage,
188190 } = options;
189− const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
190− const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" });
191+ const registry = useContext(RegistryContext);
191192 const doOAuthConnectionCompleted = useAtomSet(oauthConnectionCompleted, { mode: "promiseExit" });
192193 const reportHandledError = useReportHandledError();
193194 const blockedMessage = popupBlockedMessage ?? POPUP_BLOCKED_MESSAGE;
194− const [busy, setBusy] = useState(false);
195+ const [phase, setPhase] = useState<"idle" | "preparing" | "authorizing" | "saving">("idle");
195196 const [error, setError] = useState<string | null>(null);
196197 const cleanupRef = useRef<(() => void) | null>(null);
197198 const sessionRef = useRef<{ readonly state: string } | null>(null);
@@ -200,6 +201,7 @@ export function useOAuthPopupFlow< · 2 unmodified lines
200201 // exists. Hold it here so cancel and unmount can close it, or abandoning a
201202 // connect mid-probe strands a blank popup on screen.
202203 const reservationRef = useRef<ReservedOAuthPopup | null>(null);
204+ const generationRef = useRef(0);
203205
204206 const releaseReservation = useCallback(() => {
205207 reservationRef.current?.popup.close();
@@ -208,23 +210,33 @@ export function useOAuthPopupFlow< · 2 unmodified lines
208210
209211 const cancelSession = useCallback(
210212 (state: string) => {
211− void doCancelOAuth({ payload: { state: OAuthState.make(state) } });
213+ // Each cancellation owns its request; cancelling an older attempt must
214+ // not interrupt cleanup of another session.
215+ const request = ExecutorApiClient.runtime.atom(
216+ ExecutorApiClient.use((client) =>
217+ client.oauth.cancel({ payload: { state: OAuthState.make(state) } }),
218+ ),
219+ );
220+ void Effect.runPromiseExit(AtomRegistry.getResult(registry, request));
212221 },
213− [doCancelOAuth],
222+ [registry],
214223 );
215224
216225 const cancel = useCallback(() => {
226+ generationRef.current += 1;
227+ setError(null);
217228 const session = sessionRef.current;
218229 cleanupRef.current?.();
219230 cleanupRef.current = null;
220231 sessionRef.current = null;
221232 releaseReservation();
222233 if (session) cancelSession(session.state);
223− setBusy(false);
234+ setPhase("idle");
224235 }, [cancelSession, releaseReservation]);
225236
226237 useEffect(
227238 () => () => {
239+ generationRef.current += 1;
228240 const session = sessionRef.current;
229241 cleanupRef.current?.();
230242 cleanupRef.current = null;
@@ -267,21 +279,20 @@ export function useOAuthPopupFlow< · 36 unmodified lines
267279 // `reserve`; cancelling again here would close the window it reserved.
268280 const reservation = input.reservation ?? reserve();
269281 if (reservation.kind === "blocked") {
270− setBusy(false);
282+ setPhase("idle");
271283 setError(blockedMessage);
272284 input.onError?.(blockedMessage);
273285 return;
274286 }
275− setBusy(true);
287+ setPhase("preparing");
276288 setError(null);
277289 // Desktop hosts open the auth URL in the user's real browser, so they
278290 // reserve no in-page window and rely on the polling channel for the
279291 // result.
280292 const desktopBridge = reservation.kind === "desktop" ? reservation.bridge : null;
281293 const reservedPopup = reservation.kind === "window" ? reservation.popup : null;
282− // The window's lifetime now belongs to this flow's teardown, which closes
283− // it on every failure path below.
284− reservationRef.current = null;
294+ // Cancel/unmount owns this window even while the start request is pending.
295+ const generation = generationRef.current;
285296 const startExit = await Effect.runPromiseExit(
286297 Effect.tryPromise({
287298 try: input.run,
@@ -292,6 +303,14 @@ export function useOAuthPopupFlow< · 4 unmodified lines
292303 }),
293304 }),
294305 );
306+ if (generation !== generationRef.current) {
307+ reservedPopup?.popup.close();
308+ if (Exit.isSuccess(startExit) && startExit.value.authorizationUrl !== null) {
309+ cancelSession(startExit.value.state);
310+ }
311+ return;
312+ }
313+ reservationRef.current = null;
295314 if (Exit.isFailure(startExit)) {
296315 const message = messageFromExit(startExit, startErrorMessage ?? "Failed to start sign-in");
297316 reportHandledError(startExit.cause, {
@@ -301,7 +320,7 @@ export function useOAuthPopupFlow< · 3 unmodified lines
301320 metadata: input.reportMetadata,
302321 });
303322 reservedPopup?.popup.close();
304− setBusy(false);
323+ setPhase("idle");
305324 setError(message);
306325 input.onError?.(message);
307326 return;
@@ -311,29 +330,33 @@ export function useOAuthPopupFlow< · 3 unmodified lines
311330 const message =
312331 noAuthorizationUrlMessage ?? "OAuth start did not produce an authorization URL";
313332 reservedPopup?.popup.close();
314− setBusy(false);
333+ setPhase("idle");
315334 setError(message);
316335 input.onError?.(message);
317336 return;
318337 }
319338
339+ setPhase("authorizing");
320340 sessionRef.current = { state: response.state };
321341 input.onAuthorizationStarted?.(response);
322342 const handleResult = async (result: OAuthPopupResult<TPayload>) => {
343+ if (generation !== generationRef.current) return;
323344 cleanupRef.current = null;
324345 sessionRef.current = null;
325346
326347 if (!result.ok) {
327348 trackEvent("oauth_completed", { success: false });
328− setBusy(false);
349+ setPhase("idle");
329350 setError(result.error);
330351 input.onError?.(result.error, result.errorDetails);
331352 return;
332353 }
333354
355+ setPhase("saving");
334356 const refreshExit = await doOAuthConnectionCompleted({
335357 reactivityKeys: connectionWriteKeys,
336358 });
359+ if (generation !== generationRef.current) return;
337360 if (Exit.isFailure(refreshExit)) {
338361 const message = messageFromExit(refreshExit, "Failed to refresh connection");
339362 reportHandledError(refreshExit.cause, {
@@ -343,7 +366,7 @@ export function useOAuthPopupFlow< · 3 unmodified lines
343366 metadata: input.reportMetadata,
344367 });
345368 trackEvent("oauth_completed", { success: false });
346− setBusy(false);
369+ setPhase("idle");
347370 setError(message);
348371 input.onError?.(message);
349372 return;
@@ -362,15 +385,18 @@ export function useOAuthPopupFlow< · 12 unmodified lines
362385 metadata: input.reportMetadata,
363386 });
364387 trackEvent("oauth_completed", { success: false });
365− setBusy(false);
366− setError(message);
367− input.onError?.(message);
388+ if (generation === generationRef.current) {
389+ setPhase("idle");
390+ setError(message);
391+ input.onError?.(message);
392+ }
368393 return;
369394 }
370395 trackEvent("oauth_completed", { success: true });
371− setBusy(false);
396+ if (generation === generationRef.current) setPhase("idle");
372397 };
373398 const handleClosed = () => {
399+ if (generation !== generationRef.current) return;
374400 cleanupRef.current = null;
375401 sessionRef.current = null;
376402 // `popup.closed` is advisory: COOP redirects can make a live popup
@@ -379,16 +405,17 @@ export function useOAuthPopupFlow< · 2 unmodified lines
379405 const message =
380406 popupClosedMessage ?? "Sign-in cancelled - popup was closed before completing the flow.";
381407 trackEvent("oauth_completed", { success: false });
382− setBusy(false);
408+ setPhase("idle");
383409 setError(message);
384410 input.onError?.(message);
385411 };
386412 const handleOpenFailed = () => {
413+ if (generation !== generationRef.current) return;
387414 cleanupRef.current = null;
388415 sessionRef.current = null;
389416 cancelSession(response.state);
390417 trackEvent("oauth_completed", { success: false });
391− setBusy(false);
418+ setPhase("idle");
392419 setError(blockedMessage);
393420 input.onError?.(blockedMessage);
394421 };
@@ -442,20 +469,29 @@ export function useOAuthPopupFlow< · 47 unmodified lines
442469 name: String(input.payload.name),
443470 owner: input.payload.owner,
444471 },
445− run: () =>
446− doStartOAuth({
447− payload: {
448− client: input.payload.client,
449− clientOwner: input.payload.clientOwner,
450− owner: input.payload.owner,
451− name: input.payload.name,
452− integration: input.payload.integration,
453− template: input.payload.template,
454− identityLabel: input.payload.identityLabel,
455− newConnection: input.payload.newConnection,
456− redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath),
457− },
458− }).then((exit) =>
472+ run: () => {
473+ // A shared mutation atom returns its latest result to every waiter.
474+ // A fast retry could give the cancelled attempt the NEW session's
475+ // state and make it cancel that session. Keep one atom per attempt;
476+ // its subscription lives until this request settles, even on cancel.
477+ const request = ExecutorApiClient.runtime.atom(
478+ ExecutorApiClient.use((client) =>
479+ client.oauth.start({
480+ payload: {
481+ client: input.payload.client,
482+ clientOwner: input.payload.clientOwner,
483+ owner: input.payload.owner,
484+ name: input.payload.name,
485+ integration: input.payload.integration,
486+ template: input.payload.template,
487+ identityLabel: input.payload.identityLabel,
488+ newConnection: input.payload.newConnection,
489+ redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath),
490+ },
491+ }),
492+ ),
493+ );
494+ return Effect.runPromiseExit(AtomRegistry.getResult(registry, request)).then((exit) =>
459495 Exit.isSuccess(exit)
460496 ? // The redirect branch carries `authorizationUrl` + `state`; the
461497 // inline "connected" (client_credentials) branch has no URL to
@@ -469,14 +505,16 @@ export function useOAuthPopupFlow< · 7 unmodified lines
469505 message: messageFromExit(exit, startErrorMessage ?? "Failed to start sign-in"),
470506 }),
471507 ),
472− ),
508+ );
509+ },
473510 });
474511 },
475− [callbackPath, doStartOAuth, openAuthorization, startErrorMessage],
512+ [callbackPath, registry, openAuthorization, startErrorMessage],
476513 );
477514
478515 return {
479− busy,
516+ busy: phase !== "idle",
517+ phase,
480518 error,
481519 setError,
482520 start,