diff --git a/README.md b/README.md index 6f950c3db..495ea3f9d 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,11 @@ const ui = initializeUI({ #### `autoUpgradeAnonymousUsers` -The `autoUpgradeAnonymousUsers` behavior will automatically upgrade a user who is anonymously authenticated with your application upon a successful sign in (including OAuth). You can optionally provide a callback to handle an upgrade (such as merging account data). During the async callback, the UI will stay in a pending state. +The `autoUpgradeAnonymousUsers` behavior will automatically upgrade a user who is anonymously authenticated with your application upon a successful sign in (including OAuth). You can optionally provide callbacks to handle successful upgrades and failed upgrade attempts. During async callbacks, the UI will stay in a pending state. + +When an upgrade succeeds, the anonymous user's UID is preserved and the new credential is linked to that user. When an upgrade fails (for example, because an OAuth credential is already linked to another account), `onUpgradeFailure` receives the original error and the anonymous user's `oldUserId` so your app can decide whether to migrate anonymous user data into the existing account. Return `"handled"` from `onUpgradeFailure` to suppress the default FirebaseUI error. Return `undefined`, omit the callback, or throw from the callback to preserve the default error behavior. + +`onUpgradeFailure` also fires for provider-linking failures that only surface after a redirect round trip (i.e. when combined with [`providerRedirectStrategy`](#providerredirectstrategy)), such as `auth/credential-already-in-use` or `auth/email-already-in-use` returned from `getRedirectResult()`. In that case `provider` and `credential` may be `undefined` if FirebaseUI can't recover them from the redirect error. ```ts import { autoUpgradeAnonymousUsers } from '@firebase-oss/ui-core'; @@ -346,7 +350,12 @@ const ui = initializeUI({ behaviors: [autoUpgradeAnonymousUsers({ async onUpgrade(ui, oldUserId, credential) { // Some account upgrade logic. - } + }, + async onUpgradeFailure({ ui, oldUserId, error, credential, provider }) { + // Optional merge-conflict handling. + // Return "handled" if your app handled the failure and FirebaseUI + // should not show the default error. + }, })], }); ``` @@ -710,6 +719,7 @@ By default, any missing translations will fallback to English if not specified. |----------|:-----------------:|------------------------------------| | options | AutoUpgradeAnonymousUsersOptions? | Optional configuration. | | options.onUpgrade | function? | Optional callback when upgrade occurs. | + | options.onUpgradeFailure | function? | Optional callback when upgrade linking fails, including redirect-completed failures. | Returns `Behavior<"autoUpgradeAnonymousCredential" | "autoUpgradeAnonymousProvider" | "autoUpgradeAnonymousUserRedirectHandler">`. diff --git a/developer-docs/architecture/testing-strategy.md b/developer-docs/architecture/testing-strategy.md index 5e5a7aaea..7c8dcf495 100644 --- a/developer-docs/architecture/testing-strategy.md +++ b/developer-docs/architecture/testing-strategy.md @@ -14,6 +14,7 @@ timestamp: 2026-07-03T00:00:00Z | Compile (packages + examples) | `pnpm build` | [CONTRIBUTING.md](../../CONTRIBUTING.md) | TS, bundler, framework build errors | | Lint / format | `pnpm lint:check`, `pnpm format:check` | [CONTRIBUTING.md](../../CONTRIBUTING.md) | Style regressions | | Example smoke (browser + HTTP) | `pnpm test:e2e` | [AD-3](../decisions.md#ad-3-playwright-example-smoke-tests-mvp-scope-dev-server), [AD-4](../decisions.md#ad-4-playwright-managed-dev-servers-serial-shared-emulator), [AD-6](../decisions.md#ad-6-custom-auth-server-binds-4001-for-e2e) | Dev-server UI interactivity (five UI examples) and `custom-auth-server` HTTP boot | +| Anonymous upgrade integration | `pnpm test:e2e:react`, `pnpm test:e2e:angular` | [`anonymous-upgrade.spec.ts`](../../e2e/tests/anonymous-upgrade.spec.ts) | React/Angular credential upgrades, conflict callbacks, handled errors, and provider redirect state (including redirect-completed linking conflicts) | | Security audit | `pnpm audit` | [playbooks/dependency-update-verification.md](../playbooks/dependency-update-verification.md) | Known CVEs in lockfile | # CI today diff --git a/e2e/tests/anonymous-upgrade.spec.ts b/e2e/tests/anonymous-upgrade.spec.ts new file mode 100644 index 000000000..5df612651 --- /dev/null +++ b/e2e/tests/anonymous-upgrade.spec.ts @@ -0,0 +1,258 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { APIRequestContext, Browser, Page } from "@playwright/test"; +import { enUs } from "@firebase-oss/ui-translations"; +import { exampleMeta, type UiExampleMeta } from "../fixtures/example-meta"; +import { expect, test } from "../fixtures/test-harness"; + +const AUTH_EMULATOR_BASE_URL = "http://127.0.0.1:9099"; +const FIREBASE_PROJECT_ID = "fir-ui-rework"; +const E2E_SCENARIO_PARAM = "e2eAnonymousUpgrade"; +const ANONYMOUS_USER_ID_KEY = "firebaseui:e2e:anonymous-user-id"; +const UPGRADE_RESULT_KEY = "firebaseui:e2e:upgrade-result"; +const UPGRADE_FAILURE_KEY = "firebaseui:e2e:upgrade-failure"; +const REDIRECT_USER_ID_KEY = "fbui:upgrade:oldUserId"; +const PASSWORD = "e2e-password-123"; + +type UpgradeResult = { + oldUserId: string; + newUserId: string; +}; + +type UpgradeFailure = { + oldUserId: string; + code: string; + kind: "credential" | "provider"; +}; + +const projectsUnderTest = ["react", "angular-example"] as const; + +function scenarioUrl(scenario: "default" | "handled" | "redirect" | "redirect-handled"): string { + return `/screens/sign-in-auth-screen-w-oauth?${E2E_SCENARIO_PARAM}=${scenario}`; +} + +function uniqueEmail(projectName: string, label: string): string { + return `${projectName}-${label}-${crypto.randomUUID()}@example.test`; +} + +async function createEmailUser(request: APIRequestContext, email: string): Promise { + const response = await request.post( + `${AUTH_EMULATOR_BASE_URL}/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-api-key`, + { + data: { email, password: PASSWORD, returnSecureToken: true }, + } + ); + + expect(response.ok(), await response.text()).toBe(true); +} + +async function clearEmulatorUsers(request: APIRequestContext): Promise { + const response = await request.delete( + `${AUTH_EMULATOR_BASE_URL}/emulator/v1/projects/${FIREBASE_PROJECT_ID}/accounts` + ); + expect(response.ok(), await response.text()).toBe(true); +} + +async function waitForAnonymousUser(page: Page): Promise { + await expect + .poll(() => page.evaluate((key) => window.localStorage.getItem(key), ANONYMOUS_USER_ID_KEY)) + .not.toBeNull(); + + return page.evaluate((key) => window.localStorage.getItem(key) as string, ANONYMOUS_USER_ID_KEY); +} + +async function submitCredentials(page: Page, email: string): Promise { + await page.getByLabel(enUs.translations.labels.emailAddress).fill(email); + await page.getByLabel(enUs.translations.labels.password).fill(PASSWORD); + await page.getByRole("button", { name: enUs.translations.labels.signIn, exact: true }).click(); +} + +async function readStorage(page: Page, key: string): Promise { + return page.evaluate((storageKey) => { + const value = window.localStorage.getItem(storageKey); + return value ? JSON.parse(value) : null; + }, key); +} + +async function expectUpgradePreservedUser(page: Page, anonymousUserId: string): Promise { + await expect.poll(() => readStorage(page, UPGRADE_RESULT_KEY)).not.toBeNull(); + + const result = await readStorage(page, UPGRADE_RESULT_KEY); + expect(result).toEqual({ oldUserId: anonymousUserId, newUserId: anonymousUserId }); +} + +async function completeGoogleRedirect(page: Page, email: string): Promise { + await page.getByRole("button", { name: enUs.translations.labels.signInWithGoogle }).click(); + + // The emulator widget paints its account chooser before Angular Material attaches handlers. + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: "Add new account" }).click(); + await expect(page.locator("#email-input")).toBeVisible(); + await page.locator("#email-input").fill(email); + await page.locator("#display-name-input").fill("Playwright User"); + await page.getByRole("button", { name: /sign in with google/i }).click(); +} + +// A fresh browser context gives us an independent, unauthenticated Firebase Auth persistence +// store, so `autoAnonymousLogin` creates a brand new anonymous user instead of reusing whatever +// session the default `page` fixture already has (e.g. one already upgraded by a prior step). +async function openFreshAnonymousSession( + browser: Browser, + scenario: "default" | "handled" | "redirect" | "redirect-handled" +): Promise<{ page: Page; anonymousUserId: string }> { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.route("**/*accounts.google.com/**", (route) => route.abort()); + + await page.goto(scenarioUrl(scenario)); + const anonymousUserId = await waitForAnonymousUser(page); + + return { page, anonymousUserId }; +} + +for (const projectName of projectsUnderTest) { + const meta = exampleMeta[projectName] as UiExampleMeta; + + test.describe(`anonymous upgrade (${projectName})`, () => { + test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== projectName, `runs only on the ${projectName} project`); + }); + + test("credential upgrade preserves uid and fires onUpgrade", async ({ page }) => { + await page.goto(scenarioUrl("default")); + const anonymousUserId = await waitForAnonymousUser(page); + + await submitCredentials(page, uniqueEmail(meta.name, "success")); + + await expectUpgradePreservedUser(page, anonymousUserId); + }); + + test("credential conflict fires onUpgradeFailure and shows the default error", async ({ page, request }) => { + const email = uniqueEmail(meta.name, "default-conflict"); + await createEmailUser(request, email); + await page.goto(scenarioUrl("default")); + const anonymousUserId = await waitForAnonymousUser(page); + + await submitCredentials(page, email); + + await expect(page.getByText(enUs.translations.errors.emailAlreadyInUse)).toBeVisible(); + await expect.poll(() => readStorage(page, UPGRADE_FAILURE_KEY)).not.toBeNull(); + expect(await readStorage(page, UPGRADE_FAILURE_KEY)).toEqual({ + oldUserId: anonymousUserId, + code: "auth/email-already-in-use", + kind: "credential", + }); + }); + + test("handled credential conflict suppresses the default error", async ({ page, request }) => { + const email = uniqueEmail(meta.name, "handled-conflict"); + await createEmailUser(request, email); + await page.goto(scenarioUrl("handled")); + const anonymousUserId = await waitForAnonymousUser(page); + + await submitCredentials(page, email); + + await expect.poll(() => readStorage(page, UPGRADE_FAILURE_KEY)).not.toBeNull(); + expect(await readStorage(page, UPGRADE_FAILURE_KEY)).toEqual({ + oldUserId: anonymousUserId, + code: "auth/email-already-in-use", + kind: "credential", + }); + await expect(page.getByText(enUs.translations.errors.emailAlreadyInUse)).toHaveCount(0); + }); + + test("provider redirect preserves anonymous upgrade state across the round trip", async ({ page, request }) => { + // Keep the emulator IdP chooser deterministic even when a developer reuses an existing emulator. + await clearEmulatorUsers(request); + await page.goto(scenarioUrl("redirect")); + const anonymousUserId = await waitForAnonymousUser(page); + + await completeGoogleRedirect(page, uniqueEmail(meta.name, "redirect")); + + await expectUpgradePreservedUser(page, anonymousUserId); + await expect + .poll(() => page.evaluate((key) => window.localStorage.getItem(key), REDIRECT_USER_ID_KEY)) + .toBeNull(); + }); + + test("provider redirect conflict fires onUpgradeFailure and shows the default error", async ({ + page, + request, + browser, + }) => { + // Keep the emulator IdP chooser deterministic even when a developer reuses an existing emulator. + await clearEmulatorUsers(request); + const conflictEmail = uniqueEmail(meta.name, "redirect-conflict"); + + // The first anonymous user links via redirect and becomes the permanent owner of this + // Google identity. + await page.goto(scenarioUrl("redirect")); + await waitForAnonymousUser(page); + await completeGoogleRedirect(page, conflictEmail); + await expect.poll(() => readStorage(page, UPGRADE_RESULT_KEY)).not.toBeNull(); + + // A second, independent anonymous user attempts to link the same Google identity via + // redirect. `getRedirectResult()` rejects, so this only passes if `onUpgradeFailure` is + // wired into the redirect path in `config.ts` (not just the popup/credential paths). + const { page: conflictPage, anonymousUserId: secondAnonymousUserId } = await openFreshAnonymousSession( + browser, + "redirect" + ); + + await completeGoogleRedirect(conflictPage, conflictEmail); + + // The emulator's fake Google identity doesn't reuse the same provider UID for a repeated + // email, so linking fails on the email conflict rather than an already-linked credential. + await expect(conflictPage.getByText(enUs.translations.errors.emailAlreadyInUse)).toBeVisible(); + await expect.poll(() => readStorage(conflictPage, UPGRADE_FAILURE_KEY)).not.toBeNull(); + expect(await readStorage(conflictPage, UPGRADE_FAILURE_KEY)).toEqual({ + oldUserId: secondAnonymousUserId, + code: "auth/email-already-in-use", + kind: "provider", + }); + + await conflictPage.context().close(); + }); + + test("handled provider redirect conflict suppresses the default error", async ({ page, request, browser }) => { + await clearEmulatorUsers(request); + const conflictEmail = uniqueEmail(meta.name, "redirect-handled-conflict"); + + await page.goto(scenarioUrl("redirect-handled")); + await waitForAnonymousUser(page); + await completeGoogleRedirect(page, conflictEmail); + await expect.poll(() => readStorage(page, UPGRADE_RESULT_KEY)).not.toBeNull(); + + const { page: conflictPage, anonymousUserId: secondAnonymousUserId } = await openFreshAnonymousSession( + browser, + "redirect-handled" + ); + + await completeGoogleRedirect(conflictPage, conflictEmail); + + await expect.poll(() => readStorage(conflictPage, UPGRADE_FAILURE_KEY)).not.toBeNull(); + expect(await readStorage(conflictPage, UPGRADE_FAILURE_KEY)).toEqual({ + oldUserId: secondAnonymousUserId, + code: "auth/email-already-in-use", + kind: "provider", + }); + await expect(conflictPage.getByText(enUs.translations.errors.emailAlreadyInUse)).toHaveCount(0); + + await conflictPage.context().close(); + }); + }); +} diff --git a/examples/angular/src/app/app.config.ts b/examples/angular/src/app/app.config.ts index 69676139b..354d2a27a 100644 --- a/examples/angular/src/app/app.config.ts +++ b/examples/angular/src/app/app.config.ts @@ -23,7 +23,13 @@ import { provideClientHydration, withEventReplay } from "@angular/platform-brows import { provideFirebaseApp, initializeApp } from "@angular/fire/app"; import { provideAuth, getAuth, connectAuthEmulator } from "@angular/fire/auth"; import { provideFirebaseUI, provideFirebaseUIPolicies } from "@firebase-oss/ui-angular"; -import { initializeUI } from "@firebase-oss/ui-core"; +import { + autoAnonymousLogin, + autoUpgradeAnonymousUsers, + initializeUI, + providerRedirectStrategy, +} from "@firebase-oss/ui-core"; +import type { FirebaseApp } from "firebase/app"; const firebaseConfig = { apiKey: "AIzaSyCvMftIUCD9lUQ3BzIrimfSfBbCUQYZf-I", @@ -34,6 +40,52 @@ const firebaseConfig = { appId: "1:200312857118:web:94e3f69b0e0a4a863f040f", }; +const E2E_REDIRECT_SCENARIOS = ["redirect", "redirect-handled"]; +const E2E_HANDLED_SCENARIOS = ["handled", "redirect-handled"]; + +function initializeExampleUI(app: FirebaseApp) { + const e2eAnonymousUpgradeScenario = + isDevMode() && typeof window !== "undefined" + ? new URLSearchParams(window.location.search).get("e2eAnonymousUpgrade") + : null; + + const behaviors = e2eAnonymousUpgradeScenario + ? [ + autoAnonymousLogin(), + autoUpgradeAnonymousUsers({ + onUpgrade: (_ui, oldUserId, credential) => { + window.localStorage.setItem( + "firebaseui:e2e:upgrade-result", + JSON.stringify({ oldUserId, newUserId: credential.user.uid }) + ); + }, + onUpgradeFailure: ({ oldUserId, error, credential }) => { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : "unknown"; + window.localStorage.setItem( + "firebaseui:e2e:upgrade-failure", + JSON.stringify({ oldUserId, code, kind: credential ? "credential" : "provider" }) + ); + + return E2E_HANDLED_SCENARIOS.includes(e2eAnonymousUpgradeScenario as string) ? "handled" : undefined; + }, + }), + ...(E2E_REDIRECT_SCENARIOS.includes(e2eAnonymousUpgradeScenario as string) ? [providerRedirectStrategy()] : []), + ] + : []; + + const ui = initializeUI({ app, behaviors }); + + if (e2eAnonymousUpgradeScenario) { + ui.get().auth.onAuthStateChanged((user) => { + if (user?.isAnonymous) { + window.localStorage.setItem("firebaseui:e2e:anonymous-user-id", user.uid); + } + }); + } + + return ui; +} + export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), @@ -48,7 +100,7 @@ export const appConfig: ApplicationConfig = { } return auth; }), - provideFirebaseUI((apps) => initializeUI({ app: apps[0] })), + provideFirebaseUI((apps) => initializeExampleUI(apps[0])), provideFirebaseUIPolicies(() => ({ termsOfServiceUrl: "https://www.google.com", privacyPolicyUrl: "https://www.google.com", diff --git a/examples/react/src/firebase/firebase.ts b/examples/react/src/firebase/firebase.ts index e0132cda3..5726894bc 100644 --- a/examples/react/src/firebase/firebase.ts +++ b/examples/react/src/firebase/firebase.ts @@ -16,7 +16,14 @@ "use client"; -import { countryCodes, initializeUI, oneTapSignIn } from "@firebase-oss/ui-core"; +import { + autoAnonymousLogin, + autoUpgradeAnonymousUsers, + countryCodes, + initializeUI, + oneTapSignIn, + providerRedirectStrategy, +} from "@firebase-oss/ui-core"; import { getApps, initializeApp } from "firebase/app"; import { connectAuthEmulator, getAuth } from "firebase/auth"; @@ -26,21 +33,64 @@ export const firebaseApp = getApps().length === 0 ? initializeApp(firebaseConfig export const auth = getAuth(firebaseApp); +const e2eAnonymousUpgradeScenario = import.meta.env.DEV + ? new URLSearchParams(window.location.search).get("e2eAnonymousUpgrade") + : null; + +const E2E_REDIRECT_SCENARIOS = ["redirect", "redirect-handled"]; +const E2E_HANDLED_SCENARIOS = ["handled", "redirect-handled"]; + +function e2eAnonymousUpgradeBehaviors() { + if (!e2eAnonymousUpgradeScenario) { + return []; + } + + return [ + autoAnonymousLogin(), + autoUpgradeAnonymousUsers({ + onUpgrade: (_ui, oldUserId, credential) => { + window.localStorage.setItem( + "firebaseui:e2e:upgrade-result", + JSON.stringify({ oldUserId, newUserId: credential.user.uid }) + ); + }, + onUpgradeFailure: ({ oldUserId, error, credential }) => { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : "unknown"; + window.localStorage.setItem( + "firebaseui:e2e:upgrade-failure", + JSON.stringify({ oldUserId, code, kind: credential ? "credential" : "provider" }) + ); + + return E2E_HANDLED_SCENARIOS.includes(e2eAnonymousUpgradeScenario as string) ? "handled" : undefined; + }, + }), + ...(E2E_REDIRECT_SCENARIOS.includes(e2eAnonymousUpgradeScenario as string) ? [providerRedirectStrategy()] : []), + ]; +} + export const ui = initializeUI({ app: firebaseApp, - behaviors: [ - // autoAnonymousLogin(), - oneTapSignIn({ - clientId: "616577669988-led6l3rqek9ckn9t1unj4l8l67070fhp.apps.googleusercontent.com", - }), - countryCodes({ - allowedCountries: ["US", "CA", "GB"], - defaultCountry: "GB", - }), - // providerPopupStrategy(), - ], + behaviors: e2eAnonymousUpgradeScenario + ? e2eAnonymousUpgradeBehaviors() + : [ + oneTapSignIn({ + clientId: "616577669988-led6l3rqek9ckn9t1unj4l8l67070fhp.apps.googleusercontent.com", + }), + countryCodes({ + allowedCountries: ["US", "CA", "GB"], + defaultCountry: "GB", + }), + ], }); if (import.meta.env.MODE === "development") { connectAuthEmulator(auth, "http://localhost:9099"); } + +if (e2eAnonymousUpgradeScenario) { + auth.onAuthStateChanged((user) => { + if (user?.isAnonymous) { + window.localStorage.setItem("firebaseui:e2e:anonymous-user-id", user.uid); + } + }); +} diff --git a/packages/angular/src/lib/auth/forms/phone-auth-form.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.ts index a240c6f8d..36a0290e8 100644 --- a/packages/angular/src/lib/auth/forms/phone-auth-form.ts +++ b/packages/angular/src/lib/auth/forms/phone-auth-form.ts @@ -245,7 +245,9 @@ export class VerificationFormComponent { onSubmitAsync: async ({ value }) => { try { const credential = await confirmPhoneNumber(this.ui(), this.verificationId(), value.verificationCode); - this.signIn.emit(credential); + if (credential) { + this.signIn.emit(credential); + } return; } catch (error) { if (error instanceof FirebaseUIError) { diff --git a/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts b/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts index b65479234..6aac58f24 100644 --- a/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts +++ b/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts @@ -133,7 +133,9 @@ export class SignInAuthFormComponent { onSubmitAsync: async ({ value }) => { try { const credential = await signInWithEmailAndPassword(this.ui(), value.email, value.password); - this.signIn.emit(credential); + if (credential) { + this.signIn.emit(credential); + } return; } catch (error) { if (error instanceof FirebaseUIError) { diff --git a/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts b/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts index 5d455dce5..97db48bae 100644 --- a/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts +++ b/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts @@ -134,7 +134,9 @@ export class SignUpAuthFormComponent { value.password, value.displayName ); - this.signUp.emit(credential); + if (credential) { + this.signUp.emit(credential); + } return; } catch (error) { if (error instanceof FirebaseUIError) { diff --git a/packages/angular/src/lib/auth/oauth/oauth-button.ts b/packages/angular/src/lib/auth/oauth/oauth-button.ts index f0a6fa9be..1d205e815 100644 --- a/packages/angular/src/lib/auth/oauth/oauth-button.ts +++ b/packages/angular/src/lib/auth/oauth/oauth-button.ts @@ -70,7 +70,9 @@ export class OAuthButtonComponent { this.error.set(null); try { const credential = await signInWithProvider(this.ui(), this.provider()); - this.signIn.emit(credential); + if (credential) { + this.signIn.emit(credential); + } } catch (error) { if (error instanceof FirebaseUIError) { this.error.set(error.message); diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts index 47a6e854e..512ae4ae6 100644 --- a/packages/core/src/auth.test.ts +++ b/packages/core/src/auth.test.ts @@ -114,7 +114,7 @@ describe("signInWithEmailAndPassword", () => { expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); expect(_signInWithCredential).toHaveBeenCalledTimes(1); - expect(result.providerId).toBe("password"); + expect(result).toMatchObject({ providerId: "password" }); }); it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { @@ -133,7 +133,7 @@ describe("signInWithEmailAndPassword", () => { expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); - expect(result.providerId).toBe("password"); + expect(result).toMatchObject({ providerId: "password" }); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -161,6 +161,28 @@ describe("signInWithEmailAndPassword", () => { expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); + it("should stop when autoUpgradeAnonymousCredential handles a linking failure", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: true } } as Auth, + }); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credential).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await signInWithEmailAndPassword(mockUI, email, password); + + expect(result).toBeUndefined(); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(_signInWithCredential).not.toHaveBeenCalled(); + expect(handleFirebaseError).not.toHaveBeenCalled(); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + it("should call handleFirebaseError if an error is thrown", async () => { const mockUI = createMockUI(); const email = "test@example.com"; @@ -203,7 +225,7 @@ describe("createUserWithEmailAndPassword", () => { expect(_createUserWithEmailAndPassword).toHaveBeenCalledWith(mockUI.auth, email, password); expect(_createUserWithEmailAndPassword).toHaveBeenCalledTimes(1); - expect(result.providerId).toBe("password"); + expect(result).toMatchObject({ providerId: "password" }); }); it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { @@ -226,7 +248,7 @@ describe("createUserWithEmailAndPassword", () => { expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); - expect(result.providerId).toBe("password"); + expect(result).toMatchObject({ providerId: "password" }); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -258,6 +280,32 @@ describe("createUserWithEmailAndPassword", () => { expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); + it("should stop when autoUpgradeAnonymousCredential handles a create-account linking failure", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: true } } as Auth, + }); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockImplementation((_, behavior) => { + if (behavior === "autoUpgradeAnonymousCredential") return true; + if (behavior === "requireDisplayName") return false; + return false; + }); + vi.mocked(EmailAuthProvider.credential).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await createUserWithEmailAndPassword(mockUI, email, password); + + expect(result).toBeUndefined(); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(_createUserWithEmailAndPassword).not.toHaveBeenCalled(); + expect(handleFirebaseError).not.toHaveBeenCalled(); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + it("should call handleFirebaseError if an error is thrown", async () => { const mockUI = createMockUI(); const email = "test@example.com"; @@ -488,8 +536,8 @@ describe("confirmPhoneNumber", () => { const result = await confirmPhoneNumber(mockUI, verificationId, verificationCode); - // Since currentUser is null, the behavior should not called. - expect(hasBehavior).toHaveBeenCalledTimes(0); + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).not.toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); // Calls pending pre-_signInWithCredential call, then idle after. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); @@ -498,7 +546,7 @@ describe("confirmPhoneNumber", () => { expect(_signInWithCredential).toHaveBeenCalledTimes(1); // Assert that the result is a valid UserCredential. - expect(result.providerId).toBe("phone"); + expect(result).toMatchObject({ providerId: "phone" }); }); it("should call autoUpgradeAnonymousCredential behavior when user is anonymous", async () => { @@ -520,7 +568,7 @@ describe("confirmPhoneNumber", () => { expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); - expect(result.providerId).toBe("phone"); + expect(result).toMatchObject({ providerId: "phone" }); // Auth method sets pending at start, then idle in finally block. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); @@ -534,18 +582,19 @@ describe("confirmPhoneNumber", () => { const verificationCode = "123456"; const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + vi.mocked(hasBehavior).mockReturnValue(false); vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "phone" } as UserCredential); const result = await confirmPhoneNumber(mockUI, verificationId, verificationCode); - // Behavior should not be called when user is not anonymous - expect(hasBehavior).not.toHaveBeenCalled(); + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).not.toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); // Should proceed with normal sign-in flow expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - expect(result.providerId).toBe("phone"); + expect(result).toMatchObject({ providerId: "phone" }); }); it("should not call behavior when user is null", async () => { @@ -556,21 +605,22 @@ describe("confirmPhoneNumber", () => { const verificationCode = "123456"; const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + vi.mocked(hasBehavior).mockReturnValue(false); vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "phone" } as UserCredential); const result = await confirmPhoneNumber(mockUI, verificationId, verificationCode); - // Behavior should not be called when user is null - expect(hasBehavior).not.toHaveBeenCalled(); + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).not.toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); // Should proceed with normal sign-in flow expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - expect(result.providerId).toBe("phone"); + expect(result).toMatchObject({ providerId: "phone" }); }); - it("should fall back to normal sign-in when behavior returns undefined", async () => { + it("should stop when autoUpgradeAnonymousCredential returns undefined for an anonymous user", async () => { const mockUI = createMockUI({ auth: { currentUser: { isAnonymous: true } } as Auth, }); @@ -583,17 +633,16 @@ describe("confirmPhoneNumber", () => { const mockBehavior = vi.fn().mockResolvedValue(undefined); vi.mocked(getBehavior).mockReturnValue(mockBehavior); - await confirmPhoneNumber(mockUI, verificationId, verificationCode); + const result = await confirmPhoneNumber(mockUI, verificationId, verificationCode); expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); - expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); - expect(_signInWithCredential).toHaveBeenCalledTimes(1); - - // Calls pending pre-_signInWithCredential call, then idle after. + expect(result).toBeUndefined(); + expect(_signInWithCredential).not.toHaveBeenCalled(); + expect(handleFirebaseError).not.toHaveBeenCalled(); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -802,7 +851,7 @@ describe("signInWithEmailLink", () => { expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); expect(_signInWithCredential).toHaveBeenCalledTimes(1); - expect(result.providerId).toBe("emailLink"); + expect(result).toMatchObject({ providerId: "emailLink" }); }); it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { @@ -824,7 +873,7 @@ describe("signInWithEmailLink", () => { expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); - expect(result.providerId).toBe("emailLink"); + expect(result).toMatchObject({ providerId: "emailLink" }); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -855,6 +904,28 @@ describe("signInWithEmailLink", () => { expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); + it("should stop when autoUpgradeAnonymousCredential returns undefined for an anonymous user", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: true } } as Auth, + }); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; + + const credential = EmailAuthProvider.credentialWithLink(email, link); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await signInWithEmailLink(mockUI, email, link); + + expect(result).toBeUndefined(); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(_signInWithCredential).not.toHaveBeenCalled(); + expect(handleFirebaseError).not.toHaveBeenCalled(); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + it("should call handleFirebaseError if an error is thrown", async () => { const mockUI = createMockUI(); const email = "test@example.com"; @@ -897,7 +968,7 @@ describe("signInWithCredential", () => { expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); expect(_signInWithCredential).toHaveBeenCalledTimes(1); - expect(result.providerId).toBe("password"); + expect(result).toMatchObject({ providerId: "password" }); }); it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { @@ -914,7 +985,7 @@ describe("signInWithCredential", () => { expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); - expect(result.providerId).toBe("password"); + expect(result).toMatchObject({ providerId: "password" }); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -940,6 +1011,25 @@ describe("signInWithCredential", () => { expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); + it("should stop when autoUpgradeAnonymousCredential handles a linking failure", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: true } } as Auth, + }); + const credential = { providerId: "password" } as any; + + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await signInWithCredential(mockUI, credential); + + expect(result).toBeUndefined(); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(_signInWithCredential).not.toHaveBeenCalled(); + expect(handleFirebaseError).not.toHaveBeenCalled(); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + it("should call handleFirebaseError if an error is thrown", async () => { const mockUI = createMockUI(); const credential = { providerId: "password" } as any; @@ -1275,6 +1365,31 @@ describe("signInWithProvider", () => { expect(result).toBe(mockResult); }); + it("should stop when autoUpgradeAnonymousProvider handles a linking failure", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: true } } as Auth, + }); + const provider = { providerId: "google.com" } as AuthProvider; + + vi.mocked(hasBehavior).mockReturnValue(true); + + const mockUpgradeBehavior = vi.fn().mockResolvedValue(undefined); + const mockProviderStrategy = vi.fn(); + vi.mocked(getBehavior).mockImplementation((_ui, behavior) => { + if (behavior === "autoUpgradeAnonymousProvider") return mockUpgradeBehavior; + if (behavior === "providerSignInStrategy") return mockProviderStrategy; + return vi.fn(); + }); + + const result = await signInWithProvider(mockUI, provider); + + expect(result).toBeUndefined(); + expect(mockUpgradeBehavior).toHaveBeenCalledWith(mockUI, provider); + expect(mockProviderStrategy).not.toHaveBeenCalled(); + expect(handleFirebaseError).not.toHaveBeenCalled(); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + it("should call handleFirebaseError if an error is thrown", async () => { const mockUI = createMockUI(); const provider = { providerId: "google.com" } as AuthProvider; @@ -1499,6 +1614,31 @@ describe("completeEmailLinkSignIn", () => { expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); }); + it("should return null when anonymous email-link auto-upgrade returns undefined", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: true } } as Auth, + }); + const currentUrl = "https://example.com/auth?oobCode=abc123"; + const email = "test@example.com"; + const emailLinkCredential = { providerId: "emailLink" } as any; + + vi.mocked(_isSignInWithEmailLink).mockReturnValue(true); + window.localStorage.setItem("emailForSignIn", email); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(emailLinkCredential); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await completeEmailLinkSignIn(mockUI, currentUrl); + + expect(result).toBeNull(); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, emailLinkCredential); + expect(_signInWithCredential).not.toHaveBeenCalled(); + expect(handleFirebaseError).not.toHaveBeenCalled(); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); + it("should propagate error from signInWithEmailLink", async () => { const mockUI = createMockUI(); const currentUrl = "https://example.com/auth?oobCode=abc123"; diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 33c161d3b..f6590232d 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -96,6 +96,45 @@ function setPendingState(ui: FirebaseUI) { ui.setState("pending"); } +type AnonymousUpgradeAttempt = + { status: "upgraded"; credential: UserCredential } | { status: "stopped" } | { status: "skipped" }; + +async function attemptAnonymousCredentialUpgrade( + ui: FirebaseUI, + credential: AuthCredential +): Promise { + if (!hasBehavior(ui, "autoUpgradeAnonymousCredential")) { + return { status: "skipped" }; + } + + const wasAnonymous = ui.auth.currentUser?.isAnonymous === true; + const result = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); + + if (result) { + return { status: "upgraded", credential: result }; + } + + return wasAnonymous ? { status: "stopped" } : { status: "skipped" }; +} + +async function attemptAnonymousProviderUpgrade( + ui: FirebaseUI, + provider: AuthProvider +): Promise { + if (!hasBehavior(ui, "autoUpgradeAnonymousProvider")) { + return { status: "skipped" }; + } + + const wasAnonymous = ui.auth.currentUser?.isAnonymous === true; + const result = await getBehavior(ui, "autoUpgradeAnonymousProvider")(ui, provider); + + if (result) { + return { status: "upgraded", credential: result }; + } + + return wasAnonymous ? { status: "stopped" } : { status: "skipped" }; +} + /** * Signs in with an email and password. * @@ -104,23 +143,23 @@ function setPendingState(ui: FirebaseUI) { * @param ui - The FirebaseUI instance. * @param email - The email to sign in with. * @param password - The password to sign in with. - * @returns {Promise} A promise containing the user credential. + * @returns {Promise} A promise containing the user credential, or void if handled. */ export async function signInWithEmailAndPassword( ui: FirebaseUI, email: string, password: string -): Promise { +): Promise { try { setPendingState(ui); const credential = EmailAuthProvider.credential(email, password); - if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { - const result = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); - - if (result) { - return handlePendingCredential(ui, result); - } + const upgrade = await attemptAnonymousCredentialUpgrade(ui, credential); + if (upgrade.status === "upgraded") { + return handlePendingCredential(ui, upgrade.credential); + } + if (upgrade.status === "stopped") { + return; } const result = await _signInWithCredential(ui.auth, credential); @@ -142,14 +181,14 @@ export async function signInWithEmailAndPassword( * @param email - The email address for the new account. * @param password - The password for the new account. * @param displayName - Optional display name for the user. - * @returns {Promise} A promise containing the user credential. + * @returns {Promise} A promise containing the user credential, or void if handled. */ export async function createUserWithEmailAndPassword( ui: FirebaseUI, email: string, password: string, displayName?: string -): Promise { +): Promise { try { setPendingState(ui); const credential = EmailAuthProvider.credential(email, password); @@ -158,16 +197,16 @@ export async function createUserWithEmailAndPassword( throw new FirebaseError("auth/display-name-required", getTranslation(ui, "errors", "displayNameRequired")); } - if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { - const result = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); - - if (result) { - if (hasBehavior(ui, "requireDisplayName")) { - await getBehavior(ui, "requireDisplayName")(ui, result.user, displayName!); - } - - return handlePendingCredential(ui, result); + const upgrade = await attemptAnonymousCredentialUpgrade(ui, credential); + if (upgrade.status === "upgraded") { + if (hasBehavior(ui, "requireDisplayName")) { + await getBehavior(ui, "requireDisplayName")(ui, upgrade.credential.user, displayName!); } + + return handlePendingCredential(ui, upgrade.credential); + } + if (upgrade.status === "stopped") { + return; } const result = await _createUserWithEmailAndPassword(ui.auth, email, password); @@ -245,24 +284,23 @@ export async function verifyPhoneNumber( * @param ui - The FirebaseUI instance. * @param verificationId - The verification ID from the phone verification process. * @param verificationCode - The verification code sent to the phone. - * @returns {Promise} A promise containing the user credential. + * @returns {Promise} A promise containing the user credential, or void if handled. */ export async function confirmPhoneNumber( ui: FirebaseUI, verificationId: string, verificationCode: string -): Promise { +): Promise { try { setPendingState(ui); - const currentUser = ui.auth.currentUser; const credential = PhoneAuthProvider.credential(verificationId, verificationCode); - if (currentUser?.isAnonymous && hasBehavior(ui, "autoUpgradeAnonymousCredential")) { - const result = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); - - if (result) { - return handlePendingCredential(ui, result); - } + const upgrade = await attemptAnonymousCredentialUpgrade(ui, credential); + if (upgrade.status === "upgraded") { + return handlePendingCredential(ui, upgrade.credential); + } + if (upgrade.status === "stopped") { + return; } const result = await _signInWithCredential(ui.auth, credential); @@ -326,9 +364,9 @@ export async function sendSignInLinkToEmail(ui: FirebaseUI, email: string): Prom * @param ui - The FirebaseUI instance. * @param email - The email address associated with the sign-in link. * @param link - The sign-in link from the email. - * @returns {Promise} A promise containing the user credential. + * @returns {Promise} A promise containing the user credential, or void if handled. */ -export async function signInWithEmailLink(ui: FirebaseUI, email: string, link: string): Promise { +export async function signInWithEmailLink(ui: FirebaseUI, email: string, link: string): Promise { const credential = EmailAuthProvider.credentialWithLink(email, link); return signInWithCredential(ui, credential); } @@ -340,19 +378,17 @@ export async function signInWithEmailLink(ui: FirebaseUI, email: string, link: s * * @param ui - The FirebaseUI instance. * @param credential - The authentication credential to sign in with. - * @returns {Promise} A promise containing the user credential. + * @returns {Promise} A promise containing the user credential, or void if handled. */ -export async function signInWithCredential(ui: FirebaseUI, credential: AuthCredential): Promise { +export async function signInWithCredential(ui: FirebaseUI, credential: AuthCredential): Promise { try { setPendingState(ui); - if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { - const userCredential = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); - - // If they got here, they're either not anonymous or they've been linked. - // If the credential has been linked, we don't need to sign them in, so return early. - if (userCredential) { - return handlePendingCredential(ui, userCredential); - } + const upgrade = await attemptAnonymousCredentialUpgrade(ui, credential); + if (upgrade.status === "upgraded") { + return handlePendingCredential(ui, upgrade.credential); + } + if (upgrade.status === "stopped") { + return; } const result = await _signInWithCredential(ui.auth, credential); @@ -409,19 +445,17 @@ export async function signInAnonymously(ui: FirebaseUI): Promise * * @param ui - The FirebaseUI instance. * @param provider - The authentication provider to sign in with. - * @returns {Promise} A promise containing the user credential, or never if using redirect strategy. + * @returns {Promise} A promise containing the user credential, or void if handled. */ -export async function signInWithProvider(ui: FirebaseUI, provider: AuthProvider): Promise { +export async function signInWithProvider(ui: FirebaseUI, provider: AuthProvider): Promise { try { setPendingState(ui); - if (hasBehavior(ui, "autoUpgradeAnonymousProvider")) { - const credential = await getBehavior(ui, "autoUpgradeAnonymousProvider")(ui, provider); - - // If we got here, the user is either not anonymous, or they have been linked - // via a popup, and the credential has been created. - if (credential) { - return handlePendingCredential(ui, credential); - } + const upgrade = await attemptAnonymousProviderUpgrade(ui, provider); + if (upgrade.status === "upgraded") { + return handlePendingCredential(ui, upgrade.credential); + } + if (upgrade.status === "stopped") { + return; } const strategy = getBehavior(ui, "providerSignInStrategy"); @@ -456,9 +490,9 @@ export async function completeEmailLinkSignIn(ui: FirebaseUI, currentUrl: string const email = window.localStorage.getItem("emailForSignIn"); if (!email) return null; - // signInWithEmailLink handles behavior checks, credential creation, and error handling + // signInWithEmailLink handles behavior checks, credential creation, and error handling. const result = await signInWithEmailLink(ui, email, currentUrl); - return handlePendingCredential(ui, result); + return result ?? null; } finally { window.localStorage.removeItem("emailForSignIn"); } diff --git a/packages/core/src/behaviors/anonymous-upgrade.test.ts b/packages/core/src/behaviors/anonymous-upgrade.test.ts index f6df8e6f0..b1629026d 100644 --- a/packages/core/src/behaviors/anonymous-upgrade.test.ts +++ b/packages/core/src/behaviors/anonymous-upgrade.test.ts @@ -44,6 +44,7 @@ vi.mock("~/behaviors", () => ({ beforeEach(() => { vi.clearAllMocks(); + window.localStorage.clear(); }); describe("autoUpgradeAnonymousCredentialHandler", () => { @@ -95,6 +96,67 @@ describe("autoUpgradeAnonymousCredentialHandler", () => { ); }); + it("should call onUpgradeFailure and rethrow when credential linking fails", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockCredential = { providerId: "google.com" } as AuthCredential; + const mockError = new Error("Link failed"); + const onUpgradeFailure = vi.fn().mockResolvedValue(undefined); + + vi.mocked(linkWithCredential).mockRejectedValue(mockError); + + await expect( + autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential, undefined, onUpgradeFailure) + ).rejects.toThrow("Link failed"); + + expect(onUpgradeFailure).toHaveBeenCalledWith({ + ui: mockUI, + oldUserId: "anonymous-123", + error: mockError, + credential: mockCredential, + }); + }); + + it("should suppress credential linking errors when onUpgradeFailure returns handled", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockCredential = { providerId: "google.com" } as AuthCredential; + const mockError = new Error("Link failed"); + const onUpgradeFailure = vi.fn().mockResolvedValue("handled"); + + vi.mocked(linkWithCredential).mockRejectedValue(mockError); + + const result = await autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential, undefined, onUpgradeFailure); + + expect(result).toBeUndefined(); + expect(onUpgradeFailure).toHaveBeenCalledWith({ + ui: mockUI, + oldUserId: "anonymous-123", + error: mockError, + credential: mockCredential, + }); + }); + + it("should surface onUpgradeFailure callback errors for credential linking", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockCredential = { providerId: "google.com" } as AuthCredential; + const mockError = new Error("Link failed"); + const callbackError = new Error("Callback failed"); + const onUpgradeFailure = vi.fn().mockRejectedValue(callbackError); + + vi.mocked(linkWithCredential).mockRejectedValue(mockError); + + await expect( + autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential, undefined, onUpgradeFailure) + ).rejects.toThrow("Callback failed"); + + expect((callbackError as Error & { cause?: unknown }).cause).toBe(mockError); + }); + it("should not upgrade when user is not anonymous", async () => { const mockUser = { isAnonymous: false, uid: "regular-user-123" } as User; const mockAuth = { currentUser: mockUser } as Auth; @@ -177,6 +239,87 @@ describe("autoUpgradeAnonymousProviderHandler", () => { await expect(autoUpgradeAnonymousProviderHandler(mockUI, mockProvider, onUpgrade)).rejects.toThrow( "Callback error" ); + expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull(); + }); + + it("should preserve oldUserId in localStorage when provider linking redirects", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + const onUpgrade = vi.fn(); + const mockProviderLinkStrategy = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockProviderLinkStrategy); + + const result = await autoUpgradeAnonymousProviderHandler(mockUI, mockProvider, onUpgrade); + + expect(result).toBeUndefined(); + expect(onUpgrade).not.toHaveBeenCalled(); + expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBe("anonymous-123"); + }); + + it("should call onUpgradeFailure and rethrow when provider linking fails", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockError = new Error("Provider link failed"); + const onUpgradeFailure = vi.fn().mockResolvedValue(undefined); + const mockProviderLinkStrategy = vi.fn().mockRejectedValue(mockError); + vi.mocked(getBehavior).mockReturnValue(mockProviderLinkStrategy); + + await expect( + autoUpgradeAnonymousProviderHandler(mockUI, mockProvider, undefined, onUpgradeFailure) + ).rejects.toThrow("Provider link failed"); + + expect(onUpgradeFailure).toHaveBeenCalledWith({ + ui: mockUI, + oldUserId: "anonymous-123", + error: mockError, + provider: mockProvider, + }); + expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull(); + }); + + it("should suppress provider linking errors when onUpgradeFailure returns handled", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockError = new Error("Provider link failed"); + const onUpgradeFailure = vi.fn().mockResolvedValue("handled"); + const mockProviderLinkStrategy = vi.fn().mockRejectedValue(mockError); + vi.mocked(getBehavior).mockReturnValue(mockProviderLinkStrategy); + + const result = await autoUpgradeAnonymousProviderHandler(mockUI, mockProvider, undefined, onUpgradeFailure); + + expect(result).toBeUndefined(); + expect(onUpgradeFailure).toHaveBeenCalledWith({ + ui: mockUI, + oldUserId: "anonymous-123", + error: mockError, + provider: mockProvider, + }); + expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull(); + }); + + it("should surface onUpgradeFailure callback errors for provider linking", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockError = new Error("Provider link failed"); + const callbackError = new Error("Callback failed"); + const onUpgradeFailure = vi.fn().mockRejectedValue(callbackError); + const mockProviderLinkStrategy = vi.fn().mockRejectedValue(mockError); + vi.mocked(getBehavior).mockReturnValue(mockProviderLinkStrategy); + + await expect( + autoUpgradeAnonymousProviderHandler(mockUI, mockProvider, undefined, onUpgradeFailure) + ).rejects.toThrow("Callback failed"); + + expect((callbackError as Error & { cause?: unknown }).cause).toBe(mockError); + expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull(); }); it("should not upgrade when user is not anonymous", async () => { @@ -276,4 +419,74 @@ describe("autoUpgradeAnonymousUserRedirectHandler", () => { // Should clean up localStorage even when callback throws error expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull(); }); + + it("should call onUpgradeFailure when getRedirectResult rejects and oldUserId exists", async () => { + const mockUI = createMockUI(); + const oldUserId = "anonymous-123"; + const mockError = { + code: "auth/credential-already-in-use", + message: "In use", + credential: { providerId: "google.com" }, + }; + + window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId); + + const onUpgradeFailure = vi.fn().mockResolvedValue(undefined); + + const result = await autoUpgradeAnonymousUserRedirectHandler(mockUI, null, undefined, onUpgradeFailure, mockError); + + expect(result).toBeUndefined(); + expect(onUpgradeFailure).toHaveBeenCalledWith({ + ui: mockUI, + oldUserId, + error: mockError, + credential: mockError.credential, + }); + expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull(); + }); + + it("should return handled when onUpgradeFailure suppresses a redirect failure", async () => { + const mockUI = createMockUI(); + const oldUserId = "anonymous-123"; + const mockError = new Error("Redirect linking failed"); + + window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId); + + const onUpgradeFailure = vi.fn().mockResolvedValue("handled"); + + const result = await autoUpgradeAnonymousUserRedirectHandler(mockUI, null, undefined, onUpgradeFailure, mockError); + + expect(result).toBe("handled"); + expect(onUpgradeFailure).toHaveBeenCalledWith({ + ui: mockUI, + oldUserId, + error: mockError, + credential: undefined, + }); + }); + + it("should not call onUpgradeFailure when the redirect failure is unrelated to an anonymous upgrade", async () => { + const mockUI = createMockUI(); + const mockError = new Error("Some other redirect failure"); + + const onUpgradeFailure = vi.fn(); + + const result = await autoUpgradeAnonymousUserRedirectHandler(mockUI, null, undefined, onUpgradeFailure, mockError); + + expect(result).toBeUndefined(); + expect(onUpgradeFailure).not.toHaveBeenCalled(); + }); + + it("should return undefined for a redirect failure when no onUpgradeFailure is configured", async () => { + const mockUI = createMockUI(); + const oldUserId = "anonymous-123"; + const mockError = new Error("Redirect linking failed"); + + window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId); + + const result = await autoUpgradeAnonymousUserRedirectHandler(mockUI, null, undefined, undefined, mockError); + + expect(result).toBeUndefined(); + expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull(); + }); }); diff --git a/packages/core/src/behaviors/anonymous-upgrade.ts b/packages/core/src/behaviors/anonymous-upgrade.ts index 1f93fc311..85e470cb2 100644 --- a/packages/core/src/behaviors/anonymous-upgrade.ts +++ b/packages/core/src/behaviors/anonymous-upgrade.ts @@ -19,12 +19,49 @@ import { type FirebaseUI } from "~/config"; import { getBehavior } from "~/behaviors"; export type OnUpgradeCallback = (ui: FirebaseUI, oldUserId: string, credential: UserCredential) => Promise | void; +export type OnUpgradeFailureResult = "handled" | void; +export type OnUpgradeFailureContext = { + ui: FirebaseUI; + oldUserId: string; + error: unknown; + credential?: AuthCredential; + provider?: AuthProvider; +}; +export type OnUpgradeFailureCallback = ( + context: OnUpgradeFailureContext +) => Promise | OnUpgradeFailureResult; + +async function handleUpgradeFailure( + context: OnUpgradeFailureContext, + onUpgradeFailure?: OnUpgradeFailureCallback +): Promise { + try { + return (await onUpgradeFailure?.(context)) === "handled"; + } catch (callbackError) { + if (callbackError instanceof Error && !("cause" in callbackError)) { + (callbackError as Error & { cause?: unknown }).cause = context.error; + } + + throw callbackError; + } +} + +// Best-effort extraction for errors like auth/credential-already-in-use, which carry the +// conflicting credential so callers can offer to link/merge it themselves. +function extractCredentialFromError(error: unknown): AuthCredential | undefined { + if (error && typeof error === "object" && "credential" in error) { + return (error as { credential?: AuthCredential }).credential; + } + + return undefined; +} export const autoUpgradeAnonymousCredentialHandler = async ( ui: FirebaseUI, credential: AuthCredential, - onUpgrade?: OnUpgradeCallback -) => { + onUpgrade?: OnUpgradeCallback, + onUpgradeFailure?: OnUpgradeFailureCallback +): Promise => { const currentUser = ui.auth.currentUser; if (!currentUser?.isAnonymous) { @@ -33,7 +70,17 @@ export const autoUpgradeAnonymousCredentialHandler = async ( const oldUserId = currentUser.uid; - const result = await linkWithCredential(currentUser, credential); + let result: UserCredential; + + try { + result = await linkWithCredential(currentUser, credential); + } catch (error) { + if (await handleUpgradeFailure({ ui, oldUserId, error, credential }, onUpgradeFailure)) { + return; + } + + throw error; + } if (onUpgrade) { await onUpgrade(ui, oldUserId, result); @@ -45,8 +92,9 @@ export const autoUpgradeAnonymousCredentialHandler = async ( export const autoUpgradeAnonymousProviderHandler = async ( ui: FirebaseUI, provider: AuthProvider, - onUpgrade?: OnUpgradeCallback -) => { + onUpgrade?: OnUpgradeCallback, + onUpgradeFailure?: OnUpgradeFailureCallback +): Promise => { const currentUser = ui.auth.currentUser; if (!currentUser?.isAnonymous) { @@ -57,11 +105,24 @@ export const autoUpgradeAnonymousProviderHandler = async ( window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId); - const result = await getBehavior(ui, "providerLinkStrategy")(ui, currentUser, provider); + let result: UserCredential | void; + + try { + result = await getBehavior(ui, "providerLinkStrategy")(ui, currentUser, provider); + } catch (error) { + window.localStorage.removeItem("fbui:upgrade:oldUserId"); - // If we got here, the user has been linked via a popup, so we need to call the onUpgrade callback - // and delete the oldUserId from localStorage. - // If we didn't get here, they'll be redirected and we'll handle the result inside of the autoUpgradeAnonymousUserRedirectHandler. + if (await handleUpgradeFailure({ ui, oldUserId, error, provider }, onUpgradeFailure)) { + return; + } + + throw error; + } + + // Redirect strategies complete later, so keep oldUserId for the redirect handler. + if (!result) { + return; + } window.localStorage.removeItem("fbui:upgrade:oldUserId"); @@ -75,8 +136,10 @@ export const autoUpgradeAnonymousProviderHandler = async ( export const autoUpgradeAnonymousUserRedirectHandler = async ( ui: FirebaseUI, credential: UserCredential | null, - onUpgrade?: OnUpgradeCallback -) => { + onUpgrade?: OnUpgradeCallback, + onUpgradeFailure?: OnUpgradeFailureCallback, + error?: unknown +): Promise => { const oldUserId = window.localStorage.getItem("fbui:upgrade:oldUserId"); // Always clean up localStorage once we've retrieved the oldUserId @@ -84,6 +147,21 @@ export const autoUpgradeAnonymousUserRedirectHandler = async ( window.localStorage.removeItem("fbui:upgrade:oldUserId"); } + // getRedirectResult() rejected. Only relevant if this redirect was for an anonymous upgrade; + // otherwise defer to FirebaseUI's default redirect error handling. + if (error !== undefined) { + if (!oldUserId) { + return; + } + + const handled = await handleUpgradeFailure( + { ui, oldUserId, error, credential: extractCredentialFromError(error) }, + onUpgradeFailure + ); + + return handled ? "handled" : undefined; + } + if (!onUpgrade || !oldUserId || !credential) { return; } diff --git a/packages/core/src/behaviors/index.test.ts b/packages/core/src/behaviors/index.test.ts index 0994558eb..a9207de5d 100644 --- a/packages/core/src/behaviors/index.test.ts +++ b/packages/core/src/behaviors/index.test.ts @@ -173,9 +173,23 @@ describe("autoUpgradeAnonymousUsers", () => { expect(typeof behavior.autoUpgradeAnonymousUserRedirectHandler.handler).toBe("function"); }); + it("should work with onUpgradeFailure callback option", () => { + const mockOnUpgradeFailure = vi.fn(); + const behavior = autoUpgradeAnonymousUsers({ onUpgradeFailure: mockOnUpgradeFailure }); + + expect(behavior).toHaveProperty("autoUpgradeAnonymousCredential"); + expect(behavior).toHaveProperty("autoUpgradeAnonymousProvider"); + expect(behavior).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); + + expect(typeof behavior.autoUpgradeAnonymousCredential.handler).toBe("function"); + expect(typeof behavior.autoUpgradeAnonymousProvider.handler).toBe("function"); + expect(typeof behavior.autoUpgradeAnonymousUserRedirectHandler.handler).toBe("function"); + }); + it("should pass onUpgrade callback to handlers when called", async () => { const mockOnUpgrade = vi.fn(); - const behavior = autoUpgradeAnonymousUsers({ onUpgrade: mockOnUpgrade }); + const mockOnUpgradeFailure = vi.fn(); + const behavior = autoUpgradeAnonymousUsers({ onUpgrade: mockOnUpgrade, onUpgradeFailure: mockOnUpgradeFailure }); const mockUI = createMockUI(); const mockCredential = { providerId: "password" } as any; @@ -192,9 +206,25 @@ describe("autoUpgradeAnonymousUsers", () => { await behavior.autoUpgradeAnonymousProvider.handler(mockUI, mockProvider); await behavior.autoUpgradeAnonymousUserRedirectHandler.handler(mockUI, mockUserCredential); - expect(autoUpgradeAnonymousCredentialHandler).toHaveBeenCalledWith(mockUI, mockCredential, mockOnUpgrade); - expect(autoUpgradeAnonymousProviderHandler).toHaveBeenCalledWith(mockUI, mockProvider, mockOnUpgrade); - expect(autoUpgradeAnonymousUserRedirectHandler).toHaveBeenCalledWith(mockUI, mockUserCredential, mockOnUpgrade); + expect(autoUpgradeAnonymousCredentialHandler).toHaveBeenCalledWith( + mockUI, + mockCredential, + mockOnUpgrade, + mockOnUpgradeFailure + ); + expect(autoUpgradeAnonymousProviderHandler).toHaveBeenCalledWith( + mockUI, + mockProvider, + mockOnUpgrade, + mockOnUpgradeFailure + ); + expect(autoUpgradeAnonymousUserRedirectHandler).toHaveBeenCalledWith( + mockUI, + mockUserCredential, + mockOnUpgrade, + mockOnUpgradeFailure, + undefined + ); }); it("should not include other behaviors", () => { diff --git a/packages/core/src/behaviors/index.ts b/packages/core/src/behaviors/index.ts index 5841d41e5..ce60a94bd 100644 --- a/packages/core/src/behaviors/index.ts +++ b/packages/core/src/behaviors/index.ts @@ -32,6 +32,13 @@ import { type RedirectBehavior, } from "./utils"; +export type { + OnUpgradeCallback, + OnUpgradeFailureCallback, + OnUpgradeFailureContext, + OnUpgradeFailureResult, +} from "./anonymous-upgrade"; + type Registry = { autoAnonymousLogin: InitBehavior; autoUpgradeAnonymousCredential: CallableBehavior< @@ -42,7 +49,7 @@ type Registry = { ( ui: FirebaseUI, credential: UserCredential | null, - onUpgrade?: anonymousUpgradeHandlers.OnUpgradeCallback + error?: unknown ) => ReturnType >; recaptchaVerification: CallableBehavior<(ui: FirebaseUI, element: HTMLElement) => RecaptchaVerifier>; @@ -73,6 +80,11 @@ export function autoAnonymousLogin(): Behavior<"autoAnonymousLogin"> { export type AutoUpgradeAnonymousUsersOptions = { /** Optional callback function that is called when an anonymous user is upgraded. */ onUpgrade?: anonymousUpgradeHandlers.OnUpgradeCallback; + /** + * Optional callback function that is called when credential or provider linking fails, + * including provider-linking failures that only surface after a redirect round trip. + */ + onUpgradeFailure?: anonymousUpgradeHandlers.OnUpgradeFailureCallback; }; /** @@ -91,13 +103,29 @@ export function autoUpgradeAnonymousUsers( > { return { autoUpgradeAnonymousCredential: callableBehavior((ui, credential) => - anonymousUpgradeHandlers.autoUpgradeAnonymousCredentialHandler(ui, credential, options?.onUpgrade) + anonymousUpgradeHandlers.autoUpgradeAnonymousCredentialHandler( + ui, + credential, + options?.onUpgrade, + options?.onUpgradeFailure + ) ), autoUpgradeAnonymousProvider: callableBehavior((ui, provider) => - anonymousUpgradeHandlers.autoUpgradeAnonymousProviderHandler(ui, provider, options?.onUpgrade) + anonymousUpgradeHandlers.autoUpgradeAnonymousProviderHandler( + ui, + provider, + options?.onUpgrade, + options?.onUpgradeFailure + ) ), - autoUpgradeAnonymousUserRedirectHandler: redirectBehavior((ui, credential) => - anonymousUpgradeHandlers.autoUpgradeAnonymousUserRedirectHandler(ui, credential, options?.onUpgrade) + autoUpgradeAnonymousUserRedirectHandler: redirectBehavior((ui, credential, error) => + anonymousUpgradeHandlers.autoUpgradeAnonymousUserRedirectHandler( + ui, + credential, + options?.onUpgrade, + options?.onUpgradeFailure, + error + ) ), }; } diff --git a/packages/core/src/behaviors/provider-strategy.ts b/packages/core/src/behaviors/provider-strategy.ts index a0f02ab9c..95cb66481 100644 --- a/packages/core/src/behaviors/provider-strategy.ts +++ b/packages/core/src/behaviors/provider-strategy.ts @@ -30,7 +30,7 @@ export type ProviderLinkStrategyHandler = ( ui: FirebaseUI, user: User, provider: AuthProvider -) => Promise; +) => Promise; export const signInWithRediectHandler: ProviderSignInStrategyHandler = async (ui, provider) => { return signInWithRedirect(ui.auth, provider); diff --git a/packages/core/src/behaviors/utils.ts b/packages/core/src/behaviors/utils.ts index 6f30e39c6..7daf04218 100644 --- a/packages/core/src/behaviors/utils.ts +++ b/packages/core/src/behaviors/utils.ts @@ -20,7 +20,18 @@ import type { FirebaseUI } from "~/config"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type CallableHandler any = (...args: any[]) => any> = T; export type InitHandler = (ui: FirebaseUI) => Promise | void; -export type RedirectHandler = (ui: FirebaseUI, result: UserCredential | null) => Promise | void; +/** + * Called once after `getRedirectResult()` settles. + * + * On success, `result` is the redirect result (or `null`) and `error` is `undefined`. + * On failure, `result` is `null` and `error` is the rejection from `getRedirectResult()`. + * Return `"handled"` from the failure path to suppress FirebaseUI's default redirect error handling. + */ +export type RedirectHandler = ( + ui: FirebaseUI, + result: UserCredential | null, + error?: unknown +) => Promise<"handled" | void> | "handled" | void; export type CallableBehavior = { type: "callable"; diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 52bc5fa64..bcea78d6b 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -477,6 +477,92 @@ describe("initializeUI", () => { delete (global as any).window; }); + it("should give redirect behaviors a chance to handle a redirect failure before the default error handler", async () => { + Object.defineProperty(global, "window", { + value: {}, + writable: true, + configurable: true, + }); + + const mockAuth = { + currentUser: null, + } as any; + + const mockError = new Error("Redirect linking failed"); + const { getRedirectResult } = await import("firebase/auth"); + vi.mocked(getRedirectResult).mockClear(); + vi.mocked(getRedirectResult).mockRejectedValue(mockError); + + const mockRedirectHandler = vi.fn().mockResolvedValue("handled"); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [ + { + customRedirect: { + type: "redirect" as const, + handler: mockRedirectHandler, + }, + }, + ], + }; + + const ui = initializeUI(config); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockRedirectHandler).toHaveBeenCalledTimes(1); + expect(mockRedirectHandler.mock.calls[0][1]).toBeNull(); + expect(mockRedirectHandler.mock.calls[0][2]).toBe(mockError); + expect(ui.get().redirectError).toBeUndefined(); + + delete (global as any).window; + }); + + it("should fall back to the default error handler when no redirect behavior handles the failure", async () => { + Object.defineProperty(global, "window", { + value: {}, + writable: true, + configurable: true, + }); + + const mockAuth = { + currentUser: null, + } as any; + + const mockError = new Error("Redirect linking failed"); + const { getRedirectResult } = await import("firebase/auth"); + vi.mocked(getRedirectResult).mockClear(); + vi.mocked(getRedirectResult).mockRejectedValue(mockError); + + const mockRedirectHandler = vi.fn().mockResolvedValue(undefined); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [ + { + customRedirect: { + type: "redirect" as const, + handler: mockRedirectHandler, + }, + }, + ], + }; + + const ui = initializeUI(config); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockRedirectHandler).toHaveBeenCalledTimes(1); + expect(mockRedirectHandler.mock.calls[0][1]).toBeNull(); + expect(mockRedirectHandler.mock.calls[0][2]).toBe(mockError); + expect(ui.get().redirectError).toBe(mockError); + + delete (global as any).window; + }); + it("should convert non-Error objects to Error instances in redirect catch", async () => { Object.defineProperty(global, "window", { value: {}, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index e190397ef..157697b3d 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -169,8 +169,16 @@ export function initializeUI(config: FirebaseUIOptions, name: string = "[DEFAULT .then((result) => { return Promise.all(redirectBehaviors.map((behavior) => behavior.handler(ui, result))); }) - .catch((error) => { + .catch(async (error) => { try { + // Give redirect behaviors (e.g. autoUpgradeAnonymousUsers' onUpgradeFailure) a chance to + // handle the failure before falling back to the default error handling. + const outcomes = await Promise.all(redirectBehaviors.map((behavior) => behavior.handler(ui, null, error))); + + if (outcomes.some((outcome) => outcome === "handled")) { + return; + } + handleFirebaseError(ui, error); } catch (error) { ui.setRedirectError(error instanceof Error ? error : new Error(String(error))); diff --git a/packages/react/src/auth/forms/phone-auth-form.tsx b/packages/react/src/auth/forms/phone-auth-form.tsx index bcd3fed18..852c09064 100644 --- a/packages/react/src/auth/forms/phone-auth-form.tsx +++ b/packages/react/src/auth/forms/phone-auth-form.tsx @@ -201,7 +201,9 @@ export function useVerifyPhoneNumberForm({ verificationId, onSuccess }: UseVerif onSubmitAsync: async ({ value }) => { try { const credential = await action(value); - return onSuccess(credential); + if (credential) { + return onSuccess(credential); + } } catch (error) { return error instanceof FirebaseUIError ? error.message : String(error); } diff --git a/packages/react/src/auth/forms/sign-in-auth-form.tsx b/packages/react/src/auth/forms/sign-in-auth-form.tsx index 97cef95d7..62277d08b 100644 --- a/packages/react/src/auth/forms/sign-in-auth-form.tsx +++ b/packages/react/src/auth/forms/sign-in-auth-form.tsx @@ -78,7 +78,9 @@ export function useSignInAuthForm(onSuccess?: SignInAuthFormProps["onSignIn"]) { onSubmitAsync: async ({ value }) => { try { const credential = await action(value); - return onSuccess?.(credential); + if (credential) { + return onSuccess?.(credential); + } } catch (error) { return error instanceof Error ? error.message : String(error); } diff --git a/packages/react/src/auth/forms/sign-up-auth-form.tsx b/packages/react/src/auth/forms/sign-up-auth-form.tsx index 74f5e240a..256eb4d67 100644 --- a/packages/react/src/auth/forms/sign-up-auth-form.tsx +++ b/packages/react/src/auth/forms/sign-up-auth-form.tsx @@ -89,7 +89,9 @@ export function useSignUpAuthForm(onSuccess?: SignUpAuthFormProps["onSignUp"]) { onSubmitAsync: async ({ value }) => { try { const credential = await action(value); - return onSuccess?.(credential); + if (credential) { + return onSuccess?.(credential); + } } catch (error) { return error instanceof Error ? error.message : String(error); } diff --git a/packages/react/src/auth/oauth/oauth-button.tsx b/packages/react/src/auth/oauth/oauth-button.tsx index 160c9dbe0..85d33b19c 100644 --- a/packages/react/src/auth/oauth/oauth-button.tsx +++ b/packages/react/src/auth/oauth/oauth-button.tsx @@ -48,7 +48,9 @@ export function useSignInWithProvider(provider: AuthProvider, onSignIn?: (creden setError(null); try { const credential = await signInWithProvider(ui, provider); - onSignIn?.(credential); + if (credential) { + onSignIn?.(credential); + } } catch (error) { if (error instanceof FirebaseUIError) { setError(error.message);