Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 58 additions & 6 deletions packages/core/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
9 changes: 8 additions & 1 deletion packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<Record<string, DeepMapStore<FirebaseUIConfiguration>>>({});
Expand Down Expand Up @@ -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);
},
})
);

Expand Down
79 changes: 68 additions & 11 deletions packages/core/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");

Expand All @@ -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();
});
Expand Down Expand Up @@ -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())
Expand All @@ -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();
});
});

8 changes: 7 additions & 1 deletion packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export function createMockUI(overrides?: Partial<FirebaseUIConfiguration>): Fire
setState: vi.fn(),
locale: enUs,
behaviors: {},
multiFactorResolver: undefined,
setMultiFactorResolver: vi.fn(),
...overrides,
};
}
Loading