Skip to content

Commit 217de0c

Browse files
committed
feat(shadcn): Add MFA Assertion screen
1 parent 92015d2 commit 217de0c

13 files changed

+256
-115
lines changed

packages/shadcn/registry-spec.json

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,20 @@
244244
}
245245
]
246246
},
247+
{
248+
"name": "multi-factor-auth-assertion-screen",
249+
"type": "registry:block",
250+
"title": "Multi-Factor Auth Assertion Screen",
251+
"description": "A screen allowing users to complete multi-factor authentication during sign-in with TOTP or SMS options.",
252+
"dependencies": ["{{ DEP | @firebase-ui/react }}"],
253+
"registryDependencies": ["card", "{{ DOMAIN }}/multi-factor-auth-assertion-form.json"],
254+
"files": [
255+
{
256+
"path": "src/components/multi-factor-auth-assertion-screen.tsx",
257+
"type": "registry:component"
258+
}
259+
]
260+
},
247261
{
248262
"name": "multi-factor-auth-enrollment-form",
249263
"type": "registry:block",
@@ -299,7 +313,7 @@
299313
"registryDependencies": [
300314
"card",
301315
"{{ DOMAIN }}/policies.json",
302-
"{{ DOMAIN }}/multi-factor-auth-assertion-form.json",
316+
"{{ DOMAIN }}/multi-factor-auth-assertion-screen.json",
303317
"{{ DOMAIN }}/redirect-error.json"
304318
],
305319
"files": [
@@ -339,7 +353,7 @@
339353
"card",
340354
"separator",
341355
"{{ DOMAIN }}/phone-auth-form.json",
342-
"{{ DOMAIN }}/multi-factor-auth-assertion-form.json",
356+
"{{ DOMAIN }}/multi-factor-auth-assertion-screen.json",
343357
"{{ DOMAIN }}/redirect-error.json"
344358
],
345359
"files": [
@@ -399,7 +413,7 @@
399413
"separator",
400414
"card",
401415
"{{ DOMAIN }}/sign-in-auth-form.json",
402-
"{{ DOMAIN }}/multi-factor-auth-assertion-form.json"
416+
"{{ DOMAIN }}/multi-factor-auth-assertion-screen.json"
403417
],
404418
"files": [
405419
{
@@ -432,7 +446,7 @@
432446
"separator",
433447
"card",
434448
"{{ DOMAIN }}/r/sign-up-auth-form.json",
435-
"{{ DOMAIN }}/r/multi-factor-auth-assertion-form.json"
449+
"{{ DOMAIN }}/r/multi-factor-auth-assertion-screen.json"
436450
],
437451
"files": [
438452
{

packages/shadcn/src/components/email-link-auth-screen.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ vi.mock("./email-link-auth-form", () => ({
3232
),
3333
}));
3434

35-
vi.mock("@/components/multi-factor-auth-assertion-form", () => ({
36-
MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
37-
<div data-testid="mfa-assertion-form">
35+
vi.mock("@/components/multi-factor-auth-assertion-screen", () => ({
36+
MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
37+
<div data-testid="multi-factor-auth-assertion-screen">
3838
<button data-testid="mfa-on-success" onClick={() => onSuccess?.({ user: { uid: "email-link-mfa-user" } })}>
3939
MFA Success
4040
</button>
@@ -155,7 +155,7 @@ describe("<EmailLinkAuthScreen />", () => {
155155
expect(screen.queryByText("or")).not.toBeInTheDocument();
156156
});
157157

158-
it("should render MultiFactorAuthAssertionForm when multiFactorResolver is present", () => {
158+
it("should render MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => {
159159
const mockResolver = {
160160
auth: {} as any,
161161
session: null,
@@ -179,7 +179,7 @@ describe("<EmailLinkAuthScreen />", () => {
179179
</FirebaseUIProvider>
180180
);
181181

182-
expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument();
182+
expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeInTheDocument();
183183
expect(screen.queryByTestId("email-link-auth-form")).not.toBeInTheDocument();
184184
});
185185

@@ -213,7 +213,7 @@ describe("<EmailLinkAuthScreen />", () => {
213213
);
214214

215215
expect(screen.queryByTestId("email-link-auth-form")).not.toBeInTheDocument();
216-
expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument();
216+
expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeInTheDocument();
217217
expect(screen.queryByText("or")).not.toBeInTheDocument();
218218
expect(screen.queryByTestId("child-component")).not.toBeInTheDocument();
219219
});
@@ -237,7 +237,7 @@ describe("<EmailLinkAuthScreen />", () => {
237237
);
238238

239239
expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument();
240-
expect(screen.queryByTestId("mfa-assertion-form")).not.toBeInTheDocument();
240+
expect(screen.queryByTestId("multi-factor-auth-assertion-screen")).not.toBeInTheDocument();
241241
});
242242

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

packages/shadcn/src/components/email-link-auth-screen.tsx

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useUI, type EmailLinkAuthScreenProps } from "@firebase-ui/react";
66
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
77
import { Separator } from "@/components/ui/separator";
88
import { EmailLinkAuthForm } from "@/components/email-link-auth-form";
9-
import { MultiFactorAuthAssertionForm } from "@/components/multi-factor-auth-assertion-form";
9+
import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen";
1010
import { RedirectError } from "@/components/redirect-error";
1111

1212
export type { EmailLinkAuthScreenProps };
@@ -18,6 +18,10 @@ export function EmailLinkAuthScreen({ children, ...props }: EmailLinkAuthScreenP
1818
const subtitleText = getTranslation(ui, "prompts", "signInToAccount");
1919
const mfaResolver = ui.multiFactorResolver;
2020

21+
if (mfaResolver) {
22+
return <MultiFactorAuthAssertionScreen onSuccess={props.onSignIn} />;
23+
}
24+
2125
return (
2226
<div className="max-w-sm mx-auto">
2327
<Card>
@@ -26,22 +30,16 @@ export function EmailLinkAuthScreen({ children, ...props }: EmailLinkAuthScreenP
2630
<CardDescription>{subtitleText}</CardDescription>
2731
</CardHeader>
2832
<CardContent>
29-
{mfaResolver ? (
30-
<MultiFactorAuthAssertionForm onSuccess={(credential) => props.onSignIn?.(credential)} />
31-
) : (
33+
<EmailLinkAuthForm {...props} />
34+
{children ? (
3235
<>
33-
<EmailLinkAuthForm {...props} />
34-
{children ? (
35-
<>
36-
<Separator className="my-4" />
37-
<div className="space-y-2">
38-
{children}
39-
<RedirectError />
40-
</div>
41-
</>
42-
) : null}
36+
<Separator className="my-4" />
37+
<div className="space-y-2">
38+
{children}
39+
<RedirectError />
40+
</div>
4341
</>
44-
)}
42+
) : null}
4543
</CardContent>
4644
</Card>
4745
</div>
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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 "./multi-factor-auth-assertion-screen";
20+
import { createMockUI } from "../../tests/utils";
21+
import { registerLocale } from "@firebase-ui/translations";
22+
import { FirebaseUIProvider } from "@firebase-ui/react";
23+
24+
vi.mock("./multi-factor-auth-assertion-form", () => ({
25+
MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
26+
<div data-testid="multi-factor-auth-assertion-form">
27+
<div data-testid="form-props">{onSuccess && <div data-testid="on-success">onSuccess</div>}</div>
28+
</div>
29+
),
30+
}));
31+
32+
describe("<MultiFactorAuthAssertionScreen />", () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
});
36+
37+
afterEach(() => {
38+
cleanup();
39+
});
40+
41+
it("should render the screen correctly", () => {
42+
const mockUI = createMockUI({
43+
locale: registerLocale("test", {
44+
labels: {
45+
multiFactorAssertion: "Multi-Factor Authentication",
46+
},
47+
prompts: {
48+
mfaAssertionPrompt: "Please complete the multi-factor authentication process",
49+
},
50+
}),
51+
});
52+
53+
const { container } = render(
54+
<FirebaseUIProvider ui={mockUI}>
55+
<MultiFactorAuthAssertionScreen />
56+
</FirebaseUIProvider>
57+
);
58+
59+
expect(screen.getByText("Multi-Factor Authentication")).toBeInTheDocument();
60+
expect(screen.getByText("Please complete the multi-factor authentication process")).toBeInTheDocument();
61+
expect(screen.getByTestId("multi-factor-auth-assertion-form")).toBeInTheDocument();
62+
63+
const card = container.querySelector(".max-w-sm.mx-auto");
64+
expect(card).toBeInTheDocument();
65+
});
66+
67+
it("should pass props to the assertion form", () => {
68+
const mockOnSuccess = vi.fn();
69+
const mockUI = createMockUI();
70+
71+
render(
72+
<FirebaseUIProvider ui={mockUI}>
73+
<MultiFactorAuthAssertionScreen onSuccess={mockOnSuccess} />
74+
</FirebaseUIProvider>
75+
);
76+
77+
expect(screen.getByTestId("on-success")).toBeInTheDocument();
78+
});
79+
80+
it("should use correct translation keys", () => {
81+
const mockUI = createMockUI({
82+
locale: registerLocale("test", {
83+
labels: {
84+
multiFactorAssertion: "Complete MFA",
85+
},
86+
prompts: {
87+
mfaAssertionPrompt: "Verify your identity",
88+
},
89+
}),
90+
});
91+
92+
render(
93+
<FirebaseUIProvider ui={mockUI}>
94+
<MultiFactorAuthAssertionScreen />
95+
</FirebaseUIProvider>
96+
);
97+
98+
expect(screen.getByText("Complete MFA")).toBeInTheDocument();
99+
expect(screen.getByText("Verify your identity")).toBeInTheDocument();
100+
});
101+
102+
it("should render with correct CSS classes", () => {
103+
const mockUI = createMockUI();
104+
105+
const { container } = render(
106+
<FirebaseUIProvider ui={mockUI}>
107+
<MultiFactorAuthAssertionScreen />
108+
</FirebaseUIProvider>
109+
);
110+
111+
const mainContainer = container.querySelector(".max-w-sm.mx-auto");
112+
expect(mainContainer).toBeInTheDocument();
113+
114+
// Check for any card-like element instead of specific radix attribute
115+
const card = container.querySelector(".max-w-sm.mx-auto > div");
116+
expect(card).toBeInTheDocument();
117+
});
118+
});
119+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"use client";
2+
3+
import { getTranslation } from "@firebase-ui/core";
4+
import { useUI, type MultiFactorAuthAssertionScreenProps } from "@firebase-ui/react";
5+
6+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7+
import { MultiFactorAuthAssertionForm } from "@/components/multi-factor-auth-assertion-form";
8+
9+
export type MultiFactorAuthEnrollmentScreenProps = MultiFactorAuthAssertionScreenProps;
10+
11+
export function MultiFactorAuthAssertionScreen(props: MultiFactorAuthEnrollmentScreenProps) {
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="max-w-sm mx-auto">
19+
<Card>
20+
<CardHeader>
21+
<CardTitle>{titleText}</CardTitle>
22+
<CardDescription>{subtitleText}</CardDescription>
23+
</CardHeader>
24+
<CardContent>
25+
<MultiFactorAuthAssertionForm {...props} />
26+
</CardContent>
27+
</Card>
28+
</div>
29+
);
30+
}

packages/shadcn/src/components/oauth-screen.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ vi.mock("@/components/redirect-error", () => ({
2828
RedirectError: () => <div data-testid="redirect-error">Redirect Error</div>,
2929
}));
3030

31-
vi.mock("@/components/multi-factor-auth-assertion-form", () => ({
32-
MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
33-
<div data-testid="mfa-assertion-form">
31+
vi.mock("@/components/multi-factor-auth-assertion-screen", () => ({
32+
MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
33+
<div data-testid="multi-factor-auth-assertion-screen">
3434
<button data-testid="mfa-on-success" onClick={() => onSuccess?.({ user: { uid: "oauth-mfa-user" } })}>
3535
MFA Success
3636
</button>
@@ -143,7 +143,7 @@ describe("<OAuthScreen />", () => {
143143
expect(oauthContainerIndex).toBeLessThan(policiesContainerIndex);
144144
});
145145

146-
it("renders MultiFactorAuthAssertionForm when multiFactorResolver is present", () => {
146+
it("renders MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => {
147147
const mockResolver = {
148148
auth: {} as any,
149149
session: null,
@@ -158,7 +158,7 @@ describe("<OAuthScreen />", () => {
158158
</CreateFirebaseUIProvider>
159159
);
160160

161-
expect(screen.getByTestId("mfa-assertion-form")).toBeDefined();
161+
expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined();
162162
expect(screen.queryByText("OAuth Provider")).toBeNull();
163163
expect(screen.queryByTestId("policies")).toBeNull();
164164
});
@@ -182,7 +182,7 @@ describe("<OAuthScreen />", () => {
182182

183183
expect(screen.queryByTestId("oauth-provider")).toBeNull();
184184
expect(screen.queryByTestId("policies")).toBeNull();
185-
expect(screen.getByTestId("mfa-assertion-form")).toBeDefined();
185+
expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined();
186186
});
187187

188188
it("renders RedirectError component with children when no MFA resolver", () => {
@@ -219,7 +219,7 @@ describe("<OAuthScreen />", () => {
219219
);
220220

221221
expect(screen.queryByTestId("redirect-error")).toBeNull();
222-
expect(screen.getByTestId("mfa-assertion-form")).toBeDefined();
222+
expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined();
223223
});
224224

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

0 commit comments

Comments
 (0)