diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts index d7fbb79a..fd50ce79 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts @@ -335,4 +335,28 @@ describe("", () => { expect(onSuccessSpy).toHaveBeenCalled(); }); }); + + it("emits onSuccess with credential after successful verification", async () => { + const mockCredential = { user: { uid: "sms-verify-user" } }; + signInWithMultiFactorAssertion.mockResolvedValue(mockCredential); + + const { fixture } = await render(SmsMultiFactorAssertionVerifyFormComponent, { + componentInputs: { + verificationId: "test-verification-id", + }, + imports: [SmsMultiFactorAssertionVerifyFormComponent], + }); + + const onSuccessSpy = jest.fn(); + fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); + + const component = fixture.componentInstance; + component.form.setFieldValue("verificationCode", "123456"); + component.form.setFieldValue("verificationId", "test-verification-id"); + await component.form.handleSubmit(); + + await waitFor(() => { + expect(onSuccessSpy).toHaveBeenCalledWith(mockCredential); + }); + }); }); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts index 8256dd57..9e84f047 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts @@ -23,10 +23,9 @@ import { injectTranslation, injectUI, } from "../../../provider"; -import { RecaptchaVerifier } from "@angular/fire/auth"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; import { FirebaseUIError, verifyPhoneNumber, signInWithMultiFactorAssertion } from "@firebase-ui/core"; -import { PhoneAuthProvider, PhoneMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; +import { PhoneAuthProvider, PhoneMultiFactorGenerator, type UserCredential, type MultiFactorInfo } from "firebase/auth"; type PhoneMultiFactorInfo = MultiFactorInfo & { phoneNumber?: string; @@ -179,7 +178,7 @@ export class SmsMultiFactorAssertionVerifyFormComponent { private formSchema = injectMultiFactorPhoneAuthVerifyFormSchema(); verificationId = input.required(); - onSuccess = output(); + onSuccess = output(); verificationCodeLabel = injectTranslation("labels", "verificationCode"); verifyCodeLabel = injectTranslation("labels", "verifyCode"); @@ -208,8 +207,8 @@ export class SmsMultiFactorAssertionVerifyFormComponent { try { const credential = PhoneAuthProvider.credential(value.verificationId, value.verificationCode); const assertion = PhoneMultiFactorGenerator.assertion(credential); - await signInWithMultiFactorAssertion(this.ui(), assertion); - this.onSuccess.emit(); + const result = await signInWithMultiFactorAssertion(this.ui(), assertion); + this.onSuccess.emit(result); return; } catch (error) { if (error instanceof FirebaseUIError) { @@ -239,7 +238,7 @@ export class SmsMultiFactorAssertionVerifyFormComponent { @if (verification()) { } @else { @@ -249,7 +248,7 @@ export class SmsMultiFactorAssertionVerifyFormComponent { }) export class SmsMultiFactorAssertionFormComponent { hint = input.required(); - onSuccess = output(); + onSuccess = output(); verification = signal<{ verificationId: string } | null>(null); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts index ec780ba0..bf3abfae 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts @@ -145,6 +145,36 @@ describe("", () => { }); }); + it("emits onSuccess with credential after successful verification", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const mockCredential = { user: { uid: "totp-verify-user" } }; + signInWithMultiFactorAssertion.mockResolvedValue(mockCredential); + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const component = fixture.componentInstance; + const onSuccessSpy = jest.fn(); + component.onSuccess.subscribe(onSuccessSpy); + + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await waitFor(() => { + expect(onSuccessSpy).toHaveBeenCalledWith(mockCredential); + }); + }); + it("calls TotpMultiFactorGenerator.assertionForSignIn with correct parameters", async () => { const mockHint = { factorId: TotpMultiFactorGenerator.FACTOR_ID, diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts index 6e479bf8..307d8f53 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts @@ -19,7 +19,7 @@ import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanst import { injectMultiFactorTotpAuthVerifyFormSchema, injectTranslation, injectUI } from "../../../provider"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; import { FirebaseUIError, signInWithMultiFactorAssertion } from "@firebase-ui/core"; -import { TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; +import { TotpMultiFactorGenerator, type UserCredential, type MultiFactorInfo } from "firebase/auth"; @Component({ selector: "fui-totp-multi-factor-assertion-form", @@ -59,7 +59,7 @@ export class TotpMultiFactorAssertionFormComponent { private formSchema = injectMultiFactorTotpAuthVerifyFormSchema(); hint = input.required(); - onSuccess = output(); + onSuccess = output(); verificationCodeLabel = injectTranslation("labels", "verificationCode"); verifyCodeLabel = injectTranslation("labels", "verifyCode"); @@ -82,8 +82,8 @@ export class TotpMultiFactorAssertionFormComponent { onSubmitAsync: async ({ value }) => { try { const assertion = TotpMultiFactorGenerator.assertionForSignIn(this.hint().uid, value.verificationCode); - await signInWithMultiFactorAssertion(this.ui(), assertion); - this.onSuccess.emit(); + const result = await signInWithMultiFactorAssertion(this.ui(), assertion); + this.onSuccess.emit(result); return; } catch (error) { if (error instanceof FirebaseUIError) { diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts index 6be6e6bf..6792439e 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts @@ -136,7 +136,7 @@ describe("", () => { expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument(); }); - it("throws error when no resolver is provided", () => { + it("throws error when no resolver is provided", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ @@ -144,8 +144,10 @@ describe("", () => { }); }); - expect(() => { - new MultiFactorAuthAssertionFormComponent(); - }).toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver"); + await expect( + render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }) + ).rejects.toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver"); }); }); diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts index 848f7c7c..0c76e925 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts @@ -14,10 +14,15 @@ * limitations under the License. */ -import { Component, computed, signal } from "@angular/core"; +import { Component, computed, output, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectUI, injectTranslation } from "../../provider"; -import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; +import { + PhoneMultiFactorGenerator, + TotpMultiFactorGenerator, + type UserCredential, + type MultiFactorInfo, +} from "firebase/auth"; import { SmsMultiFactorAssertionFormComponent } from "./mfa/sms-multi-factor-assertion-form"; import { TotpMultiFactorAssertionFormComponent } from "./mfa/totp-multi-factor-assertion-form"; import { ButtonComponent } from "../../components/button"; @@ -30,9 +35,9 @@ import { ButtonComponent } from "../../components/button";
@if (selectedHint()) { @if (selectedHint()!.factorId === phoneFactorId()) { - + } @else if (selectedHint()!.factorId === totpFactorId()) { - + } } @else {

TODO: Select a multi-factor authentication method

@@ -54,6 +59,8 @@ import { ButtonComponent } from "../../components/button"; export class MultiFactorAuthAssertionFormComponent { private ui = injectUI(); + onSuccess = output(); + resolver = computed(() => { const resolver = this.ui().multiFactorResolver; if (!resolver) { diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts index e9b2d4ac..49a57e09 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts @@ -15,7 +15,7 @@ */ import { render, screen } from "@testing-library/angular"; -import { Component } from "@angular/core"; +import { Component, EventEmitter } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { OAuthScreenComponent } from "./oauth-screen"; @@ -50,13 +50,6 @@ class MockPoliciesComponent {} }) class MockRedirectErrorComponent {} -@Component({ - selector: "fui-multi-factor-auth-assertion-form", - template: '
MFA Assertion Form
', - standalone: true, -}) -class MockMultiFactorAuthAssertionFormComponent {} - @Component({ template: ` @@ -87,6 +80,15 @@ class TestHostWithMultipleProvidersComponent {} }) class TestHostWithoutContentComponent {} +@Component({ + selector: "fui-multi-factor-auth-assertion-form", + template: '
MFA Assertion Form
', + standalone: true, +}) +class MockMultiFactorAuthAssertionFormComponent { + onSuccess = new EventEmitter(); +} + describe("", () => { beforeEach(() => { const { injectTranslation, injectPolicies, injectRedirectError, injectUI } = require("../../../provider"); @@ -124,7 +126,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -144,7 +146,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -164,7 +166,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -185,7 +187,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -210,7 +212,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -230,7 +232,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -255,7 +257,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -288,7 +290,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -321,7 +323,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -334,4 +336,54 @@ describe("", () => { expect(screen.queryByTestId("policies")).not.toBeInTheDocument(); expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); }); + + it("emits onSignIn with credential when MFA flow succeeds", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [{ factorId: "totp", uid: "test" }] }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: + '
MFA Assertion Form
', + }, + }); + + const onSignInHandler = jest.fn(); + + @Component({ + template: ``, + standalone: true, + imports: [OAuthScreenComponent], + }) + class HostCaptureComponent { + onSignIn = onSignInHandler; + } + + await render(HostCaptureComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + const trigger = screen.getByTestId("mfa-on-success"); + trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onSignInHandler).toHaveBeenCalled(); + expect(onSignInHandler).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "angular-oauth-mfa-user" }) }) + ); + }); }); diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.ts b/packages/angular/src/lib/auth/screens/oauth-screen.ts index 14d60dd0..04b4e279 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, computed } from "@angular/core"; +import { Component, computed, output } from "@angular/core"; import { CommonModule } from "@angular/common"; import { CardComponent, @@ -28,6 +28,7 @@ import { PoliciesComponent } from "../../components/policies"; import { ContentComponent } from "../../components/content"; import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { RedirectErrorComponent } from "../../components/redirect-error"; +import { type UserCredential } from "firebase/auth"; @Component({ selector: "fui-oauth-screen", @@ -53,7 +54,7 @@ import { RedirectErrorComponent } from "../../components/redirect-error"; @if (mfaResolver()) { - + } @else { @@ -73,4 +74,6 @@ export class OAuthScreenComponent { titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); + + onSignIn = output(); } diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts index 11844a59..45b93772 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts @@ -27,6 +27,8 @@ import { CardContentComponent, } from "../../components/card"; import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { TotpMultiFactorAssertionFormComponent } from "../forms/mfa/totp-multi-factor-assertion-form"; +import { TotpMultiFactorGenerator } from "firebase/auth"; @Component({ selector: "fui-phone-auth-form", @@ -42,13 +44,6 @@ class MockPhoneAuthFormComponent {} }) class MockRedirectErrorComponent {} -@Component({ - selector: "fui-multi-factor-auth-assertion-form", - template: '
MFA Assertion Form
', - standalone: true, -}) -class MockMultiFactorAuthAssertionFormComponent {} - @Component({ template: ` @@ -95,7 +90,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -114,7 +109,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -134,7 +129,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -154,7 +149,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -173,7 +168,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -197,7 +192,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -229,7 +224,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -261,7 +256,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -273,4 +268,53 @@ describe("", () => { expect(screen.queryByText("Phone Auth Form")).not.toBeInTheDocument(); expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); }); + + it("emits signIn with credential when MFA flow succeeds", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }] }, + }); + }); + + TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + set: { + template: + '
TOTP
', + }, + }); + + const signInHandler = jest.fn(); + + @Component({ + template: ``, + standalone: true, + imports: [PhoneAuthScreenComponent], + }) + class HostCaptureComponent { + onSignIn = signInHandler; + } + + await render(HostCaptureComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionFormComponent, // Using real component + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const trigger = screen.getByTestId("mfa-on-success"); + trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(signInHandler).toHaveBeenCalled(); + expect(signInHandler).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "angular-phone-mfa-user" }) }) + ); + }); }); diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts index 8c3f692e..fb12e057 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts @@ -52,7 +52,7 @@ import { UserCredential } from "@angular/fire/auth"; @if (mfaResolver()) { - + } @else { diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts index 2e2361a1..d87a65e6 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts @@ -27,6 +27,8 @@ import { CardContentComponent, } from "../../components/card"; import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { TotpMultiFactorAssertionFormComponent } from "../forms/mfa/totp-multi-factor-assertion-form"; +import { TotpMultiFactorGenerator } from "firebase/auth"; @Component({ selector: "fui-sign-in-auth-form", @@ -42,13 +44,6 @@ class MockSignInAuthFormComponent {} }) class MockRedirectErrorComponent {} -@Component({ - selector: "fui-multi-factor-auth-assertion-form", - template: '
MFA Assertion Form
', - standalone: true, -}) -class MockMultiFactorAuthAssertionFormComponent {} - @Component({ template: ` @@ -95,7 +90,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -114,7 +109,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -134,7 +129,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -154,7 +149,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -173,7 +168,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -197,7 +192,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -229,7 +224,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -261,7 +256,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -273,4 +268,53 @@ describe("", () => { expect(screen.queryByRole("button", { name: "Sign in" })).not.toBeInTheDocument(); expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); }); + + it("emits signIn with credential when MFA flow succeeds", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }] }, + }); + }); + + TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + set: { + template: + '
TOTP
', + }, + }); + + const signInHandler = jest.fn(); + + @Component({ + template: ``, + standalone: true, + imports: [SignInAuthScreenComponent], + }) + class HostCaptureComponent { + onSignIn = signInHandler; + } + + await render(HostCaptureComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const trigger = screen.getByTestId("mfa-on-success"); + trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(signInHandler).toHaveBeenCalled(); + expect(signInHandler).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "angular-mfa-user" }) }) + ); + }); }); diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts index 7ff81dee..abf25481 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts @@ -52,7 +52,7 @@ import { UserCredential } from "@angular/fire/auth"; @if (mfaResolver()) { - + } @else { MFA Assertion Form
', - standalone: true, -}) -class MockMultiFactorAuthAssertionFormComponent {} - @Component({ template: ` @@ -95,7 +90,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -114,7 +109,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -133,7 +128,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -153,7 +148,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -172,7 +167,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -196,7 +191,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -228,7 +223,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -260,7 +255,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MockMultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -272,4 +267,53 @@ describe("", () => { expect(screen.queryByText("Sign Up Form")).not.toBeInTheDocument(); expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); }); + + it("emits signUp with credential when MFA flow succeeds", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }] }, + }); + }); + + TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + set: { + template: + '
TOTP
', + }, + }); + + const signUpHandler = jest.fn(); + + @Component({ + template: ``, + standalone: true, + imports: [SignUpAuthScreenComponent], + }) + class HostCaptureComponent { + onSignUp = signUpHandler; + } + + await render(HostCaptureComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const trigger = screen.getByTestId("mfa-on-success"); + trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(signUpHandler).toHaveBeenCalled(); + expect(signUpHandler).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "angular-signup-mfa-user" }) }) + ); + }); }); diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts index 53da6747..ce206f4a 100644 --- a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts @@ -53,7 +53,7 @@ import { @if (mfaResolver()) { - + } @else { diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts index 99670a2f..c1175bbd 100644 --- a/packages/core/src/auth.test.ts +++ b/packages/core/src/auth.test.ts @@ -12,6 +12,7 @@ import { signInWithProvider, signInWithCustomToken, generateTotpQrCode, + signInWithMultiFactorAssertion, } from "./auth"; vi.mock("firebase/auth", () => ({ @@ -42,7 +43,6 @@ vi.mock("./errors", () => ({ handleFirebaseError: vi.fn(), })); -// Import the mocked functions import { signInWithCredential as _signInWithCredential, EmailAuthProvider, @@ -85,13 +85,11 @@ describe("signInWithEmailAndPassword", () => { expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - // Calls pending pre-_signInWithCredential call, then idle after. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); expect(_signInWithCredential).toHaveBeenCalledTimes(1); - // Assert that the result is a valid UserCredential. expect(result.providerId).toBe("password"); }); @@ -113,7 +111,6 @@ describe("signInWithEmailAndPassword", () => { expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); expect(result.providerId).toBe("password"); - // Auth method sets pending at start, then idle in finally block. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -137,7 +134,6 @@ describe("signInWithEmailAndPassword", () => { expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); expect(_signInWithCredential).toHaveBeenCalledTimes(1); - // Calls pending pre-_signInWithCredential call, then idle after. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -178,13 +174,11 @@ describe("createUserWithEmailAndPassword", () => { expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - // Calls pending pre-createUserWithEmailAndPassword call, then idle after. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); expect(_createUserWithEmailAndPassword).toHaveBeenCalledWith(mockUI.auth, email, password); expect(_createUserWithEmailAndPassword).toHaveBeenCalledTimes(1); - // Assert that the result is a valid UserCredential. expect(result.providerId).toBe("password"); }); @@ -210,7 +204,6 @@ describe("createUserWithEmailAndPassword", () => { expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); expect(result.providerId).toBe("password"); - // Auth method sets pending at start, then idle in finally block. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -238,7 +231,6 @@ describe("createUserWithEmailAndPassword", () => { expect(_createUserWithEmailAndPassword).toHaveBeenCalledWith(mockUI.auth, email, password); expect(_createUserWithEmailAndPassword).toHaveBeenCalledTimes(1); - // Calls pending pre-createUserWithEmailAndPassword call, then idle after. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -400,15 +392,12 @@ describe("verifyPhoneNumber", () => { const result = await verifyPhoneNumber(mockUI, phoneNumber, mockAppVerifier); - // Verify state management expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - // Verify the PhoneAuthProvider was created and verifyPhoneNumber was called expect(PhoneAuthProvider).toHaveBeenCalledWith(mockUI.auth); expect(mockVerifyPhoneNumber).toHaveBeenCalledWith(phoneNumber, mockAppVerifier); expect(mockVerifyPhoneNumber).toHaveBeenCalledTimes(1); - // Verify the result expect(result).toEqual(mockVerificationId); }); @@ -605,6 +594,52 @@ describe("confirmPhoneNumber", () => { }); }); +describe("signInWithMultiFactorAssertion", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("resolves sign-in via resolver, clears resolver, and returns credential", async () => { + const mockUI = createMockUI(); + + const mockCredential = { providerId: "mfa", user: { uid: "mfa-user" } } as UserCredential; + const resolveSignIn = vi.fn().mockResolvedValue(mockCredential); + + const mockResolver = { resolveSignIn } as any; + (mockUI as any).multiFactorResolver = mockResolver; + + const mockAssertion = { assertion: true } as any; // type MultiFactorAssertion + + const result = await signInWithMultiFactorAssertion(mockUI, mockAssertion); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(resolveSignIn).toHaveBeenCalledWith(mockAssertion); + expect(resolveSignIn).toHaveBeenCalledTimes(1); + + expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(undefined); + + expect(result).toBe(mockCredential); + }); + + it("handles errors via handleFirebaseError and maintains state transitions", async () => { + const mockUI = createMockUI(); + + const error = new FirebaseError("auth/mfa-error", "MFA resolution failed"); + const resolveSignIn = vi.fn().mockRejectedValue(error); + const mockResolver = { resolveSignIn } as any; + (mockUI as any).multiFactorResolver = mockResolver; + + const mockAssertion = { assertion: true } as any; // type MultiFactorAssertion + + await signInWithMultiFactorAssertion(mockUI, mockAssertion); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + describe("sendPasswordResetEmail", () => { beforeEach(() => { vi.clearAllMocks(); @@ -618,10 +653,8 @@ describe("sendPasswordResetEmail", () => { await sendPasswordResetEmail(mockUI, email); - // Verify state management expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - // Verify the Firebase function was called with correct parameters expect(_sendPasswordResetEmail).toHaveBeenCalledWith(mockUI.auth, email); expect(_sendPasswordResetEmail).toHaveBeenCalledTimes(1); }); @@ -635,10 +668,8 @@ describe("sendPasswordResetEmail", () => { await sendPasswordResetEmail(mockUI, email); - // Verify error handling expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); - // Verify state management still happens expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); }); @@ -646,7 +677,6 @@ describe("sendPasswordResetEmail", () => { describe("sendSignInLinkToEmail", () => { beforeEach(() => { vi.clearAllMocks(); - // Mock window.location.href Object.defineProperty(window, "location", { value: { href: "https://example.com" }, writable: true, @@ -654,7 +684,6 @@ describe("sendSignInLinkToEmail", () => { }); afterEach(() => { - // Clean up localStorage after each test window.localStorage.clear(); }); @@ -675,7 +704,6 @@ describe("sendSignInLinkToEmail", () => { expect(_sendSignInLinkToEmail).toHaveBeenCalledWith(mockUI.auth, email, expectedActionCodeSettings); expect(_sendSignInLinkToEmail).toHaveBeenCalledTimes(1); - // Verify email is stored in localStorage expect(window.localStorage.getItem("emailForSignIn")).toBe(email); }); @@ -688,13 +716,10 @@ describe("sendSignInLinkToEmail", () => { await sendSignInLinkToEmail(mockUI, email); - // Verify error handling expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); - // Verify state management still happens expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - // Verify email is NOT stored in localStorage on error expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); }); @@ -750,15 +775,12 @@ describe("signInWithEmailLink", () => { const result = await signInWithEmailLink(mockUI, email, link); - // Verify credential was created correctly expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - // Verify our signInWithCredential function was called (which internally calls Firebase) expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); expect(_signInWithCredential).toHaveBeenCalledTimes(1); - // Assert that the result is a valid UserCredential. expect(result.providerId).toBe("emailLink"); }); @@ -775,17 +797,14 @@ describe("signInWithEmailLink", () => { const result = await signInWithEmailLink(mockUI, email, link); - // Verify credential was created correctly expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - // Verify our signInWithCredential function was called with behavior expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); expect(result.providerId).toBe("emailLink"); - // Auth method sets pending at start, then idle in finally block. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -802,10 +821,8 @@ describe("signInWithEmailLink", () => { await signInWithEmailLink(mockUI, email, link); - // Verify credential was created correctly expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - // Verify our signInWithCredential function was called with behavior expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); @@ -814,7 +831,6 @@ describe("signInWithEmailLink", () => { expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); expect(_signInWithCredential).toHaveBeenCalledTimes(1); - // Calls pending pre-_signInWithCredential call, then idle after. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -831,10 +847,8 @@ describe("signInWithEmailLink", () => { await signInWithEmailLink(mockUI, email, link); - // Verify credential was created correctly expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - // Verify our signInWithCredential function was called and error was handled expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); @@ -857,13 +871,11 @@ describe("signInWithCredential", () => { expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - // Calls pending pre-_signInWithCredential call, then idle after. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); expect(_signInWithCredential).toHaveBeenCalledTimes(1); - // Assert that the result is a valid UserCredential. expect(result.providerId).toBe("password"); }); @@ -883,7 +895,6 @@ describe("signInWithCredential", () => { expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); expect(result.providerId).toBe("password"); - // Auth method sets pending at start, then idle in finally block. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -905,7 +916,6 @@ describe("signInWithCredential", () => { expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); expect(_signInWithCredential).toHaveBeenCalledTimes(1); - // Calls pending pre-_signInWithCredential call, then idle after. expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); @@ -1034,7 +1044,6 @@ describe("signInWithCustomToken", () => { await signInWithCustomToken(mockUI, customToken); - // Verify redirect error is cleared even when network error occurs expect(mockUI.setRedirectError).toHaveBeenCalledWith(undefined); expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); @@ -1050,7 +1059,6 @@ describe("signInWithCustomToken", () => { await signInWithCustomToken(mockUI, customToken); - // Verify redirect error is cleared even when token is expired expect(mockUI.setRedirectError).toHaveBeenCalledWith(undefined); expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); @@ -1068,7 +1076,6 @@ describe("signInWithProvider", () => { const provider = { providerId: "google.com" } as AuthProvider; const mockResult = { user: { uid: "test-user" } } as UserCredential; - // Mock behaviors - no autoUpgradeAnonymousProvider vi.mocked(hasBehavior).mockReturnValue(false); const mockProviderStrategy = vi.fn().mockResolvedValue(mockResult); @@ -1104,7 +1111,6 @@ describe("signInWithProvider", () => { const provider = { providerId: "google.com" } as AuthProvider; const mockResult = { user: { uid: "test-user" } } as UserCredential; - // Mock behaviors - autoUpgradeAnonymousProvider enabled but returns undefined vi.mocked(hasBehavior).mockReturnValue(true); const mockUpgradeBehavior = vi.fn().mockResolvedValue(undefined); diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 3724a7cc..fe82670e 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -343,7 +343,7 @@ export function generateTotpQrCode(ui: FirebaseUI, secret: TotpSecret, accountNa export async function signInWithMultiFactorAssertion(ui: FirebaseUI, assertion: MultiFactorAssertion) { try { setPendingState(ui); - const result = await ui.multiFactorResolver?.resolveSignIn(assertion); + const result = await ui.multiFactorResolver!.resolveSignIn(assertion); ui.setMultiFactorResolver(undefined); return result; } catch (error) { diff --git a/packages/core/src/schemas.test.ts b/packages/core/src/schemas.test.ts index ecda98f1..4f21021a 100644 --- a/packages/core/src/schemas.test.ts +++ b/packages/core/src/schemas.test.ts @@ -3,6 +3,7 @@ import { createMockUI } from "~/tests/utils"; import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, + createMultiFactorPhoneAuthAssertionNumberFormSchema, createPhoneAuthNumberFormSchema, createPhoneAuthVerifyFormSchema, createSignInAuthFormSchema, @@ -309,3 +310,77 @@ describe("createPhoneAuthVerifyFormSchema", () => { ).toBe(true); }); }); + +describe("createMultiFactorPhoneAuthAssertionNumberFormSchema", () => { + it("should create a multi-factor phone auth assertion number form schema and show missing phone number error", () => { + const testLocale = registerLocale("test", { + errors: { + missingPhoneNumber: "createMultiFactorPhoneAuthAssertionNumberFormSchema + missingPhoneNumber", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createMultiFactorPhoneAuthAssertionNumberFormSchema(mockUI); + + const result = schema.safeParse({ + phoneNumber: "", + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues[0]?.message).toBe( + "createMultiFactorPhoneAuthAssertionNumberFormSchema + missingPhoneNumber" + ); + }); + + it("should create a multi-factor phone auth assertion number form schema and show an error if the phone number is too long", () => { + const testLocale = registerLocale("test", { + errors: { + invalidPhoneNumber: "createMultiFactorPhoneAuthAssertionNumberFormSchema + invalidPhoneNumber", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createMultiFactorPhoneAuthAssertionNumberFormSchema(mockUI); + + const result = schema.safeParse({ + phoneNumber: "12345678901", + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues[0]?.message).toBe( + "createMultiFactorPhoneAuthAssertionNumberFormSchema + invalidPhoneNumber" + ); + }); + + it("should accept valid phone number without requiring displayName", () => { + const testLocale = registerLocale("test", { + errors: { + missingPhoneNumber: "missing", + invalidPhoneNumber: "invalid", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createMultiFactorPhoneAuthAssertionNumberFormSchema(mockUI); + + const result = schema.safeParse({ + phoneNumber: "1234567890", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ phoneNumber: "1234567890" }); + } + }); +}); diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 354a0be2..7edf3e64 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -80,6 +80,10 @@ export function createMultiFactorPhoneAuthNumberFormSchema(ui: FirebaseUI) { }); } +export function createMultiFactorPhoneAuthAssertionNumberFormSchema(ui: FirebaseUI) { + return createPhoneAuthNumberFormSchema(ui); +} + export function createMultiFactorPhoneAuthVerifyFormSchema(ui: FirebaseUI) { return createPhoneAuthVerifyFormSchema(ui); } 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 index e9d60664..394c08a7 100644 --- 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 @@ -15,7 +15,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, renderHook, cleanup } from "@testing-library/react"; +import { render, screen, renderHook, cleanup, fireEvent, waitFor } from "@testing-library/react"; import { SmsMultiFactorAssertionForm, useSmsMultiFactorAssertionPhoneFormAction, @@ -284,4 +284,65 @@ describe("", () => { ); }).not.toThrow(); }); + + it("invokes onSuccess with credential after full SMS verification flow", async () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "sendCode", + phoneNumber: "phoneNumber", + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + }), + }); + + const mockHint = { + factorId: "phone" as const, + phoneNumber: "+123456789", // Max 10 chars for schema validation + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + // First step returns a verificationId + vi.mocked(verifyPhoneNumber).mockResolvedValue("vid-123"); + // Second step returns a credential from MFA assertion + const mockCredential = { user: { uid: "sms-cred-user" } } as any; + vi.mocked(signInWithMultiFactorAssertion).mockResolvedValue(mockCredential); + + const onSuccessMock = vi.fn(); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const sendCodeForm = screen.getByRole("button", { name: "sendCode" }).closest("form"); + await act(async () => { + fireEvent.submit(sendCodeForm!); + }); + + const codeInput = await waitFor(() => screen.findByRole("textbox", { name: /verificationCode/i })); + const form = codeInput.closest("form"); + + await act(async () => { + fireEvent.change(codeInput, { target: { value: "123456" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(verifyPhoneNumber).toHaveBeenCalled(); + expect(signInWithMultiFactorAssertion).toHaveBeenCalled(); + }); + + expect(onSuccessMock).toHaveBeenCalledTimes(1); + expect(onSuccessMock).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "sms-cred-user" }) }) + ); + }); }); 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 21132eac..a29bdc9c 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 @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from "react"; import { PhoneAuthProvider, PhoneMultiFactorGenerator, + type UserCredential, type MultiFactorInfo, type RecaptchaVerifier, } from "firebase/auth"; @@ -9,7 +10,7 @@ import { import { signInWithMultiFactorAssertion, FirebaseUIError, getTranslation, verifyPhoneNumber } from "@firebase-ui/core"; import { form } from "~/components/form"; import { - useMultiFactorPhoneAuthNumberFormSchema, + useMultiFactorPhoneAuthAssertionNumberFormSchema, useMultiFactorPhoneAuthVerifyFormSchema, useRecaptchaVerifier, useUI, @@ -42,7 +43,7 @@ export function useSmsMultiFactorAssertionPhoneForm({ onSuccess, }: UseSmsMultiFactorAssertionPhoneForm) { const action = useSmsMultiFactorAssertionPhoneFormAction(); - const schema = useMultiFactorPhoneAuthNumberFormSchema(); + const schema = useMultiFactorPhoneAuthAssertionNumberFormSchema(); return form.useAppForm({ defaultValues: { @@ -127,7 +128,7 @@ export function useSmsMultiFactorAssertionVerifyFormAction() { type UseSmsMultiFactorAssertionVerifyForm = { verificationId: string; - onSuccess: () => void; + onSuccess: (credential: UserCredential) => void; }; export function useSmsMultiFactorAssertionVerifyForm({ @@ -147,8 +148,8 @@ export function useSmsMultiFactorAssertionVerifyForm({ onBlur: schema, onSubmitAsync: async ({ value }) => { try { - await action(value); - return onSuccess(); + const credential = await action(value); + return onSuccess(credential); } catch (error) { return error instanceof FirebaseUIError ? error.message : String(error); } @@ -159,7 +160,7 @@ export function useSmsMultiFactorAssertionVerifyForm({ type SmsMultiFactorAssertionVerifyFormProps = { verificationId: string; - onSuccess: () => void; + onSuccess: (credential: UserCredential) => void; }; function SmsMultiFactorAssertionVerifyForm(props: SmsMultiFactorAssertionVerifyFormProps) { @@ -195,7 +196,7 @@ function SmsMultiFactorAssertionVerifyForm(props: SmsMultiFactorAssertionVerifyF export type SmsMultiFactorAssertionFormProps = { hint: MultiFactorInfo; - onSuccess?: () => void; + onSuccess?: (credential: UserCredential) => void; }; export function SmsMultiFactorAssertionForm(props: SmsMultiFactorAssertionFormProps) { @@ -215,8 +216,8 @@ export function SmsMultiFactorAssertionForm(props: SmsMultiFactorAssertionFormPr return ( { - props.onSuccess?.(); + onSuccess={(credential) => { + props.onSuccess?.(credential); }} /> ); 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 index 4a20ee06..f4b68674 100644 --- 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 @@ -16,7 +16,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, renderHook, cleanup } from "@testing-library/react"; +import { render, screen, renderHook, cleanup, fireEvent, waitFor } from "@testing-library/react"; import { TotpMultiFactorAssertionForm, useTotpMultiFactorAssertionFormAction, @@ -204,4 +204,53 @@ describe("", () => { const input = screen.getByRole("textbox", { name: /verificationCode/i }); expect(input).toBeInTheDocument(); }); + + it("invokes onSuccess with credential after successful verification", async () => { + 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 mockCredential = { user: { uid: "totp-cred-user" } } as any; + vi.mocked(signInWithMultiFactorAssertion).mockResolvedValue(mockCredential); + + const onSuccessMock = vi.fn(); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const input = screen.getByRole("textbox", { name: /verificationCode/i }); + const form = input.closest("form"); + + await act(async () => { + fireEvent.change(input, { target: { value: "123456" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(signInWithMultiFactorAssertion).toHaveBeenCalled(); + }); + + expect(onSuccessMock).toHaveBeenCalledTimes(1); + expect(onSuccessMock).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "totp-cred-user" }) }) + ); + }); }); 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 152385aa..4a4e902c 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,5 +1,5 @@ import { useCallback } from "react"; -import { TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; +import { TotpMultiFactorGenerator, type UserCredential, type MultiFactorInfo } from "firebase/auth"; import { signInWithMultiFactorAssertion, FirebaseUIError, getTranslation } from "@firebase-ui/core"; import { form } from "~/components/form"; import { useMultiFactorTotpAuthVerifyFormSchema, useUI } from "~/hooks"; @@ -18,7 +18,7 @@ export function useTotpMultiFactorAssertionFormAction() { type UseTotpMultiFactorAssertionForm = { hint: MultiFactorInfo; - onSuccess: () => void; + onSuccess: (credential: UserCredential) => void; }; export function useTotpMultiFactorAssertionForm({ hint, onSuccess }: UseTotpMultiFactorAssertionForm) { @@ -34,8 +34,8 @@ export function useTotpMultiFactorAssertionForm({ hint, onSuccess }: UseTotpMult onBlur: schema, onSubmitAsync: async ({ value }) => { try { - await action({ verificationCode: value.verificationCode, hint }); - return onSuccess(); + const credential = await action({ verificationCode: value.verificationCode, hint }); + return onSuccess(credential); } catch (error) { return error instanceof FirebaseUIError ? error.message : String(error); } @@ -46,15 +46,15 @@ export function useTotpMultiFactorAssertionForm({ hint, onSuccess }: UseTotpMult type TotpMultiFactorAssertionFormProps = { hint: MultiFactorInfo; - onSuccess?: () => void; + onSuccess?: (credential: UserCredential) => void; }; export function TotpMultiFactorAssertionForm(props: TotpMultiFactorAssertionFormProps) { const ui = useUI(); const form = useTotpMultiFactorAssertionForm({ hint: props.hint, - onSuccess: () => { - props.onSuccess?.(); + onSuccess: (credential) => { + props.onSuccess?.(credential); }, }); 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 index 5c567759..eb14440c 100644 --- 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 @@ -21,11 +21,25 @@ 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
, + SmsMultiFactorAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
SMS Assertion Form
+ +
+ ), })); vi.mock("~/auth/forms/mfa/totp-multi-factor-assertion-form", () => ({ - TotpMultiFactorAssertionForm: () =>
TOTP Assertion Form
, + TotpMultiFactorAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
TOTP Assertion Form
+ +
+ ), })); vi.mock("~/components/button", () => ({ @@ -79,6 +93,66 @@ describe("", () => { expect(screen.queryByTestId("mfa-button")).toBeNull(); }); + it("invokes onSuccess with credential from SMS assertion child", () => { + 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); + + const onSuccess = vi.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("sms-on-success")); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "sms-user" }) }) + ); + }); + + it("invokes onSuccess with credential from TOTP assertion child", () => { + 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); + + const onSuccess = vi.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("totp-on-success")); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "totp-user" }) }) + ); + }); + it("auto-selects TOTP factor when only one TOTP hint exists", () => { const mockResolver = { auth: {} as any, 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 e60f1c45..36ab2c85 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 @@ -1,4 +1,9 @@ -import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; +import { + PhoneMultiFactorGenerator, + TotpMultiFactorGenerator, + type UserCredential, + 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"; @@ -6,7 +11,11 @@ import { SmsMultiFactorAssertionForm } from "../forms/mfa/sms-multi-factor-asser import { Button } from "~/components/button"; import { getTranslation } from "@firebase-ui/core"; -export function MultiFactorAuthAssertionForm() { +export type MultiFactorAuthAssertionFormProps = { + onSuccess?: (credential: UserCredential) => void; +}; + +export function MultiFactorAuthAssertionForm(props: MultiFactorAuthAssertionFormProps) { const ui = useUI(); const resolver = ui.multiFactorResolver; @@ -21,11 +30,25 @@ export function MultiFactorAuthAssertionForm() { if (hint) { if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { - return ; + return ( + { + props.onSuccess?.(credential); + }} + /> + ); } if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { - return ; + return ( + { + props.onSuccess?.(credential); + }} + /> + ); } } diff --git a/packages/react/src/auth/screens/oauth-screen.test.tsx b/packages/react/src/auth/screens/oauth-screen.test.tsx index 05e33cd9..92fd79ad 100644 --- a/packages/react/src/auth/screens/oauth-screen.test.tsx +++ b/packages/react/src/auth/screens/oauth-screen.test.tsx @@ -14,7 +14,7 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; import { OAuthScreen } from "~/auth/screens/oauth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; @@ -33,7 +33,14 @@ vi.mock("~/components/redirect-error", () => ({ })); vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({ - MultiFactorAuthAssertionForm: () =>
MFA Assertion Form
, + MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
MFA Assertion Form
+ +
+ ), })); afterEach(() => { @@ -217,4 +224,29 @@ describe("", () => { expect(screen.queryByTestId("redirect-error")).toBeNull(); expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); }); + + it("calls onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignIn = vi.fn(); + + render( + + OAuth Provider + + ); + + fireEvent.click(screen.getByTestId("mfa-on-success")); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "oauth-mfa-user" }) }) + ); + }); }); diff --git a/packages/react/src/auth/screens/oauth-screen.tsx b/packages/react/src/auth/screens/oauth-screen.tsx index ac129f45..70cef7c8 100644 --- a/packages/react/src/auth/screens/oauth-screen.tsx +++ b/packages/react/src/auth/screens/oauth-screen.tsx @@ -15,6 +15,7 @@ */ import { getTranslation } from "@firebase-ui/core"; +import { type UserCredential } from "firebase/auth"; import { type PropsWithChildren } from "react"; import { useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; @@ -22,9 +23,11 @@ import { Policies } from "~/components/policies"; import { MultiFactorAuthAssertionForm } from "../forms/multi-factor-auth-assertion-form"; import { RedirectError } from "~/components/redirect-error"; -export type OAuthScreenProps = PropsWithChildren; +export type OAuthScreenProps = PropsWithChildren<{ + onSignIn?: (credential: UserCredential) => void; +}>; -export function OAuthScreen({ children }: OAuthScreenProps) { +export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); @@ -40,7 +43,11 @@ export function OAuthScreen({ children }: OAuthScreenProps) { {mfaResolver ? ( - + { + onSignIn?.(credential); + }} + /> ) : ( <> {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 090fa60a..0a74ee33 100644 --- a/packages/react/src/auth/screens/phone-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/phone-auth-screen.test.tsx @@ -41,7 +41,14 @@ vi.mock("~/components/redirect-error", () => ({ })); vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({ - MultiFactorAuthAssertionForm: () =>
MFA Assertion Form
, + MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
MFA Assertion Form
+ +
+ ), })); afterEach(() => { @@ -243,4 +250,31 @@ describe("", () => { expect(screen.queryByTestId("redirect-error")).toBeNull(); expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); }); + + it("calls onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignIn = vi.fn(); + + render( + + + + ); + + // Simulate nested MFA form success + const trigger = screen.getByTestId("mfa-on-success"); + trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "phone-mfa-user" }) }) + ); + }); }); diff --git a/packages/react/src/auth/screens/phone-auth-screen.tsx b/packages/react/src/auth/screens/phone-auth-screen.tsx index 292b0290..6414d757 100644 --- a/packages/react/src/auth/screens/phone-auth-screen.tsx +++ b/packages/react/src/auth/screens/phone-auth-screen.tsx @@ -41,7 +41,11 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { {mfaResolver ? ( - + { + props.onSignIn?.(credential); + }} + /> ) : ( <> 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 6ef6a76e..7b70c922 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 @@ -52,7 +52,14 @@ vi.mock("~/components/redirect-error", () => ({ })); vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({ - MultiFactorAuthAssertionForm: () =>
MFA Assertion Form
, + MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
MFA Assertion Form
+ +
+ ), })); describe("", () => { @@ -272,4 +279,30 @@ describe("", () => { expect(screen.queryByTestId("redirect-error")).toBeNull(); expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); }); + + it("calls onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignIn = vi.fn(); + + render( + + + + ); + + // Simulate the MFA child reporting success with a credential + fireEvent.click(screen.getByTestId("mfa-on-success")); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + ); + }); }); 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 31af077a..b53c5949 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.tsx @@ -42,7 +42,11 @@ export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps) {mfaResolver ? ( - + { + props.onSignIn?.(credential); + }} + /> ) : ( <> 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 9765a0c2..ac5e01d4 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 @@ -43,7 +43,14 @@ vi.mock("~/components/redirect-error", () => ({ })); vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({ - MultiFactorAuthAssertionForm: () =>
MFA Assertion Form
, + MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
MFA Assertion Form
+ +
+ ), })); describe("", () => { @@ -246,4 +253,31 @@ describe("", () => { expect(screen.queryByTestId("redirect-error")).toBeNull(); expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); }); + + it("calls onSignUp with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignUp = vi.fn(); + + render( + + + + ); + + // Simulate nested MFA form success + const trigger = screen.getByTestId("mfa-on-success"); + trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onSignUp).toHaveBeenCalledTimes(1); + expect(onSignUp).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "signup-mfa-user" }) }) + ); + }); }); 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 70ad50cd..dd3281b6 100644 --- a/packages/react/src/auth/screens/sign-up-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-up-auth-screen.tsx @@ -42,7 +42,11 @@ export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) {mfaResolver ? ( - + { + props.onSignUp?.(credential); + }} + /> ) : ( <> diff --git a/packages/react/src/hooks.test.tsx b/packages/react/src/hooks.test.tsx index ca0c17fe..cc1d8906 100644 --- a/packages/react/src/hooks.test.tsx +++ b/packages/react/src/hooks.test.tsx @@ -24,6 +24,7 @@ import { useSignUpAuthFormSchema, useForgotPasswordAuthFormSchema, useEmailLinkAuthFormSchema, + useMultiFactorPhoneAuthAssertionNumberFormSchema, usePhoneAuthNumberFormSchema, usePhoneAuthVerifyFormSchema, useRecaptchaVerifier, @@ -712,6 +713,116 @@ describe("usePhoneAuthVerifyFormSchema", () => { }); }); +describe("useMultiFactorPhoneAuthAssertionNumberFormSchema", () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it("returns schema with default English error messages", () => { + const mockUI = createMockUI(); + + const { result } = renderHook(() => useMultiFactorPhoneAuthAssertionNumberFormSchema(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + const schema = result.current; + + const phoneResult = schema.safeParse({ phoneNumber: "invalid-phone" }); + expect(phoneResult.success).toBe(false); + if (!phoneResult.success) { + expect(phoneResult.error.issues[0]!.message).toBe(enUs.translations.errors!.invalidPhoneNumber); + } + }); + + it("returns schema with custom error messages when locale changes", () => { + const customTranslations = { + errors: { + invalidPhoneNumber: "Por favor ingresa un número de teléfono válido", + }, + }; + + const customLocale = registerLocale("es-ES", customTranslations); + const mockUI = createMockUI({ locale: customLocale }); + + const { result } = renderHook(() => useMultiFactorPhoneAuthAssertionNumberFormSchema(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + const schema = result.current; + + const phoneResult = schema.safeParse({ phoneNumber: "invalid-phone" }); + expect(phoneResult.success).toBe(false); + if (!phoneResult.success) { + expect(phoneResult.error.issues[0]!.message).toBe("Por favor ingresa un número de teléfono válido"); + } + }); + + it("returns stable reference when UI hasn't changed", () => { + const mockUI = createMockUI(); + + const { result, rerender } = renderHook(() => useMultiFactorPhoneAuthAssertionNumberFormSchema(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + const initialSchema = result.current; + + rerender(); + + expect(result.current).toBe(initialSchema); + }); + + it("returns new schema when locale changes", () => { + const mockUI = createMockUI(); + + const { result, rerender } = renderHook(() => useMultiFactorPhoneAuthAssertionNumberFormSchema(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + const initialSchema = result.current; + + const customTranslations = { + errors: { + invalidPhoneNumber: "Custom phone error", + }, + }; + const customLocale = registerLocale("fr-FR", customTranslations); + + act(() => { + mockUI.get().setLocale(customLocale); + }); + + rerender(); + + expect(result.current).not.toBe(initialSchema); + + const phoneResult = result.current.safeParse({ phoneNumber: "invalid-phone" }); + expect(phoneResult.success).toBe(false); + + if (!phoneResult.success) { + expect(phoneResult.error.issues[0]!.message).toBe("Custom phone error"); + } + }); + + it("accepts valid phone number without requiring displayName", () => { + const mockUI = createMockUI(); + + const { result } = renderHook(() => useMultiFactorPhoneAuthAssertionNumberFormSchema(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + const schema = result.current; + + const phoneResult = schema.safeParse({ phoneNumber: "1234567890" }); + expect(phoneResult.success).toBe(true); + if (phoneResult.success) { + expect(phoneResult.data).toEqual({ phoneNumber: "1234567890" }); + // Should not have displayName field + expect(phoneResult.data).not.toHaveProperty("displayName"); + } + }); +}); + describe("useRedirectError", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index 920685a5..1017c7e2 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -20,6 +20,7 @@ import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, createMultiFactorPhoneAuthNumberFormSchema, + createMultiFactorPhoneAuthAssertionNumberFormSchema, createMultiFactorPhoneAuthVerifyFormSchema, createMultiFactorTotpAuthNumberFormSchema, createMultiFactorTotpAuthVerifyFormSchema, @@ -92,6 +93,11 @@ export function useMultiFactorPhoneAuthNumberFormSchema() { return useMemo(() => createMultiFactorPhoneAuthNumberFormSchema(ui), [ui]); } +export function useMultiFactorPhoneAuthAssertionNumberFormSchema() { + const ui = useUI(); + return useMemo(() => createMultiFactorPhoneAuthAssertionNumberFormSchema(ui), [ui]); +} + export function useMultiFactorPhoneAuthVerifyFormSchema() { const ui = useUI(); return useMemo(() => createMultiFactorPhoneAuthVerifyFormSchema(ui), [ui]);