Skip to content

Commit 40ca180

Browse files
authored
Merge pull request #1245 from firebase/@invertase/bb-44
2 parents 2de6ed1 + e3edc8a commit 40ca180

File tree

4 files changed

+240
-18
lines changed

4 files changed

+240
-18
lines changed

packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { render, screen } from "@testing-library/angular";
18-
import { Component } from "@angular/core";
17+
import { render, screen, fireEvent } from "@testing-library/angular";
18+
import { Component, EventEmitter } from "@angular/core";
1919

2020
import { EmailLinkAuthScreenComponent } from "./email-link-auth-screen";
2121
import {
@@ -40,6 +40,21 @@ class MockEmailLinkAuthFormComponent {}
4040
})
4141
class MockRedirectErrorComponent {}
4242

43+
@Component({
44+
selector: "fui-multi-factor-auth-assertion-form",
45+
template: `
46+
<div data-testid="mfa-assertion-form">MFA Assertion Form</div>
47+
<button data-testid="mfa-on-success" (click)="onSuccess.emit({ user: { uid: 'mfa-user' } })">
48+
Trigger MFA Success
49+
</button>
50+
`,
51+
standalone: true,
52+
outputs: ["onSuccess"],
53+
})
54+
class MockMultiFactorAuthAssertionFormComponent {
55+
onSuccess = new EventEmitter<any>();
56+
}
57+
4358
@Component({
4459
template: `
4560
<fui-email-link-auth-screen>
@@ -60,7 +75,7 @@ class TestHostWithoutContentComponent {}
6075

6176
describe("<fui-email-link-auth-screen>", () => {
6277
beforeEach(() => {
63-
const { injectTranslation } = require("../../../provider");
78+
const { injectTranslation, injectUI } = require("../../../provider");
6479
injectTranslation.mockImplementation((category: string, key: string) => {
6580
const mockTranslations: Record<string, Record<string, string>> = {
6681
labels: {
@@ -72,6 +87,10 @@ describe("<fui-email-link-auth-screen>", () => {
7287
};
7388
return () => mockTranslations[category]?.[key] || `${category}.${key}`;
7489
});
90+
91+
injectUI.mockImplementation(() => () => ({
92+
multiFactorResolver: null,
93+
}));
7594
});
7695

7796
it("renders with correct title and subtitle", async () => {
@@ -188,4 +207,87 @@ describe("<fui-email-link-auth-screen>", () => {
188207
expect(injectTranslation).toHaveBeenCalledWith("labels", "signIn");
189208
expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount");
190209
});
210+
211+
it("renders MFA assertion form when MFA resolver is present", async () => {
212+
const { injectUI } = require("../../../provider");
213+
injectUI.mockImplementation(() => () => ({
214+
multiFactorResolver: { auth: {}, session: null, hints: [] },
215+
}));
216+
217+
const { container } = await render(TestHostWithoutContentComponent, {
218+
imports: [
219+
EmailLinkAuthScreenComponent,
220+
MockEmailLinkAuthFormComponent,
221+
MockMultiFactorAuthAssertionFormComponent,
222+
MockRedirectErrorComponent,
223+
CardComponent,
224+
CardHeaderComponent,
225+
CardTitleComponent,
226+
CardSubtitleComponent,
227+
CardContentComponent,
228+
],
229+
});
230+
231+
// Check for the MFA form element by its selector
232+
expect(container.querySelector("fui-multi-factor-auth-assertion-form")).toBeInTheDocument();
233+
});
234+
235+
it("does not render RedirectError when MFA resolver is present", async () => {
236+
const { injectUI } = require("../../../provider");
237+
injectUI.mockImplementation(() => () => ({
238+
multiFactorResolver: { auth: {}, session: null, hints: [] },
239+
}));
240+
241+
const { container } = await render(TestHostWithContentComponent, {
242+
imports: [
243+
EmailLinkAuthScreenComponent,
244+
MockEmailLinkAuthFormComponent,
245+
MockMultiFactorAuthAssertionFormComponent,
246+
MockRedirectErrorComponent,
247+
CardComponent,
248+
CardHeaderComponent,
249+
CardTitleComponent,
250+
CardSubtitleComponent,
251+
CardContentComponent,
252+
],
253+
});
254+
255+
expect(container.querySelector("fui-redirect-error")).toBeNull();
256+
expect(container.querySelector("fui-multi-factor-auth-assertion-form")).toBeInTheDocument();
257+
});
258+
259+
it("calls signIn output when MFA flow succeeds", async () => {
260+
const { injectUI } = require("../../../provider");
261+
injectUI.mockImplementation(() => () => ({
262+
multiFactorResolver: { auth: {}, session: null, hints: [] },
263+
}));
264+
265+
const { fixture } = await render(TestHostWithoutContentComponent, {
266+
imports: [
267+
EmailLinkAuthScreenComponent,
268+
MockEmailLinkAuthFormComponent,
269+
MockMultiFactorAuthAssertionFormComponent,
270+
MockRedirectErrorComponent,
271+
CardComponent,
272+
CardHeaderComponent,
273+
CardTitleComponent,
274+
CardSubtitleComponent,
275+
CardContentComponent,
276+
],
277+
});
278+
279+
const component = fixture.debugElement.query((el) => el.name === "fui-email-link-auth-screen").componentInstance;
280+
const signInSpy = jest.spyOn(component.signIn, "emit");
281+
282+
// Simulate MFA success by directly calling the onSuccess handler
283+
const mfaComponent = fixture.debugElement.query(
284+
(el) => el.name === "fui-multi-factor-auth-assertion-form"
285+
).componentInstance;
286+
mfaComponent.onSuccess.emit({ user: { uid: "mfa-user" } });
287+
288+
expect(signInSpy).toHaveBeenCalledTimes(1);
289+
expect(signInSpy).toHaveBeenCalledWith(
290+
expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) })
291+
);
292+
});
191293
});

packages/angular/src/lib/auth/screens/email-link-auth-screen.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { Component, output } from "@angular/core";
17+
import { Component, output, computed } from "@angular/core";
1818
import { CommonModule } from "@angular/common";
1919
import {
2020
CardComponent,
@@ -23,8 +23,9 @@ import {
2323
CardSubtitleComponent,
2424
CardContentComponent,
2525
} from "../../components/card";
26-
import { injectTranslation } from "../../provider";
26+
import { injectTranslation, injectUI } from "../../provider";
2727
import { EmailLinkAuthFormComponent } from "../forms/email-link-auth-form";
28+
import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form";
2829
import { RedirectErrorComponent } from "../../components/redirect-error";
2930
import { UserCredential } from "@angular/fire/auth";
3031

@@ -39,6 +40,7 @@ import { UserCredential } from "@angular/fire/auth";
3940
CardSubtitleComponent,
4041
CardContentComponent,
4142
EmailLinkAuthFormComponent,
43+
MultiFactorAuthAssertionFormComponent,
4244
RedirectErrorComponent,
4345
],
4446
template: `
@@ -49,15 +51,23 @@ import { UserCredential } from "@angular/fire/auth";
4951
<fui-card-subtitle>{{ subtitleText() }}</fui-card-subtitle>
5052
</fui-card-header>
5153
<fui-card-content>
52-
<fui-email-link-auth-form (emailSent)="emailSent.emit()" (signIn)="signIn.emit($event)" />
53-
<fui-redirect-error />
54-
<ng-content></ng-content>
54+
@if (mfaResolver()) {
55+
<fui-multi-factor-auth-assertion-form (onSuccess)="signIn.emit($event)" />
56+
} @else {
57+
<fui-email-link-auth-form (emailSent)="emailSent.emit()" (signIn)="signIn.emit($event)" />
58+
<fui-redirect-error />
59+
<ng-content></ng-content>
60+
}
5561
</fui-card-content>
5662
</fui-card>
5763
</div>
5864
`,
5965
})
6066
export class EmailLinkAuthScreenComponent {
67+
private ui = injectUI();
68+
69+
mfaResolver = computed(() => this.ui().multiFactorResolver);
70+
6171
titleText = injectTranslation("labels", "signIn");
6272
subtitleText = injectTranslation("prompts", "signInToAccount");
6373

packages/react/src/auth/screens/email-link-auth-screen.test.tsx

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
*/
1616

1717
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
18-
import { render, screen, cleanup } from "@testing-library/react";
18+
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
1919
import { EmailLinkAuthScreen } from "~/auth/screens/email-link-auth-screen";
2020
import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils";
2121
import { registerLocale } from "@firebase-ui/translations";
22+
import type { MultiFactorResolver } from "firebase/auth";
2223

2324
vi.mock("~/auth/forms/email-link-auth-form", () => ({
2425
EmailLinkAuthForm: () => <div data-testid="email-link-auth-form">Email Link Form</div>,
@@ -32,6 +33,17 @@ vi.mock("~/components/redirect-error", () => ({
3233
RedirectError: () => <div data-testid="redirect-error">Redirect Error</div>,
3334
}));
3435

36+
vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({
37+
MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
38+
<div>
39+
<div data-testid="mfa-assertion-form">MFA Assertion Form</div>
40+
<button data-testid="mfa-on-success" onClick={() => onSuccess?.({ user: { uid: "mfa-user" } })}>
41+
Trigger MFA Success
42+
</button>
43+
</div>
44+
),
45+
}));
46+
3547
describe("<EmailLinkAuthScreen />", () => {
3648
beforeEach(() => {
3749
vi.clearAllMocks();
@@ -122,4 +134,89 @@ describe("<EmailLinkAuthScreen />", () => {
122134

123135
expect(screen.queryByTestId("redirect-error")).toBeNull();
124136
});
137+
138+
it("renders MFA assertion form when MFA resolver is present", () => {
139+
const mockResolver = {
140+
auth: {} as any,
141+
session: null,
142+
hints: [],
143+
};
144+
const ui = createMockUI();
145+
ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver);
146+
147+
render(
148+
<CreateFirebaseUIProvider ui={ui}>
149+
<EmailLinkAuthScreen />
150+
</CreateFirebaseUIProvider>
151+
);
152+
153+
expect(screen.getByTestId("mfa-assertion-form")).toBeDefined();
154+
});
155+
156+
it("renders RedirectError component in children section when no MFA resolver", () => {
157+
const ui = createMockUI({
158+
locale: registerLocale("test", {
159+
messages: {
160+
dividerOr: "dividerOr",
161+
},
162+
}),
163+
});
164+
165+
render(
166+
<CreateFirebaseUIProvider ui={ui}>
167+
<EmailLinkAuthScreen>
168+
<div data-testid="test-child">Test Child</div>
169+
</EmailLinkAuthScreen>
170+
</CreateFirebaseUIProvider>
171+
);
172+
173+
expect(screen.getByTestId("redirect-error")).toBeDefined();
174+
expect(screen.getByTestId("test-child")).toBeDefined();
175+
});
176+
177+
it("does not render RedirectError when MFA resolver is present", () => {
178+
const mockResolver = {
179+
auth: {} as any,
180+
session: null,
181+
hints: [],
182+
};
183+
const ui = createMockUI();
184+
ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver);
185+
186+
render(
187+
<CreateFirebaseUIProvider ui={ui}>
188+
<EmailLinkAuthScreen>
189+
<div data-testid="test-child">Test Child</div>
190+
</EmailLinkAuthScreen>
191+
</CreateFirebaseUIProvider>
192+
);
193+
194+
expect(screen.queryByTestId("redirect-error")).toBeNull();
195+
expect(screen.getByTestId("mfa-assertion-form")).toBeDefined();
196+
});
197+
198+
it("calls onSignIn with credential when MFA flow succeeds", () => {
199+
const mockResolver = {
200+
auth: {} as any,
201+
session: null,
202+
hints: [],
203+
};
204+
const ui = createMockUI();
205+
ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver);
206+
207+
const onSignIn = vi.fn();
208+
209+
render(
210+
<CreateFirebaseUIProvider ui={ui}>
211+
<EmailLinkAuthScreen onSignIn={onSignIn} />
212+
</CreateFirebaseUIProvider>
213+
);
214+
215+
fireEvent.click(screen.getByTestId("mfa-on-success"));
216+
217+
expect(onSignIn).toHaveBeenCalledTimes(1);
218+
expect(onSignIn).toHaveBeenCalledWith(
219+
expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) })
220+
);
221+
});
125222
});

packages/react/src/auth/screens/email-link-auth-screen.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,19 @@ import { Divider } from "~/components/divider";
2020
import { useUI } from "~/hooks";
2121
import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card";
2222
import { EmailLinkAuthForm, type EmailLinkAuthFormProps } from "../forms/email-link-auth-form";
23+
import { MultiFactorAuthAssertionForm } from "../forms/multi-factor-auth-assertion-form";
2324
import { RedirectError } from "~/components/redirect-error";
2425

2526
export type EmailLinkAuthScreenProps = PropsWithChildren<EmailLinkAuthFormProps>;
2627

27-
export function EmailLinkAuthScreen({ children, onEmailSent }: EmailLinkAuthScreenProps) {
28+
export function EmailLinkAuthScreen({ children, onEmailSent, onSignIn }: EmailLinkAuthScreenProps) {
2829
const ui = useUI();
2930

3031
const titleText = getTranslation(ui, "labels", "signIn");
3132
const subtitleText = getTranslation(ui, "prompts", "signInToAccount");
3233

34+
const mfaResolver = ui.multiFactorResolver;
35+
3336
return (
3437
<div className="fui-screen">
3538
<Card>
@@ -38,16 +41,26 @@ export function EmailLinkAuthScreen({ children, onEmailSent }: EmailLinkAuthScre
3841
<CardSubtitle>{subtitleText}</CardSubtitle>
3942
</CardHeader>
4043
<CardContent>
41-
<EmailLinkAuthForm onEmailSent={onEmailSent} />
42-
{children ? (
44+
{mfaResolver ? (
45+
<MultiFactorAuthAssertionForm
46+
onSuccess={(credential) => {
47+
onSignIn?.(credential);
48+
}}
49+
/>
50+
) : (
4351
<>
44-
<Divider>{getTranslation(ui, "messages", "dividerOr")}</Divider>
45-
<div className="fui-screen__children">
46-
{children}
47-
<RedirectError />
48-
</div>
52+
<EmailLinkAuthForm onEmailSent={onEmailSent} onSignIn={onSignIn} />
53+
{children ? (
54+
<>
55+
<Divider>{getTranslation(ui, "messages", "dividerOr")}</Divider>
56+
<div className="fui-screen__children">
57+
{children}
58+
<RedirectError />
59+
</div>
60+
</>
61+
) : null}
4962
</>
50-
) : null}
63+
)}
5164
</CardContent>
5265
</Card>
5366
</div>

0 commit comments

Comments
 (0)