Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions apps/web/src/cloud/connectAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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). */
Expand All @@ -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,
Expand All @@ -77,6 +85,7 @@ export function ClerkConnectAuthProvider({ children }: { readonly children: Reac
const value = useMemo<T3ConnectAuth>(
() => ({
isLoaded,
isIdentityPending: false,
isSignedIn: isSignedIn === true,
userId: userId ?? null,
identity: null,
Expand Down Expand Up @@ -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]);
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

// 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
Expand All @@ -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 () => {
Expand Down Expand Up @@ -266,6 +274,7 @@ export function DesktopConnectAuthProvider({ children }: { readonly children: Re
const value = useMemo<T3ConnectAuth>(
() => ({
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.
Expand Down
78 changes: 78 additions & 0 deletions apps/web/src/cloud/connectAuthState.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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<void>((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);
});
});
36 changes: 36 additions & 0 deletions apps/web/src/cloud/connectAuthState.ts
Original file line number Diff line number Diff line change
@@ -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<void>, intervalMs: number): () => void {
let cancelled = false;
let timeout: ReturnType<typeof setTimeout> | 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);
}
8 changes: 5 additions & 3 deletions apps/web/src/components/cloud/ConnectOnboardingDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading