From 4853a03207e59d72b6f80ebeba314083f2223724 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Mon, 6 Oct 2025 13:20:37 +0100 Subject: [PATCH 01/11] feat(react): WIP - Add MFA Enrollment SMS --- packages/core/src/auth.ts | 34 +++- packages/core/src/schemas.ts | 11 ++ .../mfa/sms-multi-factor-enrollment-form.tsx | 184 ++++++++++++++++++ .../multi-factor-auth-enrollment-form.tsx | 45 +++++ .../multi-factor-auth-enrollment-screen.tsx | 30 +++ packages/react/src/hooks.ts | 6 + 6 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.tsx create mode 100644 packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx create mode 100644 packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 785d2268..46c38d97 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -30,7 +30,9 @@ import { UserCredential, AuthCredential, TotpSecret, - PhoneInfoOptions, + MultiFactorAssertion, + multiFactor, + MultiFactorUser, } from "firebase/auth"; import QRCode from "qrcode-generator"; import { FirebaseUIConfiguration } from "./config"; @@ -122,13 +124,23 @@ export async function createUserWithEmailAndPassword( export async function verifyPhoneNumber( ui: FirebaseUIConfiguration, - phoneNumber: PhoneInfoOptions | string, - appVerifier: ApplicationVerifier + phoneNumber: string, + appVerifier: ApplicationVerifier, + multiFactorUser?: MultiFactorUser ): Promise { try { ui.setState("pending"); const provider = new PhoneAuthProvider(ui.auth); - return await provider.verifyPhoneNumber(phoneNumber, appVerifier); + const session = await multiFactorUser?.getSession(); + return await provider.verifyPhoneNumber( + session + ? { + phoneNumber, + session, + } + : phoneNumber, + appVerifier + ); } catch (error) { handleFirebaseError(ui, error); } finally { @@ -310,3 +322,17 @@ export function generateTotpQrCode( qr.make(); return qr.createDataURL(); } + +export async function signInWithMultiFactorAssertion(ui: FirebaseUIConfiguration, assertion: MultiFactorAssertion) { + await ui.multiFactorResolver?.resolveSignIn(assertion); + throw new Error("Not implemented"); +} + +export async function enrollWithMultiFactorAssertion( + ui: FirebaseUIConfiguration, + assertion: MultiFactorAssertion, + displayName?: string +) { + await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName); + throw new Error("Not implemented"); +} diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index bf5d424b..ead7ac4f 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -73,9 +73,20 @@ export function createPhoneAuthVerifyFormSchema(ui: FirebaseUIConfiguration) { }); } +export function createMultiFactorPhoneAuthVerifyFormSchema(ui: FirebaseUIConfiguration) { + const schema = createPhoneAuthVerifyFormSchema(ui); + return schema.extend({ + // TODO: Translation... + displayName: z.string().min(1, "TODO!!!"), + }); +} + 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 +>; 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..6c959741 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.tsx @@ -0,0 +1,184 @@ +import { + CountryCode, + countryData, + FirebaseUIError, + formatPhoneNumberWithCountry, + getTranslation, + signInWithMultiFactorAssertion, + verifyPhoneNumber, +} from "@firebase-ui/core"; +import { multiFactor, PhoneAuthProvider, PhoneMultiFactorGenerator, RecaptchaVerifier } from "firebase/auth"; +import { useCallback, useRef, useState } from "react"; +import { CountrySelector } from "~/components/country-selector"; +import { form } from "~/components/form"; +import { useMultiFactorPhoneAuthVerifyFormSchema, useRecaptchaVerifier, useUI } from "~/hooks"; +import { usePhoneNumberForm } from "../phone-auth-form"; + +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 MultiFactorEnrollmentPhoneNumberFormProps = { + onSubmit: (verificationId: string) => void; +}; + +function MultiFactorEnrollmentPhoneNumberForm(props: MultiFactorEnrollmentPhoneNumberFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const form = usePhoneNumberForm({ + recaptchaVerifier: recaptchaVerifier!, + onSuccess: props.onSubmit, + formatPhoneNumber: (phoneNumber) => formatPhoneNumberWithCountry(phoneNumber, selectedCountry), + }); + + const [selectedCountry, setSelectedCountry] = useState(countryData[0].code); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => ( + setSelectedCountry(code as CountryCode)} + className="fui-phone-input__country-selector" + /> + } + /> + )} + +
+
+
+
+
+ {getTranslation(ui, "labels", "sendCode")} + +
+
+
+ ); +} + +export function useMultiFactorEnrollmentVerifyPhoneNumberFormAction() { + 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 UseMultiFactorEnrollmentVerifyPhoneNumberForm = { + verificationId: string; + onSuccess: () => void; +}; + +export function useMultiFactorEnrollmentVerifyPhoneNumberForm({ + verificationId, + onSuccess, +}: UseMultiFactorEnrollmentVerifyPhoneNumberForm) { + const schema = useMultiFactorPhoneAuthVerifyFormSchema(); + const action = useMultiFactorEnrollmentVerifyPhoneNumberFormAction(); + + return form.useAppForm({ + defaultValues: { + displayName: "", + verificationCode: "", + }, + validators: { + onSubmit: schema, + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + await action({ verificationId, verificationCode: value.verificationCode }); + return onSuccess(); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type MultiFactorEnrollmentVerifyPhoneNumberFormProps = { + verificationId: string; + onSuccess: () => void; +}; + +export function MultiFactorEnrollmentVerifyPhoneNumberForm(props: MultiFactorEnrollmentVerifyPhoneNumberFormProps) { + const ui = useUI(); + const form = useMultiFactorEnrollmentVerifyPhoneNumberForm({ + verificationId: props.verificationId, + onSuccess: props.onSuccess, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => } + +
+
+ + {(field) => } + +
+
+
+ ); +} + +export type SmsMultiFactorEnrollmentFormProps = { + onSuccess?: () => void; +}; + +export function SmsMultiFactorEnrollmentForm(props: SmsMultiFactorEnrollmentFormProps) { + const [verificationId, setVerificationId] = useState(null); + + if (!verificationId) { + return ; + } + + return ( + { + props.onSuccess?.(); + }} + /> + ); +} 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..b48e0adf --- /dev/null +++ b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx @@ -0,0 +1,45 @@ +import { FactorId } from "firebase/auth"; +import { useState } from "react"; + +import { SmsMultiFactorEnrollmentForm } from "./mfa/sms-multi-factor-enrollment-form"; + +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"); + } + + const [selectedHint, setSelectedHint] = useState(hints.length === 1 ? hints[0] : undefined); + + if (selectedHint) { + if (selectedHint === FactorId.TOTP) { + return
TotpMultiFactorEnrollmentForm
; + } + + if (selectedHint === FactorId.PHONE) { + return ; + } + + throw new Error(`Unknown multi-factor enrollment type: ${selectedHint}`); + } + + return ( + <> + {hints.map((hint) => ( +
setSelectedHint(hint)}> + {hint} +
+ ))} + + ); +} 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..9768e7fb --- /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, + MultiFactorAuthEnrollmentFormProps, +} from "../forms/multi-factor-auth-enrollment-form"; + +export type MultiFactorAuthEnrollmentScreenProps = MultiFactorAuthEnrollmentFormProps; + +export function MultiFactorAuthEnrollmentScreen({}: MultiFactorAuthEnrollmentScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "register"); + const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); + + return ( +
+ + + {titleText} + {subtitleText} + + + + + +
+ ); +} diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index d888c59b..04738039 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -18,6 +18,7 @@ import { useContext, useMemo, useEffect } from "react"; import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, + createMultiFactorPhoneAuthVerifyFormSchema, createPhoneAuthNumberFormSchema, createPhoneAuthVerifyFormSchema, createSignInAuthFormSchema, @@ -72,6 +73,11 @@ export function usePhoneAuthVerifyFormSchema() { return useMemo(() => createPhoneAuthVerifyFormSchema(ui), [ui]); } +export function useMultiFactorPhoneAuthVerifyFormSchema() { + const ui = useUI(); + return useMemo(() => createMultiFactorPhoneAuthVerifyFormSchema(ui), [ui]); +} + export function useRecaptchaVerifier(ref: React.RefObject) { const ui = useUI(); From 51dd08ee97763346679007f6aa79cf3f1d976527 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 21 Oct 2025 09:48:48 +0100 Subject: [PATCH 02/11] fix(core,react): Align sms enrollment with dev changes --- packages/core/src/auth.ts | 22 +-- packages/core/src/schemas.ts | 16 ++- .../mfa/sms-multi-factor-enrollment-form.tsx | 127 +++++++++++++----- .../react/src/auth/forms/phone-auth-form.tsx | 2 +- packages/react/src/hooks.ts | 6 + 5 files changed, 121 insertions(+), 52 deletions(-) diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 9ff9a1aa..bb88ca2c 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -24,16 +24,15 @@ import { EmailAuthProvider, linkWithCredential, PhoneAuthProvider, + multiFactor, type ActionCodeSettings, type ApplicationVerifier, type AuthProvider, type UserCredential, type AuthCredential, type TotpSecret, - type PhoneInfoOptions, - MultiFactorAssertion, - multiFactor, - MultiFactorUser, + type MultiFactorAssertion, + type MultiFactorSession, } from "firebase/auth"; import QRCode from "qrcode-generator"; import { type FirebaseUI } from "./config"; @@ -127,12 +126,11 @@ export async function verifyPhoneNumber( ui: FirebaseUI, phoneNumber: string, appVerifier: ApplicationVerifier, - multiFactorUser?: MultiFactorUser + session?: MultiFactorSession ): Promise { try { ui.setState("pending"); const provider = new PhoneAuthProvider(ui.auth); - const session = await multiFactorUser?.getSession(); return await provider.verifyPhoneNumber( session ? { @@ -315,7 +313,13 @@ export async function enrollWithMultiFactorAssertion( ui: FirebaseUI, assertion: MultiFactorAssertion, displayName?: string -) { - await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName); - throw new Error("Not implemented"); +): Promise { + try { + ui.setState("pending"); + await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } } diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 46326931..96a6fb4a 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -73,14 +73,18 @@ export function createPhoneAuthVerifyFormSchema(ui: FirebaseUI) { }); } -export function createMultiFactorPhoneAuthVerifyFormSchema(ui: FirebaseUIConfiguration) { - const schema = createPhoneAuthVerifyFormSchema(ui); - return schema.extend({ - // TODO: Translation... - displayName: z.string().min(1, "TODO!!!"), +export function createMultiFactorPhoneAuthNumberFormSchema(ui: FirebaseUI) { + const base = createPhoneAuthNumberFormSchema(ui); + return base.extend({ + // TODO(ehesp): Translation... + displayName: z.string().min(1, "TODO!"), }); } +export function createMultiFactorPhoneAuthVerifyFormSchema(ui: FirebaseUI) { + return createPhoneAuthVerifyFormSchema(ui); +} + export type SignInAuthFormSchema = z.infer>; export type SignUpAuthFormSchema = z.infer>; export type ForgotPasswordAuthFormSchema = z.infer>; @@ -88,5 +92,5 @@ export type EmailLinkAuthFormSchema = z.infer>; export type PhoneAuthVerifyFormSchema = z.infer>; export type MultiFactorPhoneAuthNumberFormSchema = z.infer< - ReturnType + ReturnType >; 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 index 6c959741..801034fa 100644 --- 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 @@ -1,18 +1,20 @@ +import { useCallback, useRef, useState } from "react"; +import { multiFactor, PhoneAuthProvider, PhoneMultiFactorGenerator, type RecaptchaVerifier } from "firebase/auth"; import { - CountryCode, - countryData, + enrollWithMultiFactorAssertion, FirebaseUIError, - formatPhoneNumberWithCountry, + formatPhoneNumber, getTranslation, - signInWithMultiFactorAssertion, verifyPhoneNumber, } from "@firebase-ui/core"; -import { multiFactor, PhoneAuthProvider, PhoneMultiFactorGenerator, RecaptchaVerifier } from "firebase/auth"; -import { useCallback, useRef, useState } from "react"; -import { CountrySelector } from "~/components/country-selector"; +import { CountrySelector, type CountrySelectorRef } from "~/components/country-selector"; import { form } from "~/components/form"; -import { useMultiFactorPhoneAuthVerifyFormSchema, useRecaptchaVerifier, useUI } from "~/hooks"; -import { usePhoneNumberForm } from "../phone-auth-form"; +import { + useMultiFactorPhoneAuthNumberFormSchema, + useMultiFactorPhoneAuthVerifyFormSchema, + useRecaptchaVerifier, + useUI, +} from "~/hooks"; export function useSmsMultiFactorEnrollmentPhoneAuthFormAction() { const ui = useUI(); @@ -20,28 +22,63 @@ export function useSmsMultiFactorEnrollmentPhoneAuthFormAction() { return useCallback( async ({ phoneNumber, recaptchaVerifier }: { phoneNumber: string; recaptchaVerifier: RecaptchaVerifier }) => { const mfaUser = multiFactor(ui.auth.currentUser!); - return await verifyPhoneNumber(ui, phoneNumber, recaptchaVerifier, mfaUser); + const session = await mfaUser.getSession(); + return await verifyPhoneNumber(ui, phoneNumber, recaptchaVerifier, session); }, [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) => void; + onSubmit: (verificationId: string, displayName?: string) => void; }; function MultiFactorEnrollmentPhoneNumberForm(props: MultiFactorEnrollmentPhoneNumberFormProps) { const ui = useUI(); const recaptchaContainerRef = useRef(null); const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); - const form = usePhoneNumberForm({ + const countrySelector = useRef(null); + const form = useSmsMultiFactorEnrollmentPhoneNumberForm({ recaptchaVerifier: recaptchaVerifier!, onSuccess: props.onSubmit, - formatPhoneNumber: (phoneNumber) => formatPhoneNumberWithCountry(phoneNumber, selectedCountry), + formatPhoneNumber: (phoneNumber) => formatPhoneNumber(phoneNumber, countrySelector.current!.getCountry()), }); - const [selectedCountry, setSelectedCountry] = useState(countryData[0].code); - return (
+
+ + {(field) => } + +
{(field) => ( setSelectedCountry(code as CountryCode)} - className="fui-phone-input__country-selector" - /> - } + before={} /> )} @@ -84,10 +120,18 @@ function MultiFactorEnrollmentPhoneNumberForm(props: MultiFactorEnrollmentPhoneN export function useMultiFactorEnrollmentVerifyPhoneNumberFormAction() { const ui = useUI(); return useCallback( - async ({ verificationId, verificationCode }: { verificationId: string; verificationCode: string }) => { + async ({ + verificationId, + verificationCode, + displayName, + }: { + verificationId: string; + verificationCode: string; + displayName?: string; + }) => { const credential = PhoneAuthProvider.credential(verificationId, verificationCode); const assertion = PhoneMultiFactorGenerator.assertion(credential); - return await signInWithMultiFactorAssertion(ui, assertion); + return await enrollWithMultiFactorAssertion(ui, assertion, displayName); }, [ui] ); @@ -95,11 +139,13 @@ export function useMultiFactorEnrollmentVerifyPhoneNumberFormAction() { type UseMultiFactorEnrollmentVerifyPhoneNumberForm = { verificationId: string; + displayName?: string; onSuccess: () => void; }; export function useMultiFactorEnrollmentVerifyPhoneNumberForm({ verificationId, + displayName, onSuccess, }: UseMultiFactorEnrollmentVerifyPhoneNumberForm) { const schema = useMultiFactorPhoneAuthVerifyFormSchema(); @@ -107,7 +153,7 @@ export function useMultiFactorEnrollmentVerifyPhoneNumberForm({ return form.useAppForm({ defaultValues: { - displayName: "", + verificationId, verificationCode: "", }, validators: { @@ -115,7 +161,7 @@ export function useMultiFactorEnrollmentVerifyPhoneNumberForm({ onBlur: schema, onSubmitAsync: async ({ value }) => { try { - await action({ verificationId, verificationCode: value.verificationCode }); + await action({ ...value, displayName }); return onSuccess(); } catch (error) { return error instanceof FirebaseUIError ? error.message : String(error); @@ -127,13 +173,14 @@ export function useMultiFactorEnrollmentVerifyPhoneNumberForm({ type MultiFactorEnrollmentVerifyPhoneNumberFormProps = { verificationId: string; + displayName?: string; onSuccess: () => void; }; export function MultiFactorEnrollmentVerifyPhoneNumberForm(props: MultiFactorEnrollmentVerifyPhoneNumberFormProps) { const ui = useUI(); const form = useMultiFactorEnrollmentVerifyPhoneNumberForm({ - verificationId: props.verificationId, + ...props, onSuccess: props.onSuccess, }); @@ -147,11 +194,6 @@ export function MultiFactorEnrollmentVerifyPhoneNumberForm(props: MultiFactorEnr }} > -
- - {(field) => } - -
{(field) => } @@ -167,15 +209,28 @@ export type SmsMultiFactorEnrollmentFormProps = { }; export function SmsMultiFactorEnrollmentForm(props: SmsMultiFactorEnrollmentFormProps) { - const [verificationId, setVerificationId] = useState(null); + 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 (!verificationId) { - return ; + if (!verification) { + return ( + setVerification({ verificationId, displayName })} + /> + ); } return ( { props.onSuccess?.(); }} 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/hooks.ts b/packages/react/src/hooks.ts index 04738039..7a5f7944 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -18,6 +18,7 @@ import { useContext, useMemo, useEffect } from "react"; import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, + createMultiFactorPhoneAuthNumberFormSchema, createMultiFactorPhoneAuthVerifyFormSchema, createPhoneAuthNumberFormSchema, createPhoneAuthVerifyFormSchema, @@ -73,6 +74,11 @@ 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]); From 4e40134ca62d5344d91ad812673e3f2d3426ecf0 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 21 Oct 2025 11:25:20 +0100 Subject: [PATCH 03/11] feat(react): Totp enrollment flow --- packages/core/src/auth.ts | 18 +- packages/core/src/schemas.ts | 20 ++ .../mfa/sms-multi-factor-enrollment-form.tsx | 3 +- .../mfa/totp-multi-factor-enrollment-form.tsx | 199 ++++++++++++++++++ .../multi-factor-auth-enrollment-form.tsx | 5 +- packages/react/src/hooks.ts | 12 ++ 6 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.tsx diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index bb88ca2c..2d023493 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -24,6 +24,7 @@ import { EmailAuthProvider, linkWithCredential, PhoneAuthProvider, + TotpMultiFactorGenerator, multiFactor, type ActionCodeSettings, type ApplicationVerifier, @@ -33,6 +34,7 @@ import { type TotpSecret, type MultiFactorAssertion, type MultiFactorSession, + MultiFactorUser, } from "firebase/auth"; import QRCode from "qrcode-generator"; import { type FirebaseUI } from "./config"; @@ -126,11 +128,12 @@ export async function verifyPhoneNumber( ui: FirebaseUI, phoneNumber: string, appVerifier: ApplicationVerifier, - session?: MultiFactorSession + mfaUser?: MultiFactorUser ): Promise { try { ui.setState("pending"); const provider = new PhoneAuthProvider(ui.auth); + const session = await mfaUser?.getSession(); return await provider.verifyPhoneNumber( session ? { @@ -323,3 +326,16 @@ export async function enrollWithMultiFactorAssertion( ui.setState("idle"); } } + +export async function generateTotpSecret(ui: FirebaseUI): Promise { + try { + ui.setState("pending"); + 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/schemas.ts b/packages/core/src/schemas.ts index 96a6fb4a..6968b64c 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -85,6 +85,20 @@ 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>; @@ -94,3 +108,9 @@ export type PhoneAuthVerifyFormSchema = z.infer >; +export type MultiFactorTotpAuthNumberFormSchema = z.infer< + ReturnType +>; +export type MultiFactorTotpAuthVerifyFormSchema = z.infer< + ReturnType +>; 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 index 801034fa..f5f2dcb3 100644 --- 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 @@ -22,8 +22,7 @@ export function useSmsMultiFactorEnrollmentPhoneAuthFormAction() { return useCallback( async ({ phoneNumber, recaptchaVerifier }: { phoneNumber: string; recaptchaVerifier: RecaptchaVerifier }) => { const mfaUser = multiFactor(ui.auth.currentUser!); - const session = await mfaUser.getSession(); - return await verifyPhoneNumber(ui, phoneNumber, recaptchaVerifier, session); + return await verifyPhoneNumber(ui, phoneNumber, recaptchaVerifier, mfaUser); }, [ui] ); 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..a689d586 --- /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) => } + +
+
+ Generate QR Code + +
+
+ + ); +} + +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-enrollment-form.tsx b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx index b48e0adf..ef4e754f 100644 --- a/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx +++ b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx @@ -2,6 +2,7 @@ import { FactorId } from "firebase/auth"; import { useState } from "react"; import { SmsMultiFactorEnrollmentForm } from "./mfa/sms-multi-factor-enrollment-form"; +import { TotpMultiFactorEnrollmentForm } from "./mfa/totp-multi-factor-enrollment-form"; type Hint = (typeof FactorId)[keyof typeof FactorId]; @@ -23,11 +24,11 @@ export function MultiFactorAuthEnrollmentForm(props: MultiFactorAuthEnrollmentFo if (selectedHint) { if (selectedHint === FactorId.TOTP) { - return
TotpMultiFactorEnrollmentForm
; + return ; } if (selectedHint === FactorId.PHONE) { - return ; + return ; } throw new Error(`Unknown multi-factor enrollment type: ${selectedHint}`); diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index 7a5f7944..ea4723cb 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -20,6 +20,8 @@ import { createForgotPasswordAuthFormSchema, createMultiFactorPhoneAuthNumberFormSchema, createMultiFactorPhoneAuthVerifyFormSchema, + createMultiFactorTotpAuthNumberFormSchema, + createMultiFactorTotpAuthVerifyFormSchema, createPhoneAuthNumberFormSchema, createPhoneAuthVerifyFormSchema, createSignInAuthFormSchema, @@ -84,6 +86,16 @@ export function useMultiFactorPhoneAuthVerifyFormSchema() { 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(); From 483d9d340e2ffc115115fc7ba83ae17e3e2c1ce1 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 21 Oct 2025 11:32:04 +0100 Subject: [PATCH 04/11] chore(react): Simplify mfa enrollment form --- .../forms/multi-factor-auth-enrollment-form.tsx | 13 +++++++------ .../screens/multi-factor-auth-enrollment-screen.tsx | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) 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 index ef4e754f..5fad5b8b 100644 --- a/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx +++ b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx @@ -20,24 +20,25 @@ export function MultiFactorAuthEnrollmentForm(props: MultiFactorAuthEnrollmentFo throw new Error("MultiFactorAuthEnrollmentForm must have at least one hint"); } - const [selectedHint, setSelectedHint] = useState(hints.length === 1 ? hints[0] : undefined); + // 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 (selectedHint) { - if (selectedHint === FactorId.TOTP) { + if (hint) { + if (hint === FactorId.TOTP) { return ; } - if (selectedHint === FactorId.PHONE) { + if (hint === FactorId.PHONE) { return ; } - throw new Error(`Unknown multi-factor enrollment type: ${selectedHint}`); + throw new Error(`Unknown multi-factor enrollment type: ${hint}`); } return ( <> {hints.map((hint) => ( -
setSelectedHint(hint)}> +
setHint(hint)}> {hint}
))} 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 index 9768e7fb..44841f7e 100644 --- a/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx +++ b/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx @@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/compon import { useUI } from "~/hooks"; import { MultiFactorAuthEnrollmentForm, - MultiFactorAuthEnrollmentFormProps, + type MultiFactorAuthEnrollmentFormProps, } from "../forms/multi-factor-auth-enrollment-form"; export type MultiFactorAuthEnrollmentScreenProps = MultiFactorAuthEnrollmentFormProps; From da62412edf3057f748bb9f847326867b1b3f73cb Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 21 Oct 2025 11:40:39 +0100 Subject: [PATCH 05/11] feat(react): Add basic MultiFactorAuthAssertionForm --- .../mfa/sms-multi-factor-assertion-form.tsx | 3 ++ .../mfa/totp-multi-factor-assertion-form.tsx | 3 ++ .../multi-factor-auth-assertion-form.tsx | 39 +++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.tsx create mode 100644 packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.tsx create mode 100644 packages/react/src/auth/screens/multi-factor-auth-assertion-form.tsx 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..82ef7b14 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.tsx @@ -0,0 +1,3 @@ +export function SmsMultiFactorAssertionForm() { + return
TODO: SmsMultiFactorAssertionForm
; +} 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..6bb243cc --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.tsx @@ -0,0 +1,3 @@ +export function TotpMultiFactorAssertionForm() { + return
TODO: TotpMultiFactorAssertionForm
; +} diff --git a/packages/react/src/auth/screens/multi-factor-auth-assertion-form.tsx b/packages/react/src/auth/screens/multi-factor-auth-assertion-form.tsx new file mode 100644 index 00000000..e57c290d --- /dev/null +++ b/packages/react/src/auth/screens/multi-factor-auth-assertion-form.tsx @@ -0,0 +1,39 @@ +import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; +import { 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"; + +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 [factor, setFactor] = useState( + resolver.hints.length === 1 ? resolver.hints[0] : undefined + ); + + if (factor) { + if (factor.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return ; + } + + if (factor.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return ; + } + } + + return ( + <> + {resolver.hints.map((hint) => ( +
setFactor(hint)}> + {hint.factorId} +
+ ))} + + ); +} From 9e715dae3074552ad9660dd3bccd83c23b0bbf29 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 21 Oct 2025 11:41:46 +0100 Subject: [PATCH 06/11] chore(core): Formatting --- packages/core/src/auth.ts | 3 +-- packages/core/src/schemas.ts | 8 ++------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 2d023493..acc42e41 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -33,8 +33,7 @@ import { type AuthCredential, type TotpSecret, type MultiFactorAssertion, - type MultiFactorSession, - MultiFactorUser, + type MultiFactorUser, } from "firebase/auth"; import QRCode from "qrcode-generator"; import { type FirebaseUI } from "./config"; diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 6968b64c..d925d8b6 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -108,9 +108,5 @@ export type PhoneAuthVerifyFormSchema = z.infer >; -export type MultiFactorTotpAuthNumberFormSchema = z.infer< - ReturnType ->; -export type MultiFactorTotpAuthVerifyFormSchema = z.infer< - ReturnType ->; +export type MultiFactorTotpAuthNumberFormSchema = z.infer>; +export type MultiFactorTotpAuthVerifyFormSchema = z.infer>; From b551eeb815ff9e69aebb5b114e6913b301255549 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Wed, 22 Oct 2025 15:21:01 +0100 Subject: [PATCH 07/11] feat(core,react,styles,translations): Add MFA enrollment screens/forms --- examples/react/package.json | 8 +- examples/react/src/App.tsx | 5 + examples/react/src/firebase/config.ts | 9 +- examples/react/src/main.tsx | 4 + .../src/screens/mfa-enrollment-screen.tsx | 29 ++ packages/core/src/schemas.ts | 3 +- .../sms-multi-factor-enrollment-form.test.tsx | 328 +++++++++++++++++ ...totp-multi-factor-enrollment-form.test.tsx | 271 ++++++++++++++ .../mfa/totp-multi-factor-enrollment-form.tsx | 2 +- ...multi-factor-auth-enrollment-form.test.tsx | 335 ++++++++++++++++++ .../multi-factor-auth-enrollment-form.tsx | 37 +- packages/react/src/auth/index.ts | 9 + ...lti-factor-auth-enrollment-screen.test.tsx | 199 +++++++++++ .../multi-factor-auth-enrollment-screen.tsx | 8 +- packages/styles/src/base.css | 6 +- packages/translations/src/locales/en-us.ts | 6 + packages/translations/src/mapping.ts | 1 + packages/translations/src/types.ts | 6 + pnpm-lock.yaml | 22 +- 19 files changed, 1256 insertions(+), 32 deletions(-) create mode 100644 examples/react/src/screens/mfa-enrollment-screen.tsx create mode 100644 packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.test.tsx create mode 100644 packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.test.tsx create mode 100644 packages/react/src/auth/forms/multi-factor-auth-enrollment-form.test.tsx create mode 100644 packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.test.tsx diff --git a/examples/react/package.json b/examples/react/package.json index 7e1c7cfd..70d6be2f 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -18,15 +18,15 @@ "@firebase-ui/styles": "workspace:*", "@firebase-ui/translations": "workspace:*", "firebase": "^11.6.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "catalog:", + "react-dom": "catalog:", "react-router": "^7.5.1" }, "devDependencies": { "@tailwindcss/vite": "^4.1.4", "@eslint/js": "^9.22.0", - "@types/react": "^19.0.10", - "@types/react-dom": "^19.0.4", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.22.0", "eslint-plugin-react-hooks": "^5.2.0", 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/firebase/config.ts b/examples/react/src/firebase/config.ts index ef4305c0..93845001 100644 --- a/examples/react/src/firebase/config.ts +++ b/examples/react/src/firebase/config.ts @@ -14,4 +14,11 @@ * limitations under the License. */ -export const firebaseConfig = {}; +export const firebaseConfig = { + apiKey: "AIzaSyCvMftIUCD9lUQ3BzIrimfSfBbCUQYZf-I", + authDomain: "fir-ui-rework.firebaseapp.com", + projectId: "fir-ui-rework", + storageBucket: "fir-ui-rework.firebasestorage.app", + messagingSenderId: "200312857118", + appId: "1:200312857118:web:94e3f69b0e0a4a863f040f" +}; 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..63506dae --- /dev/null +++ b/examples/react/src/screens/mfa-enrollment-screen.tsx @@ -0,0 +1,29 @@ +/** + * 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/schemas.ts b/packages/core/src/schemas.ts index d925d8b6..354a0be2 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -76,8 +76,7 @@ export function createPhoneAuthVerifyFormSchema(ui: FirebaseUI) { export function createMultiFactorPhoneAuthNumberFormSchema(ui: FirebaseUI) { const base = createPhoneAuthNumberFormSchema(ui); return base.extend({ - // TODO(ehesp): Translation... - displayName: z.string().min(1, "TODO!"), + displayName: z.string().min(1, getTranslation(ui, "errors", "displayNameRequired")), }); } 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/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 index a689d586..fa9806cf 100644 --- 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 @@ -71,7 +71,7 @@ function TotpMultiFactorSecretGenerationForm(props: TotpMultiFactorSecretGenerat
    - Generate QR Code + {getTranslation(ui, "labels", "generateQrCode")}
    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..3a93c4c2 --- /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: ({ onEnrollment }: { onEnrollment?: () => void }) => ( +
    +
    {onEnrollment &&
    onEnrollment
    }
    +
    + ), +})); + +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: { + mfaTotpEnrollment: "Set up TOTP", + mfaSmsEnrollment: "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: { + mfaTotpEnrollment: "Set up TOTP", + mfaSmsEnrollment: "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: { + mfaTotpEnrollment: "Set up TOTP", + mfaSmsEnrollment: "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: { + mfaTotpEnrollment: "Set up TOTP", + mfaSmsEnrollment: "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: { + mfaTotpEnrollment: "Set up TOTP", + mfaSmsEnrollment: "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: { + mfaTotpEnrollment: "Configure TOTP Authentication", + mfaSmsEnrollment: "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: { + mfaTotpEnrollment: "Set up TOTP", + mfaSmsEnrollment: "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: { + mfaTotpEnrollment: "Set up TOTP", + mfaSmsEnrollment: "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 index 5fad5b8b..04c37aca 100644 --- a/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx +++ b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx @@ -1,8 +1,11 @@ import { FactorId } from "firebase/auth"; -import { useState } from "react"; +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]; @@ -36,12 +39,30 @@ export function MultiFactorAuthEnrollmentForm(props: MultiFactorAuthEnrollmentFo } return ( - <> - {hints.map((hint) => ( -
    setHint(hint)}> - {hint} -
    - ))} - +
    + {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", "mfaTotpEnrollment"); + return ; +} + +function SmsButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaSmsEnrollment"); + 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/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 index 44841f7e..d7150f37 100644 --- a/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx +++ b/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx @@ -8,11 +8,11 @@ import { export type MultiFactorAuthEnrollmentScreenProps = MultiFactorAuthEnrollmentFormProps; -export function MultiFactorAuthEnrollmentScreen({}: MultiFactorAuthEnrollmentScreenProps) { +export function MultiFactorAuthEnrollmentScreen(props: MultiFactorAuthEnrollmentScreenProps) { const ui = useUI(); - const titleText = getTranslation(ui, "labels", "register"); - const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); + const titleText = getTranslation(ui, "labels", "multiFactorEnrollment"); + const subtitleText = getTranslation(ui, "prompts", "mfaEnrollmentPrompt"); return (
    @@ -22,7 +22,7 @@ export function MultiFactorAuthEnrollmentScreen({}: MultiFactorAuthEnrollmentScr {subtitleText} - +
    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..ee5a5115 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", + mfaTotpEnrollment: "TOTP Verification", + mfaSmsEnrollment: "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.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..77bf6082 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; + mfaTotpEnrollment?: string; + mfaSmsEnrollment?: string; + generateQrCode?: string; }; prompts?: { noAccount?: string; @@ -96,5 +101,6 @@ export type Translations = { enterPhoneNumber?: string; enterVerificationCode?: string; enterEmailForLink?: string; + mfaEnrollmentPrompt?: string; }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78c768b7..70b2365c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,14 +362,14 @@ importers: specifier: ^11.6.0 version: 11.10.0 react: - specifier: ^19.0.0 + specifier: 'catalog:' version: 19.1.1 react-dom: - specifier: ^19.0.0 - version: 19.2.0(react@19.1.1) + specifier: 'catalog:' + version: 19.1.1(react@19.1.1) react-router: specifier: ^7.5.1 - version: 7.9.3(react-dom@19.2.0(react@19.1.1))(react@19.1.1) + version: 7.9.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1) devDependencies: '@eslint/js': specifier: ^9.22.0 @@ -378,10 +378,10 @@ importers: specifier: ^4.1.4 version: 4.1.14(vite@6.3.6(@types/node@24.3.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.1)(sass@1.92.1)(terser@5.43.1)(tsx@4.20.6)) '@types/react': - specifier: ^19.0.10 + specifier: 'catalog:' version: 19.1.16 '@types/react-dom': - specifier: ^19.0.4 + specifier: 'catalog:' version: 19.1.9(@types/react@19.1.16) '@vitejs/plugin-react': specifier: ^4.3.4 @@ -14151,7 +14151,7 @@ snapshots: eslint: 9.35.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.6.1)))(eslint@9.35.0(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.6.1)))(eslint@9.35.0(jiti@2.6.1)))(eslint@9.35.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.35.0(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0(jiti@2.6.1)) @@ -14185,7 +14185,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.6.1)))(eslint@9.35.0(jiti@2.6.1)))(eslint@9.35.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -14200,7 +14200,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.6.1)))(eslint@9.35.0(jiti@2.6.1)))(eslint@9.35.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -16870,13 +16870,13 @@ snapshots: react-refresh@0.17.0: {} - react-router@7.9.3(react-dom@19.2.0(react@19.1.1))(react@19.1.1): + react-router@7.9.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: cookie: 1.0.2 react: 19.1.1 set-cookie-parser: 2.7.1 optionalDependencies: - react-dom: 19.2.0(react@19.1.1) + react-dom: 19.1.1(react@19.1.1) react@19.1.1: {} From d3a35e2969bce530d445b84aceaebba682529bba Mon Sep 17 00:00:00 2001 From: Darren Ackers Date: Wed, 22 Oct 2025 16:33:22 +0100 Subject: [PATCH 08/11] Update examples/react/src/firebase/config.ts --- examples/react/src/firebase/config.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/examples/react/src/firebase/config.ts b/examples/react/src/firebase/config.ts index 93845001..f2b50c19 100644 --- a/examples/react/src/firebase/config.ts +++ b/examples/react/src/firebase/config.ts @@ -15,10 +15,4 @@ */ export const firebaseConfig = { - apiKey: "AIzaSyCvMftIUCD9lUQ3BzIrimfSfBbCUQYZf-I", - authDomain: "fir-ui-rework.firebaseapp.com", - projectId: "fir-ui-rework", - storageBucket: "fir-ui-rework.firebasestorage.app", - messagingSenderId: "200312857118", - appId: "1:200312857118:web:94e3f69b0e0a4a863f040f" }; From db6e92893f77c92cd944352b61923feaa953c088 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 23 Oct 2025 15:53:01 +0100 Subject: [PATCH 09/11] feat(react): Handle mfa error in screens --- examples/react/src/firebase/config.ts | 3 +- .../src/screens/mfa-enrollment-screen.tsx | 14 +- packages/core/src/auth.ts | 29 +- packages/core/src/config.test.ts | 109 ++++++ packages/core/src/config.ts | 22 +- packages/core/tests/utils.ts | 2 + .../auth/forms/email-link-auth-form.test.tsx | 8 + .../forms/forgot-password-auth-form.test.tsx | 8 + .../mfa/sms-multi-factor-enrollment-form.tsx | 4 + .../multi-factor-auth-assertion-form.test.tsx | 343 ++++++++++++++++++ .../multi-factor-auth-assertion-form.tsx | 60 +++ ...multi-factor-auth-enrollment-form.test.tsx | 36 +- .../multi-factor-auth-enrollment-form.tsx | 4 +- .../src/auth/forms/phone-auth-form.test.tsx | 21 +- .../src/auth/forms/sign-in-auth-form.test.tsx | 8 + .../src/auth/forms/sign-up-auth-form.test.tsx | 8 + .../auth/oauth/apple-sign-in-button.test.tsx | 20 +- .../oauth/facebook-sign-in-button.test.tsx | 20 +- .../auth/oauth/github-sign-in-button.test.tsx | 20 +- .../auth/oauth/google-sign-in-button.test.tsx | 20 +- .../oauth/microsoft-sign-in-button.test.tsx | 20 +- .../src/auth/oauth/oauth-button.test.tsx | 8 + .../oauth/twitter-sign-in-button.test.tsx | 20 +- .../screens/email-link-auth-screen.test.tsx | 31 ++ .../auth/screens/email-link-auth-screen.tsx | 8 +- .../multi-factor-auth-assertion-form.tsx | 39 -- .../src/auth/screens/oauth-screen.test.tsx | 90 ++++- .../react/src/auth/screens/oauth-screen.tsx | 18 +- .../auth/screens/phone-auth-screen.test.tsx | 111 +++++- .../src/auth/screens/phone-auth-screen.tsx | 24 +- .../auth/screens/sign-in-auth-screen.test.tsx | 89 +++++ .../src/auth/screens/sign-in-auth-screen.tsx | 23 +- .../auth/screens/sign-up-auth-screen.test.tsx | 90 ++++- .../src/auth/screens/sign-up-auth-screen.tsx | 23 +- .../src/components/redirect-error.test.tsx | 114 ++++++ .../react/src/components/redirect-error.tsx | 11 + packages/react/src/hooks.test.tsx | 145 +++++++- packages/react/src/hooks.ts | 11 + packages/react/tests/utils.tsx | 8 +- packages/translations/src/locales/en-us.ts | 4 +- packages/translations/src/types.ts | 4 +- 41 files changed, 1462 insertions(+), 188 deletions(-) create mode 100644 packages/react/src/auth/forms/multi-factor-auth-assertion-form.test.tsx create mode 100644 packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx delete mode 100644 packages/react/src/auth/screens/multi-factor-auth-assertion-form.tsx create mode 100644 packages/react/src/components/redirect-error.test.tsx create mode 100644 packages/react/src/components/redirect-error.tsx diff --git a/examples/react/src/firebase/config.ts b/examples/react/src/firebase/config.ts index f2b50c19..ef4305c0 100644 --- a/examples/react/src/firebase/config.ts +++ b/examples/react/src/firebase/config.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export const firebaseConfig = { -}; +export const firebaseConfig = {}; diff --git a/examples/react/src/screens/mfa-enrollment-screen.tsx b/examples/react/src/screens/mfa-enrollment-screen.tsx index 63506dae..2f52e711 100644 --- a/examples/react/src/screens/mfa-enrollment-screen.tsx +++ b/examples/react/src/screens/mfa-enrollment-screen.tsx @@ -20,10 +20,12 @@ import { MultiFactorAuthEnrollmentScreen } from "@firebase-ui/react"; import { FactorId } from "firebase/auth"; export default function MultiFactorAuthEnrollmentScreenPage() { - return { - console.log("Enrollment successful"); - }} - />; + return ( + { + console.log("Enrollment successful"); + }} + /> + ); } diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index acc42e41..ebf241cc 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -57,13 +57,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")) { @@ -90,7 +95,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) { @@ -130,7 +135,7 @@ export async function verifyPhoneNumber( mfaUser?: MultiFactorUser ): Promise { try { - ui.setState("pending"); + setPendingState(ui); const provider = new PhoneAuthProvider(ui.auth); const session = await mfaUser?.getSession(); return await provider.verifyPhoneNumber( @@ -155,7 +160,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); @@ -178,7 +183,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); @@ -189,7 +194,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... @@ -213,7 +218,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); @@ -235,7 +240,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) { @@ -247,7 +252,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); @@ -280,7 +285,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) { @@ -317,7 +322,7 @@ export async function enrollWithMultiFactorAssertion( displayName?: string ): Promise { try { - ui.setState("pending"); + setPendingState(ui); await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName); } catch (error) { handleFirebaseError(ui, error); @@ -328,7 +333,7 @@ export async function enrollWithMultiFactorAssertion( export async function generateTotpSecret(ui: FirebaseUI): Promise { try { - ui.setState("pending"); + setPendingState(ui); const mfaUser = multiFactor(ui.auth.currentUser!); const session = await mfaUser.getSession(); return await TotpMultiFactorGenerator.generateSecret(session); 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/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-enrollment-form.tsx b/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.tsx index f5f2dcb3..84f0d2db 100644 --- 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 @@ -198,6 +198,10 @@ export function MultiFactorEnrollmentVerifyPhoneNumberForm(props: MultiFactorEnr {(field) => }
    +
    + {getTranslation(ui, "labels", "verifyCode")} + +
    ); 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..2a461802 --- /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 [factor, setFactor] = useState( + resolver.hints.length === 1 ? resolver.hints[0] : undefined + ); + + if (factor) { + if (factor.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return ; + } + + if (factor.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return ; + } + } + + return ( +
    +

    TODO: Select a multi-factor authentication method

    + {resolver.hints.map((hint) => { + if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return setFactor(hint)} />; + } + + if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return setFactor(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 index 3a93c4c2..7bc796ab 100644 --- 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 @@ -22,9 +22,9 @@ import { registerLocale } from "@firebase-ui/translations"; import { FactorId } from "firebase/auth"; vi.mock("./mfa/sms-multi-factor-enrollment-form", () => ({ - SmsMultiFactorEnrollmentForm: ({ onEnrollment }: { onEnrollment?: () => void }) => ( + SmsMultiFactorEnrollmentForm: ({ onSuccess }: { onSuccess?: () => void }) => (
    -
    {onEnrollment &&
    onEnrollment
    }
    +
    {onSuccess &&
    onSuccess
    }
    ), })); @@ -50,8 +50,8 @@ describe("", () => { const ui = createMockUI({ locale: registerLocale("test", { labels: { - mfaTotpEnrollment: "Set up TOTP", - mfaSmsEnrollment: "Set up SMS", + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", }, }), }); @@ -107,8 +107,8 @@ describe("", () => { const ui = createMockUI({ locale: registerLocale("test", { labels: { - mfaTotpEnrollment: "Set up TOTP", - mfaSmsEnrollment: "Set up SMS", + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", }, }), }); @@ -131,8 +131,8 @@ describe("", () => { const ui = createMockUI({ locale: registerLocale("test", { labels: { - mfaTotpEnrollment: "Set up TOTP", - mfaSmsEnrollment: "Set up SMS", + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", }, }), }); @@ -182,8 +182,8 @@ describe("", () => { const ui = createMockUI({ locale: registerLocale("test", { labels: { - mfaTotpEnrollment: "Set up TOTP", - mfaSmsEnrollment: "Set up SMS", + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", }, }), }); @@ -204,8 +204,8 @@ describe("", () => { const ui = createMockUI({ locale: registerLocale("test", { labels: { - mfaTotpEnrollment: "Set up TOTP", - mfaSmsEnrollment: "Set up SMS", + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", }, }), }); @@ -251,8 +251,8 @@ describe("", () => { const ui = createMockUI({ locale: registerLocale("test", { labels: { - mfaTotpEnrollment: "Configure TOTP Authentication", - mfaSmsEnrollment: "Configure SMS Authentication", + mfaTotpVerification: "Configure TOTP Authentication", + mfaSmsVerification: "Configure SMS Authentication", }, }), }); @@ -284,8 +284,8 @@ describe("", () => { const ui = createMockUI({ locale: registerLocale("test", { labels: { - mfaTotpEnrollment: "Set up TOTP", - mfaSmsEnrollment: "Set up SMS", + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", }, }), }); @@ -309,8 +309,8 @@ describe("", () => { const ui = createMockUI({ locale: registerLocale("test", { labels: { - mfaTotpEnrollment: "Set up TOTP", - mfaSmsEnrollment: "Set up SMS", + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", }, }), }); 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 index 04c37aca..2311ee19 100644 --- a/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx +++ b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx @@ -57,12 +57,12 @@ export function MultiFactorAuthEnrollmentForm(props: MultiFactorAuthEnrollmentFo function TotpButton(props: ComponentProps) { const ui = useUI(); - const labelText = getTranslation(ui, "labels", "mfaTotpEnrollment"); + const labelText = getTranslation(ui, "labels", "mfaTotpVerification"); return ; } function SmsButton(props: ComponentProps) { const ui = useUI(); - const labelText = getTranslation(ui, "labels", "mfaSmsEnrollment"); + 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/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/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-assertion-form.tsx b/packages/react/src/auth/screens/multi-factor-auth-assertion-form.tsx deleted file mode 100644 index e57c290d..00000000 --- a/packages/react/src/auth/screens/multi-factor-auth-assertion-form.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; -import { 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"; - -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 [factor, setFactor] = useState( - resolver.hints.length === 1 ? resolver.hints[0] : undefined - ); - - if (factor) { - if (factor.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { - return ; - } - - if (factor.factorId === TotpMultiFactorGenerator.FACTOR_ID) { - return ; - } - } - - return ( - <> - {resolver.hints.map((hint) => ( -
    setFactor(hint)}> - {hint.factorId} -
    - ))} - - ); -} 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 ea4723cb..5cff1d8f 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -46,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]); 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/translations/src/locales/en-us.ts b/packages/translations/src/locales/en-us.ts index ee5a5115..ce27e667 100644 --- a/packages/translations/src/locales/en-us.ts +++ b/packages/translations/src/locales/en-us.ts @@ -83,8 +83,8 @@ export const enUS = { resendCode: "Resend Code", sending: "Sending...", multiFactorEnrollment: "Multi-factor Enrollment", - mfaTotpEnrollment: "TOTP Verification", - mfaSmsEnrollment: "SMS Verification", + mfaTotpVerification: "TOTP Verification", + mfaSmsVerification: "SMS Verification", generateQrCode: "Generate QR Code", }, prompts: { diff --git a/packages/translations/src/types.ts b/packages/translations/src/types.ts index 77bf6082..ac94b468 100644 --- a/packages/translations/src/types.ts +++ b/packages/translations/src/types.ts @@ -88,8 +88,8 @@ export type Translations = { resendCode?: string; sending?: string; multiFactorEnrollment?: string; - mfaTotpEnrollment?: string; - mfaSmsEnrollment?: string; + mfaTotpVerification?: string; + mfaSmsVerification?: string; generateQrCode?: string; }; prompts?: { From 32b3a41b0c73e74553af8984dadba775e7204a24 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Mon, 27 Oct 2025 08:49:45 +0000 Subject: [PATCH 10/11] feat(react): MFA Assertion --- packages/core/src/auth.ts | 50 ++- .../sms-multi-factor-assertion-form.test.tsx | 287 ++++++++++++++++++ .../mfa/sms-multi-factor-assertion-form.tsx | 224 +++++++++++++- .../multi-factor-auth-assertion-form.tsx | 2 +- packages/translations/src/mapping.test.ts | 1 + 5 files changed, 548 insertions(+), 16 deletions(-) create mode 100644 packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.test.tsx diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index ebf241cc..25cc7705 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -34,6 +34,7 @@ import { type TotpSecret, type MultiFactorAssertion, type MultiFactorUser, + type MultiFactorInfo, } from "firebase/auth"; import QRCode from "qrcode-generator"; import { type FirebaseUI } from "./config"; @@ -132,21 +133,36 @@ export async function verifyPhoneNumber( ui: FirebaseUI, phoneNumber: string, appVerifier: ApplicationVerifier, - mfaUser?: MultiFactorUser + mfaUser?: MultiFactorUser, + mfaHint?: MultiFactorInfo ): Promise { try { setPendingState(ui); const provider = new PhoneAuthProvider(ui.auth); - const session = await mfaUser?.getSession(); - return await provider.verifyPhoneNumber( - session - ? { - phoneNumber, - session, - } - : 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 { @@ -312,8 +328,16 @@ export function generateTotpQrCode(ui: FirebaseUI, secret: TotpSecret, accountNa } export async function signInWithMultiFactorAssertion(ui: FirebaseUI, assertion: MultiFactorAssertion) { - await ui.multiFactorResolver?.resolveSignIn(assertion); - throw new Error("Not implemented"); + 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( 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 index 82ef7b14..21132eac 100644 --- 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 @@ -1,3 +1,223 @@ -export function SmsMultiFactorAssertionForm() { - return
    TODO: SmsMultiFactorAssertionForm
    ; +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/multi-factor-auth-assertion-form.tsx b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx index 2a461802..3391b62b 100644 --- a/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx +++ b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx @@ -21,7 +21,7 @@ export function MultiFactorAuthAssertionForm() { if (factor) { if (factor.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { - return ; + return ; } if (factor.factorId === TotpMultiFactorGenerator.FACTOR_ID) { 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) => { From 2d6882ebcace10da8927fe6ce8c18e60a0b25a55 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Mon, 27 Oct 2025 11:05:46 +0000 Subject: [PATCH 11/11] feat(react): MFA TOTP assertion flow --- .../totp-multi-factor-assertion-form.test.tsx | 207 ++++++++++++++++++ .../mfa/totp-multi-factor-assertion-form.tsx | 91 +++++++- .../multi-factor-auth-assertion-form.tsx | 16 +- 3 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.test.tsx 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 index 6bb243cc..152385aa 100644 --- 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 @@ -1,3 +1,90 @@ -export function TotpMultiFactorAssertionForm() { - return
    TODO: TotpMultiFactorAssertionForm
    ; +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/multi-factor-auth-assertion-form.tsx b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx index 3391b62b..e60f1c45 100644 --- a/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx +++ b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx @@ -15,17 +15,17 @@ export function MultiFactorAuthAssertionForm() { } // If only a single hint is provided, select it by default to improve UX. - const [factor, setFactor] = useState( + const [hint, setHint] = useState( resolver.hints.length === 1 ? resolver.hints[0] : undefined ); - if (factor) { - if (factor.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { - return ; + if (hint) { + if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return ; } - if (factor.factorId === TotpMultiFactorGenerator.FACTOR_ID) { - return ; + if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return ; } } @@ -34,11 +34,11 @@ export function MultiFactorAuthAssertionForm() {

    TODO: Select a multi-factor authentication method

    {resolver.hints.map((hint) => { if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { - return setFactor(hint)} />; + return setHint(hint)} />; } if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { - return setFactor(hint)} />; + return setHint(hint)} />; } return null;