diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index a7e2dfe0..bc036743 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -22,12 +22,12 @@ import { signInAnonymously as _signInAnonymously, signInWithPhoneNumber as _signInWithPhoneNumber, ActionCodeSettings, + ApplicationVerifier, AuthProvider, ConfirmationResult, EmailAuthProvider, linkWithCredential, PhoneAuthProvider, - RecaptchaVerifier, signInWithCredential, signInWithRedirect, UserCredential, @@ -108,11 +108,11 @@ export async function createUserWithEmailAndPassword( export async function signInWithPhoneNumber( ui: FirebaseUIConfiguration, phoneNumber: string, - recaptchaVerifier: RecaptchaVerifier + appVerifier: ApplicationVerifier ): Promise { try { ui.setState("pending"); - return await _signInWithPhoneNumber(ui.auth, phoneNumber, recaptchaVerifier); + return await _signInWithPhoneNumber(ui.auth, phoneNumber, appVerifier); } catch (error) { handleFirebaseError(ui, error); } finally { diff --git a/packages/core/src/behaviors.test.ts b/packages/core/src/behaviors.test.ts index fad81aa9..24433f25 100644 --- a/packages/core/src/behaviors.test.ts +++ b/packages/core/src/behaviors.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createMockUI } from "~/tests/utils"; -import { autoAnonymousLogin, autoUpgradeAnonymousUsers, getBehavior, hasBehavior } from "./behaviors"; -import { Auth, signInAnonymously, User, UserCredential, linkWithCredential, linkWithRedirect, AuthCredential, AuthProvider } from "firebase/auth"; +import { autoAnonymousLogin, autoUpgradeAnonymousUsers, getBehavior, hasBehavior, recaptchaVerification } from "./behaviors"; +import { Auth, signInAnonymously, User, UserCredential, linkWithCredential, linkWithRedirect, AuthCredential, AuthProvider, RecaptchaVerifier } from "firebase/auth"; vi.mock("firebase/auth", () => ({ signInAnonymously: vi.fn(), linkWithCredential: vi.fn(), linkWithRedirect: vi.fn(), + RecaptchaVerifier: vi.fn(), })); describe("hasBehavior", () => { @@ -218,3 +219,104 @@ describe("autoUpgradeAnonymousUsers", () => { }); }); }); + +describe("recaptchaVerification", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should create a RecaptchaVerifier with default options", () => { + const mockRecaptchaVerifier = { render: vi.fn() }; + vi.mocked(RecaptchaVerifier).mockImplementation(() => mockRecaptchaVerifier as any); + + const mockElement = document.createElement("div"); + const mockUI = createMockUI(); + + const behavior = recaptchaVerification(); + const result = behavior.recaptchaVerification(mockUI, mockElement); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "invisible", + theme: "light", + tabindex: 0, + }); + expect(result).toBe(mockRecaptchaVerifier); + }); + + it("should create a RecaptchaVerifier with custom options", () => { + const mockRecaptchaVerifier = { render: vi.fn() }; + vi.mocked(RecaptchaVerifier).mockImplementation(() => mockRecaptchaVerifier as any); + + const mockElement = document.createElement("div"); + const mockUI = createMockUI(); + const customOptions = { + size: "normal" as const, + theme: "dark" as const, + tabindex: 5, + }; + + const behavior = recaptchaVerification(customOptions); + const result = behavior.recaptchaVerification(mockUI, mockElement); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "normal", + theme: "dark", + tabindex: 5, + }); + expect(result).toBe(mockRecaptchaVerifier); + }); + + it("should create a RecaptchaVerifier with partial custom options", () => { + const mockRecaptchaVerifier = { render: vi.fn() }; + vi.mocked(RecaptchaVerifier).mockImplementation(() => mockRecaptchaVerifier as any); + + const mockElement = document.createElement("div"); + const mockUI = createMockUI(); + const partialOptions = { + size: "compact" as const, + }; + + const behavior = recaptchaVerification(partialOptions); + const result = behavior.recaptchaVerification(mockUI, mockElement); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "compact", + theme: "light", + tabindex: 0, + }); + expect(result).toBe(mockRecaptchaVerifier); + }); + + it("should work with hasBehavior and getBehavior", () => { + const mockRecaptchaVerifier = { render: vi.fn() }; + vi.mocked(RecaptchaVerifier).mockImplementation(() => mockRecaptchaVerifier as any); + + const mockElement = document.createElement("div"); + const mockUI = createMockUI({ + behaviors: { + recaptchaVerification: recaptchaVerification().recaptchaVerification, + }, + }); + + expect(hasBehavior(mockUI, "recaptchaVerification")).toBe(true); + + const behavior = getBehavior(mockUI, "recaptchaVerification"); + const result = behavior(mockUI, mockElement); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "invisible", + theme: "light", + tabindex: 0, + }); + expect(result).toBe(mockRecaptchaVerifier); + }); + + it("should throw error when trying to get non-existent recaptchaVerification behavior", () => { + const mockUI = createMockUI(); + + expect(hasBehavior(mockUI, "recaptchaVerification")).toBe(false); + expect(() => getBehavior(mockUI, "recaptchaVerification")).toThrow("Behavior recaptchaVerification not found"); + }); +}); + + diff --git a/packages/core/src/behaviors.ts b/packages/core/src/behaviors.ts index 5b7d2c0e..43d0b8af 100644 --- a/packages/core/src/behaviors.ts +++ b/packages/core/src/behaviors.ts @@ -22,6 +22,7 @@ import { signInAnonymously, User, UserCredential, + RecaptchaVerifier, } from "firebase/auth"; import { FirebaseUIConfiguration } from "./config"; @@ -32,6 +33,7 @@ export type BehaviorHandlers = { credential: AuthCredential ) => Promise; autoUpgradeAnonymousProvider: (ui: FirebaseUIConfiguration, provider: AuthProvider) => Promise; + recaptchaVerification: (ui: FirebaseUIConfiguration, element: HTMLElement) => RecaptchaVerifier; }; export type Behavior = Pick; @@ -112,40 +114,24 @@ export function autoUpgradeAnonymousUsers(): Behavior< }; } -// export function autoUpgradeAnonymousCredential(): RegisteredBehavior<'autoUpgradeAnonymousCredential'> { -// return { -// key: 'autoUpgradeAnonymousCredential', -// handler: async (auth, credential) => { -// const currentUser = auth.currentUser; - -// // Check if the user is anonymous. If not, we can't upgrade them. -// if (!currentUser?.isAnonymous) { -// return; -// } - -// $state.set('linking'); -// const result = await linkWithCredential(currentUser, credential); -// $state.set('idle'); -// return result; -// }, -// }; -// } - -// export function autoUpgradeAnonymousProvider(): RegisteredBehavior<'autoUpgradeAnonymousCredential'> { -// return { -// key: 'autoUpgradeAnonymousProvider', -// handler: async (auth, credential) => { -// const currentUser = auth.currentUser; - -// // Check if the user is anonymous. If not, we can't upgrade them. -// if (!currentUser?.isAnonymous) { -// return; -// } - -// $state.set('linking'); -// const result = await linkWithRedirect(currentUser, credential); -// $state.set('idle'); -// return result; -// }, -// }; -// } +export type RecaptchaVerification = { + size?: "normal" | "invisible" | "compact"; + theme?: "light" | "dark"; + tabindex?: number; +}; + +export function recaptchaVerification(options?: RecaptchaVerification): Behavior<"recaptchaVerification"> { + return { + recaptchaVerification: (ui, element) => { + return new RecaptchaVerifier(ui.auth, element, { + size: options?.size ?? "invisible", + theme: options?.theme ?? "light", + tabindex: options?.tabindex ?? 0, + }); + }, + }; +} + +export const defaultBehaviors = { + ...recaptchaVerification(), +}; \ No newline at end of file diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 8391f575..7a1b9438 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -3,7 +3,7 @@ import { Auth } from "firebase/auth"; import { describe, it, expect } from "vitest"; import { initializeUI } from "./config"; import { enUs, registerLocale } from "@firebase-ui/translations"; -import { autoUpgradeAnonymousUsers } from "./behaviors"; +import { autoUpgradeAnonymousUsers, defaultBehaviors } from "./behaviors"; describe('initializeUI', () => { it('should return a valid deep store with default values', () => { @@ -17,12 +17,12 @@ describe('initializeUI', () => { expect(ui.get()).toBeDefined(); expect(ui.get().app).toBe(config.app); expect(ui.get().auth).toBe(config.auth); - expect(ui.get().behaviors).toEqual({}); + expect(ui.get().behaviors).toEqual(defaultBehaviors); expect(ui.get().state).toEqual("idle"); expect(ui.get().locale).toEqual(enUs); }); - it('should merge behaviors', () => { + it('should merge behaviors with defaultBehaviors', () => { const config = { app: {} as FirebaseApp, auth: {} as Auth, @@ -32,6 +32,11 @@ describe('initializeUI', () => { const ui = initializeUI(config); expect(ui).toBeDefined(); expect(ui.get()).toBeDefined(); + + // Should have default behaviors + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + + // Should have custom behaviors expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential"); expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider"); }); @@ -66,5 +71,63 @@ describe('initializeUI', () => { ui.get().setLocale(testLocale2); expect(ui.get().locale.locale).toEqual('test2'); }); + + it('should include defaultBehaviors even when no custom behaviors are provided', () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui.get().behaviors).toEqual(defaultBehaviors); + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + }); + + it('should allow overriding default behaviors', () => { + const customRecaptchaVerification = { + recaptchaVerification: () => { + // Custom implementation + return {} as any; + } + }; + + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + behaviors: [customRecaptchaVerification], + }; + + const ui = initializeUI(config); + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + expect(ui.get().behaviors.recaptchaVerification).toBe(customRecaptchaVerification.recaptchaVerification); + }); + + it('should merge multiple behavior objects correctly', () => { + const behavior1 = autoUpgradeAnonymousUsers(); + const behavior2 = { + recaptchaVerification: () => { + // Custom recaptcha implementation + return {} as any; + } + }; + + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + behaviors: [behavior1, behavior2], + }; + + const ui = initializeUI(config); + + // Should have default behaviors + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + + // Should have autoUpgrade behaviors + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider"); + + // Should have custom recaptcha implementation + expect(ui.get().behaviors.recaptchaVerification).toBe(behavior2.recaptchaVerification); + }); }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a8a9704a..365ced65 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -18,7 +18,14 @@ import { enUs, RegisteredLocale } from "@firebase-ui/translations"; import type { FirebaseApp } from "firebase/app"; import { Auth, getAuth } from "firebase/auth"; import { deepMap, DeepMapStore, map } from "nanostores"; -import { Behavior, type BehaviorHandlers, type BehaviorKey, getBehavior, hasBehavior } from "./behaviors"; +import { + Behavior, + type BehaviorHandlers, + type BehaviorKey, + defaultBehaviors, + getBehavior, + hasBehavior, +} from "./behaviors"; import { FirebaseUIState } from "./state"; type FirebaseUIConfigurationOptions = { @@ -44,14 +51,14 @@ export type FirebaseUI = DeepMapStore; export function initializeUI(config: FirebaseUIConfigurationOptions, name: string = "[DEFAULT]"): FirebaseUI { // Reduce the behaviors to a single object. - const behaviors = config.behaviors?.reduce( + const behaviors = config.behaviors?.reduce>>( (acc, behavior) => { return { ...acc, ...behavior, }; }, - {} as Record + defaultBehaviors ); $config.setKey( @@ -69,7 +76,7 @@ export function initializeUI(config: FirebaseUIConfigurationOptions, name: strin const current = $config.get()[name]!; current.setKey(`state`, state); }, - behaviors: behaviors ?? {}, + behaviors: behaviors ?? defaultBehaviors, }) );