diff --git a/apps/web/src/cloud/connectAuth.tsx b/apps/web/src/cloud/connectAuth.tsx index 5ad0fb911248..9c74b80d64df 100644 --- a/apps/web/src/cloud/connectAuth.tsx +++ b/apps/web/src/cloud/connectAuth.tsx @@ -20,6 +20,11 @@ import { resolveClerkSignInProps } from "../components/clerk/authRedirect"; import { PrimaryEnvironmentHttpClient } from "../environments/primary/httpClient"; import { runPrimaryHttp } from "../lib/runtime"; import { resolveRelayClerkTokenOptions } from "./publicConfig"; +import { + isDesktopConnectAuthIdentityPending, + shouldRetryDesktopConnectAuthState, + startSettledPolling, +} from "./connectAuthState"; /** * One T3 Connect session surface across auth backends. The hosted web app @@ -29,6 +34,8 @@ import { resolveRelayClerkTokenOptions } from "./publicConfig"; */ export interface T3ConnectAuth { readonly isLoaded: boolean; + /** A legacy desktop credential is waiting for its account id backfill. */ + readonly isIdentityPending: boolean; readonly isSignedIn: boolean; readonly userId: string | null; /** Account label for display (desktop; web renders Clerk's UserButton). */ @@ -52,6 +59,7 @@ export interface T3ConnectAuth { // gates itself on hasCloudPublicConfig, so this is just a safe floor. const signedOutAuth: T3ConnectAuth = { isLoaded: true, + isIdentityPending: false, isSignedIn: false, userId: null, identity: null, @@ -77,6 +85,7 @@ export function ClerkConnectAuthProvider({ children }: { readonly children: Reac const value = useMemo( () => ({ isLoaded, + isIdentityPending: false, isSignedIn: isSignedIn === true, userId: userId ?? null, identity: null, @@ -191,11 +200,11 @@ export function DesktopConnectAuthProvider({ children }: { readonly children: Re // The bundled server may still be starting when the app mounts; retry until // the first state read lands. const isLoaded = state !== null; + const shouldRetryAuthState = shouldRetryDesktopConnectAuthState(state); useEffect(() => { - if (isLoaded) return; - const interval = setInterval(() => void refresh(), 3_000); - return () => clearInterval(interval); - }, [isLoaded, refresh]); + if (!shouldRetryAuthState) return; + return startSettledPolling(refresh, 3_000); + }, [refresh, shouldRetryAuthState]); // The credential is shared with `t3 connect`, so a CLI sign-in or logout // can change it while the app is open; re-read when the window regains @@ -211,8 +220,7 @@ export function DesktopConnectAuthProvider({ children }: { readonly children: Re const pendingLogin = state?.pendingLogin ?? false; useEffect(() => { if (!pendingLogin) return; - const interval = setInterval(() => void refresh(), LOGIN_WATCH_INTERVAL_MS); - return () => clearInterval(interval); + return startSettledPolling(refresh, LOGIN_WATCH_INTERVAL_MS); }, [pendingLogin, refresh]); const getToken = useCallback(async () => { @@ -266,6 +274,7 @@ export function DesktopConnectAuthProvider({ children }: { readonly children: Re const value = useMemo( () => ({ isLoaded, + isIdentityPending: isDesktopConnectAuthIdentityPending(state), // accountId can lag behind authorization for legacy `t3 connect` // credentials while the server backfills it; relay features need the // account id, so hold "signed in" until it resolves. diff --git a/apps/web/src/cloud/connectAuthState.test.ts b/apps/web/src/cloud/connectAuthState.test.ts new file mode 100644 index 000000000000..31fa87081ad4 --- /dev/null +++ b/apps/web/src/cloud/connectAuthState.test.ts @@ -0,0 +1,78 @@ +import type { EnvironmentConnectAuthState } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + isDesktopConnectAuthIdentityPending, + shouldRetryDesktopConnectAuthState, + startSettledPolling, +} from "./connectAuthState"; + +const authState = ( + overrides: Partial = {}, +): EnvironmentConnectAuthState => ({ + authorized: false, + pendingLogin: false, + authorizationUrl: null, + accountId: null, + identity: null, + ...overrides, +}); + +describe("desktop Connect auth state retry", () => { + it("retries before the first auth state response arrives", () => { + expect(shouldRetryDesktopConnectAuthState(null)).toBe(true); + }); + + it("retries an authorized legacy credential until its account id is backfilled", () => { + expect(shouldRetryDesktopConnectAuthState(authState({ authorized: true }))).toBe(true); + }); + + it("marks legacy identity backfill without changing stable load states", () => { + expect(isDesktopConnectAuthIdentityPending(authState({ authorized: true }))).toBe(true); + expect(isDesktopConnectAuthIdentityPending(authState())).toBe(false); + expect( + isDesktopConnectAuthIdentityPending(authState({ authorized: true, accountId: "user-123" })), + ).toBe(false); + }); + + it("waits for each refresh to settle before scheduling another poll", async () => { + vi.useFakeTimers(); + try { + let releaseFirst: (() => void) | undefined; + let refreshCount = 0; + const refresh = vi.fn(() => { + refreshCount += 1; + if (refreshCount === 1) { + return new Promise((resolve) => { + releaseFirst = resolve; + }); + } + return Promise.resolve(); + }); + const stop = startSettledPolling(refresh, 3_000); + + await vi.advanceTimersByTimeAsync(3_000); + expect(refresh).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(3_000); + expect(refresh).toHaveBeenCalledTimes(1); + + releaseFirst?.(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(2_999); + expect(refresh).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(refresh).toHaveBeenCalledTimes(2); + + stop(); + } finally { + vi.useRealTimers(); + } + }); + + it("stops retrying once the state is stable", () => { + expect(shouldRetryDesktopConnectAuthState(authState())).toBe(false); + expect( + shouldRetryDesktopConnectAuthState(authState({ authorized: true, accountId: "user-123" })), + ).toBe(false); + }); +}); diff --git a/apps/web/src/cloud/connectAuthState.ts b/apps/web/src/cloud/connectAuthState.ts new file mode 100644 index 000000000000..fff66aebdf5e --- /dev/null +++ b/apps/web/src/cloud/connectAuthState.ts @@ -0,0 +1,36 @@ +import type { EnvironmentConnectAuthState } from "@t3tools/contracts"; + +export function isDesktopConnectAuthIdentityPending( + state: EnvironmentConnectAuthState | null, +): boolean { + return state?.authorized === true && state.accountId === null; +} + +export function startSettledPolling(task: () => Promise, intervalMs: number): () => void { + let cancelled = false; + let timeout: ReturnType | undefined; + + const poll = async () => { + await task(); + if (!cancelled) { + timeout = setTimeout(poll, intervalMs); + } + }; + + timeout = setTimeout(poll, intervalMs); + return () => { + cancelled = true; + if (timeout !== undefined) { + clearTimeout(timeout); + } + }; +} + +export function shouldRetryDesktopConnectAuthState( + state: EnvironmentConnectAuthState | null, +): boolean { + // Legacy CLI credentials are authorized before the server lazily backfills + // their Clerk account id. Keep polling so the desktop can reach a usable + // signed-in state without requiring a focus change or another login. + return state === null || isDesktopConnectAuthIdentityPending(state); +} diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 2c348c6af727..333999c5d47b 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -47,7 +47,7 @@ export function ConnectOnboardingDialog() { type OnboardingStep = "publish" | "devices"; function ConfiguredConnectOnboardingDialog() { - const { isLoaded, isSignedIn, userId } = useT3ConnectAuth(); + const { isIdentityPending, isLoaded, isSignedIn, userId } = useT3ConnectAuth(); const [optOutState, setOptOutState] = useLocalStorage( CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, @@ -94,7 +94,9 @@ function ConfiguredConnectOnboardingDialog() { // environments, so each new session starts with no devices to reach. A cold // load observes undefined → account and must not re-prompt. useEffect(() => { - if (!isLoaded) return; + // A legacy credential is authorized before its account id is backfilled; + // do not record that incomplete snapshot as a signed-out cold load. + if (!isLoaded || isIdentityPending) return; // A loaded-but-incomplete snapshot (signed in, user id not yet populated) // must not be recorded as signed-out — the next render would then look // like a fresh sign-in on a cold load. @@ -105,7 +107,7 @@ function ConfiguredConnectOnboardingDialog() { if (previousAccount !== undefined && previousAccount !== nextAccount && nextAccount !== null) { setRequestedAccount(nextAccount); } - }, [isLoaded, isSignedIn, userId]); + }, [isIdentityPending, isLoaded, isSignedIn, userId]); // A manageable session implies a primary environment, so when the scopes // allow publishing, wait for the connection target too — otherwise the