From b0530ada60889ea85b895e6476d0fe60156339f0 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 6 May 2026 13:13:52 +0100 Subject: [PATCH 1/8] feat(auth): onUpgradeFailure callback when account linking fails --- .../core/src/behaviors/anonymous-upgrade.ts | 61 ++++++++++++++++--- packages/core/src/behaviors/index.ts | 16 ++++- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/packages/core/src/behaviors/anonymous-upgrade.ts b/packages/core/src/behaviors/anonymous-upgrade.ts index 1f93fc311..e80c0e0d5 100644 --- a/packages/core/src/behaviors/anonymous-upgrade.ts +++ b/packages/core/src/behaviors/anonymous-upgrade.ts @@ -19,11 +19,38 @@ 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; + } +} export const autoUpgradeAnonymousCredentialHandler = async ( ui: FirebaseUI, credential: AuthCredential, - onUpgrade?: OnUpgradeCallback + onUpgrade?: OnUpgradeCallback, + onUpgradeFailure?: OnUpgradeFailureCallback ) => { const currentUser = ui.auth.currentUser; @@ -33,7 +60,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,7 +82,8 @@ export const autoUpgradeAnonymousCredentialHandler = async ( export const autoUpgradeAnonymousProviderHandler = async ( ui: FirebaseUI, provider: AuthProvider, - onUpgrade?: OnUpgradeCallback + onUpgrade?: OnUpgradeCallback, + onUpgradeFailure?: OnUpgradeFailureCallback ) => { const currentUser = ui.auth.currentUser; @@ -57,13 +95,20 @@ export const autoUpgradeAnonymousProviderHandler = async ( window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId); - const result = await getBehavior(ui, "providerLinkStrategy")(ui, currentUser, provider); + let result: UserCredential; - // 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. + try { + result = await getBehavior(ui, "providerLinkStrategy")(ui, currentUser, provider); + } catch (error) { + if (await handleUpgradeFailure({ ui, oldUserId, error, provider }, onUpgradeFailure)) { + return; + } - window.localStorage.removeItem("fbui:upgrade:oldUserId"); + throw error; + } finally { + // When the link attempt settles locally, the stored ID is no longer needed. + window.localStorage.removeItem("fbui:upgrade:oldUserId"); + } if (onUpgrade) { await onUpgrade(ui, oldUserId, result); diff --git a/packages/core/src/behaviors/index.ts b/packages/core/src/behaviors/index.ts index 5841d41e5..ada2e251f 100644 --- a/packages/core/src/behaviors/index.ts +++ b/packages/core/src/behaviors/index.ts @@ -73,6 +73,8 @@ 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 locally. */ + onUpgradeFailure?: anonymousUpgradeHandlers.OnUpgradeFailureCallback; }; /** @@ -91,10 +93,20 @@ 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) From 679248a17da1db82b59dac29f1a0c6c822f5ba2c Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 6 May 2026 13:14:02 +0100 Subject: [PATCH 2/8] test: onUpgradeFailure implementation --- .../src/behaviors/anonymous-upgrade.test.ts | 126 ++++++++++++++++++ packages/core/src/behaviors/index.test.ts | 30 ++++- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/packages/core/src/behaviors/anonymous-upgrade.test.ts b/packages/core/src/behaviors/anonymous-upgrade.test.ts index e551a25ad..34d679eeb 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; @@ -179,6 +241,70 @@ describe("autoUpgradeAnonymousProviderHandler", () => { ); }); + 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 () => { const mockUser = { isAnonymous: false, uid: "regular-user-123" } as User; const mockAuth = { currentUser: mockUser } as Auth; diff --git a/packages/core/src/behaviors/index.test.ts b/packages/core/src/behaviors/index.test.ts index 0994558eb..34fda0074 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,8 +206,18 @@ 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(autoUpgradeAnonymousCredentialHandler).toHaveBeenCalledWith( + mockUI, + mockCredential, + mockOnUpgrade, + mockOnUpgradeFailure + ); + expect(autoUpgradeAnonymousProviderHandler).toHaveBeenCalledWith( + mockUI, + mockProvider, + mockOnUpgrade, + mockOnUpgradeFailure + ); expect(autoUpgradeAnonymousUserRedirectHandler).toHaveBeenCalledWith(mockUI, mockUserCredential, mockOnUpgrade); }); From 3557923cf91ce9e1e31ea28cced7eac75291fa20 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 6 May 2026 13:18:23 +0100 Subject: [PATCH 3/8] docs: update documentation on how to handle failed auto-linking --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1c5b45120..b620ec174 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,9 @@ 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. ```ts import { autoUpgradeAnonymousUsers } from '@firebase-oss/ui-core'; @@ -346,7 +348,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. + }, })], }); ``` From 08deffb5de0f7661b4c7e4c9799691a4675a2213 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 6 May 2026 15:22:24 +0100 Subject: [PATCH 4/8] fix: ensure redirect linking preserves fbui:upgrade:oldUserId --- packages/core/src/behaviors/anonymous-upgrade.ts | 14 ++++++++++---- packages/core/src/behaviors/provider-strategy.ts | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/behaviors/anonymous-upgrade.ts b/packages/core/src/behaviors/anonymous-upgrade.ts index e80c0e0d5..7699a4c38 100644 --- a/packages/core/src/behaviors/anonymous-upgrade.ts +++ b/packages/core/src/behaviors/anonymous-upgrade.ts @@ -95,21 +95,27 @@ export const autoUpgradeAnonymousProviderHandler = async ( window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId); - let result: UserCredential; + let result: UserCredential | void; try { result = await getBehavior(ui, "providerLinkStrategy")(ui, currentUser, provider); } catch (error) { + window.localStorage.removeItem("fbui:upgrade:oldUserId"); + if (await handleUpgradeFailure({ ui, oldUserId, error, provider }, onUpgradeFailure)) { return; } throw error; - } finally { - // When the link attempt settles locally, the stored ID is no longer needed. - window.localStorage.removeItem("fbui:upgrade:oldUserId"); } + // Redirect strategies complete later, so keep oldUserId for the redirect handler. + if (!result) { + return; + } + + window.localStorage.removeItem("fbui:upgrade:oldUserId"); + if (onUpgrade) { await onUpgrade(ui, oldUserId, result); } 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); From fd835ca179015ec06c4004977efa874ee759f205 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 6 May 2026 15:22:42 +0100 Subject: [PATCH 5/8] test: preserve oldUserId in localStorage when provider linking redirects --- .../src/behaviors/anonymous-upgrade.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/core/src/behaviors/anonymous-upgrade.test.ts b/packages/core/src/behaviors/anonymous-upgrade.test.ts index 34d679eeb..eed58eed1 100644 --- a/packages/core/src/behaviors/anonymous-upgrade.test.ts +++ b/packages/core/src/behaviors/anonymous-upgrade.test.ts @@ -239,6 +239,23 @@ 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 () => { From f9c5333cf7798a10da66cc2bdf99be89e7afdf8c Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Thu, 7 May 2026 17:19:03 +0100 Subject: [PATCH 6/8] chore: stop error surfacing when consumer has "handled" unhappy path --- packages/core/src/auth.test.ts | 188 +++++++++++++++--- packages/core/src/auth.ts | 140 ++++++++----- .../core/src/behaviors/anonymous-upgrade.ts | 4 +- packages/core/src/behaviors/index.ts | 7 + 4 files changed, 261 insertions(+), 78 deletions(-) diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts index 692757355..152a73e66 100644 --- a/packages/core/src/auth.test.ts +++ b/packages/core/src/auth.test.ts @@ -107,7 +107,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 () => { @@ -126,7 +126,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"]]); }); @@ -154,6 +154,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"; @@ -196,7 +218,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 () => { @@ -219,7 +241,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"]]); }); @@ -251,6 +273,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"; @@ -484,8 +532,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"]]); @@ -494,7 +542,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 () => { @@ -516,7 +564,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"]]); @@ -530,18 +578,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 () => { @@ -552,21 +601,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, }); @@ -579,17 +629,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"]]); }); @@ -798,7 +847,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 () => { @@ -820,7 +869,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"]]); }); @@ -851,6 +900,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"; @@ -893,7 +964,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 () => { @@ -910,7 +981,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"]]); }); @@ -936,6 +1007,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; @@ -1146,6 +1236,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; @@ -1370,6 +1485,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 60b0bf08f..b2e7ff0b3 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -64,6 +64,47 @@ 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. * @@ -72,23 +113,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); @@ -110,14 +151,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); @@ -126,16 +167,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); @@ -213,24 +254,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); @@ -294,9 +334,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); } @@ -308,19 +348,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); @@ -377,19 +415,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"); @@ -424,9 +460,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.ts b/packages/core/src/behaviors/anonymous-upgrade.ts index 7699a4c38..7aaaaa4a5 100644 --- a/packages/core/src/behaviors/anonymous-upgrade.ts +++ b/packages/core/src/behaviors/anonymous-upgrade.ts @@ -51,7 +51,7 @@ export const autoUpgradeAnonymousCredentialHandler = async ( credential: AuthCredential, onUpgrade?: OnUpgradeCallback, onUpgradeFailure?: OnUpgradeFailureCallback -) => { +): Promise => { const currentUser = ui.auth.currentUser; if (!currentUser?.isAnonymous) { @@ -84,7 +84,7 @@ export const autoUpgradeAnonymousProviderHandler = async ( provider: AuthProvider, onUpgrade?: OnUpgradeCallback, onUpgradeFailure?: OnUpgradeFailureCallback -) => { +): Promise => { const currentUser = ui.auth.currentUser; if (!currentUser?.isAnonymous) { diff --git a/packages/core/src/behaviors/index.ts b/packages/core/src/behaviors/index.ts index ada2e251f..8f005ffc2 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< From 2480b937dced425e631ebf29d0e6ccb6afdc3c10 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 14 Jul 2026 12:10:33 +0100 Subject: [PATCH 7/8] tests: existing and latest updates Verify existing anonymous user auto-upgrade behavior is unchanged. Verify the new onUpgradeFailure callback fires correctly on account-linking conflicts. Verify returning "handled" from the callback suppresses the default FirebaseUI error UI. Verify anonymous user upgrade state is preserved correctly across provider redirect flows. --- LOCAL_DEVELOPMENT.md | 2 +- .../architecture/testing-strategy.md | 1 + developer-docs/decisions.md | 2 +- .../work-queues/playwright-e2e-smoke.md | 2 +- e2e/global-setup.ts | 23 +-- e2e/tests/anonymous-upgrade.spec.ts | 175 ++++++++++++++++++ examples/angular/src/app/app.config.ts | 53 +++++- examples/react/src/firebase/firebase.ts | 71 +++++-- package.json | 12 +- .../src/lib/auth/forms/phone-auth-form.ts | 4 +- .../src/lib/auth/forms/sign-in-auth-form.ts | 4 +- .../src/lib/auth/forms/sign-up-auth-form.ts | 4 +- .../src/lib/auth/oauth/oauth-button.ts | 4 +- packages/core/src/auth.ts | 4 +- .../react/src/auth/forms/phone-auth-form.tsx | 4 +- .../src/auth/forms/sign-in-auth-form.tsx | 4 +- .../src/auth/forms/sign-up-auth-form.tsx | 4 +- .../react/src/auth/oauth/oauth-button.tsx | 4 +- scripts/e2e-run.mjs | 1 - 19 files changed, 323 insertions(+), 55 deletions(-) create mode 100644 e2e/tests/anonymous-upgrade.spec.ts diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md index cc15ef21c..c6250d23d 100644 --- a/LOCAL_DEVELOPMENT.md +++ b/LOCAL_DEVELOPMENT.md @@ -6,7 +6,7 @@ This guide walks you through running FirebaseUI Web locally so you can test feat Make sure you have these installed: -- [Node.js](https://nodejs.org/) v18+ locally; **CI uses v24** — use Node 24 for pre-push verification to match CI ([change-authoring-verification.md](developer-docs/playbooks/change-authoring-verification.md)) +- [Node.js](https://nodejs.org/) v24.15+ locally; **CI uses v24.18** — match CI for pre-push verification ([change-authoring-verification.md](developer-docs/playbooks/change-authoring-verification.md)) - [pnpm](https://pnpm.io/) — if you have Node.js 18+, [corepack](https://nodejs.org/api/corepack.html) can install pnpm on demand when you run it - [Firebase CLI](https://firebase.google.com/docs/cli): ```bash diff --git a/developer-docs/architecture/testing-strategy.md b/developer-docs/architecture/testing-strategy.md index 5e5a7aaea..876f0d4a9 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 | | Security audit | `pnpm audit` | [playbooks/dependency-update-verification.md](../playbooks/dependency-update-verification.md) | Known CVEs in lockfile | # CI today diff --git a/developer-docs/decisions.md b/developer-docs/decisions.md index 9cb747adc..753e90ba4 100644 --- a/developer-docs/decisions.md +++ b/developer-docs/decisions.md @@ -30,7 +30,7 @@ Example apps are verified with **Playwright smoke tests**, not full user-flow e2 ## AD-4: Playwright-managed dev servers, serial, shared emulator -Each example has a **unique e2e port** — see [examples-inventory.md](architecture/examples-inventory.md). Playwright's **top-level** `webServer` (one at a time, selected via `E2E_PROJECT`) starts each UI example's dev server, waits for its URL, and stops it — replacing hand-rolled preflight/postflight/PID scripts. Execution is **serial** (`workers: 1`) for resource predictability and clean logs; because ports are unique, parallelization is possible later but deferred ([Deferred](work-queues/playwright-e2e-smoke.md#deferred)). **`globalSetup`** builds packages (`build:packages`, asserting `dist/` exists — examples consume built `@firebase-oss/ui-*`), ensures a single Auth emulator on `:9099` for the whole run (reuse-aware), and starts it when not already running; **`globalTeardown`** stops what globalSetup started unless the serial runner is mid-suite (`E2E_KEEP_EMULATOR=1`). Angular reuses its package `start` semantics (`pnpm clean && ng serve`) rather than duplicating the `.angular/cache` clean. +Each example has a **unique e2e port** — see [examples-inventory.md](architecture/examples-inventory.md). Playwright's **top-level** `webServer` (one at a time, selected via `E2E_PROJECT`) starts each UI example's dev server, waits for its URL, and stops it — replacing hand-rolled preflight/postflight/PID scripts. Execution is **serial** (`workers: 1`) for resource predictability and clean logs; because ports are unique, parallelization is possible later but deferred ([Deferred](work-queues/playwright-e2e-smoke.md#deferred)). Supported root entrypoints build packages **before Playwright starts the dev server** (`test:e2e` once for the suite; `test:e2e:` once for that project), preventing Vite/Angular from observing partially rebuilt `dist/` files. **`globalSetup`** asserts the required package artifacts exist, ensures a single Auth emulator on `:9099` for the whole run (reuse-aware), and starts it when not already running; **`globalTeardown`** stops what globalSetup started unless the serial runner is mid-suite (`E2E_KEEP_EMULATOR=1`). Angular reuses its package `start` semantics (`pnpm clean && ng serve`) rather than duplicating the `.angular/cache` clean. --- diff --git a/developer-docs/work-queues/playwright-e2e-smoke.md b/developer-docs/work-queues/playwright-e2e-smoke.md index 2ec94adde..548a344bd 100644 --- a/developer-docs/work-queues/playwright-e2e-smoke.md +++ b/developer-docs/work-queues/playwright-e2e-smoke.md @@ -59,7 +59,7 @@ scripts/e2e-run.mjs (pnpm test:e2e) - **Serial:** `workers: 1`; `E2E_PROJECT` selects both the single Playwright project and the top-level `webServer`, so one dev server is up at a time. - **Emulator:** `globalSetup` ensures `:9099` is reachable (starts via `npx firebase-tools` when not already running); serial runner sets `E2E_KEEP_EMULATOR=1` so teardown does not stop it between examples; `pnpm emulators` remains the first-class manual target ([LOCAL_DEVELOPMENT.md](../../LOCAL_DEVELOPMENT.md)). -- **Per-example debug:** `pnpm test:e2e:react` runs one project; `globalSetup` starts the emulator with reuse if not already running. +- **Per-example debug:** `pnpm test:e2e:react` builds packages before Playwright starts Vite, then runs one project; `globalSetup` asserts `dist/` and starts the emulator with reuse if not already running. # Example ports, commands, and root scripts diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 0c211ceb4..aaa5ab19f 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { execSync, spawn, type ChildProcess } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import net from "node:net"; import path from "node:path"; @@ -97,24 +97,11 @@ function assertPackagesBuilt(): void { if (missing.length > 0) { throw new Error( - `Expected built package artifacts are missing after build:packages:\n${missing.map((entry) => ` - ${entry}`).join("\n")}` + `Expected built package artifacts are missing. Run pnpm build:packages before Playwright:\n${missing.map((entry) => ` - ${entry}`).join("\n")}` ); } } -function runBuildPackages(): void { - try { - execSync("pnpm build:packages", { - cwd: REPO_ROOT, - stdio: "inherit", - }); - } catch { - throw new Error("pnpm build:packages failed — examples require built @firebase-oss/ui-* packages"); - } - - assertPackagesBuilt(); -} - function writeEmulatorState(state: EmulatorState): void { mkdirSync(STATE_DIR, { recursive: true }); writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); @@ -161,11 +148,7 @@ function startAuthEmulator(version: string): ChildProcess { } export default async function globalSetup(): Promise { - if (process.env.E2E_SKIP_BUILD_PACKAGES === "1") { - assertPackagesBuilt(); - } else { - runBuildPackages(); - } + assertPackagesBuilt(); if (await isAuthEmulatorReachable()) { const existing = readEmulatorState(); diff --git a/e2e/tests/anonymous-upgrade.spec.ts b/e2e/tests/anonymous-upgrade.spec.ts new file mode 100644 index 000000000..ee41ffe58 --- /dev/null +++ b/e2e/tests/anonymous-upgrade.spec.ts @@ -0,0 +1,175 @@ +/** + * 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, 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"): 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(); +} + +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(); + }); + }); +} diff --git a/examples/angular/src/app/app.config.ts b/examples/angular/src/app/app.config.ts index 69676139b..b5825da1a 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,49 @@ const firebaseConfig = { appId: "1:200312857118:web:94e3f69b0e0a4a863f040f", }; +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 e2eAnonymousUpgradeScenario === "handled" ? "handled" : undefined; + }, + }), + ...(e2eAnonymousUpgradeScenario === "redirect" ? [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 +97,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..b7ab2bae8 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,61 @@ 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; + +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 e2eAnonymousUpgradeScenario === "handled" ? "handled" : undefined; + }, + }), + ...(e2eAnonymousUpgradeScenario === "redirect" ? [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/package.json b/package.json index 9bb7a8a78..05f9909b0 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,12 @@ "format:write": "prettier --write **/{src,tests}/**/*.{ts,tsx}", "test": "pnpm --filter=@firebase-oss/ui-core run test && pnpm --filter=@firebase-oss/ui-translations run test && pnpm --filter=@firebase-oss/ui-styles run test && pnpm --filter=@firebase-oss/ui-react run test && pnpm --filter=@firebase-oss/ui-shadcn run test && pnpm --filter=@firebase-oss/ui-angular run test", "test:e2e": "node scripts/e2e-run.mjs", - "test:e2e:react": "E2E_PROJECT=react pnpm --filter=e2e exec playwright test --project=react", - "test:e2e:shadcn": "E2E_PROJECT=shadcn pnpm --filter=e2e exec playwright test --project=shadcn", - "test:e2e:nextjs": "E2E_PROJECT=nextjs pnpm --filter=e2e exec playwright test --project=nextjs", - "test:e2e:nextjs-ssr": "E2E_PROJECT=nextjs-ssr pnpm --filter=e2e exec playwright test --project=nextjs-ssr", - "test:e2e:angular": "E2E_PROJECT=angular-example pnpm --filter=e2e exec playwright test --project=angular-example", - "test:e2e:custom-auth-server": "E2E_PROJECT=custom-auth-server pnpm --filter=e2e exec playwright test --project=custom-auth-server", + "test:e2e:react": "pnpm build:packages && E2E_PROJECT=react pnpm --filter=e2e exec playwright test --project=react", + "test:e2e:shadcn": "pnpm build:packages && E2E_PROJECT=shadcn pnpm --filter=e2e exec playwright test --project=shadcn", + "test:e2e:nextjs": "pnpm build:packages && E2E_PROJECT=nextjs pnpm --filter=e2e exec playwright test --project=nextjs", + "test:e2e:nextjs-ssr": "pnpm build:packages && E2E_PROJECT=nextjs-ssr pnpm --filter=e2e exec playwright test --project=nextjs-ssr", + "test:e2e:angular": "pnpm build:packages && E2E_PROJECT=angular-example pnpm --filter=e2e exec playwright test --project=angular-example", + "test:e2e:custom-auth-server": "pnpm build:packages && E2E_PROJECT=custom-auth-server pnpm --filter=e2e exec playwright test --project=custom-auth-server", "test:watch": "pnpm --filter=@firebase-oss/ui-core run test:unit:watch & pnpm --filter=@firebase-oss/ui-react run test:unit:watch & pnpm --filter=@firebase-oss/ui-angular run test:watch", "version:bump:all": "pnpm --filter=@firebase-oss/ui-core run version:bump && pnpm --filter=@firebase-oss/ui-translations run version:bump && pnpm --filter=@firebase-oss/ui-react run version:bump && pnpm --filter=@firebase-oss/ui-styles run version:bump && pnpm --filter=@firebase-oss/ui-angular run version:bump", "publish:tags:all": "pnpm i && pnpm --filter=@firebase-oss/ui-core run publish:tags && pnpm --filter=@firebase-oss/ui-translations run publish:tags && pnpm --filter=@firebase-oss/ui-react run publish:tags && pnpm --filter=@firebase-oss/ui-styles run publish:tags && pnpm --filter=@firebase-oss/ui-angular run publish:tags", 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.ts b/packages/core/src/auth.ts index b2e7ff0b3..c7dd1546a 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -65,9 +65,7 @@ function setPendingState(ui: FirebaseUI) { } type AnonymousUpgradeAttempt = - | { status: "upgraded"; credential: UserCredential } - | { status: "stopped" } - | { status: "skipped" }; + { status: "upgraded"; credential: UserCredential } | { status: "stopped" } | { status: "skipped" }; async function attemptAnonymousCredentialUpgrade( ui: FirebaseUI, 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); diff --git a/scripts/e2e-run.mjs b/scripts/e2e-run.mjs index 3c001da6e..3287a4650 100644 --- a/scripts/e2e-run.mjs +++ b/scripts/e2e-run.mjs @@ -48,7 +48,6 @@ function runExample(example) { env: { ...process.env, E2E_PROJECT: example, - E2E_SKIP_BUILD_PACKAGES: "1", E2E_KEEP_EMULATOR: "1", }, }); From 304223963ca8691d95d89645faac33e284448317 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 14 Jul 2026 12:47:30 +0100 Subject: [PATCH 8/8] fix(e2e): restore execSync import in global-setup Global setup dropped the execSync import while removing the unused runBuildPackages() helper, but ensureFirebaseToolsCached() still calls execSync. The resulting ReferenceError was swallowed by a bare catch and rethrown as a misleading "Auth emulator cannot start" error, failing the ui-e2e CI job before any tests could run. --- e2e/global-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index aaa5ab19f..5729a9085 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { spawn, type ChildProcess } from "node:child_process"; +import { execSync, spawn, type ChildProcess } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import net from "node:net"; import path from "node:path";