diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 613f886c..2555291b 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -1,5 +1,5 @@ import { FirebaseApp } from "firebase/app"; -import { Auth } from "firebase/auth"; +import { Auth, MultiFactorResolver } from "firebase/auth"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { initializeUI } from "./config"; import { enUs, registerLocale } from "@firebase-ui/translations"; @@ -121,9 +121,6 @@ describe("initializeUI", () => { const ui = initializeUI(config); expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable"); - expect(ui.get().behaviors.recaptchaVerification.handler).toBe( - customRecaptchaVerification.recaptchaVerification.handler - ); }); it("should merge multiple behavior objects correctly", () => { @@ -152,8 +149,6 @@ describe("initializeUI", () => { expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential"); expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider"); expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); - - expect(ui.get().behaviors.recaptchaVerification.handler).toBe(behavior2.recaptchaVerification.handler); }); it("should handle init behaviors correctly", () => { @@ -331,4 +326,61 @@ describe("initializeUI", () => { expect(ui.get().state).toBe("idle"); }); + + it("should have multiFactorResolver undefined by default", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui.get().multiFactorResolver).toBeUndefined(); + }); + + it("should set and get multiFactorResolver correctly", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + const mockMultiFactorResolver = { + auth: {} as Auth, + session: null, + hints: [], + } as unknown as MultiFactorResolver; + + expect(ui.get().multiFactorResolver).toBeUndefined(); + ui.get().setMultiFactorResolver(mockMultiFactorResolver); + expect(ui.get().multiFactorResolver).toBe(mockMultiFactorResolver); + ui.get().setMultiFactorResolver(undefined); + expect(ui.get().multiFactorResolver).toBeUndefined(); + }); + + it("should update multiFactorResolver multiple times", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + const mockResolver1 = { + auth: {} as Auth, + session: null, + hints: [], + } as unknown as MultiFactorResolver; + + const mockResolver2 = { + auth: {} as Auth, + session: null, + hints: [], + } as unknown as MultiFactorResolver; + + ui.get().setMultiFactorResolver(mockResolver1); + expect(ui.get().multiFactorResolver).toBe(mockResolver1); + ui.get().setMultiFactorResolver(mockResolver2); + expect(ui.get().multiFactorResolver).toBe(mockResolver2); + ui.get().setMultiFactorResolver(undefined); + expect(ui.get().multiFactorResolver).toBeUndefined(); + }); }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index b5b0604d..1b631a0f 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -16,7 +16,7 @@ import { enUs, RegisteredLocale } from "@firebase-ui/translations"; import type { FirebaseApp } from "firebase/app"; -import { Auth, getAuth, getRedirectResult } from "firebase/auth"; +import { Auth, getAuth, getRedirectResult, MultiFactorResolver } from "firebase/auth"; import { deepMap, DeepMapStore, map } from "nanostores"; import { Behavior, Behaviors, defaultBehaviors } from "./behaviors"; import type { InitBehavior, RedirectBehavior } from "./behaviors/utils"; @@ -37,6 +37,8 @@ export type FirebaseUIConfiguration = { setState: (state: FirebaseUIState) => void; locale: RegisteredLocale; behaviors: Behaviors; + multiFactorResolver?: MultiFactorResolver; + setMultiFactorResolver: (multiFactorResolver?: MultiFactorResolver) => void; }; export const $config = map>>({}); @@ -70,6 +72,11 @@ export function initializeUI(config: FirebaseUIConfigurationOptions, name: strin // Since we've got config.behaviors?.reduce above, we need to default to defaultBehaviors // if no behaviors are provided, as they wont be in the reducer. behaviors: behaviors ?? (defaultBehaviors as Behavior), + multiFactorResolver: undefined, + setMultiFactorResolver: (resolver?: MultiFactorResolver) => { + const current = $config.get()[name]!; + current.setKey(`multiFactorResolver`, resolver); + }, }) ); diff --git a/packages/core/src/errors.test.ts b/packages/core/src/errors.test.ts index 0cf66527..ac59f333 100644 --- a/packages/core/src/errors.test.ts +++ b/packages/core/src/errors.test.ts @@ -1,23 +1,26 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { FirebaseError } from "firebase/app"; -import { AuthCredential } from "firebase/auth"; +import { Auth, AuthCredential, MultiFactorResolver } from "firebase/auth"; import { FirebaseUIError, handleFirebaseError } from "./errors"; import { createMockUI } from "~/tests/utils"; import { ERROR_CODE_MAP } from "@firebase-ui/translations"; -// Mock the translations module vi.mock("./translations", () => ({ getTranslation: vi.fn(), })); +vi.mock("firebase/auth", () => ({ + getMultiFactorResolver: vi.fn(), +})); + import { getTranslation } from "./translations"; +import { getMultiFactorResolver } from "firebase/auth"; let mockSessionStorage: { [key: string]: string }; beforeEach(() => { vi.clearAllMocks(); - // Mock sessionStorage mockSessionStorage = {}; Object.defineProperty(window, 'sessionStorage', { value: { @@ -112,7 +115,6 @@ describe("handleFirebaseError", () => { try { handleFirebaseError(mockUI, mockFirebaseError); } catch (error) { - // Should be an instance of both FirebaseUIError and FirebaseError expect(error).toBeInstanceOf(FirebaseUIError); expect(error).toBeInstanceOf(FirebaseError); expect((error as FirebaseUIError).code).toBe("auth/user-not-found"); @@ -168,17 +170,75 @@ describe("handleFirebaseError", () => { vi.mocked(getTranslation).mockReturnValue(expectedTranslation); expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); - - // Should not try to store credential if it doesn't exist expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); }); + + it("should call setMultiFactorResolver when auth/multi-factor-auth-required error is thrown", () => { + const mockUI = createMockUI(); + const mockResolver = { + auth: {} as Auth, + session: null, + hints: [], + } as unknown as MultiFactorResolver; + + const error = new FirebaseError("auth/multi-factor-auth-required", "Multi-factor authentication required"); + const expectedTranslation = "Multi-factor authentication required (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getMultiFactorResolver).mockReturnValue(mockResolver); + + expect(() => handleFirebaseError(mockUI, error)).toThrow(FirebaseUIError); + expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error); + expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver); + }); + + it("should still throw FirebaseUIError after setting multi-factor resolver", () => { + const mockUI = createMockUI(); + const mockResolver = { + auth: {} as Auth, + session: null, + hints: [], + } as unknown as MultiFactorResolver; + + const error = new FirebaseError("auth/multi-factor-auth-required", "Multi-factor authentication required"); + const expectedTranslation = "Multi-factor authentication required (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getMultiFactorResolver).mockReturnValue(mockResolver); + + expect(() => handleFirebaseError(mockUI, error)).toThrow(FirebaseUIError); + + expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error); + expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver); + + try { + handleFirebaseError(mockUI, error); + } catch (error) { + expect(error).toBeInstanceOf(FirebaseUIError); + expect(error).toBeInstanceOf(FirebaseError); + expect((error as FirebaseUIError).code).toBe("auth/multi-factor-auth-required"); + expect((error as FirebaseUIError).message).toBe(expectedTranslation); + } + }); + + it("should not call setMultiFactorResolver for other error types", () => { + const mockUI = createMockUI(); + const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); + const expectedTranslation = "User not found (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + + expect(getMultiFactorResolver).not.toHaveBeenCalled(); + expect(mockUI.setMultiFactorResolver).not.toHaveBeenCalled(); + }); }); describe("isFirebaseError utility", () => { it("should identify FirebaseError objects", () => { const firebaseError = new FirebaseError("auth/user-not-found", "User not found"); - // We can't directly test the private function, but we can test it through handleFirebaseError const mockUI = createMockUI(); vi.mocked(getTranslation).mockReturnValue("translated message"); @@ -187,7 +247,7 @@ describe("isFirebaseError utility", () => { it("should reject non-FirebaseError objects", () => { const mockUI = createMockUI(); - const nonFirebaseError = { code: "test", message: "test" }; // Missing proper structure + const nonFirebaseError = { code: "test", message: "test" }; expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow(); }); @@ -218,7 +278,6 @@ describe("errorContainsCredential utility", () => { expect(() => handleFirebaseError(mockUI, firebaseErrorWithCredential)).toThrowError(FirebaseUIError); - // Should have stored the credential expect(window.sessionStorage.setItem).toHaveBeenCalledWith( "pendingCred", JSON.stringify(mockCredential.toJSON()) @@ -236,8 +295,6 @@ describe("errorContainsCredential utility", () => { expect(() => handleFirebaseError(mockUI, firebaseErrorWithoutCredential)).toThrowError(FirebaseUIError); - // Should not have stored any credential expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); }); }); - diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index c538a0c4..54f465ac 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -16,7 +16,7 @@ import { ERROR_CODE_MAP, ErrorCode } from "@firebase-ui/translations"; import { FirebaseError } from "firebase/app"; -import { AuthCredential } from "firebase/auth"; +import { AuthCredential, getMultiFactorResolver, MultiFactorError } from "firebase/auth"; import { FirebaseUIConfiguration } from "./config"; import { getTranslation } from "./translations"; export class FirebaseUIError extends FirebaseError { @@ -44,6 +44,12 @@ export function handleFirebaseError( window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential.toJSON())); } + // Update the UI with the multi-factor resolver if the error is thrown. + if (error.code === "auth/multi-factor-auth-required") { + const resolver = getMultiFactorResolver(ui.auth, error as MultiFactorError); + ui.setMultiFactorResolver(resolver); + } + throw new FirebaseUIError(ui, error); } diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts index 5c4360f9..2f0be48b 100644 --- a/packages/core/tests/utils.ts +++ b/packages/core/tests/utils.ts @@ -14,6 +14,8 @@ export function createMockUI(overrides?: Partial): Fire setState: vi.fn(), locale: enUs, behaviors: {}, + multiFactorResolver: undefined, + setMultiFactorResolver: vi.fn(), ...overrides, }; } \ No newline at end of file