Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 105 additions & 3 deletions packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -40,6 +40,21 @@ class MockEmailLinkAuthFormComponent {}
})
class MockRedirectErrorComponent {}

@Component({
selector: "fui-multi-factor-auth-assertion-form",
template: `
<div data-testid="mfa-assertion-form">MFA Assertion Form</div>
<button data-testid="mfa-on-success" (click)="onSuccess.emit({ user: { uid: 'mfa-user' } })">
Trigger MFA Success
</button>
`,
standalone: true,
outputs: ["onSuccess"],
})
class MockMultiFactorAuthAssertionFormComponent {
onSuccess = new EventEmitter<any>();
}

@Component({
template: `
<fui-email-link-auth-screen>
Expand All @@ -60,7 +75,7 @@ class TestHostWithoutContentComponent {}

describe("<fui-email-link-auth-screen>", () => {
beforeEach(() => {
const { injectTranslation } = require("../../../provider");
const { injectTranslation, injectUI } = require("../../../provider");
injectTranslation.mockImplementation((category: string, key: string) => {
const mockTranslations: Record<string, Record<string, string>> = {
labels: {
Expand All @@ -72,6 +87,10 @@ describe("<fui-email-link-auth-screen>", () => {
};
return () => mockTranslations[category]?.[key] || `${category}.${key}`;
});

injectUI.mockImplementation(() => () => ({
multiFactorResolver: null,
}));
});

it("renders with correct title and subtitle", async () => {
Expand Down Expand Up @@ -188,4 +207,87 @@ describe("<fui-email-link-auth-screen>", () => {
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" }) })
);
});
});
20 changes: 15 additions & 5 deletions packages/angular/src/lib/auth/screens/email-link-auth-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

Expand All @@ -39,6 +40,7 @@ import { UserCredential } from "@angular/fire/auth";
CardSubtitleComponent,
CardContentComponent,
EmailLinkAuthFormComponent,
MultiFactorAuthAssertionFormComponent,
RedirectErrorComponent,
],
template: `
Expand All @@ -49,15 +51,23 @@ import { UserCredential } from "@angular/fire/auth";
<fui-card-subtitle>{{ subtitleText() }}</fui-card-subtitle>
</fui-card-header>
<fui-card-content>
<fui-email-link-auth-form (emailSent)="emailSent.emit()" (signIn)="signIn.emit($event)" />
<fui-redirect-error />
<ng-content></ng-content>
@if (mfaResolver()) {
<fui-multi-factor-auth-assertion-form (onSuccess)="signIn.emit($event)" />
} @else {
<fui-email-link-auth-form (emailSent)="emailSent.emit()" (signIn)="signIn.emit($event)" />
<fui-redirect-error />
<ng-content></ng-content>
}
</fui-card-content>
</fui-card>
</div>
`,
})
export class EmailLinkAuthScreenComponent {
private ui = injectUI();

mfaResolver = computed(() => this.ui().multiFactorResolver);

titleText = injectTranslation("labels", "signIn");
subtitleText = injectTranslation("prompts", "signInToAccount");

Expand Down
99 changes: 98 additions & 1 deletion packages/react/src/auth/screens/email-link-auth-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <div data-testid="email-link-auth-form">Email Link Form</div>,
Expand All @@ -32,6 +33,17 @@ vi.mock("~/components/redirect-error", () => ({
RedirectError: () => <div data-testid="redirect-error">Redirect Error</div>,
}));

vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({
MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
<div>
<div data-testid="mfa-assertion-form">MFA Assertion Form</div>
<button data-testid="mfa-on-success" onClick={() => onSuccess?.({ user: { uid: "mfa-user" } })}>
Trigger MFA Success
</button>
</div>
),
}));

describe("<EmailLinkAuthScreen />", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -122,4 +134,89 @@ describe("<EmailLinkAuthScreen />", () => {

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(
<CreateFirebaseUIProvider ui={ui}>
<EmailLinkAuthScreen />
</CreateFirebaseUIProvider>
);

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(
<CreateFirebaseUIProvider ui={ui}>
<EmailLinkAuthScreen>
<div data-testid="test-child">Test Child</div>
</EmailLinkAuthScreen>
</CreateFirebaseUIProvider>
);

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(
<CreateFirebaseUIProvider ui={ui}>
<EmailLinkAuthScreen>
<div data-testid="test-child">Test Child</div>
</EmailLinkAuthScreen>
</CreateFirebaseUIProvider>
);

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(
<CreateFirebaseUIProvider ui={ui}>
<EmailLinkAuthScreen onSignIn={onSignIn} />
</CreateFirebaseUIProvider>
);

fireEvent.click(screen.getByTestId("mfa-on-success"));

expect(onSignIn).toHaveBeenCalledTimes(1);
expect(onSignIn).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) })
);
});
});
31 changes: 22 additions & 9 deletions packages/react/src/auth/screens/email-link-auth-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EmailLinkAuthFormProps>;

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 (
<div className="fui-screen">
<Card>
Expand All @@ -38,16 +41,26 @@ export function EmailLinkAuthScreen({ children, onEmailSent }: EmailLinkAuthScre
<CardSubtitle>{subtitleText}</CardSubtitle>
</CardHeader>
<CardContent>
<EmailLinkAuthForm onEmailSent={onEmailSent} />
{children ? (
{mfaResolver ? (
<MultiFactorAuthAssertionForm
onSuccess={(credential) => {
onSignIn?.(credential);
}}
/>
) : (
<>
<Divider>{getTranslation(ui, "messages", "dividerOr")}</Divider>
<div className="fui-screen__children">
{children}
<RedirectError />
</div>
<EmailLinkAuthForm onEmailSent={onEmailSent} onSignIn={onSignIn} />
{children ? (
<>
<Divider>{getTranslation(ui, "messages", "dividerOr")}</Divider>
<div className="fui-screen__children">
{children}
<RedirectError />
</div>
</>
) : null}
</>
) : null}
)}
</CardContent>
</Card>
</div>
Expand Down