diff --git a/services/recordly-share/worker/web/src/components/CloudLibrary.tsx b/services/recordly-share/worker/web/src/components/CloudLibrary.tsx index df9aff8cd..0fc14ce9b 100644 --- a/services/recordly-share/worker/web/src/components/CloudLibrary.tsx +++ b/services/recordly-share/worker/web/src/components/CloudLibrary.tsx @@ -5,6 +5,7 @@ import LibraryPage from "./LibraryPage"; import { CenteredCard } from "./ShareUI"; import { RecordlySignInFields, + SIGN_IN_PROVIDER_CHECK_TIMEOUT_MS, type SignInProvider, } from "../../../../../../src/components/auth/RecordlySignInFields"; import { RecordlyPasswordForm } from "../../../../../../src/components/auth/RecordlyPasswordForm"; @@ -45,7 +46,6 @@ export default function CloudLibrary({ authConfig, commentAccount }: Props = {}) const [action, setAction] = useState(); const [providers, setProviders] = useState([]); const [providersKnown, setProvidersKnown] = useState(false); - const [loadingProviders, setLoadingProviders] = useState(true); // biome-ignore lint/correctness/useExhaustiveDependencies: The parent loads config before mounting; keep one Supabase client for this mount. useEffect(() => { let active = true; @@ -82,10 +82,10 @@ export default function CloudLibrary({ authConfig, commentAccount }: Props = {}) unsubscribe(); return; } - // Discover only enabled providers; email stays usable if discovery fails. + // Disable only providers that Auth confirms are unavailable. void fetch(`${config.url}/auth/v1/settings`, { headers: { apikey: config.publishableKey }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(SIGN_IN_PROVIDER_CHECK_TIMEOUT_MS), }) .then(async (response) => { if (!response.ok) throw new Error("Provider discovery unavailable"); @@ -100,10 +100,7 @@ export default function CloudLibrary({ authConfig, commentAccount }: Props = {}) } }) .catch(() => { - /* Email remains available when providers cannot be discovered. */ - }) - .finally(() => { - if (active) setLoadingProviders(false); + /* Leave provider availability unknown so users can still try signing in. */ }); // Keep the client available even if a stale callback needs a fresh link. setClient(auth); @@ -200,7 +197,7 @@ export default function CloudLibrary({ authConfig, commentAccount }: Props = {}) } async function signInWithProvider(provider: SignInProvider) { - if (!client || pending || !providers.includes(provider)) return; + if (!client || pending || (providersKnown && !providers.includes(provider))) return; setPending(true); setAction(provider); setError(""); @@ -394,7 +391,6 @@ export default function CloudLibrary({ authConfig, commentAccount }: Props = {}) onProvider={(provider) => void signInWithProvider(provider)} providers={providers} providersKnown={providersKnown} - loadingProviders={loadingProviders} configured={Boolean(client)} busy={action} labels={{ diff --git a/src/components/auth/RecordlySignInDialog.tsx b/src/components/auth/RecordlySignInDialog.tsx index 90e08f42e..09b6b2d37 100644 --- a/src/components/auth/RecordlySignInDialog.tsx +++ b/src/components/auth/RecordlySignInDialog.tsx @@ -18,7 +18,7 @@ import { signInWithSocial, signOutRecordly, } from "@/lib/auth/recordlyAuth"; -import { RecordlySignInFields } from "./RecordlySignInFields"; +import { RecordlySignInFields, SIGN_IN_PROVIDER_CHECK_TIMEOUT_MS } from "./RecordlySignInFields"; import { FirstBootMusic } from "./FirstBootMusic"; import { OnboardingBody, @@ -130,15 +130,12 @@ export function RecordlySignInDialog({ const [reportDiagnostic, setReportDiagnostic] = useState(); const [linkSent, setLinkSent] = useState(false); const [providers, setProviders] = useState([]); - const [loadingProviders, setLoadingProviders] = useState(false); const [providersKnown, setProvidersKnown] = useState(false); useEffect(() => { if (!open || !configured || user) return; - let disposed = false; const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), 10000); - setLoadingProviders(true); + const timeout = window.setTimeout(() => controller.abort(), SIGN_IN_PROVIDER_CHECK_TIMEOUT_MS); setProvidersKnown(false); setProviders([]); void getEnabledSignInProviders(controller.signal) @@ -149,15 +146,13 @@ export function RecordlySignInDialog({ } }) .catch((error) => { - // Email remains available to retry when provider discovery cannot reach Auth. + // Allow sign-in attempts when provider availability could not be checked. if (!controller.signal.aborted) captureAuthFailure("provider-discovery", error); }) .finally(() => { window.clearTimeout(timeout); - if (!disposed) setLoadingProviders(false); }); return () => { - disposed = true; window.clearTimeout(timeout); controller.abort(); }; @@ -344,14 +339,12 @@ export function RecordlySignInDialog({ void run(provider, () => signInWithSocial(provider, consent)) } providers={providers} - loadingProviders={loadingProviders} providersKnown={providersKnown} configured={configured} busy={busy} compact={onboarding} assetBase={import.meta.env.BASE_URL} labels={{ - loadingProviders: ot("loadingProviders"), checkInbox: ot("checkInbox"), code: ot("code"), verifyCode: ot("verifyCode"), diff --git a/src/components/auth/RecordlySignInFields.tsx b/src/components/auth/RecordlySignInFields.tsx index 9c86ffa08..915b02b89 100644 --- a/src/components/auth/RecordlySignInFields.tsx +++ b/src/components/auth/RecordlySignInFields.tsx @@ -16,6 +16,8 @@ import { RecordlyBotChallenge } from "./RecordlyBotChallenge"; export type SignInProvider = "google" | "azure" | "apple"; +export const SIGN_IN_PROVIDER_CHECK_TIMEOUT_MS = 5_000; + /** The same primary sign-in controls for desktop and the hosted cloud library. */ export function RecordlySignInFields({ email, @@ -25,7 +27,6 @@ export function RecordlySignInFields({ onPasswordSignIn, onProvider, providers, - loadingProviders, providersKnown, configured, busy, @@ -41,7 +42,6 @@ export function RecordlySignInFields({ onPasswordSignIn: (password: string, captchaToken?: string) => Promise; onProvider: (provider: SignInProvider) => void; providers: SignInProvider[]; - loadingProviders: boolean; providersKnown: boolean; configured: boolean; busy?: string; @@ -49,7 +49,6 @@ export function RecordlySignInFields({ assetBase?: string; authProtectionConfigUrl?: string; labels: { - loadingProviders: string; continueWith: (provider: string) => string; providerDisabled: (provider: string) => string; continueEmail: string; @@ -165,12 +164,7 @@ export function RecordlySignInFields({ className="space-y-4 @container" > {!sentTo && !passwordMode && challengeVersion === 0 && ( -
+
{( [ { provider: "google", label: "Google", image: "google-logo.png" }, @@ -190,9 +184,7 @@ export function RecordlySignInFields({ isDisabled={ disabled || !configured || - loadingProviders || - !providersKnown || - !providers.includes(provider) + (providersKnown && !providers.includes(provider)) } aria-describedby={ providersKnown && !providers.includes(provider) diff --git a/tests/ui/login.spec.ts b/tests/ui/login.spec.ts index 65c4efa6b..0720117e7 100644 --- a/tests/ui/login.spec.ts +++ b/tests/ui/login.spec.ts @@ -87,6 +87,32 @@ test("sign-in keeps all providers visible and marks unavailable providers disabl }); }); +test("production onboarding allows OAuth before provider discovery finishes", async ({ + page, +}) => { + await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false }); + await page.addInitScript(() => { + window.electronAPI.openExternalUrl = async (url) => { + document.documentElement.dataset.authProvider = + new URL(url).searchParams.get("provider") ?? ""; + return { success: true }; + }; + }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.route("https://auth.recordly.test/auth/v1/settings", () => { + // Leave the request pending until the app's timeout aborts it. + }); + const discovery = page.waitForRequest("https://auth.recordly.test/auth/v1/settings"); + await page.goto("/?windowType=onboarding"); + await discovery; + const login = page.getByRole("dialog", { name: "Welcome to Recordly" }); + const google = login.getByRole("button", { name: "Continue with Google" }); + + await google.click({ timeout: 3_500 }); + await expect(page.locator("html")).toHaveAttribute("data-auth-provider", "google"); + await expect(login.getByRole("button", { name: "Permissions", exact: true })).toBeDisabled(); +}); + test("email sign-in reports localized failures and allows retry without granting access", async ({ page, }) => { diff --git a/tests/ui/onboarding-startup.spec.ts b/tests/ui/onboarding-startup.spec.ts index 947f9df1a..168825be3 100644 --- a/tests/ui/onboarding-startup.spec.ts +++ b/tests/ui/onboarding-startup.spec.ts @@ -42,7 +42,7 @@ for (const signedIn of [false, true]) { await page.route("**/auth/v1/settings", async (route) => { await providersReady; await route.fulfill({ - json: { external: { google: true, azure: true, apple: true } }, + json: { external: { google: true, azure: true, apple: false } }, }); }); // Even unavailable artwork cannot gate the form. @@ -70,7 +70,7 @@ for (const signedIn of [false, true]) { ).toBeVisible(); await expect( tour.getByRole("button", { name: `Continue with ${provider}` }), - ).toBeDisabled(); + ).toBeEnabled(); } const before = await email.boundingBox(); if (!signedIn) @@ -78,7 +78,7 @@ for (const signedIn of [false, true]) { path: test.info().outputPath("pending-session.png"), }); releaseProviders(); - await expect(tour.getByRole("button", { name: "Continue with Google" })).toBeEnabled(); + await expect(tour.getByRole("button", { name: "Continue with Apple" })).toBeDisabled(); await expect(email).toHaveValue("person@example.com"); expect(await email.boundingBox()).toEqual(before); await expect(email).toHaveValue("person@example.com"); @@ -89,15 +89,14 @@ for (const signedIn of [false, true]) { }); } -test("provider discovery failure leaves the real email form usable", async ({ page }) => { +test("provider discovery failure leaves social and email sign-in usable", async ({ page }) => { await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false }); await page.route("**/auth/v1/settings", (route) => route.abort()); await page.goto("/?windowType=onboarding"); const tour = page.getByRole("dialog", { name: "Welcome to Recordly" }); await tour.getByPlaceholder("you@example.com").fill("person@example.com"); await expect(tour.getByRole("button", { name: "Email me a code", exact: true })).toBeEnabled(); - await expect(tour.getByRole("button", { name: "Continue with Google" })).toBeVisible(); - await expect(tour.getByRole("button", { name: "Continue with Google" })).toBeDisabled(); + await expect(tour.getByRole("button", { name: "Continue with Google" })).toBeEnabled(); await tour.getByRole("button", { name: "Close onboarding window" }).click(); await expect(page.locator("html")).toHaveAttribute("data-onboarding-closed", "true"); });