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 03355da3..b9e4328f 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 20c4ab63..87106720 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 fa42dde9..af370c38 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 368f6eef..2e0b3569 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} + )}