From a7c5ad5db167b3a172ccb26408b0638c99b2a459 Mon Sep 17 00:00:00 2001 From: Torben Wetter Date: Sat, 15 Aug 2026 14:01:08 +0200 Subject: [PATCH] fix(web): unstick /connect after in-modal sign-in by redirecting to the authorize endpoint (#5133) --- apps/web/src/cloud/connectCliAuth.test.ts | 24 +++++++++++++++ apps/web/src/cloud/connectCliAuth.ts | 17 +++++++++++ .../src/components/clerk/authRedirect.test.ts | 5 +++- apps/web/src/components/clerk/authRedirect.ts | 4 ++- .../cloud/ConnectCliAuthSurface.tsx | 29 +++++++++++++------ 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts index 59b443a49d93b2c43de6ffa7c458f34e52876e24..3d41c4166332f9ddd4784d1df3aa738bc5c961a6 100644 --- a/apps/web/src/cloud/connectCliAuth.test.ts +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { buildConnectCliClerkAuthorizeUrl, + connectCliSignInRedirectUrl, hasConnectCliAuthConfig, readConnectCliCallbackResult, } from "./connectCliAuth"; @@ -69,6 +70,29 @@ describe("connectCliAuth", () => { ).toBeNull(); }); + it("sends the sign-in redirect to the authorize endpoint, not back to /connect", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + + const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + const redirectUrl = connectCliSignInRedirectUrl( + { state: "state-1", challenge: "challenge-1" }, + connectUrl, + ); + + expect(redirectUrl).not.toBe(connectUrl); + expect(new URL(redirectUrl).pathname).toBe("/oauth/authorize"); + }); + + it("falls back to the current URL when the authorize URL cannot be built", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + + const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + expect( + connectCliSignInRedirectUrl({ state: "state-1", challenge: "challenge-1" }, connectUrl), + ).toBe(connectUrl); + }); + it("reads the code and state Clerk echoes back to the callback", () => { expect( readConnectCliCallbackResult( diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 969215d97ad31cd7d49a038e7b38ad6bc5397f3b..815715da249973c36fc214a573c38143f1006b0b 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -60,6 +60,23 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques }); } +/** + * Where Clerk sends the browser once the sign-in modal on /connect completes. + * It has to be the authorize endpoint rather than this page: /connect carries + * the CLI request in its fragment, so navigating back to the same URL is a + * same-document fragment navigation the browser never reloads — and Clerk + * treats any post-sign-in navigation as a page unload and skips the state emit + * that would otherwise re-render the surface, so the session never arrives + * either. Falls back to the current URL when the authorize URL cannot be + * built, which only happens on a deployment without the CLI OAuth config. + */ +export function connectCliSignInRedirectUrl( + request: ConnectAuthorizeRequest, + currentHref: string, +): string { + return buildConnectCliClerkAuthorizeUrl(request) ?? currentHref; +} + export function rememberConnectCliAuthState(state: string): void { try { window.sessionStorage.setItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY, state); diff --git a/apps/web/src/components/clerk/authRedirect.test.ts b/apps/web/src/components/clerk/authRedirect.test.ts index 140474120cca97e4e408168aa73db48b5d34e32d..e948d1d9c0498b768b2e315d1b872d8c9c97fefe 100644 --- a/apps/web/src/components/clerk/authRedirect.test.ts +++ b/apps/web/src/components/clerk/authRedirect.test.ts @@ -5,7 +5,10 @@ import { resolveClerkSignInProps } from "./authRedirect"; describe("resolveClerkSignInProps", () => { it("returns to the current browser URL on the web", () => { const href = "https://app.t3.codes/connect?state=state-1#details"; - expect(resolveClerkSignInProps(href, false)).toEqual({ forceRedirectUrl: href }); + expect(resolveClerkSignInProps(href, false)).toEqual({ + forceRedirectUrl: href, + signUpForceRedirectUrl: href, + }); }); it("removes a Clerk virtual pathname and callback params while preserving the desktop route", () => { diff --git a/apps/web/src/components/clerk/authRedirect.ts b/apps/web/src/components/clerk/authRedirect.ts index 251c5ee365027759ab3a086c5273d593bf2d8af3..e0b07241c0680d452eaa976b98e8b47a6813c691 100644 --- a/apps/web/src/components/clerk/authRedirect.ts +++ b/apps/web/src/components/clerk/authRedirect.ts @@ -15,5 +15,7 @@ export function resolveClerkSignInProps(href: string, isElectron: boolean): Cler signUpForceRedirectUrl: redirectUrl.toString(), }; } - return { forceRedirectUrl: href }; + // The sign-in modal can switch to sign-up, which follows its own redirect + // target; without one Clerk falls back to the URL the modal was opened from. + return { forceRedirectUrl: href, signUpForceRedirectUrl: href }; } diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index e47d8ddf7f7cd5cec93f8b474cc28f60b0bbf8dc..5d5c280bb81c62f1567a80b29a206489d67f18e9 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -1,9 +1,10 @@ import { useAuth, useClerk, useUser } from "@clerk/react"; import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { buildConnectCliClerkAuthorizeUrl, + connectCliSignInRedirectUrl, readConnectCliAuthState, readConnectCliCallbackResult, rememberConnectCliAuthState, @@ -56,6 +57,21 @@ export function ConnectCliAuthorizeSurface() { const signInOpened = useRef(false); const redirecting = useRef(false); + const openSignIn = useCallback(() => { + if (!request) { + return; + } + // Clerk redirects to the authorize endpoint itself once sign-in completes, + // so the callback's state check has to be armed before handing off. + rememberConnectCliAuthState(request.state); + clerk.openSignIn( + resolveClerkSignInProps( + connectCliSignInRedirectUrl(request, window.location.href), + isElectron, + ), + ); + }, [clerk, request]); + useEffect(() => { if (!request || !isLoaded || redirecting.current) { return; @@ -63,7 +79,7 @@ export function ConnectCliAuthorizeSurface() { if (!isSignedIn) { if (!signInOpened.current) { signInOpened.current = true; - clerk.openSignIn(resolveClerkSignInProps(window.location.href, isElectron)); + openSignIn(); } return; } @@ -74,7 +90,7 @@ export function ConnectCliAuthorizeSurface() { redirecting.current = true; rememberConnectCliAuthState(request.state); window.location.assign(authorizeUrl); - }, [clerk, isLoaded, isSignedIn, request]); + }, [isLoaded, isSignedIn, openSignIn, request]); if (!request) { return ( @@ -101,12 +117,7 @@ export function ConnectCliAuthorizeSurface() { /> {isLoaded && !isSignedIn ? (
-
-- 2.54.0