Skip to content

Commit 2780c04

Browse files
committed
feat(react): MFA Assertion Screen
1 parent efbd1ea commit 2780c04

15 files changed

+299
-124
lines changed

examples/react/src/App.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { routes } from "./routes";
1919
import { useUser } from "./firebase/hooks";
2020
import { auth } from "./firebase/firebase";
2121
import { multiFactor, sendEmailVerification, signOut } from "firebase/auth";
22+
import { MultiFactorAuthAssertionScreen, useUI } from "@firebase-ui/react";
2223

2324
function App() {
2425
const user = useUser();
@@ -31,6 +32,13 @@ function App() {
3132
}
3233

3334
function UnauthenticatedApp() {
35+
const ui = useUI();
36+
37+
// This can trigger if the user is not on a screen already, and gets an MFA challenge - e.g. on One-Tap sign in.
38+
if (ui.multiFactorResolver) {
39+
return <MultiFactorAuthAssertionScreen />;
40+
}
41+
3442
return (
3543
<div className="max-w-sm mx-auto pt-36 space-y-6 pb-36">
3644
<div className="text-center space-y-4">

packages/react/src/auth/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export {
5151

5252
export { EmailLinkAuthScreen, type EmailLinkAuthScreenProps } from "./screens/email-link-auth-screen";
5353
export { ForgotPasswordAuthScreen, type ForgotPasswordAuthScreenProps } from "./screens/forgot-password-auth-screen";
54+
export {
55+
MultiFactorAuthAssertionScreen,
56+
type MultiFactorAuthAssertionScreenProps,
57+
} from "./screens/multi-factor-auth-assertion-screen";
5458
export { OAuthScreen, type OAuthScreenProps } from "./screens/oauth-screen";
5559
export { PhoneAuthScreen, type PhoneAuthScreenProps } from "./screens/phone-auth-screen";
5660
export { SignInAuthScreen, type SignInAuthScreenProps } from "./screens/sign-in-auth-screen";

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

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ 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";
2423
import { RedirectError } from "~/components/redirect-error";
24+
import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen";
2525

2626
export type EmailLinkAuthScreenProps = PropsWithChildren<EmailLinkAuthFormProps>;
2727

@@ -30,9 +30,12 @@ export function EmailLinkAuthScreen({ children, onEmailSent, onSignIn }: EmailLi
3030

3131
const titleText = getTranslation(ui, "labels", "signIn");
3232
const subtitleText = getTranslation(ui, "prompts", "signInToAccount");
33-
3433
const mfaResolver = ui.multiFactorResolver;
3534

35+
if (mfaResolver) {
36+
return <MultiFactorAuthAssertionScreen onSuccess={onSignIn} />;
37+
}
38+
3639
return (
3740
<div className="fui-screen">
3841
<Card>
@@ -41,26 +44,16 @@ export function EmailLinkAuthScreen({ children, onEmailSent, onSignIn }: EmailLi
4144
<CardSubtitle>{subtitleText}</CardSubtitle>
4245
</CardHeader>
4346
<CardContent>
44-
{mfaResolver ? (
45-
<MultiFactorAuthAssertionForm
46-
onSuccess={(credential) => {
47-
onSignIn?.(credential);
48-
}}
49-
/>
50-
) : (
47+
<EmailLinkAuthForm onEmailSent={onEmailSent} onSignIn={onSignIn} />
48+
{children ? (
5149
<>
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}
50+
<Divider>{getTranslation(ui, "messages", "dividerOr")}</Divider>
51+
<div className="fui-screen__children">
52+
{children}
53+
<RedirectError />
54+
</div>
6255
</>
63-
)}
56+
) : null}
6457
</CardContent>
6558
</Card>
6659
</div>
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
18+
import { render, screen, cleanup } from "@testing-library/react";
19+
import { MultiFactorAuthAssertionScreen } from "~/auth/screens/multi-factor-auth-assertion-screen";
20+
import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils";
21+
import { registerLocale } from "@firebase-ui/translations";
22+
import { type UserCredential } from "firebase/auth";
23+
24+
vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({
25+
MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: UserCredential) => void }) => (
26+
<div data-testid="multi-factor-auth-assertion-form">
27+
<div data-testid="assertion-form-props">
28+
{onSuccess ? <div data-testid="on-success-prop">onSuccess</div> : null}
29+
</div>
30+
</div>
31+
),
32+
}));
33+
34+
describe("<MultiFactorAuthAssertionScreen />", () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
});
38+
39+
afterEach(() => {
40+
cleanup();
41+
});
42+
43+
it("renders with correct title and subtitle", () => {
44+
const ui = createMockUI({
45+
locale: registerLocale("test", {
46+
labels: {
47+
multiFactorAssertion: "multiFactorAssertion",
48+
},
49+
prompts: {
50+
mfaAssertionPrompt: "mfaAssertionPrompt",
51+
},
52+
}),
53+
});
54+
55+
render(
56+
<CreateFirebaseUIProvider ui={ui}>
57+
<MultiFactorAuthAssertionScreen />
58+
</CreateFirebaseUIProvider>
59+
);
60+
61+
const title = screen.getByText("multiFactorAssertion");
62+
expect(title).toBeInTheDocument();
63+
expect(title).toHaveClass("fui-card__title");
64+
65+
const subtitle = screen.getByText("mfaAssertionPrompt");
66+
expect(subtitle).toBeInTheDocument();
67+
expect(subtitle).toHaveClass("fui-card__subtitle");
68+
});
69+
70+
it("renders the <MultiFactorAuthAssertionForm /> component", () => {
71+
const ui = createMockUI();
72+
73+
render(
74+
<CreateFirebaseUIProvider ui={ui}>
75+
<MultiFactorAuthAssertionScreen />
76+
</CreateFirebaseUIProvider>
77+
);
78+
79+
expect(screen.getByTestId("multi-factor-auth-assertion-form")).toBeInTheDocument();
80+
});
81+
82+
it("passes onSuccess prop to MultiFactorAuthAssertionForm", () => {
83+
const mockOnSuccess = vi.fn();
84+
const ui = createMockUI();
85+
86+
render(
87+
<CreateFirebaseUIProvider ui={ui}>
88+
<MultiFactorAuthAssertionScreen onSuccess={mockOnSuccess} />
89+
</CreateFirebaseUIProvider>
90+
);
91+
92+
expect(screen.getByTestId("on-success-prop")).toBeInTheDocument();
93+
});
94+
95+
it("renders with default props when no props are provided", () => {
96+
const ui = createMockUI();
97+
98+
render(
99+
<CreateFirebaseUIProvider ui={ui}>
100+
<MultiFactorAuthAssertionScreen />
101+
</CreateFirebaseUIProvider>
102+
);
103+
104+
// Should render the form without onSuccess prop
105+
expect(screen.queryByTestId("on-success-prop")).not.toBeInTheDocument();
106+
});
107+
108+
it("renders with correct screen structure", () => {
109+
const ui = createMockUI();
110+
111+
render(
112+
<CreateFirebaseUIProvider ui={ui}>
113+
<MultiFactorAuthAssertionScreen />
114+
</CreateFirebaseUIProvider>
115+
);
116+
117+
const screenContainer = screen.getByTestId("multi-factor-auth-assertion-form").closest(".fui-screen");
118+
expect(screenContainer).toBeInTheDocument();
119+
expect(screenContainer).toHaveClass("fui-screen");
120+
121+
const card = screenContainer?.querySelector(".fui-card");
122+
expect(card).toBeInTheDocument();
123+
124+
const cardHeader = screenContainer?.querySelector(".fui-card__header");
125+
expect(cardHeader).toBeInTheDocument();
126+
127+
const cardContent = screenContainer?.querySelector(".fui-card__content");
128+
expect(cardContent).toBeInTheDocument();
129+
});
130+
131+
it("uses correct translation keys", () => {
132+
const ui = createMockUI({
133+
locale: registerLocale("test", {
134+
labels: {
135+
multiFactorAssertion: "Multi-factor Authentication",
136+
},
137+
prompts: {
138+
mfaAssertionPrompt: "Please complete the multi-factor authentication process",
139+
},
140+
}),
141+
});
142+
143+
render(
144+
<CreateFirebaseUIProvider ui={ui}>
145+
<MultiFactorAuthAssertionScreen />
146+
</CreateFirebaseUIProvider>
147+
);
148+
149+
expect(screen.getByText("Multi-factor Authentication")).toBeInTheDocument();
150+
expect(screen.getByText("Please complete the multi-factor authentication process")).toBeInTheDocument();
151+
});
152+
153+
it("passes through all props correctly", () => {
154+
const mockOnSuccess = vi.fn();
155+
const ui = createMockUI();
156+
157+
render(
158+
<CreateFirebaseUIProvider ui={ui}>
159+
<MultiFactorAuthAssertionScreen onSuccess={mockOnSuccess} />
160+
</CreateFirebaseUIProvider>
161+
);
162+
163+
expect(screen.getByTestId("on-success-prop")).toBeInTheDocument();
164+
});
165+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { getTranslation } from "@firebase-ui/core";
2+
import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card";
3+
import { useUI } from "~/hooks";
4+
import {
5+
MultiFactorAuthAssertionForm,
6+
type MultiFactorAuthAssertionFormProps,
7+
} from "../forms/multi-factor-auth-assertion-form";
8+
9+
export type MultiFactorAuthAssertionScreenProps = MultiFactorAuthAssertionFormProps;
10+
11+
export function MultiFactorAuthAssertionScreen(props: MultiFactorAuthAssertionScreenProps) {
12+
const ui = useUI();
13+
14+
const titleText = getTranslation(ui, "labels", "multiFactorAssertion");
15+
const subtitleText = getTranslation(ui, "prompts", "mfaAssertionPrompt");
16+
17+
return (
18+
<div className="fui-screen">
19+
<Card>
20+
<CardHeader>
21+
<CardTitle>{titleText}</CardTitle>
22+
<CardSubtitle>{subtitleText}</CardSubtitle>
23+
</CardHeader>
24+
<CardContent>
25+
<MultiFactorAuthAssertionForm {...props} />
26+
</CardContent>
27+
</Card>
28+
</div>
29+
);
30+
}

packages/react/src/auth/screens/oauth-screen.test.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,9 @@ vi.mock("~/components/redirect-error", () => ({
3232
RedirectError: () => <div data-testid="redirect-error">Redirect Error</div>,
3333
}));
3434

35-
vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({
36-
MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
37-
<div>
38-
<div data-testid="mfa-assertion-form">MFA Assertion Form</div>
35+
vi.mock("~/auth/screens/multi-factor-auth-assertion-screen", () => ({
36+
MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
37+
<div data-testid="multi-factor-auth-assertion-screen">
3938
<button data-testid="mfa-on-success" onClick={() => onSuccess?.({ user: { uid: "oauth-mfa-user" } })}>
4039
Trigger MFA Success
4140
</button>
@@ -146,7 +145,7 @@ describe("<OAuthScreen />", () => {
146145
expect(oauthIndex).toBeLessThan(policiesIndex);
147146
});
148147

149-
it("renders MultiFactorAuthAssertionForm when multiFactorResolver is present", () => {
148+
it("renders MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => {
150149
const mockResolver = {
151150
auth: {} as any,
152151
session: null,
@@ -161,7 +160,7 @@ describe("<OAuthScreen />", () => {
161160
</CreateFirebaseUIProvider>
162161
);
163162

164-
expect(screen.getByTestId("mfa-assertion-form")).toBeDefined();
163+
expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined();
165164
expect(screen.queryByText("OAuth Provider")).toBeNull();
166165
expect(screen.queryByTestId("policies")).toBeNull();
167166
});
@@ -185,7 +184,7 @@ describe("<OAuthScreen />", () => {
185184

186185
expect(screen.queryByTestId("oauth-provider")).toBeNull();
187186
expect(screen.queryByTestId("policies")).toBeNull();
188-
expect(screen.getByTestId("mfa-assertion-form")).toBeDefined();
187+
expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined();
189188
});
190189

191190
it("renders RedirectError component with children when no MFA resolver", () => {
@@ -222,7 +221,7 @@ describe("<OAuthScreen />", () => {
222221
);
223222

224223
expect(screen.queryByTestId("redirect-error")).toBeNull();
225-
expect(screen.getByTestId("mfa-assertion-form")).toBeDefined();
224+
expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined();
226225
});
227226

228227
it("calls onSignIn with credential when MFA flow succeeds", () => {

packages/react/src/auth/screens/oauth-screen.tsx

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { type PropsWithChildren } from "react";
2020
import { useUI } from "~/hooks";
2121
import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card";
2222
import { Policies } from "~/components/policies";
23-
import { MultiFactorAuthAssertionForm } from "../forms/multi-factor-auth-assertion-form";
23+
import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen";
2424
import { RedirectError } from "~/components/redirect-error";
2525

2626
export type OAuthScreenProps = PropsWithChildren<{
@@ -34,6 +34,10 @@ export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) {
3434
const subtitleText = getTranslation(ui, "prompts", "signInToAccount");
3535
const mfaResolver = ui.multiFactorResolver;
3636

37+
if (mfaResolver) {
38+
return <MultiFactorAuthAssertionScreen onSuccess={onSignIn} />;
39+
}
40+
3741
return (
3842
<div className="fui-screen">
3943
<Card>
@@ -42,19 +46,9 @@ export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) {
4246
<CardSubtitle>{subtitleText}</CardSubtitle>
4347
</CardHeader>
4448
<CardContent className="fui-screen__children">
45-
{mfaResolver ? (
46-
<MultiFactorAuthAssertionForm
47-
onSuccess={(credential) => {
48-
onSignIn?.(credential);
49-
}}
50-
/>
51-
) : (
52-
<>
53-
{children}
54-
<RedirectError />
55-
<Policies />
56-
</>
57-
)}
49+
{children}
50+
<RedirectError />
51+
<Policies />
5852
</CardContent>
5953
</Card>
6054
</div>

0 commit comments

Comments
 (0)