diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index 3ef015d3..a75790e3 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -82,6 +82,11 @@ function App() { Password Reset Screen +
  • + + MFA Enrollment Screen + +
  • diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index 16705a86..2f63bf4b 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -47,6 +47,9 @@ import OAuthScreenPage from "./screens/oauth-screen"; /** Password Reset */ import ForgotPasswordPage from "./screens/forgot-password-screen"; +/** MFA Enrollment */ +import MultiFactorAuthEnrollmentScreenPage from "./screens/mfa-enrollment-screen"; + const root = document.getElementById("root")!; ReactDOM.createRoot(root).render( @@ -72,6 +75,7 @@ ReactDOM.createRoot(root).render( } /> } /> } /> + } /> diff --git a/examples/react/src/screens/mfa-enrollment-screen.tsx b/examples/react/src/screens/mfa-enrollment-screen.tsx new file mode 100644 index 00000000..2f52e711 --- /dev/null +++ b/examples/react/src/screens/mfa-enrollment-screen.tsx @@ -0,0 +1,31 @@ +/** + * Copyright 2025 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. + */ + +"use client"; + +import { MultiFactorAuthEnrollmentScreen } from "@firebase-ui/react"; +import { FactorId } from "firebase/auth"; + +export default function MultiFactorAuthEnrollmentScreenPage() { + return ( + { + console.log("Enrollment successful"); + }} + /> + ); +} diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 8458a1cd..25cc7705 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -24,13 +24,17 @@ import { EmailAuthProvider, linkWithCredential, PhoneAuthProvider, + TotpMultiFactorGenerator, + multiFactor, type ActionCodeSettings, type ApplicationVerifier, type AuthProvider, type UserCredential, type AuthCredential, type TotpSecret, - type PhoneInfoOptions, + type MultiFactorAssertion, + type MultiFactorUser, + type MultiFactorInfo, } from "firebase/auth"; import QRCode from "qrcode-generator"; import { type FirebaseUI } from "./config"; @@ -54,13 +58,18 @@ async function handlePendingCredential(_ui: FirebaseUI, user: UserCredential): P } } +function setPendingState(ui: FirebaseUI) { + ui.setRedirectError(undefined); + ui.setState("pending"); +} + export async function signInWithEmailAndPassword( ui: FirebaseUI, email: string, password: string ): Promise { try { - ui.setState("pending"); + setPendingState(ui); const credential = EmailAuthProvider.credential(email, password); if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { @@ -87,7 +96,7 @@ export async function createUserWithEmailAndPassword( displayName?: string ): Promise { try { - ui.setState("pending"); + setPendingState(ui); const credential = EmailAuthProvider.credential(email, password); if (hasBehavior(ui, "requireDisplayName") && !displayName) { @@ -122,13 +131,38 @@ export async function createUserWithEmailAndPassword( export async function verifyPhoneNumber( ui: FirebaseUI, - phoneNumber: PhoneInfoOptions | string, - appVerifier: ApplicationVerifier + phoneNumber: string, + appVerifier: ApplicationVerifier, + mfaUser?: MultiFactorUser, + mfaHint?: MultiFactorInfo ): Promise { try { - ui.setState("pending"); + setPendingState(ui); const provider = new PhoneAuthProvider(ui.auth); - return await provider.verifyPhoneNumber(phoneNumber, appVerifier); + + if (mfaHint && ui.multiFactorResolver) { + // MFA assertion flow + return await provider.verifyPhoneNumber( + { + multiFactorHint: mfaHint, + session: ui.multiFactorResolver.session, + }, + appVerifier + ); + } else if (mfaUser) { + // MFA enrollment flow + const session = await mfaUser.getSession(); + return await provider.verifyPhoneNumber( + { + phoneNumber, + session, + }, + appVerifier + ); + } else { + // Regular phone auth flow + return await provider.verifyPhoneNumber(phoneNumber, appVerifier); + } } catch (error) { handleFirebaseError(ui, error); } finally { @@ -142,7 +176,7 @@ export async function confirmPhoneNumber( verificationCode: string ): Promise { try { - ui.setState("pending"); + setPendingState(ui); const currentUser = ui.auth.currentUser; const credential = PhoneAuthProvider.credential(verificationId, verificationCode); @@ -165,7 +199,7 @@ export async function confirmPhoneNumber( export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Promise { try { - ui.setState("pending"); + setPendingState(ui); await _sendPasswordResetEmail(ui.auth, email); } catch (error) { handleFirebaseError(ui, error); @@ -176,7 +210,7 @@ export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Pro export async function sendSignInLinkToEmail(ui: FirebaseUI, email: string): Promise { try { - ui.setState("pending"); + setPendingState(ui); const actionCodeSettings = { url: window.location.href, // TODO(ehesp): Check this... @@ -200,7 +234,7 @@ export async function signInWithEmailLink(ui: FirebaseUI, email: string, link: s export async function signInWithCredential(ui: FirebaseUI, credential: AuthCredential): Promise { try { - ui.setState("pending"); + setPendingState(ui); if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { const userCredential = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); @@ -222,7 +256,7 @@ export async function signInWithCredential(ui: FirebaseUI, credential: AuthCrede export async function signInAnonymously(ui: FirebaseUI): Promise { try { - ui.setState("pending"); + setPendingState(ui); const result = await _signInAnonymously(ui.auth); return handlePendingCredential(ui, result); } catch (error) { @@ -234,7 +268,7 @@ export async function signInAnonymously(ui: FirebaseUI): Promise export async function signInWithProvider(ui: FirebaseUI, provider: AuthProvider): Promise { try { - ui.setState("pending"); + setPendingState(ui); if (hasBehavior(ui, "autoUpgradeAnonymousProvider")) { const credential = await getBehavior(ui, "autoUpgradeAnonymousProvider")(ui, provider); @@ -267,7 +301,7 @@ export async function completeEmailLinkSignIn(ui: FirebaseUI, currentUrl: string const email = window.localStorage.getItem("emailForSignIn"); if (!email) return null; - ui.setState("pending"); + setPendingState(ui); const result = await signInWithEmailLink(ui, email, currentUrl); return handlePendingCredential(ui, result); } catch (error) { @@ -292,3 +326,44 @@ export function generateTotpQrCode(ui: FirebaseUI, secret: TotpSecret, accountNa qr.make(); return qr.createDataURL(); } + +export async function signInWithMultiFactorAssertion(ui: FirebaseUI, assertion: MultiFactorAssertion) { + try { + setPendingState(ui); + const result = await ui.multiFactorResolver?.resolveSignIn(assertion); + ui.setMultiFactorResolver(undefined); + return result; + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +export async function enrollWithMultiFactorAssertion( + ui: FirebaseUI, + assertion: MultiFactorAssertion, + displayName?: string +): Promise { + try { + setPendingState(ui); + await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +export async function generateTotpSecret(ui: FirebaseUI): Promise { + try { + setPendingState(ui); + const mfaUser = multiFactor(ui.auth.currentUser!); + const session = await mfaUser.getSession(); + return await TotpMultiFactorGenerator.generateSecret(session); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 8d117978..a583702b 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -383,4 +383,113 @@ describe("initializeUI", () => { ui.get().setMultiFactorResolver(undefined); expect(ui.get().multiFactorResolver).toBeUndefined(); }); + + it("should have redirectError undefined by default", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui.get().redirectError).toBeUndefined(); + }); + + it("should set and get redirectError correctly", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + const mockError = new Error("Test redirect error"); + + expect(ui.get().redirectError).toBeUndefined(); + ui.get().setRedirectError(mockError); + expect(ui.get().redirectError).toBe(mockError); + ui.get().setRedirectError(undefined); + expect(ui.get().redirectError).toBeUndefined(); + }); + + it("should update redirectError multiple times", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + const mockError1 = new Error("First error"); + const mockError2 = new Error("Second error"); + + ui.get().setRedirectError(mockError1); + expect(ui.get().redirectError).toBe(mockError1); + ui.get().setRedirectError(mockError2); + expect(ui.get().redirectError).toBe(mockError2); + ui.get().setRedirectError(undefined); + expect(ui.get().redirectError).toBeUndefined(); + }); + + it("should handle redirect error when getRedirectResult throws", async () => { + Object.defineProperty(global, "window", { + value: {}, + writable: true, + configurable: true, + }); + + const mockAuth = { + currentUser: null, + } as any; + + const mockError = new Error("Redirect failed"); + const { getRedirectResult } = await import("firebase/auth"); + vi.mocked(getRedirectResult).mockClear(); + vi.mocked(getRedirectResult).mockRejectedValue(mockError); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + }; + + const ui = initializeUI(config); + + // Process next tick to make sure the promise is resolved + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(getRedirectResult).toHaveBeenCalledTimes(1); + expect(getRedirectResult).toHaveBeenCalledWith(mockAuth); + expect(ui.get().redirectError).toBe(mockError); + + delete (global as any).window; + }); + + it("should convert non-Error objects to Error instances in redirect catch", async () => { + Object.defineProperty(global, "window", { + value: {}, + writable: true, + configurable: true, + }); + + const mockAuth = { + currentUser: null, + } as any; + + const { getRedirectResult } = await import("firebase/auth"); + vi.mocked(getRedirectResult).mockClear(); + vi.mocked(getRedirectResult).mockRejectedValue("String error"); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + }; + + const ui = initializeUI(config); + + // Process next tick to make sure the promise is resolved + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(getRedirectResult).toHaveBeenCalledTimes(1); + expect(ui.get().redirectError).toBeInstanceOf(Error); + expect(ui.get().redirectError?.message).toBe("String error"); + + delete (global as any).window; + }); }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 1249449b..0ffbf72e 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -21,6 +21,7 @@ import { deepMap, type DeepMapStore, map } from "nanostores"; import { type Behavior, type Behaviors, defaultBehaviors } from "./behaviors"; import type { InitBehavior, RedirectBehavior } from "./behaviors/utils"; import { type FirebaseUIState } from "./state"; +import { handleFirebaseError } from "./errors"; export type FirebaseUIOptions = { app: FirebaseApp; @@ -40,6 +41,8 @@ export type FirebaseUI = { behaviors: Behaviors; multiFactorResolver?: MultiFactorResolver; setMultiFactorResolver: (multiFactorResolver?: MultiFactorResolver) => void; + redirectError?: Error; + setRedirectError: (error?: Error) => void; }; export const $config = map>>({}); @@ -78,6 +81,11 @@ export function initializeUI(config: FirebaseUIOptions, name: string = "[DEFAULT const current = $config.get()[name]!; current.setKey(`multiFactorResolver`, resolver); }, + redirectError: undefined, + setRedirectError: (error?: Error) => { + const current = $config.get()[name]!; + current.setKey(`redirectError`, error); + }, }) ); @@ -106,11 +114,17 @@ export function initializeUI(config: FirebaseUIOptions, name: string = "[DEFAULT }); } - if (redirectBehaviors.length > 0) { - getRedirectResult(ui.auth).then((result) => { - Promise.all(redirectBehaviors.map((behavior) => behavior.handler(ui, result))); + getRedirectResult(ui.auth) + .then((result) => { + return Promise.all(redirectBehaviors.map((behavior) => behavior.handler(ui, result))); + }) + .catch((error) => { + try { + handleFirebaseError(ui, error); + } catch (error) { + ui.setRedirectError(error instanceof Error ? error : new Error(String(error))); + } }); - } } return store; diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 03033280..354a0be2 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -73,9 +73,39 @@ export function createPhoneAuthVerifyFormSchema(ui: FirebaseUI) { }); } +export function createMultiFactorPhoneAuthNumberFormSchema(ui: FirebaseUI) { + const base = createPhoneAuthNumberFormSchema(ui); + return base.extend({ + displayName: z.string().min(1, getTranslation(ui, "errors", "displayNameRequired")), + }); +} + +export function createMultiFactorPhoneAuthVerifyFormSchema(ui: FirebaseUI) { + return createPhoneAuthVerifyFormSchema(ui); +} + +export function createMultiFactorTotpAuthNumberFormSchema(ui: FirebaseUI) { + return z.object({ + displayName: z.string().min(1, getTranslation(ui, "errors", "displayNameRequired")), + }); +} + +export function createMultiFactorTotpAuthVerifyFormSchema(ui: FirebaseUI) { + return z.object({ + verificationCode: z.string().refine((val) => val.length === 6, { + error: getTranslation(ui, "errors", "invalidVerificationCode"), + }), + }); +} + export type SignInAuthFormSchema = z.infer>; export type SignUpAuthFormSchema = z.infer>; export type ForgotPasswordAuthFormSchema = z.infer>; export type EmailLinkAuthFormSchema = z.infer>; export type PhoneAuthNumberFormSchema = z.infer>; export type PhoneAuthVerifyFormSchema = z.infer>; +export type MultiFactorPhoneAuthNumberFormSchema = z.infer< + ReturnType +>; +export type MultiFactorTotpAuthNumberFormSchema = z.infer>; +export type MultiFactorTotpAuthVerifyFormSchema = z.infer>; diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts index f6d23445..6f052853 100644 --- a/packages/core/tests/utils.ts +++ b/packages/core/tests/utils.ts @@ -16,6 +16,8 @@ export function createMockUI(overrides?: Partial): FirebaseUI { behaviors: {}, multiFactorResolver: undefined, setMultiFactorResolver: vi.fn(), + redirectError: undefined, + setRedirectError: vi.fn(), ...overrides, }; } diff --git a/packages/react/src/auth/forms/email-link-auth-form.test.tsx b/packages/react/src/auth/forms/email-link-auth-form.test.tsx index 528f1f6b..06dd37b3 100644 --- a/packages/react/src/auth/forms/email-link-auth-form.test.tsx +++ b/packages/react/src/auth/forms/email-link-auth-form.test.tsx @@ -29,6 +29,14 @@ import { registerLocale } from "@firebase-ui/translations"; import { FirebaseUIProvider } from "~/context"; import type { UserCredential } from "firebase/auth"; +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); + vi.mock("@firebase-ui/core", async (importOriginal) => { const mod = await importOriginal(); return { diff --git a/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx b/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx index e8182d71..7b8255c0 100644 --- a/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx +++ b/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx @@ -27,6 +27,14 @@ import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; import { FirebaseUIProvider } from "~/context"; +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); + vi.mock("@firebase-ui/core", async (importOriginal) => { const mod = await importOriginal(); return { diff --git a/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.test.tsx b/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.test.tsx new file mode 100644 index 00000000..e9d60664 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.test.tsx @@ -0,0 +1,287 @@ +/** + * Copyright 2025 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, renderHook, cleanup } from "@testing-library/react"; +import { + SmsMultiFactorAssertionForm, + useSmsMultiFactorAssertionPhoneFormAction, + useSmsMultiFactorAssertionVerifyFormAction, +} from "./sms-multi-factor-assertion-form"; +import { act } from "react"; +import { verifyPhoneNumber, signInWithMultiFactorAssertion } from "@firebase-ui/core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { PhoneAuthProvider, PhoneMultiFactorGenerator } from "firebase/auth"; + +vi.mock("@firebase-ui/core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + verifyPhoneNumber: vi.fn(), + signInWithMultiFactorAssertion: vi.fn(), + }; +}); + +vi.mock("firebase/auth", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + PhoneAuthProvider: { + credential: vi.fn(), + }, + PhoneMultiFactorGenerator: { + assertion: vi.fn(), + }, + }; +}); + +vi.mock("~/hooks", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useRecaptchaVerifier: vi.fn().mockReturnValue({ + render: vi.fn(), + clear: vi.fn(), + verify: vi.fn(), + }), + }; +}); + +describe("useSmsMultiFactorAssertionPhoneFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a function", () => { + const mockUI = createMockUI(); + const { result } = renderHook(() => useSmsMultiFactorAssertionPhoneFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(typeof result.current).toBe("function"); + }); + + it("should call verifyPhoneNumber with correct parameters", async () => { + const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber); + const mockUI = createMockUI(); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + const mockHint = { + factorId: "phone" as const, + phoneNumber: "+1234567890", + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const { result } = renderHook(() => useSmsMultiFactorAssertionPhoneFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ hint: mockHint, recaptchaVerifier: mockRecaptchaVerifier as any }); + }); + + expect(verifyPhoneNumberMock).toHaveBeenCalledWith( + expect.any(Object), // UI object + "", // empty phone number + mockRecaptchaVerifier, + undefined, // no mfaUser + mockHint // mfaHint + ); + }); +}); + +describe("useSmsMultiFactorAssertionVerifyFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a function", () => { + const mockUI = createMockUI(); + const { result } = renderHook(() => useSmsMultiFactorAssertionVerifyFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(typeof result.current).toBe("function"); + }); + + it("should call PhoneAuthProvider.credential and PhoneMultiFactorGenerator.assertion", async () => { + const mockUI = createMockUI(); + const mockCredential = { credential: true }; + const mockAssertion = { assertion: true }; + + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(mockCredential as any); + vi.mocked(PhoneMultiFactorGenerator.assertion).mockReturnValue(mockAssertion as any); + + const { result } = renderHook(() => useSmsMultiFactorAssertionVerifyFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ verificationId: "test-verification-id", verificationCode: "123456" }); + }); + + expect(PhoneAuthProvider.credential).toHaveBeenCalledWith("test-verification-id", "123456"); + expect(PhoneMultiFactorGenerator.assertion).toHaveBeenCalledWith(mockCredential); + }); + + it("should call signInWithMultiFactorAssertion with correct parameters", async () => { + const signInWithMultiFactorAssertionMock = vi.mocked(signInWithMultiFactorAssertion); + const mockUI = createMockUI(); + const mockCredential = { credential: true }; + const mockAssertion = { assertion: true }; + + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(mockCredential as any); + vi.mocked(PhoneMultiFactorGenerator.assertion).mockReturnValue(mockAssertion as any); + + const { result } = renderHook(() => useSmsMultiFactorAssertionVerifyFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ verificationId: "test-verification-id", verificationCode: "123456" }); + }); + + expect(signInWithMultiFactorAssertionMock).toHaveBeenCalledWith(expect.any(Object), mockAssertion); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the phone form initially", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "sendCode", + phoneNumber: "phoneNumber", + }, + }), + }); + + const mockHint = { + factorId: "phone" as const, + phoneNumber: "+1234567890", + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toHaveValue("+1234567890"); + + const sendCodeButton = screen.getByRole("button", { name: "sendCode" }); + expect(sendCodeButton).toBeInTheDocument(); + expect(sendCodeButton).toHaveAttribute("type", "submit"); + + expect(container.querySelector(".fui-recaptcha-container")).toBeInTheDocument(); + }); + + it("should display phone number from hint", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + phoneNumber: "phoneNumber", + }, + }), + }); + + const mockHint = { + factorId: "phone" as const, + phoneNumber: "+1234567890", + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const phoneInput = screen.getByRole("textbox", { name: /phoneNumber/i }); + expect(phoneInput).toHaveValue("+1234567890"); + }); + + it("should handle missing phone number in hint", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + phoneNumber: "phoneNumber", + }, + }), + }); + + const mockHint = { + factorId: "phone" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const phoneInput = screen.getByRole("textbox", { name: /phoneNumber/i }); + expect(phoneInput).toHaveValue(""); + }); + + it("should accept onSuccess callback prop", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + phoneNumber: "phoneNumber", + }, + }), + }); + + const mockHint = { + factorId: "phone" as const, + phoneNumber: "+1234567890", + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + const onSuccessMock = vi.fn(); + + expect(() => { + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + }).not.toThrow(); + }); +}); diff --git a/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.tsx b/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.tsx new file mode 100644 index 00000000..21132eac --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.tsx @@ -0,0 +1,223 @@ +import { useCallback, useRef, useState } from "react"; +import { + PhoneAuthProvider, + PhoneMultiFactorGenerator, + type MultiFactorInfo, + type RecaptchaVerifier, +} from "firebase/auth"; + +import { signInWithMultiFactorAssertion, FirebaseUIError, getTranslation, verifyPhoneNumber } from "@firebase-ui/core"; +import { form } from "~/components/form"; +import { + useMultiFactorPhoneAuthNumberFormSchema, + useMultiFactorPhoneAuthVerifyFormSchema, + useRecaptchaVerifier, + useUI, +} from "~/hooks"; + +type PhoneMultiFactorInfo = MultiFactorInfo & { + phoneNumber?: string; +}; + +export function useSmsMultiFactorAssertionPhoneFormAction() { + const ui = useUI(); + + return useCallback( + async ({ hint, recaptchaVerifier }: { hint: MultiFactorInfo; recaptchaVerifier: RecaptchaVerifier }) => { + return await verifyPhoneNumber(ui, "", recaptchaVerifier, undefined, hint); + }, + [ui] + ); +} + +type UseSmsMultiFactorAssertionPhoneForm = { + hint: MultiFactorInfo; + recaptchaVerifier: RecaptchaVerifier; + onSuccess: (verificationId: string) => void; +}; + +export function useSmsMultiFactorAssertionPhoneForm({ + hint, + recaptchaVerifier, + onSuccess, +}: UseSmsMultiFactorAssertionPhoneForm) { + const action = useSmsMultiFactorAssertionPhoneFormAction(); + const schema = useMultiFactorPhoneAuthNumberFormSchema(); + + return form.useAppForm({ + defaultValues: { + phoneNumber: (hint as PhoneMultiFactorInfo).phoneNumber || "", + }, + validators: { + onBlur: schema, + onSubmit: schema, + onSubmitAsync: async () => { + try { + const verificationId = await action({ hint, recaptchaVerifier }); + return onSuccess(verificationId); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type SmsMultiFactorAssertionPhoneFormProps = { + hint: MultiFactorInfo; + onSubmit: (verificationId: string) => void; +}; + +function SmsMultiFactorAssertionPhoneForm(props: SmsMultiFactorAssertionPhoneFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const form = useSmsMultiFactorAssertionPhoneForm({ + hint: props.hint, + recaptchaVerifier: recaptchaVerifier!, + onSuccess: props.onSubmit, + }); + + return ( +
    { + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
    + + {(field) => ( + + )} + +
    +
    +
    +
    +
    + {getTranslation(ui, "labels", "sendCode")} + +
    +
    +
    + ); +} + +export function useSmsMultiFactorAssertionVerifyFormAction() { + const ui = useUI(); + + return useCallback( + async ({ verificationId, verificationCode }: { verificationId: string; verificationCode: string }) => { + const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + const assertion = PhoneMultiFactorGenerator.assertion(credential); + return await signInWithMultiFactorAssertion(ui, assertion); + }, + [ui] + ); +} + +type UseSmsMultiFactorAssertionVerifyForm = { + verificationId: string; + onSuccess: () => void; +}; + +export function useSmsMultiFactorAssertionVerifyForm({ + verificationId, + onSuccess, +}: UseSmsMultiFactorAssertionVerifyForm) { + const action = useSmsMultiFactorAssertionVerifyFormAction(); + const schema = useMultiFactorPhoneAuthVerifyFormSchema(); + + return form.useAppForm({ + defaultValues: { + verificationId, + verificationCode: "", + }, + validators: { + onSubmit: schema, + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + await action(value); + return onSuccess(); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type SmsMultiFactorAssertionVerifyFormProps = { + verificationId: string; + onSuccess: () => void; +}; + +function SmsMultiFactorAssertionVerifyForm(props: SmsMultiFactorAssertionVerifyFormProps) { + const ui = useUI(); + const form = useSmsMultiFactorAssertionVerifyForm({ + verificationId: props.verificationId, + onSuccess: props.onSuccess, + }); + + return ( +
    { + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
    + + {(field) => } + +
    +
    + {getTranslation(ui, "labels", "verifyCode")} + +
    +
    +
    + ); +} + +export type SmsMultiFactorAssertionFormProps = { + hint: MultiFactorInfo; + onSuccess?: () => void; +}; + +export function SmsMultiFactorAssertionForm(props: SmsMultiFactorAssertionFormProps) { + const [verification, setVerification] = useState<{ + verificationId: string; + } | null>(null); + + if (!verification) { + return ( + setVerification({ verificationId })} + /> + ); + } + + return ( + { + props.onSuccess?.(); + }} + /> + ); +} diff --git a/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.test.tsx b/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.test.tsx new file mode 100644 index 00000000..dd8d41e3 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.test.tsx @@ -0,0 +1,328 @@ +/** + * Copyright 2025 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, renderHook, cleanup } from "@testing-library/react"; +import { + SmsMultiFactorEnrollmentForm, + useSmsMultiFactorEnrollmentPhoneAuthFormAction, + useMultiFactorEnrollmentVerifyPhoneNumberFormAction, + MultiFactorEnrollmentVerifyPhoneNumberForm, +} from "./sms-multi-factor-enrollment-form"; +import { act } from "react"; +import { verifyPhoneNumber, enrollWithMultiFactorAssertion } from "@firebase-ui/core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { PhoneAuthProvider, PhoneMultiFactorGenerator } from "firebase/auth"; + +vi.mock("@firebase-ui/core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + verifyPhoneNumber: vi.fn(), + enrollWithMultiFactorAssertion: vi.fn(), + formatPhoneNumber: vi.fn(), + }; +}); + +vi.mock("firebase/auth", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + PhoneAuthProvider: { + credential: vi.fn(), + }, + PhoneMultiFactorGenerator: { + assertion: vi.fn(), + }, + multiFactor: vi.fn(() => ({ + enroll: vi.fn(), + })), + }; +}); + +vi.mock("~/components/form", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + form: { + ...mod.form, + ErrorMessage: () =>
    Error Message
    , + }, + }; +}); + +vi.mock("~/components/country-selector", () => ({ + CountrySelector: ({ ref }: { ref: any }) => ( +
    + Country Selector +
    + ), +})); + +vi.mock("~/hooks", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useRecaptchaVerifier: () => ({ + render: vi.fn(), + verify: vi.fn(), + }), + }; +}); + +describe("useSmsMultiFactorEnrollmentPhoneAuthFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accepts phone number and recaptcha verifier", async () => { + const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber); + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useSmsMultiFactorEnrollmentPhoneAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + const mockRecaptchaVerifier = {} as any; + + await act(async () => { + await result.current({ phoneNumber: "+1234567890", recaptchaVerifier: mockRecaptchaVerifier }); + }); + + expect(verifyPhoneNumberMock).toHaveBeenCalledWith( + expect.any(Object), + "+1234567890", + mockRecaptchaVerifier, + expect.any(Object) + ); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber).mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const { result } = renderHook(() => useSmsMultiFactorEnrollmentPhoneAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ phoneNumber: "+1234567890", recaptchaVerifier: {} as any }); + }); + }).rejects.toThrow("Unknown error"); + + expect(verifyPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), "+1234567890", {}, expect.any(Object)); + }); +}); + +describe("useMultiFactorEnrollmentVerifyPhoneNumberFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accepts verification details", async () => { + const enrollWithMultiFactorAssertionMock = vi.mocked(enrollWithMultiFactorAssertion); + const PhoneAuthProviderCredentialMock = vi.mocked(PhoneAuthProvider.credential); + const PhoneMultiFactorGeneratorAssertionMock = vi.mocked(PhoneMultiFactorGenerator.assertion); + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useMultiFactorEnrollmentVerifyPhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + const mockCredential = { credential: true }; + const mockAssertion = { assertion: true }; + PhoneAuthProviderCredentialMock.mockReturnValue(mockCredential as any); + PhoneMultiFactorGeneratorAssertionMock.mockReturnValue(mockAssertion as any); + + await act(async () => { + await result.current({ + verificationId: "verification-id-123", + verificationCode: "123456", + displayName: "Test User", + }); + }); + + expect(PhoneAuthProviderCredentialMock).toHaveBeenCalledWith("verification-id-123", "123456"); + expect(PhoneMultiFactorGeneratorAssertionMock).toHaveBeenCalledWith(mockCredential); + expect(enrollWithMultiFactorAssertionMock).toHaveBeenCalledWith(expect.any(Object), mockAssertion, "Test User"); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const enrollWithMultiFactorAssertionMock = vi + .mocked(enrollWithMultiFactorAssertion) + .mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const { result } = renderHook(() => useMultiFactorEnrollmentVerifyPhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ + verificationId: "verification-id-123", + verificationCode: "123456", + displayName: "Test User", + }); + }); + }).rejects.toThrow("Unknown error"); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: ( + + ), + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /verificationCode/i })).toBeInTheDocument(); + + const verifyCodeButton = screen.getByRole("button", { name: "verifyCode" }); + expect(verifyCodeButton).toBeInTheDocument(); + expect(verifyCodeButton).toHaveAttribute("type", "submit"); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the phone number form initially", () => { + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + displayName: "displayName", + phoneNumber: "phoneNumber", + sendCode: "sendCode", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /displayName/i })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument(); + + const sendCodeButton = screen.getByRole("button", { name: "sendCode" }); + expect(sendCodeButton).toBeInTheDocument(); + expect(sendCodeButton).toHaveAttribute("type", "submit"); + + expect(screen.getByTestId("country-selector")).toBeInTheDocument(); + expect(container.querySelector(".fui-recaptcha-container")).toBeInTheDocument(); + }); + + it("should throw error when user is not authenticated", () => { + const mockUI = createMockUI({ + auth: { currentUser: null } as any, + }); + + expect(() => { + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + }).toThrow("User must be authenticated to enroll with multi-factor authentication"); + }); + + it("should render form elements correctly", () => { + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + displayName: "displayName", + phoneNumber: "phoneNumber", + sendCode: "sendCode", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect(screen.getByRole("textbox", { name: /displayName/i })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "sendCode" })).toBeInTheDocument(); + expect(screen.getByTestId("country-selector")).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.tsx b/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.tsx new file mode 100644 index 00000000..84f0d2db --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.tsx @@ -0,0 +1,242 @@ +import { useCallback, useRef, useState } from "react"; +import { multiFactor, PhoneAuthProvider, PhoneMultiFactorGenerator, type RecaptchaVerifier } from "firebase/auth"; +import { + enrollWithMultiFactorAssertion, + FirebaseUIError, + formatPhoneNumber, + getTranslation, + verifyPhoneNumber, +} from "@firebase-ui/core"; +import { CountrySelector, type CountrySelectorRef } from "~/components/country-selector"; +import { form } from "~/components/form"; +import { + useMultiFactorPhoneAuthNumberFormSchema, + useMultiFactorPhoneAuthVerifyFormSchema, + useRecaptchaVerifier, + useUI, +} from "~/hooks"; + +export function useSmsMultiFactorEnrollmentPhoneAuthFormAction() { + const ui = useUI(); + + return useCallback( + async ({ phoneNumber, recaptchaVerifier }: { phoneNumber: string; recaptchaVerifier: RecaptchaVerifier }) => { + const mfaUser = multiFactor(ui.auth.currentUser!); + return await verifyPhoneNumber(ui, phoneNumber, recaptchaVerifier, mfaUser); + }, + [ui] + ); +} + +type UseSmsMultiFactorEnrollmentPhoneNumberForm = { + recaptchaVerifier: RecaptchaVerifier; + onSuccess: (verificationId: string, displayName?: string) => void; + formatPhoneNumber?: (phoneNumber: string) => string; +}; + +export function useSmsMultiFactorEnrollmentPhoneNumberForm({ + recaptchaVerifier, + onSuccess, + formatPhoneNumber, +}: UseSmsMultiFactorEnrollmentPhoneNumberForm) { + const action = useSmsMultiFactorEnrollmentPhoneAuthFormAction(); + const schema = useMultiFactorPhoneAuthNumberFormSchema(); + + return form.useAppForm({ + defaultValues: { + displayName: "", + phoneNumber: "", + }, + validators: { + onBlur: schema, + onSubmit: schema, + onSubmitAsync: async ({ value }) => { + try { + const formatted = formatPhoneNumber ? formatPhoneNumber(value.phoneNumber) : value.phoneNumber; + const confirmationResult = await action({ phoneNumber: formatted, recaptchaVerifier }); + return onSuccess(confirmationResult, value.displayName); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type MultiFactorEnrollmentPhoneNumberFormProps = { + onSubmit: (verificationId: string, displayName?: string) => void; +}; + +function MultiFactorEnrollmentPhoneNumberForm(props: MultiFactorEnrollmentPhoneNumberFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const countrySelector = useRef(null); + const form = useSmsMultiFactorEnrollmentPhoneNumberForm({ + recaptchaVerifier: recaptchaVerifier!, + onSuccess: props.onSubmit, + formatPhoneNumber: (phoneNumber) => formatPhoneNumber(phoneNumber, countrySelector.current!.getCountry()), + }); + + return ( +
    { + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
    + + {(field) => } + +
    +
    + + {(field) => ( + } + /> + )} + +
    +
    +
    +
    +
    + {getTranslation(ui, "labels", "sendCode")} + +
    +
    +
    + ); +} + +export function useMultiFactorEnrollmentVerifyPhoneNumberFormAction() { + const ui = useUI(); + return useCallback( + async ({ + verificationId, + verificationCode, + displayName, + }: { + verificationId: string; + verificationCode: string; + displayName?: string; + }) => { + const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + const assertion = PhoneMultiFactorGenerator.assertion(credential); + return await enrollWithMultiFactorAssertion(ui, assertion, displayName); + }, + [ui] + ); +} + +type UseMultiFactorEnrollmentVerifyPhoneNumberForm = { + verificationId: string; + displayName?: string; + onSuccess: () => void; +}; + +export function useMultiFactorEnrollmentVerifyPhoneNumberForm({ + verificationId, + displayName, + onSuccess, +}: UseMultiFactorEnrollmentVerifyPhoneNumberForm) { + const schema = useMultiFactorPhoneAuthVerifyFormSchema(); + const action = useMultiFactorEnrollmentVerifyPhoneNumberFormAction(); + + return form.useAppForm({ + defaultValues: { + verificationId, + verificationCode: "", + }, + validators: { + onSubmit: schema, + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + await action({ ...value, displayName }); + return onSuccess(); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type MultiFactorEnrollmentVerifyPhoneNumberFormProps = { + verificationId: string; + displayName?: string; + onSuccess: () => void; +}; + +export function MultiFactorEnrollmentVerifyPhoneNumberForm(props: MultiFactorEnrollmentVerifyPhoneNumberFormProps) { + const ui = useUI(); + const form = useMultiFactorEnrollmentVerifyPhoneNumberForm({ + ...props, + onSuccess: props.onSuccess, + }); + + return ( +
    { + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
    + + {(field) => } + +
    +
    + {getTranslation(ui, "labels", "verifyCode")} + +
    +
    +
    + ); +} + +export type SmsMultiFactorEnrollmentFormProps = { + onSuccess?: () => void; +}; + +export function SmsMultiFactorEnrollmentForm(props: SmsMultiFactorEnrollmentFormProps) { + const ui = useUI(); + + const [verification, setVerification] = useState<{ + verificationId: string; + displayName?: string; + } | null>(null); + + if (!ui.auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + + if (!verification) { + return ( + setVerification({ verificationId, displayName })} + /> + ); + } + + return ( + { + props.onSuccess?.(); + }} + /> + ); +} diff --git a/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.test.tsx b/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.test.tsx new file mode 100644 index 00000000..4a20ee06 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.test.tsx @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2024 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, renderHook, cleanup } from "@testing-library/react"; +import { + TotpMultiFactorAssertionForm, + useTotpMultiFactorAssertionFormAction, +} from "./totp-multi-factor-assertion-form"; +import { act } from "react"; +import { signInWithMultiFactorAssertion } from "@firebase-ui/core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { TotpMultiFactorGenerator } from "firebase/auth"; + +vi.mock("@firebase-ui/core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + signInWithMultiFactorAssertion: vi.fn(), + }; +}); + +vi.mock("firebase/auth", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + TotpMultiFactorGenerator: { + assertionForSignIn: vi.fn(), + }, + }; +}); + +describe("useTotpMultiFactorAssertionFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a function", () => { + const mockUI = createMockUI(); + const { result } = renderHook(() => useTotpMultiFactorAssertionFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(typeof result.current).toBe("function"); + }); + + it("should call TotpMultiFactorGenerator.assertionForSignIn and signInWithMultiFactorAssertion", async () => { + const mockUI = createMockUI(); + const mockAssertion = { assertion: true }; + const signInWithMultiFactorAssertionMock = vi.mocked(signInWithMultiFactorAssertion); + const mockHint = { + factorId: "totp" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + vi.mocked(TotpMultiFactorGenerator.assertionForSignIn).mockReturnValue(mockAssertion as any); + + const { result } = renderHook(() => useTotpMultiFactorAssertionFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ verificationCode: "123456", hint: mockHint }); + }); + + expect(TotpMultiFactorGenerator.assertionForSignIn).toHaveBeenCalledWith("test-uid", "123456"); + expect(signInWithMultiFactorAssertionMock).toHaveBeenCalledWith(expect.any(Object), mockAssertion); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + }), + }); + + const mockHint = { + factorId: "totp" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /verificationCode/i })).toBeInTheDocument(); + + const verifyCodeButton = screen.getByRole("button", { name: "verifyCode" }); + expect(verifyCodeButton).toBeInTheDocument(); + expect(verifyCodeButton).toHaveAttribute("type", "submit"); + }); + + it("should accept onSuccess callback prop", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + }, + }), + }); + + const mockHint = { + factorId: "totp" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + const onSuccessMock = vi.fn(); + + expect(() => { + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + }).not.toThrow(); + }); + + it("should render form elements correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + }), + }); + + const mockHint = { + factorId: "totp" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect(screen.getByRole("textbox", { name: /verificationCode/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "verifyCode" })).toBeInTheDocument(); + }); + + it("should render input field for TOTP code", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + }, + }), + }); + + const mockHint = { + factorId: "totp" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const input = screen.getByRole("textbox", { name: /verificationCode/i }); + expect(input).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.tsx b/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.tsx new file mode 100644 index 00000000..152385aa --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.tsx @@ -0,0 +1,90 @@ +import { useCallback } from "react"; +import { TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; +import { signInWithMultiFactorAssertion, FirebaseUIError, getTranslation } from "@firebase-ui/core"; +import { form } from "~/components/form"; +import { useMultiFactorTotpAuthVerifyFormSchema, useUI } from "~/hooks"; + +export function useTotpMultiFactorAssertionFormAction() { + const ui = useUI(); + + return useCallback( + async ({ verificationCode, hint }: { verificationCode: string; hint: MultiFactorInfo }) => { + const assertion = TotpMultiFactorGenerator.assertionForSignIn(hint.uid, verificationCode); + return await signInWithMultiFactorAssertion(ui, assertion); + }, + [ui] + ); +} + +type UseTotpMultiFactorAssertionForm = { + hint: MultiFactorInfo; + onSuccess: () => void; +}; + +export function useTotpMultiFactorAssertionForm({ hint, onSuccess }: UseTotpMultiFactorAssertionForm) { + const action = useTotpMultiFactorAssertionFormAction(); + const schema = useMultiFactorTotpAuthVerifyFormSchema(); + + return form.useAppForm({ + defaultValues: { + verificationCode: "", + }, + validators: { + onSubmit: schema, + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + await action({ verificationCode: value.verificationCode, hint }); + return onSuccess(); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type TotpMultiFactorAssertionFormProps = { + hint: MultiFactorInfo; + onSuccess?: () => void; +}; + +export function TotpMultiFactorAssertionForm(props: TotpMultiFactorAssertionFormProps) { + const ui = useUI(); + const form = useTotpMultiFactorAssertionForm({ + hint: props.hint, + onSuccess: () => { + props.onSuccess?.(); + }, + }); + + return ( +
    { + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
    + + {(field) => ( + + )} + +
    +
    + {getTranslation(ui, "labels", "verifyCode")} + +
    +
    +
    + ); +} diff --git a/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.test.tsx b/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.test.tsx new file mode 100644 index 00000000..370e9289 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.test.tsx @@ -0,0 +1,271 @@ +/** + * Copyright 2025 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, renderHook, cleanup } from "@testing-library/react"; +import { + TotpMultiFactorEnrollmentForm, + useTotpMultiFactorSecretGenerationFormAction, + useMultiFactorEnrollmentVerifyTotpFormAction, + MultiFactorEnrollmentVerifyTotpForm, +} from "./totp-multi-factor-enrollment-form"; +import { act } from "react"; +import { generateTotpSecret, generateTotpQrCode, enrollWithMultiFactorAssertion } from "@firebase-ui/core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { TotpMultiFactorGenerator } from "firebase/auth"; + +vi.mock("@firebase-ui/core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + generateTotpSecret: vi.fn(), + generateTotpQrCode: vi.fn(), + enrollWithMultiFactorAssertion: vi.fn(), + }; +}); + +vi.mock("firebase/auth", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + TotpMultiFactorGenerator: { + ...mod.TotpMultiFactorGenerator, + assertionForEnrollment: vi.fn(), + }, + }; +}); + +describe("useTotpMultiFactorSecretGenerationFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which generates a TOTP secret", async () => { + const generateTotpSecretMock = vi.mocked(generateTotpSecret); + const mockSecret = { secretKey: "test-secret" } as any; + generateTotpSecretMock.mockResolvedValue(mockSecret); + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useTotpMultiFactorSecretGenerationFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + const secret = await result.current(); + expect(secret).toBe(mockSecret); + }); + + expect(generateTotpSecretMock).toHaveBeenCalledWith(expect.any(Object)); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const generateTotpSecretMock = vi.mocked(generateTotpSecret).mockRejectedValue(new Error("Unknown error")); + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useTotpMultiFactorSecretGenerationFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current(); + }); + }).rejects.toThrow("Unknown error"); + + expect(generateTotpSecretMock).toHaveBeenCalledWith(expect.any(Object)); + }); +}); + +describe("useMultiFactorEnrollmentVerifyTotpFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accepts verification details", async () => { + const enrollWithMultiFactorAssertionMock = vi.mocked(enrollWithMultiFactorAssertion); + const TotpMultiFactorGeneratorAssertionMock = vi.mocked(TotpMultiFactorGenerator.assertionForEnrollment); + const mockAssertion = { assertion: true } as any; + const mockSecret = { secretKey: "test-secret" } as any; + TotpMultiFactorGeneratorAssertionMock.mockReturnValue(mockAssertion); + enrollWithMultiFactorAssertionMock.mockResolvedValue(undefined); + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useMultiFactorEnrollmentVerifyTotpFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ + secret: mockSecret, + verificationCode: "123456", + displayName: "Test User", + }); + }); + + expect(TotpMultiFactorGeneratorAssertionMock).toHaveBeenCalledWith(mockSecret, "123456"); + expect(enrollWithMultiFactorAssertionMock).toHaveBeenCalledWith(expect.any(Object), mockAssertion, "123456"); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + vi.mocked(enrollWithMultiFactorAssertion).mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useMultiFactorEnrollmentVerifyTotpFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ + secret: { secretKey: "test-secret" } as any, + verificationCode: "123456", + displayName: "Test User", + }); + }); + }).rejects.toThrow("Unknown error"); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the form correctly", () => { + const generateTotpQrCodeMock = vi.mocked(generateTotpQrCode); + generateTotpQrCodeMock.mockReturnValue("data:image/png;base64,test-qr-code"); + const mockSecret = { secretKey: "test-secret" } as any; + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: ( + + ), + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /verificationCode/i })).toBeInTheDocument(); + + const verifyCodeButton = screen.getByRole("button", { name: "verifyCode" }); + expect(verifyCodeButton).toBeInTheDocument(); + expect(verifyCodeButton).toHaveAttribute("type", "submit"); + + expect(container.querySelector(".fui-qr-code-container")).toBeInTheDocument(); + expect(container.querySelector("img[alt='TOTP QR Code']")).toBeInTheDocument(); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the secret generation form initially", () => { + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + displayName: "displayName", + generateQrCode: "generateQrCode", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /displayName/i })).toBeInTheDocument(); + + const generateQrCodeButton = screen.getByRole("button", { name: "generateQrCode" }); + expect(generateQrCodeButton).toBeInTheDocument(); + expect(generateQrCodeButton).toHaveAttribute("type", "submit"); + }); + + it("should throw error when user is not authenticated", () => { + const mockUI = createMockUI({ + auth: { currentUser: null } as any, + }); + + expect(() => { + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + }).toThrow("User must be authenticated to enroll with multi-factor authentication"); + }); + + it("should render form elements correctly", () => { + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + displayName: "displayName", + generateQrCode: "generateQrCode", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect(screen.getByRole("textbox", { name: /displayName/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "generateQrCode" })).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.tsx b/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.tsx new file mode 100644 index 00000000..fa9806cf --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.tsx @@ -0,0 +1,199 @@ +import { useCallback, useState } from "react"; +import { TotpMultiFactorGenerator, type TotpSecret } from "firebase/auth"; +import { + enrollWithMultiFactorAssertion, + FirebaseUIError, + generateTotpQrCode, + generateTotpSecret, + getTranslation, +} from "@firebase-ui/core"; +import { form } from "~/components/form"; +import { useMultiFactorTotpAuthNumberFormSchema, useMultiFactorTotpAuthVerifyFormSchema, useUI } from "~/hooks"; + +export function useTotpMultiFactorSecretGenerationFormAction() { + const ui = useUI(); + + return useCallback(async () => { + return await generateTotpSecret(ui); + }, [ui]); +} + +type UseTotpMultiFactorEnrollmentForm = { + onSuccess: (secret: TotpSecret, displayName: string) => void; +}; + +export function useTotpMultiFactorSecretGenerationForm({ onSuccess }: UseTotpMultiFactorEnrollmentForm) { + const action = useTotpMultiFactorSecretGenerationFormAction(); + const schema = useMultiFactorTotpAuthNumberFormSchema(); + + return form.useAppForm({ + defaultValues: { + displayName: "", + }, + validators: { + onBlur: schema, + onSubmit: schema, + onSubmitAsync: async ({ value }) => { + try { + const secret = await action(); + return onSuccess(secret, value.displayName); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type TotpMultiFactorSecretGenerationFormProps = { + onSubmit: (secret: TotpSecret, displayName: string) => void; +}; + +function TotpMultiFactorSecretGenerationForm(props: TotpMultiFactorSecretGenerationFormProps) { + const ui = useUI(); + const form = useTotpMultiFactorSecretGenerationForm({ + onSuccess: props.onSubmit, + }); + + return ( +
    { + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
    + + {(field) => } + +
    +
    + {getTranslation(ui, "labels", "generateQrCode")} + +
    +
    +
    + ); +} + +export function useMultiFactorEnrollmentVerifyTotpFormAction() { + const ui = useUI(); + return useCallback( + async ({ secret, verificationCode }: { secret: TotpSecret; verificationCode: string; displayName: string }) => { + const assertion = TotpMultiFactorGenerator.assertionForEnrollment(secret, verificationCode); + return await enrollWithMultiFactorAssertion(ui, assertion, verificationCode); + }, + [ui] + ); +} + +type UseMultiFactorEnrollmentVerifyTotpForm = { + secret: TotpSecret; + displayName: string; + onSuccess: () => void; +}; + +export function useMultiFactorEnrollmentVerifyTotpForm({ + secret, + displayName, + onSuccess, +}: UseMultiFactorEnrollmentVerifyTotpForm) { + const schema = useMultiFactorTotpAuthVerifyFormSchema(); + const action = useMultiFactorEnrollmentVerifyTotpFormAction(); + + return form.useAppForm({ + defaultValues: { + verificationCode: "", + }, + validators: { + onSubmit: schema, + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + await action({ secret, verificationCode: value.verificationCode, displayName }); + return onSuccess(); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type MultiFactorEnrollmentVerifyTotpFormProps = { + secret: TotpSecret; + displayName: string; + onSuccess: () => void; +}; + +export function MultiFactorEnrollmentVerifyTotpForm(props: MultiFactorEnrollmentVerifyTotpFormProps) { + const ui = useUI(); + const form = useMultiFactorEnrollmentVerifyTotpForm({ + ...props, + onSuccess: props.onSuccess, + }); + + const qrCodeDataUrl = generateTotpQrCode(ui, props.secret, props.displayName); + + return ( +
    { + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > +
    + TOTP QR Code +

    TODO: Scan this QR code with your authenticator app

    +
    + +
    + + {(field) => } + +
    +
    + {getTranslation(ui, "labels", "verifyCode")} + +
    +
    +
    + ); +} + +export type TotpMultiFactorEnrollmentFormProps = { + onSuccess?: () => void; +}; + +export function TotpMultiFactorEnrollmentForm(props: TotpMultiFactorEnrollmentFormProps) { + const ui = useUI(); + + const [enrollment, setEnrollment] = useState<{ + secret: TotpSecret; + displayName: string; + } | null>(null); + + if (!ui.auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + + if (!enrollment) { + return ( + setEnrollment({ secret, displayName })} /> + ); + } + + return ( + { + props.onSuccess?.(); + }} + /> + ); +} diff --git a/packages/react/src/auth/forms/multi-factor-auth-assertion-form.test.tsx b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.test.tsx new file mode 100644 index 00000000..5c567759 --- /dev/null +++ b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.test.tsx @@ -0,0 +1,343 @@ +/** + * Copyright 2025 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 { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { MultiFactorAuthAssertionForm } from "~/auth/forms/multi-factor-auth-assertion-form"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FactorId, MultiFactorResolver, PhoneMultiFactorGenerator, TotpMultiFactorGenerator } from "firebase/auth"; + +vi.mock("~/auth/forms/mfa/sms-multi-factor-assertion-form", () => ({ + SmsMultiFactorAssertionForm: () =>
    SMS Assertion Form
    , +})); + +vi.mock("~/auth/forms/mfa/totp-multi-factor-assertion-form", () => ({ + TotpMultiFactorAssertionForm: () =>
    TOTP Assertion Form
    , +})); + +vi.mock("~/components/button", () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("", () => { + it("throws error when no multiFactorResolver is present", () => { + const ui = createMockUI(); + + expect(() => { + render( + + + + ); + }).toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver"); + }); + + it("auto-selects single factor when only one hint exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid", + displayName: "Test Phone", + }, + ], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("sms-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("mfa-button")).toBeNull(); + }); + + it("auto-selects TOTP factor when only one TOTP hint exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("totp-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("mfa-button")).toBeNull(); + }); + + it("displays factor selection UI when multiple hints exist", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-1", + displayName: "Test Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-2", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "TOTP Verification", + mfaSmsVerification: "SMS Verification", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByText("TODO: Select a multi-factor authentication method")).toBeDefined(); + expect(screen.getAllByTestId("mfa-button")).toHaveLength(2); + expect(screen.getByText("TOTP Verification")).toBeDefined(); + expect(screen.getByText("SMS Verification")).toBeDefined(); + }); + + it("renders SmsMultiFactorAssertionForm when SMS factor is selected", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-1", + displayName: "Test Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-2", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "TOTP Verification", + mfaSmsVerification: "SMS Verification", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + const smsButton = screen.getByText("SMS Verification"); + fireEvent.click(smsButton); + + expect(screen.getByTestId("sms-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("totp-assertion-form")).toBeNull(); + }); + + it("renders TotpMultiFactorAssertionForm when TOTP factor is selected", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-1", + displayName: "Test Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-2", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "TOTP Verification", + mfaSmsVerification: "SMS Verification", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + const totpButton = screen.getByText("TOTP Verification"); + fireEvent.click(totpButton); + + expect(screen.getByTestId("totp-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("sms-assertion-form")).toBeNull(); + }); + + it("buttons display correct translated labels", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-1", + displayName: "Test Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-2", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Custom TOTP Label", + mfaSmsVerification: "Custom SMS Label", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByText("Custom TOTP Label")).toBeDefined(); + expect(screen.getByText("Custom SMS Label")).toBeDefined(); + }); + + it("factor selection triggers correct form rendering", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-1", + displayName: "Test Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-2", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "TOTP Verification", + mfaSmsVerification: "SMS Verification", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const { rerender } = render( + + + + ); + + // Initially shows selection UI + expect(screen.getByText("TODO: Select a multi-factor authentication method")).toBeDefined(); + expect(screen.queryByTestId("sms-assertion-form")).toBeNull(); + expect(screen.queryByTestId("totp-assertion-form")).toBeNull(); + + // Click SMS button + const smsButton = screen.getByText("SMS Verification"); + fireEvent.click(smsButton); + + rerender( + + + + ); + + // Should now show SMS form + expect(screen.getByTestId("sms-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("totp-assertion-form")).toBeNull(); + expect(screen.queryByText("TODO: Select a multi-factor authentication method")).toBeNull(); + }); + + it("handles unknown factor types gracefully", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: "unknown-factor" as any, + uid: "test-uid", + displayName: "Unknown Factor", + }, + ], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + // Should show selection UI for unknown factor + expect(screen.getByText("TODO: Select a multi-factor authentication method")).toBeDefined(); + expect(screen.queryByTestId("sms-assertion-form")).toBeNull(); + expect(screen.queryByTestId("totp-assertion-form")).toBeNull(); + }); +}); diff --git a/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx new file mode 100644 index 00000000..e60f1c45 --- /dev/null +++ b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx @@ -0,0 +1,60 @@ +import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; +import { type ComponentProps, useState } from "react"; +import { useUI } from "~/hooks"; +import { TotpMultiFactorAssertionForm } from "../forms/mfa/totp-multi-factor-assertion-form"; +import { SmsMultiFactorAssertionForm } from "../forms/mfa/sms-multi-factor-assertion-form"; +import { Button } from "~/components/button"; +import { getTranslation } from "@firebase-ui/core"; + +export function MultiFactorAuthAssertionForm() { + const ui = useUI(); + const resolver = ui.multiFactorResolver; + + if (!resolver) { + throw new Error("MultiFactorAuthAssertionForm requires a multi-factor resolver"); + } + + // If only a single hint is provided, select it by default to improve UX. + const [hint, setHint] = useState( + resolver.hints.length === 1 ? resolver.hints[0] : undefined + ); + + if (hint) { + if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return ; + } + + if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return ; + } + } + + return ( +
    +

    TODO: Select a multi-factor authentication method

    + {resolver.hints.map((hint) => { + if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return setHint(hint)} />; + } + + if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return setHint(hint)} />; + } + + return null; + })} +
    + ); +} + +function TotpButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaTotpVerification"); + return ; +} + +function SmsButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaSmsVerification"); + return ; +} diff --git a/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.test.tsx b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.test.tsx new file mode 100644 index 00000000..7bc796ab --- /dev/null +++ b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.test.tsx @@ -0,0 +1,335 @@ +/** + * Copyright 2025 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { MultiFactorAuthEnrollmentForm } from "./multi-factor-auth-enrollment-form"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FactorId } from "firebase/auth"; + +vi.mock("./mfa/sms-multi-factor-enrollment-form", () => ({ + SmsMultiFactorEnrollmentForm: ({ onSuccess }: { onSuccess?: () => void }) => ( +
    +
    {onSuccess &&
    onSuccess
    }
    +
    + ), +})); + +vi.mock("./mfa/totp-multi-factor-enrollment-form", () => ({ + TotpMultiFactorEnrollmentForm: ({ onSuccess }: { onSuccess?: () => void }) => ( +
    +
    {onSuccess &&
    onSuccess
    }
    +
    + ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with default hints (TOTP and PHONE) when no hints provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + // Should show both buttons since we have multiple hints (since no prop) + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + }); + + it("renders with custom hints when provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("auto-selects single hint and renders corresponding form", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("auto-selects SMS hint and renders corresponding form", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("sms-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("shows buttons for multiple hints and allows selection", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("shows buttons for multiple hints and allows SMS selection", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Set up SMS" })); + + expect(screen.getByTestId("sms-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to TOTP form when auto-selected", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("totp-on-success")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to SMS form when auto-selected", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("sms-on-enrollment")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to TOTP form when selected via button", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + + expect(screen.getByTestId("totp-on-success")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to SMS form when selected via button", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Set up SMS" })); + + expect(screen.getByTestId("sms-on-enrollment")).toBeInTheDocument(); + }); + + it("throws error when hints array is empty", () => { + const ui = createMockUI(); + + expect(() => { + render( + + + + ); + }).toThrow("MultiFactorAuthEnrollmentForm must have at least one hint"); + }); + + it("throws error for unknown hint type", () => { + const ui = createMockUI(); + + const unknownHint = "unknown" as any; + + expect(() => { + render( + + + + ); + }).toThrow("Unknown multi-factor enrollment type: unknown"); + }); + + it("uses correct translation keys for buttons", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Configure TOTP Authentication", + mfaSmsVerification: "Configure SMS Authentication", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Configure TOTP Authentication" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Configure SMS Authentication" })).toBeInTheDocument(); + }); + + it("renders with correct CSS classes", () => { + const ui = createMockUI(); + + const { container } = render( + + + + ); + + const contentDiv = container.querySelector(".fui-content"); + expect(contentDiv).toBeInTheDocument(); + }); + + it("handles mixed hint types correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + expect(screen.queryByTestId("sms-multi-factor-enrollment-form")).not.toBeInTheDocument(); + }); + + it("maintains state correctly when switching between hints", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + const { rerender } = render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx new file mode 100644 index 00000000..2311ee19 --- /dev/null +++ b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx @@ -0,0 +1,68 @@ +import { FactorId } from "firebase/auth"; +import { getTranslation } from "@firebase-ui/core"; +import { type ComponentProps, useState } from "react"; + +import { SmsMultiFactorEnrollmentForm } from "./mfa/sms-multi-factor-enrollment-form"; +import { TotpMultiFactorEnrollmentForm } from "./mfa/totp-multi-factor-enrollment-form"; +import { Button } from "~/components/button"; +import { useUI } from "~/hooks"; + +type Hint = (typeof FactorId)[keyof typeof FactorId]; + +export type MultiFactorAuthEnrollmentFormProps = { + onEnrollment?: () => void; + hints?: Hint[]; +}; + +const DEFAULT_HINTS = [FactorId.TOTP, FactorId.PHONE] as const; + +export function MultiFactorAuthEnrollmentForm(props: MultiFactorAuthEnrollmentFormProps) { + const hints = props.hints ?? DEFAULT_HINTS; + + if (hints.length === 0) { + throw new Error("MultiFactorAuthEnrollmentForm must have at least one hint"); + } + + // If only a single hint is provided, select it by default to improve UX. + const [hint, setHint] = useState(hints.length === 1 ? hints[0] : undefined); + + if (hint) { + if (hint === FactorId.TOTP) { + return ; + } + + if (hint === FactorId.PHONE) { + return ; + } + + throw new Error(`Unknown multi-factor enrollment type: ${hint}`); + } + + return ( +
    + {hints.map((hint) => { + if (hint === FactorId.TOTP) { + return setHint(hint)} />; + } + + if (hint === FactorId.PHONE) { + return setHint(hint)} />; + } + + return null; + })} +
    + ); +} + +function TotpButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaTotpVerification"); + return ; +} + +function SmsButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaSmsVerification"); + return ; +} diff --git a/packages/react/src/auth/forms/phone-auth-form.test.tsx b/packages/react/src/auth/forms/phone-auth-form.test.tsx index 61152e7b..bfeac38e 100644 --- a/packages/react/src/auth/forms/phone-auth-form.test.tsx +++ b/packages/react/src/auth/forms/phone-auth-form.test.tsx @@ -27,14 +27,19 @@ import { import { act } from "react"; import type { UserCredential } from "firebase/auth"; -vi.mock("firebase/auth", () => ({ - RecaptchaVerifier: vi.fn().mockImplementation(() => ({ - render: vi.fn().mockResolvedValue(123), - clear: vi.fn(), - verify: vi.fn().mockResolvedValue("verification-token"), - })), - ConfirmationResult: vi.fn(), -})); +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + RecaptchaVerifier: vi.fn().mockImplementation(() => ({ + render: vi.fn().mockResolvedValue(123), + clear: vi.fn(), + verify: vi.fn().mockResolvedValue("verification-token"), + })), + ConfirmationResult: vi.fn(), + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); vi.mock("@firebase-ui/core", async (importOriginal) => { const mod = await importOriginal(); diff --git a/packages/react/src/auth/forms/phone-auth-form.tsx b/packages/react/src/auth/forms/phone-auth-form.tsx index a7610388..40d5146d 100644 --- a/packages/react/src/auth/forms/phone-auth-form.tsx +++ b/packages/react/src/auth/forms/phone-auth-form.tsx @@ -102,7 +102,7 @@ export function PhoneNumberForm(props: PhoneNumberFormProps) { } + before={} /> )} diff --git a/packages/react/src/auth/forms/sign-in-auth-form.test.tsx b/packages/react/src/auth/forms/sign-in-auth-form.test.tsx index aef6bf80..1ccc731f 100644 --- a/packages/react/src/auth/forms/sign-in-auth-form.test.tsx +++ b/packages/react/src/auth/forms/sign-in-auth-form.test.tsx @@ -24,6 +24,14 @@ import { registerLocale } from "@firebase-ui/translations"; import type { UserCredential } from "firebase/auth"; import { FirebaseUIProvider } from "~/context"; +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); + vi.mock("@firebase-ui/core", async (importOriginal) => { const mod = await importOriginal(); return { diff --git a/packages/react/src/auth/forms/sign-up-auth-form.test.tsx b/packages/react/src/auth/forms/sign-up-auth-form.test.tsx index 849023e0..7860a106 100644 --- a/packages/react/src/auth/forms/sign-up-auth-form.test.tsx +++ b/packages/react/src/auth/forms/sign-up-auth-form.test.tsx @@ -24,6 +24,14 @@ import { registerLocale } from "@firebase-ui/translations"; import type { UserCredential } from "firebase/auth"; import { FirebaseUIProvider } from "~/context"; +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); + vi.mock("@firebase-ui/core", async (importOriginal) => { const mod = await importOriginal(); return { diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts index 6026b08f..970bc591 100644 --- a/packages/react/src/auth/index.ts +++ b/packages/react/src/auth/index.ts @@ -65,3 +65,12 @@ export { } from "./oauth/microsoft-sign-in-button"; export { TwitterSignInButton, TwitterLogo, type TwitterSignInButtonProps } from "./oauth/twitter-sign-in-button"; export { OAuthButton, useSignInWithProvider, type OAuthButtonProps } from "./oauth/oauth-button"; + +export { + MultiFactorAuthEnrollmentScreen, + type MultiFactorAuthEnrollmentScreenProps, +} from "./screens/multi-factor-auth-enrollment-screen"; +export { + MultiFactorAuthEnrollmentForm, + type MultiFactorAuthEnrollmentFormProps, +} from "./forms/multi-factor-auth-enrollment-form"; diff --git a/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx b/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx index 8119cf82..be131ccf 100644 --- a/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx @@ -20,14 +20,18 @@ import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; import { OAuthProvider } from "firebase/auth"; -vi.mock("firebase/auth", () => ({ - OAuthProvider: class OAuthProvider { - constructor(providerId: string) { - this.providerId = providerId; - } - providerId: string; - }, -})); +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + OAuthProvider: class OAuthProvider { + constructor(providerId: string) { + this.providerId = providerId; + } + providerId: string; + }, + }; +}); afterEach(() => { cleanup(); diff --git a/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx b/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx index d3c94fbe..6bafb76e 100644 --- a/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx @@ -19,14 +19,18 @@ import { FacebookLogo, FacebookSignInButton } from "./facebook-sign-in-button"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; -vi.mock("firebase/auth", () => ({ - FacebookAuthProvider: class FacebookAuthProvider { - constructor() { - this.providerId = "facebook.com"; - } - providerId: string; - }, -})); +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + FacebookAuthProvider: class FacebookAuthProvider { + constructor() { + this.providerId = "facebook.com"; + } + providerId: string; + }, + }; +}); afterEach(() => { cleanup(); diff --git a/packages/react/src/auth/oauth/github-sign-in-button.test.tsx b/packages/react/src/auth/oauth/github-sign-in-button.test.tsx index 11352246..b57145be 100644 --- a/packages/react/src/auth/oauth/github-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/github-sign-in-button.test.tsx @@ -19,14 +19,18 @@ import { GitHubLogo, GitHubSignInButton } from "./github-sign-in-button"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; -vi.mock("firebase/auth", () => ({ - GithubAuthProvider: class GithubAuthProvider { - constructor() { - this.providerId = "github.com"; - } - providerId: string; - }, -})); +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + GithubAuthProvider: class GithubAuthProvider { + constructor() { + this.providerId = "github.com"; + } + providerId: string; + }, + }; +}); afterEach(() => { cleanup(); diff --git a/packages/react/src/auth/oauth/google-sign-in-button.test.tsx b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx index 12adcf70..16cfdba5 100644 --- a/packages/react/src/auth/oauth/google-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx @@ -19,14 +19,18 @@ import { GoogleLogo, GoogleSignInButton } from "./google-sign-in-button"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; -vi.mock("firebase/auth", () => ({ - GoogleAuthProvider: class GoogleAuthProvider { - constructor() { - this.providerId = "google.com"; - } - providerId: string; - }, -})); +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + GoogleAuthProvider: class GoogleAuthProvider { + constructor() { + this.providerId = "google.com"; + } + providerId: string; + }, + }; +}); afterEach(() => { cleanup(); diff --git a/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx b/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx index 78239903..7227b7e3 100644 --- a/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx @@ -20,14 +20,18 @@ import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; import { OAuthProvider } from "firebase/auth"; -vi.mock("firebase/auth", () => ({ - OAuthProvider: class OAuthProvider { - constructor(providerId: string) { - this.providerId = providerId; - } - providerId: string; - }, -})); +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + OAuthProvider: class OAuthProvider { + constructor(providerId: string) { + this.providerId = providerId; + } + providerId: string; + }, + }; +}); afterEach(() => { cleanup(); diff --git a/packages/react/src/auth/oauth/oauth-button.test.tsx b/packages/react/src/auth/oauth/oauth-button.test.tsx index da0b7458..ecd6d5b0 100644 --- a/packages/react/src/auth/oauth/oauth-button.test.tsx +++ b/packages/react/src/auth/oauth/oauth-button.test.tsx @@ -24,6 +24,14 @@ import { ComponentProps } from "react"; import { signInWithProvider } from "@firebase-ui/core"; import { FirebaseError } from "firebase/app"; +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); + vi.mock("@firebase-ui/core", async (importOriginal) => { const mod = await importOriginal(); return { diff --git a/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx b/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx index 648d11ef..e36b5b7d 100644 --- a/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx @@ -19,14 +19,18 @@ import { TwitterLogo, TwitterSignInButton } from "./twitter-sign-in-button"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; -vi.mock("firebase/auth", () => ({ - TwitterAuthProvider: class TwitterAuthProvider { - constructor() { - this.providerId = "twitter.com"; - } - providerId: string; - }, -})); +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + TwitterAuthProvider: class TwitterAuthProvider { + constructor() { + this.providerId = "twitter.com"; + } + providerId: string; + }, + }; +}); afterEach(() => { cleanup(); diff --git a/packages/react/src/auth/screens/email-link-auth-screen.test.tsx b/packages/react/src/auth/screens/email-link-auth-screen.test.tsx index da8cabf2..fa42dde9 100644 --- a/packages/react/src/auth/screens/email-link-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/email-link-auth-screen.test.tsx @@ -28,6 +28,10 @@ vi.mock("~/components/divider", () => ({ Divider: () =>
    Divider
    , })); +vi.mock("~/components/redirect-error", () => ({ + RedirectError: () =>
    Redirect Error
    , +})); + describe("", () => { beforeEach(() => { vi.clearAllMocks(); @@ -91,4 +95,31 @@ describe("", () => { expect(screen.getByTestId("divider")).toBeInTheDocument(); expect(screen.getByTestId("test-child")).toBeInTheDocument(); }); + + it("renders RedirectError component in children section", () => { + const ui = createMockUI(); + + render( + + +
    Test Child
    +
    +
    + ); + + expect(screen.getByTestId("redirect-error")).toBeInTheDocument(); + expect(screen.getByTestId("test-child")).toBeInTheDocument(); + }); + + it("does not render RedirectError when no children are provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + }); }); diff --git a/packages/react/src/auth/screens/email-link-auth-screen.tsx b/packages/react/src/auth/screens/email-link-auth-screen.tsx index d2671a25..368f6eef 100644 --- a/packages/react/src/auth/screens/email-link-auth-screen.tsx +++ b/packages/react/src/auth/screens/email-link-auth-screen.tsx @@ -18,8 +18,9 @@ import type { PropsWithChildren } from "react"; import { getTranslation } from "@firebase-ui/core"; import { Divider } from "~/components/divider"; import { useUI } from "~/hooks"; -import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; import { EmailLinkAuthForm, type EmailLinkAuthFormProps } from "../forms/email-link-auth-form"; +import { RedirectError } from "~/components/redirect-error"; export type EmailLinkAuthScreenProps = PropsWithChildren; @@ -41,7 +42,10 @@ export function EmailLinkAuthScreen({ children, onEmailSent }: EmailLinkAuthScre {children ? ( <> {getTranslation(ui, "messages", "dividerOr")} -
    {children}
    +
    + {children} + +
    ) : null} diff --git a/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.test.tsx b/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.test.tsx new file mode 100644 index 00000000..d43e09ae --- /dev/null +++ b/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.test.tsx @@ -0,0 +1,199 @@ +/** + * Copyright 2025 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { MultiFactorAuthEnrollmentScreen } from "~/auth/screens/multi-factor-auth-enrollment-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FactorId } from "firebase/auth"; + +vi.mock("~/auth/forms/multi-factor-auth-enrollment-form", () => ({ + MultiFactorAuthEnrollmentForm: ({ onEnrollment, hints }: { onEnrollment?: () => void; hints?: string[] }) => ( +
    +
    + {onEnrollment ?
    onEnrollment
    : null} + {hints ?
    {hints.join(",")}
    : null} +
    +
    + ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + multiFactorEnrollment: "multiFactorEnrollment", + }, + prompts: { + mfaEnrollmentPrompt: "mfaEnrollmentPrompt", + }, + }), + }); + + render( + + + + ); + + const title = screen.getByText("multiFactorEnrollment"); + expect(title).toBeInTheDocument(); + expect(title).toHaveClass("fui-card__title"); + + const subtitle = screen.getByText("mfaEnrollmentPrompt"); + expect(subtitle).toBeInTheDocument(); + expect(subtitle).toHaveClass("fui-card__subtitle"); + }); + + it("renders the component", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("multi-factor-auth-enrollment-form")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to MultiFactorAuthEnrollmentForm", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("on-enrollment-prop")).toBeInTheDocument(); + }); + + it("passes hints prop to MultiFactorAuthEnrollmentForm", () => { + const mockHints = [FactorId.TOTP, FactorId.PHONE]; + const ui = createMockUI(); + + render( + + + + ); + + const hintsElement = screen.getByTestId("hints-prop"); + expect(hintsElement).toBeInTheDocument(); + expect(hintsElement.textContent).toBe("totp,phone"); + }); + + it("renders with default props when no props are provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + // Should render the form without onEnrollment prop + expect(screen.queryByTestId("on-enrollment-prop")).not.toBeInTheDocument(); + expect(screen.queryByTestId("hints-prop")).not.toBeInTheDocument(); + }); + + it("renders with correct screen structure", () => { + const ui = createMockUI(); + + render( + + + + ); + + const screenContainer = screen.getByTestId("multi-factor-auth-enrollment-form").closest(".fui-screen"); + expect(screenContainer).toBeInTheDocument(); + expect(screenContainer).toHaveClass("fui-screen"); + + const card = screenContainer?.querySelector(".fui-card"); + expect(card).toBeInTheDocument(); + + const cardHeader = screenContainer?.querySelector(".fui-card__header"); + expect(cardHeader).toBeInTheDocument(); + + const cardContent = screenContainer?.querySelector(".fui-card__content"); + expect(cardContent).toBeInTheDocument(); + }); + + it("uses correct translation keys", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + multiFactorEnrollment: "Set up Multi-Factor Authentication", + }, + prompts: { + mfaEnrollmentPrompt: "Choose a method to secure your account", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Set up Multi-Factor Authentication")).toBeInTheDocument(); + expect(screen.getByText("Choose a method to secure your account")).toBeInTheDocument(); + }); + + it("handles all supported factor IDs", () => { + const allHints = [FactorId.TOTP, FactorId.PHONE]; + const ui = createMockUI(); + + render( + + + + ); + + const hintsElement = screen.getByTestId("hints-prop"); + expect(hintsElement.textContent).toBe("totp,phone"); + }); + + it("passes through all props correctly", () => { + const mockOnEnrollment = vi.fn(); + const mockHints = [FactorId.TOTP]; + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("on-enrollment-prop")).toBeInTheDocument(); + expect(screen.getByTestId("hints-prop")).toBeInTheDocument(); + expect(screen.getByTestId("hints-prop").textContent).toBe("totp"); + }); +}); diff --git a/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx b/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx new file mode 100644 index 00000000..d7150f37 --- /dev/null +++ b/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx @@ -0,0 +1,30 @@ +import { getTranslation } from "@firebase-ui/core"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; +import { useUI } from "~/hooks"; +import { + MultiFactorAuthEnrollmentForm, + type MultiFactorAuthEnrollmentFormProps, +} from "../forms/multi-factor-auth-enrollment-form"; + +export type MultiFactorAuthEnrollmentScreenProps = MultiFactorAuthEnrollmentFormProps; + +export function MultiFactorAuthEnrollmentScreen(props: MultiFactorAuthEnrollmentScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "multiFactorEnrollment"); + const subtitleText = getTranslation(ui, "prompts", "mfaEnrollmentPrompt"); + + return ( +
    + + + {titleText} + {subtitleText} + + + + + +
    + ); +} diff --git a/packages/react/src/auth/screens/oauth-screen.test.tsx b/packages/react/src/auth/screens/oauth-screen.test.tsx index 29d7dbed..05e33cd9 100644 --- a/packages/react/src/auth/screens/oauth-screen.test.tsx +++ b/packages/react/src/auth/screens/oauth-screen.test.tsx @@ -18,6 +18,7 @@ import { render, screen, cleanup } from "@testing-library/react"; import { OAuthScreen } from "~/auth/screens/oauth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; +import { MultiFactorResolver } from "firebase/auth"; vi.mock("~/components/policies", async (originalModule) => { const module = await originalModule(); @@ -27,6 +28,14 @@ vi.mock("~/components/policies", async (originalModule) => { }; }); +vi.mock("~/components/redirect-error", () => ({ + RedirectError: () =>
    Redirect Error
    , +})); + +vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({ + MultiFactorAuthAssertionForm: () =>
    MFA Assertion Form
    , +})); + afterEach(() => { cleanup(); }); @@ -121,7 +130,7 @@ describe("", () => { expect(oauthProvider).toBeDefined(); expect(policies).toBeDefined(); - // OAuth provider should come before policies in the DOM + // OAuth provider should come before policies const cardContent = oauthProvider.parentElement; const children = Array.from(cardContent?.children || []); const oauthIndex = children.indexOf(oauthProvider); @@ -129,4 +138,83 @@ describe("", () => { expect(oauthIndex).toBeLessThan(policiesIndex); }); + + it("renders MultiFactorAuthAssertionForm when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + OAuth Provider + + ); + + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + expect(screen.queryByText("OAuth Provider")).toBeNull(); + expect(screen.queryByTestId("policies")).toBeNull(); + }); + + it("does not render children or Policies when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
    OAuth Provider
    +
    +
    + ); + + expect(screen.queryByTestId("oauth-provider")).toBeNull(); + expect(screen.queryByTestId("policies")).toBeNull(); + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + }); + + it("renders RedirectError component with children when no MFA resolver", () => { + const ui = createMockUI(); + + render( + + +
    OAuth Provider
    +
    +
    + ); + + expect(screen.getByTestId("redirect-error")).toBeDefined(); + expect(screen.getByTestId("oauth-provider")).toBeDefined(); + expect(screen.getByTestId("policies")).toBeDefined(); + }); + + it("does not render RedirectError when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
    OAuth Provider
    +
    +
    + ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + }); }); diff --git a/packages/react/src/auth/screens/oauth-screen.tsx b/packages/react/src/auth/screens/oauth-screen.tsx index c1f6da82..ac129f45 100644 --- a/packages/react/src/auth/screens/oauth-screen.tsx +++ b/packages/react/src/auth/screens/oauth-screen.tsx @@ -15,10 +15,12 @@ */ import { getTranslation } from "@firebase-ui/core"; -import { useUI } from "~/hooks"; -import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; import { type PropsWithChildren } from "react"; +import { useUI } from "~/hooks"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; import { Policies } from "~/components/policies"; +import { MultiFactorAuthAssertionForm } from "../forms/multi-factor-auth-assertion-form"; +import { RedirectError } from "~/components/redirect-error"; export type OAuthScreenProps = PropsWithChildren; @@ -27,6 +29,7 @@ export function OAuthScreen({ children }: OAuthScreenProps) { const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; return (
    @@ -36,8 +39,15 @@ export function OAuthScreen({ children }: OAuthScreenProps) { {subtitleText} - {children} - + {mfaResolver ? ( + + ) : ( + <> + {children} + + + + )}
    diff --git a/packages/react/src/auth/screens/phone-auth-screen.test.tsx b/packages/react/src/auth/screens/phone-auth-screen.test.tsx index dd8dc9b3..090fa60a 100644 --- a/packages/react/src/auth/screens/phone-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/phone-auth-screen.test.tsx @@ -18,6 +18,7 @@ import { render, screen, cleanup } from "@testing-library/react"; import { PhoneAuthScreen } from "~/auth/screens/phone-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; +import { MultiFactorResolver } from "firebase/auth"; vi.mock("~/auth/forms/phone-auth-form", () => ({ PhoneAuthForm: ({ resendDelay }: { resendDelay?: number }) => ( @@ -35,6 +36,14 @@ vi.mock("~/components/divider", async (originalModule) => { }; }); +vi.mock("~/components/redirect-error", () => ({ + RedirectError: () =>
    Redirect Error
    , +})); + +vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({ + MultiFactorAuthAssertionForm: () =>
    MFA Assertion Form
    , +})); + afterEach(() => { cleanup(); }); @@ -84,19 +93,19 @@ describe("", () => { expect(screen.getByTestId("phone-auth-form")).toBeDefined(); }); - it("passes resendDelay prop to PhoneAuthForm", () => { - const ui = createMockUI(); + // it("passes resendDelay prop to PhoneAuthForm", () => { + // const ui = createMockUI(); - render( - - - - ); + // render( + // + // + // + // ); - const phoneForm = screen.getByTestId("phone-auth-form"); - expect(phoneForm).toBeDefined(); - expect(phoneForm.getAttribute("data-resend-delay")).toBe("60"); - }); + // const phoneForm = screen.getByTestId("phone-auth-form"); + // expect(phoneForm).toBeDefined(); + // expect(phoneForm.getAttribute("data-resend-delay")).toBe("60"); + // }); it("renders a divider with children when present", () => { const ui = createMockUI({ @@ -154,4 +163,84 @@ describe("", () => { expect(screen.getByTestId("child-1")).toBeDefined(); expect(screen.getByTestId("child-2")).toBeDefined(); }); + + it("renders MultiFactorAuthAssertionForm when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("phone-auth-form")).toBeNull(); + }); + + it("does not render PhoneAuthForm when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.queryByTestId("phone-auth-form")).toBeNull(); + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + }); + + it("renders RedirectError component in children section when no MFA resolver", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
    Test Child
    +
    +
    + ); + + expect(screen.getByTestId("redirect-error")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render RedirectError when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
    Test Child
    +
    +
    + ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + }); }); diff --git a/packages/react/src/auth/screens/phone-auth-screen.tsx b/packages/react/src/auth/screens/phone-auth-screen.tsx index 32c99f82..292b0290 100644 --- a/packages/react/src/auth/screens/phone-auth-screen.tsx +++ b/packages/react/src/auth/screens/phone-auth-screen.tsx @@ -18,8 +18,10 @@ import type { PropsWithChildren } from "react"; import { getTranslation } from "@firebase-ui/core"; import { Divider } from "~/components/divider"; import { useUI } from "~/hooks"; -import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; import { PhoneAuthForm, type PhoneAuthFormProps } from "../forms/phone-auth-form"; +import { MultiFactorAuthAssertionForm } from "../forms/multi-factor-auth-assertion-form"; +import { RedirectError } from "~/components/redirect-error"; export type PhoneAuthScreenProps = PropsWithChildren; @@ -28,6 +30,7 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; return (
    @@ -37,13 +40,22 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { {subtitleText} - - {children ? ( + {mfaResolver ? ( + + ) : ( <> - {getTranslation(ui, "messages", "dividerOr")} -
    {children}
    + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
    + {children} + +
    + + ) : null} - ) : null} + )}
    diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx index 0c2b5ad6..6ef6a76e 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx @@ -18,6 +18,7 @@ import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { SignInAuthScreen } from "~/auth/screens/sign-in-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; +import { MultiFactorResolver } from "firebase/auth"; vi.mock("~/auth/forms/sign-in-auth-form", () => ({ SignInAuthForm: ({ @@ -46,6 +47,14 @@ vi.mock("~/components/divider", async (originalModule) => { }; }); +vi.mock("~/components/redirect-error", () => ({ + RedirectError: () =>
    Redirect Error
    , +})); + +vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({ + MultiFactorAuthAssertionForm: () =>
    MFA Assertion Form
    , +})); + describe("", () => { beforeEach(() => { vi.clearAllMocks(); @@ -183,4 +192,84 @@ describe("", () => { expect(screen.getByTestId("child-1")).toBeDefined(); expect(screen.getByTestId("child-2")).toBeDefined(); }); + + it("renders MultiFactorAuthAssertionForm when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("sign-in-auth-form")).toBeNull(); + }); + + it("does not render SignInAuthForm when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.queryByTestId("sign-in-auth-form")).toBeNull(); + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + }); + + it("renders RedirectError component in children section when no MFA resolver", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
    Test Child
    +
    +
    + ); + + expect(screen.getByTestId("redirect-error")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render RedirectError when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
    Test Child
    +
    +
    + ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + }); }); diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.tsx index 91aa1662..31af077a 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.tsx @@ -20,6 +20,8 @@ import { Divider } from "~/components/divider"; import { useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; import { SignInAuthForm, type SignInAuthFormProps } from "../forms/sign-in-auth-form"; +import { MultiFactorAuthAssertionForm } from "../forms/multi-factor-auth-assertion-form"; +import { RedirectError } from "~/components/redirect-error"; export type SignInAuthScreenProps = PropsWithChildren; @@ -29,6 +31,8 @@ export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps) const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; + return (
    @@ -37,13 +41,22 @@ export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps) {subtitleText} - - {children ? ( + {mfaResolver ? ( + + ) : ( <> - {getTranslation(ui, "messages", "dividerOr")} -
    {children}
    + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
    + {children} + +
    + + ) : null} - ) : null} + )}
    diff --git a/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx index 7060c582..bb4a3ee5 100644 --- a/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx @@ -18,6 +18,7 @@ import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { SignUpAuthScreen } from "~/auth/screens/sign-up-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; +import { MultiFactorResolver } from "firebase/auth"; vi.mock("~/auth/forms/sign-up-auth-form", () => ({ SignUpAuthForm: ({ onBackToSignInClick }: { onBackToSignInClick?: () => void }) => ( @@ -37,6 +38,14 @@ vi.mock("~/components/divider", async (originalModule) => { }; }); +vi.mock("~/components/redirect-error", () => ({ + RedirectError: () =>
    Redirect Error
    , +})); + +vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({ + MultiFactorAuthAssertionForm: () =>
    MFA Assertion Form
    , +})); + describe("", () => { beforeEach(() => { vi.clearAllMocks(); @@ -82,7 +91,6 @@ describe("", () => { ); - // Mocked so only has as test id expect(screen.getByTestId("sign-up-auth-form")).toBeDefined(); }); @@ -158,4 +166,84 @@ describe("", () => { expect(screen.getByTestId("child-1")).toBeDefined(); expect(screen.getByTestId("child-2")).toBeDefined(); }); + + it("renders MultiFactorAuthAssertionForm when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("sign-up-auth-form")).toBeNull(); + }); + + it("does not render SignUpAuthForm when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.queryByTestId("sign-up-auth-form")).toBeNull(); + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + }); + + it("renders RedirectError component in children section when no MFA resolver", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
    Test Child
    +
    +
    + ); + + expect(screen.getByTestId("redirect-error")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render RedirectError when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
    Test Child
    +
    +
    + ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + }); }); diff --git a/packages/react/src/auth/screens/sign-up-auth-screen.tsx b/packages/react/src/auth/screens/sign-up-auth-screen.tsx index b1804a02..35278ac4 100644 --- a/packages/react/src/auth/screens/sign-up-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-up-auth-screen.tsx @@ -20,6 +20,8 @@ import { useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; import { SignUpAuthForm, type SignUpAuthFormProps } from "../forms/sign-up-auth-form"; import { getTranslation } from "@firebase-ui/core"; +import { RedirectError } from "~/components/redirect-error"; +import { MultiFactorAuthAssertionForm } from "../forms/multi-factor-auth-assertion-form"; export type SignUpAuthScreenProps = PropsWithChildren; @@ -29,6 +31,8 @@ export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) const titleText = getTranslation(ui, "labels", "register"); const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); + const mfaResolver = ui.multiFactorResolver; + return (
    @@ -37,13 +41,22 @@ export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) {subtitleText} - - {children ? ( + {mfaResolver ? ( + + ) : ( <> - {getTranslation(ui, "messages", "dividerOr")} -
    {children}
    + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
    + {children} + +
    + + ) : null} - ) : null} + )}
    diff --git a/packages/react/src/components/redirect-error.test.tsx b/packages/react/src/components/redirect-error.test.tsx new file mode 100644 index 00000000..c103edc7 --- /dev/null +++ b/packages/react/src/components/redirect-error.test.tsx @@ -0,0 +1,114 @@ +/** + * Copyright 2025 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 { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { RedirectError } from "~/components/redirect-error"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("", () => { + it("renders error message when redirectError is present in UI state", () => { + const errorMessage = "Authentication failed"; + const ui = createMockUI(); + ui.get().setRedirectError(new Error(errorMessage)); + + render( + + + + ); + + const errorElement = screen.getByText(errorMessage); + expect(errorElement).toBeDefined(); + expect(errorElement.className).toContain("fui-form__error"); + }); + + it("returns null when no redirectError exists", () => { + const ui = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("properly formats error messages for Error objects", () => { + const errorMessage = "Network error occurred"; + const ui = createMockUI(); + ui.get().setRedirectError(new Error(errorMessage)); + + render( + + + + ); + + const errorElement = screen.getByText(errorMessage); + expect(errorElement).toBeDefined(); + expect(errorElement.className).toContain("fui-form__error"); + }); + + it("properly formats error messages for string values", () => { + const errorMessage = "Custom error string"; + const ui = createMockUI(); + ui.get().setRedirectError(errorMessage as any); + + render( + + + + ); + + const errorElement = screen.getByText(errorMessage); + expect(errorElement).toBeDefined(); + expect(errorElement.className).toContain("fui-form__error"); + }); + + it("displays error with correct CSS class", () => { + const errorMessage = "Test error"; + const ui = createMockUI(); + ui.get().setRedirectError(new Error(errorMessage)); + + render( + + + + ); + + const errorElement = screen.getByText(errorMessage); + expect(errorElement.className).toBe("fui-form__error"); + }); + + it("handles undefined redirectError", () => { + const ui = createMockUI(); + ui.get().setRedirectError(undefined); + + const { container } = render( + + + + ); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/packages/react/src/components/redirect-error.tsx b/packages/react/src/components/redirect-error.tsx new file mode 100644 index 00000000..5c1fd476 --- /dev/null +++ b/packages/react/src/components/redirect-error.tsx @@ -0,0 +1,11 @@ +import { useRedirectError } from "~/hooks"; + +export function RedirectError() { + const error = useRedirectError(); + + if (!error) { + return null; + } + + return
    {error}
    ; +} diff --git a/packages/react/src/hooks.test.tsx b/packages/react/src/hooks.test.tsx index 985764e1..c05c67c9 100644 --- a/packages/react/src/hooks.test.tsx +++ b/packages/react/src/hooks.test.tsx @@ -18,6 +18,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act, cleanup } from "@testing-library/react"; import { useUI, + useRedirectError, useSignInAuthFormSchema, useSignUpAuthFormSchema, useForgotPasswordAuthFormSchema, @@ -191,7 +192,7 @@ describe("useSignInAuthFormSchema", () => { const customLocale = registerLocale("fr-FR", customTranslations); act(() => { - mockUI.setKey("locale", customLocale); + mockUI.get().setLocale(customLocale); }); rerender(); @@ -305,7 +306,7 @@ describe("useSignUpAuthFormSchema", () => { const customLocale = registerLocale("fr-FR", customTranslations); act(() => { - mockUI.setKey("locale", customLocale); + mockUI.get().setLocale(customLocale); }); rerender(); @@ -401,7 +402,7 @@ describe("useForgotPasswordAuthFormSchema", () => { const customLocale = registerLocale("fr-FR", customTranslations); act(() => { - mockUI.setKey("locale", customLocale); + mockUI.get().setLocale(customLocale); }); rerender(); @@ -493,7 +494,7 @@ describe("useEmailLinkAuthFormSchema", () => { const customLocale = registerLocale("fr-FR", customTranslations); act(() => { - mockUI.setKey("locale", customLocale); + mockUI.get().setLocale(customLocale); }); rerender(); @@ -585,7 +586,7 @@ describe("usePhoneAuthNumberFormSchema", () => { const customLocale = registerLocale("fr-FR", customTranslations); act(() => { - mockUI.setKey("locale", customLocale); + mockUI.get().setLocale(customLocale); }); rerender(); @@ -677,7 +678,7 @@ describe("usePhoneAuthVerifyFormSchema", () => { const customLocale = registerLocale("fr-FR", customTranslations); act(() => { - mockUI.setKey("locale", customLocale); + mockUI.get().setLocale(customLocale); }); rerender(); @@ -692,3 +693,135 @@ describe("usePhoneAuthVerifyFormSchema", () => { } }); }); + +describe("useRedirectError", () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it("returns undefined when no redirect error exists", () => { + const mockUI = createMockUI(); + + const { result } = renderHook(() => useRedirectError(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toBeUndefined(); + }); + + it("returns error message string when Error object is present", () => { + const errorMessage = "Authentication failed"; + const mockUI = createMockUI(); + mockUI.get().setRedirectError(new Error(errorMessage)); + + const { result } = renderHook(() => useRedirectError(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toBe(errorMessage); + }); + + it("returns string value when error is not an Error object", () => { + const errorMessage = "Custom error string"; + const mockUI = createMockUI(); + mockUI.get().setRedirectError(errorMessage as any); + + const { result } = renderHook(() => useRedirectError(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toBe(errorMessage); + }); + + it("returns stable reference when error hasn't changed", () => { + const mockUI = createMockUI(); + const error = new Error("Test error"); + mockUI.get().setRedirectError(error); + + let hookCallCount = 0; + const results: any[] = []; + + const TestHook = () => { + hookCallCount++; + const result = useRedirectError(); + results.push(result); + return result; + }; + + const { rerender } = renderHook(() => TestHook(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(hookCallCount).toBe(1); + expect(results).toHaveLength(1); + + rerender(); + + expect(hookCallCount).toBe(2); + expect(results).toHaveLength(2); + + expect(results[0]).toBe(results[1]); + expect(results[0]).toBe("Test error"); + }); + + it("updates when redirectError changes in UI state", () => { + const mockUI = createMockUI(); + + const { result, rerender } = renderHook(() => useRedirectError(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toBeUndefined(); + + act(() => { + mockUI.get().setRedirectError(new Error("First error")); + }); + + rerender(); + + expect(result.current).toBe("First error"); + + act(() => { + mockUI.get().setRedirectError(new Error("Second error")); + }); + + rerender(); + + expect(result.current).toBe("Second error"); + + act(() => { + mockUI.get().setRedirectError(undefined); + }); + + rerender(); + + expect(result.current).toBeUndefined(); + }); + + it("handles null and undefined errors", () => { + const mockUI = createMockUI(); + + const { result, rerender } = renderHook(() => useRedirectError(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toBeUndefined(); + + act(() => { + mockUI.get().setRedirectError(null as any); + }); + + rerender(); + + expect(result.current).toBeUndefined(); + + act(() => { + mockUI.get().setRedirectError(undefined); + }); + + rerender(); + + expect(result.current).toBeUndefined(); + }); +}); diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index d888c59b..5cff1d8f 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -18,6 +18,10 @@ import { useContext, useMemo, useEffect } from "react"; import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, + createMultiFactorPhoneAuthNumberFormSchema, + createMultiFactorPhoneAuthVerifyFormSchema, + createMultiFactorTotpAuthNumberFormSchema, + createMultiFactorTotpAuthVerifyFormSchema, createPhoneAuthNumberFormSchema, createPhoneAuthVerifyFormSchema, createSignInAuthFormSchema, @@ -42,6 +46,17 @@ export function useUI() { return ui; } +export function useRedirectError() { + const ui = useUI(); + return useMemo(() => { + if (!ui.redirectError) { + return; + } + + return ui.redirectError instanceof Error ? ui.redirectError.message : String(ui.redirectError); + }, [ui.redirectError]); +} + export function useSignInAuthFormSchema() { const ui = useUI(); return useMemo(() => createSignInAuthFormSchema(ui), [ui]); @@ -72,6 +87,26 @@ export function usePhoneAuthVerifyFormSchema() { return useMemo(() => createPhoneAuthVerifyFormSchema(ui), [ui]); } +export function useMultiFactorPhoneAuthNumberFormSchema() { + const ui = useUI(); + return useMemo(() => createMultiFactorPhoneAuthNumberFormSchema(ui), [ui]); +} + +export function useMultiFactorPhoneAuthVerifyFormSchema() { + const ui = useUI(); + return useMemo(() => createMultiFactorPhoneAuthVerifyFormSchema(ui), [ui]); +} + +export function useMultiFactorTotpAuthNumberFormSchema() { + const ui = useUI(); + return useMemo(() => createMultiFactorTotpAuthNumberFormSchema(ui), [ui]); +} + +export function useMultiFactorTotpAuthVerifyFormSchema() { + const ui = useUI(); + return useMemo(() => createMultiFactorTotpAuthVerifyFormSchema(ui), [ui]); +} + export function useRecaptchaVerifier(ref: React.RefObject) { const ui = useUI(); diff --git a/packages/react/tests/utils.tsx b/packages/react/tests/utils.tsx index 3386aa75..9815b191 100644 --- a/packages/react/tests/utils.tsx +++ b/packages/react/tests/utils.tsx @@ -1,10 +1,10 @@ import type { FirebaseApp } from "firebase/app"; import type { Auth } from "firebase/auth"; import { enUs } from "@firebase-ui/translations"; -import { Behavior, FirebaseUI, FirebaseUIOptions, initializeUI } from "@firebase-ui/core"; +import { Behavior, FirebaseUI, FirebaseUIOptions, FirebaseUIStore, initializeUI } from "@firebase-ui/core"; import { FirebaseUIProvider } from "../src/context"; -export function createMockUI(overrides?: Partial): FirebaseUI { +export function createMockUI(overrides?: Partial): FirebaseUIStore { return initializeUI({ app: {} as FirebaseApp, auth: {} as Auth, @@ -14,10 +14,10 @@ export function createMockUI(overrides?: Partial): FirebaseUI }); } -export const createFirebaseUIProvider = ({ children, ui }: { children: React.ReactNode; ui: FirebaseUI }) => ( +export const createFirebaseUIProvider = ({ children, ui }: { children: React.ReactNode; ui: FirebaseUIStore }) => ( {children} ); -export function CreateFirebaseUIProvider({ children, ui }: { children: React.ReactNode; ui: FirebaseUI }) { +export function CreateFirebaseUIProvider({ children, ui }: { children: React.ReactNode; ui: FirebaseUIStore }) { return {children}; } diff --git a/packages/styles/src/base.css b/packages/styles/src/base.css index d03880b4..18ad41a5 100644 --- a/packages/styles/src/base.css +++ b/packages/styles/src/base.css @@ -83,6 +83,11 @@ @apply space-y-2; } + :where(.fui-content) { + @apply space-y-2; + + } + :where(.fui-card) { @apply bg-background p-10 border border-border rounded-card space-y-6; } @@ -212,7 +217,6 @@ @apply hover:underline font-semibold; } - .fui-provider__button[data-provider="google.com"][data-themed="true"] { --google-primary: #131314; --color-primary: var(--google-primary); diff --git a/packages/translations/src/locales/en-us.ts b/packages/translations/src/locales/en-us.ts index d24bf649..ce27e667 100644 --- a/packages/translations/src/locales/en-us.ts +++ b/packages/translations/src/locales/en-us.ts @@ -45,6 +45,7 @@ export const enUS = { accountExistsWithDifferentCredential: "An account already exists with this email. Please sign in with the original provider.", displayNameRequired: "Please provide a display name", + secondFactorAlreadyInUse: "This phone number is already enrolled with this account.", }, messages: { passwordResetEmailSent: "Password reset email sent successfully", @@ -81,6 +82,10 @@ export const enUS = { privacyPolicy: "Privacy Policy", resendCode: "Resend Code", sending: "Sending...", + multiFactorEnrollment: "Multi-factor Enrollment", + mfaTotpVerification: "TOTP Verification", + mfaSmsVerification: "SMS Verification", + generateQrCode: "Generate QR Code", }, prompts: { noAccount: "Don't have an account?", @@ -91,5 +96,6 @@ export const enUS = { enterPhoneNumber: "Enter your phone number", enterVerificationCode: "Enter the verification code", enterEmailForLink: "Enter your email to receive a sign-in link", + mfaEnrollmentPrompt: "Select a new multi-factor enrollment method", }, } satisfies Translations; diff --git a/packages/translations/src/mapping.test.ts b/packages/translations/src/mapping.test.ts index c810c1de..9f83dbf7 100644 --- a/packages/translations/src/mapping.test.ts +++ b/packages/translations/src/mapping.test.ts @@ -92,6 +92,7 @@ describe("mapping.ts", () => { "invalidVerificationCode", "accountExistsWithDifferentCredential", "displayNameRequired", + "secondFactorAlreadyInUse", ]; errorKeys.forEach((key) => { diff --git a/packages/translations/src/mapping.ts b/packages/translations/src/mapping.ts index 75b0d2e6..506fa4e8 100644 --- a/packages/translations/src/mapping.ts +++ b/packages/translations/src/mapping.ts @@ -42,6 +42,7 @@ export const ERROR_CODE_MAP = { "auth/invalid-verification-code": "invalidVerificationCode", "auth/account-exists-with-different-credential": "accountExistsWithDifferentCredential", "auth/display-name-required": "displayNameRequired", + "auth/second-factor-already-in-use": "secondFactorAlreadyInUse", } satisfies Record; export type ErrorCode = keyof typeof ERROR_CODE_MAP; diff --git a/packages/translations/src/types.ts b/packages/translations/src/types.ts index b00016ec..ac94b468 100644 --- a/packages/translations/src/types.ts +++ b/packages/translations/src/types.ts @@ -50,6 +50,7 @@ export type Translations = { unknownError?: string; popupClosed?: string; accountExistsWithDifferentCredential?: string; + secondFactorAlreadyInUse?: string; }; messages?: { passwordResetEmailSent?: string; @@ -86,6 +87,10 @@ export type Translations = { privacyPolicy?: string; resendCode?: string; sending?: string; + multiFactorEnrollment?: string; + mfaTotpVerification?: string; + mfaSmsVerification?: string; + generateQrCode?: string; }; prompts?: { noAccount?: string; @@ -96,5 +101,6 @@ export type Translations = { enterPhoneNumber?: string; enterVerificationCode?: string; enterEmailForLink?: string; + mfaEnrollmentPrompt?: string; }; };