From f0976000d3b3b46f40ce4fa28770176aacdc6bf0 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 30 Oct 2025 19:16:51 +0000 Subject: [PATCH] fix(react,shadcn,angular): Email link form handles MFA assertion --- .../screens/email-link-auth-screen.spec.ts | 108 +++++++++++++++++- .../auth/screens/email-link-auth-screen.ts | 20 +++- .../screens/email-link-auth-screen.test.tsx | 99 +++++++++++++++- .../auth/screens/email-link-auth-screen.tsx | 31 +++-- .../registry/email-link-auth-screen.test.tsx | 92 ++++++++++++++- .../src/registry/email-link-auth-screen.tsx | 23 +++- 6 files changed, 349 insertions(+), 24 deletions(-) diff --git a/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts index 03355da3c..b9e4328f4 100644 --- a/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { render, screen } from "@testing-library/angular"; -import { Component } from "@angular/core"; +import { render, screen, fireEvent } from "@testing-library/angular"; +import { Component, EventEmitter } from "@angular/core"; import { EmailLinkAuthScreenComponent } from "./email-link-auth-screen"; import { @@ -40,6 +40,21 @@ class MockEmailLinkAuthFormComponent {} }) class MockRedirectErrorComponent {} +@Component({ + selector: "fui-multi-factor-auth-assertion-form", + template: ` +
MFA Assertion Form
+ + `, + standalone: true, + outputs: ["onSuccess"], +}) +class MockMultiFactorAuthAssertionFormComponent { + onSuccess = new EventEmitter(); +} + @Component({ template: ` @@ -60,7 +75,7 @@ class TestHostWithoutContentComponent {} describe("", () => { beforeEach(() => { - const { injectTranslation } = require("../../../provider"); + const { injectTranslation, injectUI } = require("../../../provider"); injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -72,6 +87,10 @@ describe("", () => { }; return () => mockTranslations[category]?.[key] || `${category}.${key}`; }); + + injectUI.mockImplementation(() => () => ({ + multiFactorResolver: null, + })); }); it("renders with correct title and subtitle", async () => { @@ -188,4 +207,87 @@ describe("", () => { expect(injectTranslation).toHaveBeenCalledWith("labels", "signIn"); expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount"); }); + + it("renders MFA assertion form when MFA resolver is present", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + })); + + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockMultiFactorAuthAssertionFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + // Check for the MFA form element by its selector + expect(container.querySelector("fui-multi-factor-auth-assertion-form")).toBeInTheDocument(); + }); + + it("does not render RedirectError when MFA resolver is present", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + })); + + const { container } = await render(TestHostWithContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockMultiFactorAuthAssertionFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector("fui-redirect-error")).toBeNull(); + expect(container.querySelector("fui-multi-factor-auth-assertion-form")).toBeInTheDocument(); + }); + + it("calls signIn output when MFA flow succeeds", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + })); + + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockMultiFactorAuthAssertionFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-email-link-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate MFA success by directly calling the onSuccess handler + const mfaComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-form" + ).componentInstance; + mfaComponent.onSuccess.emit({ user: { uid: "mfa-user" } }); + + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + ); + }); }); diff --git a/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts b/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts index 20c4ab634..87106720c 100644 --- a/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, output } from "@angular/core"; +import { Component, output, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { CardComponent, @@ -23,8 +23,9 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; -import { injectTranslation } from "../../provider"; +import { injectTranslation, injectUI } from "../../provider"; import { EmailLinkAuthFormComponent } from "../forms/email-link-auth-form"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { RedirectErrorComponent } from "../../components/redirect-error"; import { UserCredential } from "@angular/fire/auth"; @@ -39,6 +40,7 @@ import { UserCredential } from "@angular/fire/auth"; CardSubtitleComponent, CardContentComponent, EmailLinkAuthFormComponent, + MultiFactorAuthAssertionFormComponent, RedirectErrorComponent, ], template: ` @@ -49,15 +51,23 @@ import { UserCredential } from "@angular/fire/auth"; {{ subtitleText() }} - - - + @if (mfaResolver()) { + + } @else { + + + + } `, }) export class EmailLinkAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); 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 fa42dde97..af370c38b 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 @@ -15,10 +15,11 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; import { EmailLinkAuthScreen } from "~/auth/screens/email-link-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; +import type { MultiFactorResolver } from "firebase/auth"; vi.mock("~/auth/forms/email-link-auth-form", () => ({ EmailLinkAuthForm: () =>
Email Link Form
, @@ -32,6 +33,17 @@ vi.mock("~/components/redirect-error", () => ({ RedirectError: () =>
Redirect Error
, })); +vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({ + MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
MFA Assertion Form
+ +
+ ), +})); + describe("", () => { beforeEach(() => { vi.clearAllMocks(); @@ -122,4 +134,89 @@ describe("", () => { expect(screen.queryByTestId("redirect-error")).toBeNull(); }); + + it("renders MFA assertion form 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( + + + + ); + + 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(); + }); + + 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( + + + + ); + + 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/email-link-auth-screen.tsx b/packages/react/src/auth/screens/email-link-auth-screen.tsx index 368f6eef7..2e0b3569e 100644 --- a/packages/react/src/auth/screens/email-link-auth-screen.tsx +++ b/packages/react/src/auth/screens/email-link-auth-screen.tsx @@ -20,16 +20,19 @@ import { Divider } from "~/components/divider"; import { useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; import { EmailLinkAuthForm, type EmailLinkAuthFormProps } from "../forms/email-link-auth-form"; +import { MultiFactorAuthAssertionForm } from "../forms/multi-factor-auth-assertion-form"; import { RedirectError } from "~/components/redirect-error"; export type EmailLinkAuthScreenProps = PropsWithChildren; -export function EmailLinkAuthScreen({ children, onEmailSent }: EmailLinkAuthScreenProps) { +export function EmailLinkAuthScreen({ children, onEmailSent, onSignIn }: EmailLinkAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; + return (
@@ -38,16 +41,26 @@ export function EmailLinkAuthScreen({ children, onEmailSent }: EmailLinkAuthScre {subtitleText} - - {children ? ( + {mfaResolver ? ( + { + onSignIn?.(credential); + }} + /> + ) : ( <> - {getTranslation(ui, "messages", "dividerOr")} -
- {children} - -
+ + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
+ {children} + +
+ + ) : null} - ) : null} + )}
diff --git a/packages/shadcn/src/registry/email-link-auth-screen.test.tsx b/packages/shadcn/src/registry/email-link-auth-screen.test.tsx index 48879ecaf..6cbde9c55 100644 --- a/packages/shadcn/src/registry/email-link-auth-screen.test.tsx +++ b/packages/shadcn/src/registry/email-link-auth-screen.test.tsx @@ -15,11 +15,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; import { EmailLinkAuthScreen } from "./email-link-auth-screen"; import { createMockUI } from "../../tests/utils"; import { registerLocale } from "@firebase-ui/translations"; import { FirebaseUIProvider } from "@firebase-ui/react"; +import type { MultiFactorResolver } from "firebase/auth"; vi.mock("./email-link-auth-form", () => ({ EmailLinkAuthForm: ({ onEmailSent, onSignIn }: any) => ( @@ -31,6 +32,17 @@ vi.mock("./email-link-auth-form", () => ({ ), })); +vi.mock("./multi-factor-auth-assertion-form", () => ({ + MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
MFA Assertion Form
+ +
+ ), +})); + describe("", () => { beforeEach(() => { vi.clearAllMocks(); @@ -144,4 +156,82 @@ describe("", () => { expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument(); expect(screen.queryByText("or")).not.toBeInTheDocument(); }); + + it("should render MFA assertion form when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const mockUI = createMockUI(); + mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + }); + + it("should not render children when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + messages: { + dividerOr: "or", + }, + }), + }); + mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
Child Component
+
+
+ ); + + expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.queryByTestId("child-component")).not.toBeInTheDocument(); + expect(screen.queryByText("or")).not.toBeInTheDocument(); + }); + + it("should call onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const mockUI = createMockUI(); + mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignInMock = vi.fn(); + + render( + + + + ); + + // Simulate the MFA child reporting success with a credential + fireEvent.click(screen.getByTestId("mfa-on-success")); + + expect(onSignInMock).toHaveBeenCalledTimes(1); + expect(onSignInMock).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + ); + }); }); diff --git a/packages/shadcn/src/registry/email-link-auth-screen.tsx b/packages/shadcn/src/registry/email-link-auth-screen.tsx index 27c4beafa..ae7d10f17 100644 --- a/packages/shadcn/src/registry/email-link-auth-screen.tsx +++ b/packages/shadcn/src/registry/email-link-auth-screen.tsx @@ -6,6 +6,7 @@ import { useUI, type EmailLinkAuthScreenProps } from "@firebase-ui/react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { EmailLinkAuthForm } from "@/registry/email-link-auth-form"; +import { MultiFactorAuthAssertionForm } from "@/registry/multi-factor-auth-assertion-form"; export type { EmailLinkAuthScreenProps }; @@ -15,6 +16,8 @@ export function EmailLinkAuthScreen({ children, ...props }: EmailLinkAuthScreenP const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; + return (
@@ -23,13 +26,23 @@ export function EmailLinkAuthScreen({ children, ...props }: EmailLinkAuthScreenP {subtitleText} - - {children ? ( + {mfaResolver ? ( + { + props.onSignIn?.(credential); + }} + /> + ) : ( <> - {getTranslation(ui, "messages", "dividerOr")} -
{children}
+ + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
{children}
+ + ) : null} - ) : null} + )}