History

Keep the connection form visible during sign-in

Rhys Sullivan committed 1 month ago

Commit 2fbc1fb1 parent 1b19b373 files · +46 −54Browse files

Unified · Split

3 files
  1. .changeset/connection-oauth-cancel.md +1 −1
  2. e2e/scenarios/connection-setup-ux.test.ts +22 −0
  3. packages/react/src/components/add-account-modal.tsx +23 −53
.changeset/connection-oauth-cancel.md+1−1
@@ -2,4 +2,4 @@ · 1 unmodified line
2"executor": patch2"executor": patch
3---3---
4 4
5Show a clear, cancellable sign-in state and prevent cancelled OAuth requests from disrupting a retry.5Let users cancel sign-in from the connection form and prevent cancelled OAuth requests from disrupting a retry.
e2e/scenarios/connection-setup-ux.test.ts+22−0
@@ -122,6 +122,11 @@ scenario( · 121 unmodified lines
122 await step("Sign in with a provider account without naming the connection", async () => {122 await step("Sign in with a provider account without naming the connection", async () => {
123 await visit(page, `/integrations/${slug}?addAccount=1`);123 await visit(page, `/integrations/${slug}?addAccount=1`);
124 await page.getByRole("tab", { name: "OAuth2", exact: true }).click();124 await page.getByRole("tab", { name: "OAuth2", exact: true }).click();
125 // Attached previews can contain other compatible apps. Use this
126 // scenario's provider instance for the full callback and token exchange.
127 await page
128 .getByRole("radio", { name: new RegExp(slug.replace("setup-", ""), "i") })
129 .check();
125 const opened = page.waitForEvent("popup");130 const opened = page.waitForEvent("popup");
126 await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();131 await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();
127 const popup = await opened;132 const popup = await opened;
@@ -299,6 +304,11 @@ scenario( · 171 unmodified lines
299 await step("Start provider sign-in without entering a name", async () => {304 await step("Start provider sign-in without entering a name", async () => {
300 await visit(page, `/integrations/${slug}?addAccount=1`);305 await visit(page, `/integrations/${slug}?addAccount=1`);
301 await page.getByRole("tab", { name: "OAuth2", exact: true }).click();306 await page.getByRole("tab", { name: "OAuth2", exact: true }).click();
307 // Attached previews can contain other compatible apps. Use this
308 // scenario's provider instance for the full callback and token exchange.
309 await page
310 .getByRole("radio", { name: new RegExp(slug.replace("setup-", ""), "i") })
311 .check();
302 const opened = page.waitForEvent("popup");312 const opened = page.waitForEvent("popup");
303 await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();313 await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();
304 const popup = await opened;314 const popup = await opened;
@@ -313,6 +323,11 @@ scenario( · 8 unmodified lines
313 ).toBe(true);323 ).toBe(true);
314 expect(await cancel.isEnabled()).toBe(true);324 expect(await cancel.isEnabled()).toBe(true);
315 await page.getByText("Continue in the sign-in window", { exact: true }).waitFor();325 await page.getByText("Continue in the sign-in window", { exact: true }).waitFor();
326 expect(
327 await page.getByRole("tab", { name: "OAuth2", exact: true }).isVisible(),
328 "the authentication form stays visible during sign-in",
329 ).toBe(true);
330 expect(await page.getByRole("textbox", { name: /Display name/ }).isVisible()).toBe(true);
316 expect(331 expect(
317 await page.getByRole("button", { name: "Connecting…", exact: true }).count(),332 await page.getByRole("button", { name: "Connecting…", exact: true }).count(),
318 "waiting for provider consent must not leave a dead Connecting button",333 "waiting for provider consent must not leave a dead Connecting button",
@@ -371,10 +386,17 @@ scenario( · 52 unmodified lines
371 try {386 try {
372 await step("Cancel while the authorization response is still in flight", async () => {387 await step("Cancel while the authorization response is still in flight", async () => {
373 await visit(page, `/integrations/${slug}?addAccount=1`);388 await visit(page, `/integrations/${slug}?addAccount=1`);
389 await page
390 .getByRole("radio", { name: new RegExp(slug.replace("setup-", ""), "i") })
391 .check();
374 const opened = page.waitForEvent("popup");392 const opened = page.waitForEvent("popup");
375 await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();393 await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();
376 const popup = await opened;394 const popup = await opened;
377 const oldState = await started.promise;395 const oldState = await started.promise;
396 expect(
397 await page.getByRole("textbox", { name: /Display name/ }).isVisible(),
398 "the connection form stays visible while sign-in is being prepared",
399 ).toBe(true);
378 await page.getByRole("button", { name: "Cancel sign-in", exact: true }).click();400 await page.getByRole("button", { name: "Cancel sign-in", exact: true }).click();
379 await expect.poll(() => popup.isClosed()).toBe(true);401 await expect.poll(() => popup.isClosed()).toBe(true);
380 const cancelled = page.waitForResponse((response) =>402 const cancelled = page.waitForResponse((response) =>
packages/react/src/components/add-account-modal.tsx+23−53
@@ -100,15 +100,7 @@ import { · 99 unmodified lines
100 DropdownMenuItem,100 DropdownMenuItem,
101 DropdownMenuTrigger,101 DropdownMenuTrigger,
102} from "./dropdown-menu";102} from "./dropdown-menu";
103import {103import { ChevronDown, EyeIcon, EyeOffIcon, PlusIcon, XIcon } from "lucide-react";
104 ChevronDown,
105 EyeIcon,
106 EyeOffIcon,
107 LoaderCircleIcon,
108 PanelsTopLeftIcon,
109 PlusIcon,
110 XIcon,
111} from "lucide-react";
112import {104import {
113 Dialog,105 Dialog,
114 DialogContent,106 DialogContent,
@@ -2749,49 +2741,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 2634 unmodified lines
2749 : "sm:max-w-xl",2741 : "sm:max-w-xl",
2750 )}2742 )}
2751 >2743 >
2752 {signInPending ? (2744 {addingMethod && createCustomMethod ? (
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 ? (
2795 <>2745 <>
2796 <DialogHeader className="border-b border-border/60 px-5 py-4">2746 <DialogHeader className="border-b border-border/60 px-5 py-4">
2797 <DialogTitle className="text-base">Add authentication method</DialogTitle>2747 <DialogTitle className="text-base">Add authentication method</DialogTitle>
@@ -3442,10 +3392,20 @@ function AddAccountModalView(props: AddAccountModalProps) { · 644 unmodified lines
3442 </p>3392 </p>
3443 ) : null}3393 ) : null}
3444 <DialogFooter>3394 <DialogFooter>
3395 {signInPending ? (
3396 <p role="status" className="flex-1 self-center text-xs text-muted-foreground">
3397 {oauthPopup.phase === "authorizing"
3398 ? "Continue in the sign-in window"
3399 : oauthPopup.phase === "saving"
3400 ? "Finishing connection…"
3401 : "Preparing sign-in…"}
3402 </p>
3403 ) : null}
3445 <Button type="button" variant="ghost" onClick={close} disabled={submitting || ccBusy}>3404 <Button type="button" variant="ghost" onClick={close} disabled={submitting || ccBusy}>
3446 {isOAuth ? "Close" : "Cancel"}3405 {isOAuth ? "Close" : "Cancel"}
3447 </Button>3406 </Button>
3448 {/* Footer action, in precedence order:3407 {/* Footer action, in precedence order:
3408 - pending sign-in: cancel, or wait for an authorized connection to save;
3449 - transparent CIMD (no app registration): create/reuse a public3409 - transparent CIMD (no app registration): create/reuse a public
3450 metadata-document client and start OAuth;3410 metadata-document client and start OAuth;
3451 - transparent DCR (no picker): a single Connect that runs3411 - transparent DCR (no picker): a single Connect that runs
@@ -3453,7 +3413,17 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1 unmodified line
3453 - registering a BYO app: the form owns its own submit, no footer;3413 - registering a BYO app: the form owns its own submit, no footer;
3454 - picked BYO OAuth app: Connect with OAuth / Connect (client creds);3414 - picked BYO OAuth app: Connect with OAuth / Connect (client creds);
3455 - credential/no-auth method: Add connection. */}3415 - credential/no-auth method: Add connection. */}
3456 {cimdActive ? (3416 {signInPending ? (
3417 oauthPopup.phase === "saving" ? (
3418 <Button type="button" loading>
3419 Finishing…
3420 </Button>
3421 ) : (
3422 <Button type="button" variant="outline" onClick={cancelSignIn}>
3423 Cancel sign-in
3424 </Button>
3425 )
3426 ) : cimdActive ? (
3457 <Button3427 <Button
3458 type="button"3428 type="button"
3459 onClick={() => void handleCimdConnect()}3429 onClick={() => void handleCimdConnect()}