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
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,7 +46,6 @@ export default function CloudLibrary({ authConfig, commentAccount }: Props = {})
const [action, setAction] = useState<string>();
const [providers, setProviders] = useState<SignInProvider[]>([]);
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;
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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={{
Expand Down
13 changes: 3 additions & 10 deletions src/components/auth/RecordlySignInDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -130,15 +130,12 @@ export function RecordlySignInDialog({
const [reportDiagnostic, setReportDiagnostic] = useState<string>();
const [linkSent, setLinkSent] = useState(false);
const [providers, setProviders] = useState<RecordlyOAuthProvider[]>([]);
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)
Expand All @@ -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();
};
Expand Down Expand Up @@ -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"),
Expand Down
16 changes: 4 additions & 12 deletions src/components/auth/RecordlySignInFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,7 +27,6 @@ export function RecordlySignInFields({
onPasswordSignIn,
onProvider,
providers,
loadingProviders,
providersKnown,
configured,
busy,
Expand All @@ -41,15 +42,13 @@ export function RecordlySignInFields({
onPasswordSignIn: (password: string, captchaToken?: string) => Promise<boolean>;
onProvider: (provider: SignInProvider) => void;
providers: SignInProvider[];
loadingProviders: boolean;
providersKnown: boolean;
configured: boolean;
busy?: string;
compact?: boolean;
assetBase?: string;
authProtectionConfigUrl?: string;
labels: {
loadingProviders: string;
continueWith: (provider: string) => string;
providerDisabled: (provider: string) => string;
continueEmail: string;
Expand Down Expand Up @@ -165,12 +164,7 @@ export function RecordlySignInFields({
className="space-y-4 @container"
>
{!sentTo && !passwordMode && challengeVersion === 0 && (
<div
className="flex gap-2"
role="group"
aria-label={loadingProviders ? labels.loadingProviders : undefined}
aria-busy={loadingProviders}
>
<div className="flex gap-2" role="group">
{(
[
{ provider: "google", label: "Google", image: "google-logo.png" },
Expand All @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions tests/ui/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down
11 changes: 5 additions & 6 deletions tests/ui/onboarding-startup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -70,15 +70,15 @@ 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)
await page.screenshot({
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");
Expand All @@ -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");
});
Expand Down
Loading