Skip to content

Commit 55b7ddb

Browse files
committed
feat(shadcn): MFA Enrollment
1 parent 604e015 commit 55b7ddb

9 files changed

+1594
-0
lines changed

packages/shadcn/registry-spec.json

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,62 @@
348348
"type": "registry:component"
349349
}
350350
]
351+
},
352+
{
353+
"name": "multi-factor-auth-enrollment-screen",
354+
"type": "registry:block",
355+
"title": "Multi-Factor Auth Enrollment Screen",
356+
"description": "A screen allowing users to set up multi-factor authentication with TOTP or SMS options.",
357+
"dependencies": ["{{ DEP | @firebase-ui/react }}"],
358+
"registryDependencies": ["card", "{{ DOMAIN }}/multi-factor-auth-enrollment-form.json"],
359+
"files": [
360+
{
361+
"path": "src/registry/multi-factor-auth-enrollment-screen.tsx",
362+
"type": "registry:component"
363+
}
364+
]
365+
},
366+
{
367+
"name": "multi-factor-auth-enrollment-form",
368+
"type": "registry:block",
369+
"title": "Multi-Factor Auth Enrollment Form",
370+
"description": "A form allowing users to select and configure multi-factor authentication methods.",
371+
"dependencies": ["{{ DEP | @firebase-ui/react }}"],
372+
"registryDependencies": ["button", "{{ DOMAIN }}/sms-multi-factor-enrollment-form.json", "{{ DOMAIN }}/totp-multi-factor-enrollment-form.json"],
373+
"files": [
374+
{
375+
"path": "src/registry/multi-factor-auth-enrollment-form.tsx",
376+
"type": "registry:component"
377+
}
378+
]
379+
},
380+
{
381+
"name": "sms-multi-factor-enrollment-form",
382+
"type": "registry:block",
383+
"title": "SMS Multi-Factor Enrollment Form",
384+
"description": "A form allowing users to enroll SMS-based multi-factor authentication.",
385+
"dependencies": ["{{ DEP | @firebase-ui/react }}"],
386+
"registryDependencies": ["form", "input", "button", "input-otp", "{{ DOMAIN }}/country-selector.json"],
387+
"files": [
388+
{
389+
"path": "src/registry/sms-multi-factor-enrollment-form.tsx",
390+
"type": "registry:component"
391+
}
392+
]
393+
},
394+
{
395+
"name": "totp-multi-factor-enrollment-form",
396+
"type": "registry:block",
397+
"title": "TOTP Multi-Factor Enrollment Form",
398+
"description": "A form allowing users to enroll TOTP-based multi-factor authentication with QR code generation.",
399+
"dependencies": ["{{ DEP | @firebase-ui/react }}"],
400+
"registryDependencies": ["form", "input", "button", "input-otp"],
401+
"files": [
402+
{
403+
"path": "src/registry/totp-multi-factor-enrollment-form.tsx",
404+
"type": "registry:component"
405+
}
406+
]
351407
}
352408
]
353409
}
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
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, fireEvent, cleanup } from "@testing-library/react";
19+
import { MultiFactorAuthEnrollmentForm } from "./multi-factor-auth-enrollment-form";
20+
import { createMockUI } from "../../tests/utils";
21+
import { registerLocale } from "@firebase-ui/translations";
22+
import { FirebaseUIProvider } from "@firebase-ui/react";
23+
import { FactorId } from "firebase/auth";
24+
25+
vi.mock("./sms-multi-factor-enrollment-form", () => ({
26+
SmsMultiFactorEnrollmentForm: ({ onSuccess }: { onSuccess?: () => void }) => (
27+
<div data-testid="sms-multi-factor-enrollment-form">
28+
<div data-testid="sms-form-props">{onSuccess && <div data-testid="sms-on-enrollment">onSuccess</div>}</div>
29+
</div>
30+
),
31+
}));
32+
33+
vi.mock("./totp-multi-factor-enrollment-form", () => ({
34+
TotpMultiFactorEnrollmentForm: ({ onSuccess }: { onSuccess?: () => void }) => (
35+
<div data-testid="totp-multi-factor-enrollment-form">
36+
<div data-testid="totp-form-props">{onSuccess && <div data-testid="totp-on-success">onSuccess</div>}</div>
37+
</div>
38+
),
39+
}));
40+
41+
describe("<MultiFactorAuthEnrollmentForm />", () => {
42+
beforeEach(() => {
43+
vi.clearAllMocks();
44+
});
45+
46+
afterEach(() => {
47+
cleanup();
48+
});
49+
50+
it("renders with default hints (TOTP and PHONE) when no hints provided", () => {
51+
const ui = createMockUI({
52+
locale: registerLocale("test", {
53+
labels: {
54+
mfaTotpVerification: "Set up TOTP",
55+
mfaSmsVerification: "Set up SMS",
56+
},
57+
}),
58+
});
59+
60+
render(
61+
<FirebaseUIProvider ui={ui}>
62+
<MultiFactorAuthEnrollmentForm />
63+
</FirebaseUIProvider>
64+
);
65+
66+
// Should show both buttons since we have multiple hints (since no prop)
67+
expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument();
68+
expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument();
69+
});
70+
71+
it("renders with custom hints when provided", () => {
72+
const ui = createMockUI();
73+
74+
render(
75+
<FirebaseUIProvider ui={ui}>
76+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP]} />
77+
</FirebaseUIProvider>
78+
);
79+
80+
expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument();
81+
});
82+
83+
it("auto-selects single hint and renders corresponding form", () => {
84+
const ui = createMockUI();
85+
86+
render(
87+
<FirebaseUIProvider ui={ui}>
88+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP]} />
89+
</FirebaseUIProvider>
90+
);
91+
92+
expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument();
93+
});
94+
95+
it("auto-selects SMS hint and renders corresponding form", () => {
96+
const ui = createMockUI();
97+
98+
render(
99+
<FirebaseUIProvider ui={ui}>
100+
<MultiFactorAuthEnrollmentForm hints={[FactorId.PHONE]} />
101+
</FirebaseUIProvider>
102+
);
103+
104+
expect(screen.getByTestId("sms-multi-factor-enrollment-form")).toBeInTheDocument();
105+
});
106+
107+
it("shows buttons for multiple hints and allows selection", () => {
108+
const ui = createMockUI({
109+
locale: registerLocale("test", {
110+
labels: {
111+
mfaTotpVerification: "Set up TOTP",
112+
mfaSmsVerification: "Set up SMS",
113+
},
114+
}),
115+
});
116+
117+
render(
118+
<FirebaseUIProvider ui={ui}>
119+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP, FactorId.PHONE]} />
120+
</FirebaseUIProvider>
121+
);
122+
123+
expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument();
124+
expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument();
125+
126+
fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" }));
127+
128+
expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument();
129+
});
130+
131+
it("shows buttons for multiple hints and allows SMS selection", () => {
132+
const ui = createMockUI({
133+
locale: registerLocale("test", {
134+
labels: {
135+
mfaTotpVerification: "Set up TOTP",
136+
mfaSmsVerification: "Set up SMS",
137+
},
138+
}),
139+
});
140+
141+
render(
142+
<FirebaseUIProvider ui={ui}>
143+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP, FactorId.PHONE]} />
144+
</FirebaseUIProvider>
145+
);
146+
147+
expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument();
148+
expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument();
149+
150+
fireEvent.click(screen.getByRole("button", { name: "Set up SMS" }));
151+
152+
expect(screen.getByTestId("sms-multi-factor-enrollment-form")).toBeInTheDocument();
153+
});
154+
155+
it("passes onEnrollment prop to TOTP form when auto-selected", () => {
156+
const mockOnEnrollment = vi.fn();
157+
const ui = createMockUI();
158+
159+
render(
160+
<FirebaseUIProvider ui={ui}>
161+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP]} onEnrollment={mockOnEnrollment} />
162+
</FirebaseUIProvider>
163+
);
164+
165+
expect(screen.getByTestId("totp-on-success")).toBeInTheDocument();
166+
});
167+
168+
it("passes onEnrollment prop to SMS form when auto-selected", () => {
169+
const mockOnEnrollment = vi.fn();
170+
const ui = createMockUI();
171+
172+
render(
173+
<FirebaseUIProvider ui={ui}>
174+
<MultiFactorAuthEnrollmentForm hints={[FactorId.PHONE]} onEnrollment={mockOnEnrollment} />
175+
</FirebaseUIProvider>
176+
);
177+
178+
expect(screen.getByTestId("sms-on-enrollment")).toBeInTheDocument();
179+
});
180+
181+
it("passes onEnrollment prop to TOTP form when selected via button", () => {
182+
const mockOnEnrollment = vi.fn();
183+
const ui = createMockUI({
184+
locale: registerLocale("test", {
185+
labels: {
186+
mfaTotpVerification: "Set up TOTP",
187+
mfaSmsVerification: "Set up SMS",
188+
},
189+
}),
190+
});
191+
192+
render(
193+
<FirebaseUIProvider ui={ui}>
194+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP, FactorId.PHONE]} onEnrollment={mockOnEnrollment} />
195+
</FirebaseUIProvider>
196+
);
197+
198+
fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" }));
199+
200+
expect(screen.getByTestId("totp-on-success")).toBeInTheDocument();
201+
});
202+
203+
it("passes onEnrollment prop to SMS form when selected via button", () => {
204+
const mockOnEnrollment = vi.fn();
205+
const ui = createMockUI({
206+
locale: registerLocale("test", {
207+
labels: {
208+
mfaTotpVerification: "Set up TOTP",
209+
mfaSmsVerification: "Set up SMS",
210+
},
211+
}),
212+
});
213+
214+
render(
215+
<FirebaseUIProvider ui={ui}>
216+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP, FactorId.PHONE]} onEnrollment={mockOnEnrollment} />
217+
</FirebaseUIProvider>
218+
);
219+
220+
fireEvent.click(screen.getByRole("button", { name: "Set up SMS" }));
221+
222+
expect(screen.getByTestId("sms-on-enrollment")).toBeInTheDocument();
223+
});
224+
225+
it("throws error when hints array is empty", () => {
226+
const ui = createMockUI();
227+
228+
expect(() => {
229+
render(
230+
<FirebaseUIProvider ui={ui}>
231+
<MultiFactorAuthEnrollmentForm hints={[]} />
232+
</FirebaseUIProvider>
233+
);
234+
}).toThrow("MultiFactorAuthEnrollmentForm must have at least one hint");
235+
});
236+
237+
it("throws error for unknown hint type", () => {
238+
const ui = createMockUI();
239+
240+
const unknownHint = "unknown" as any;
241+
242+
expect(() => {
243+
render(
244+
<FirebaseUIProvider ui={ui}>
245+
<MultiFactorAuthEnrollmentForm hints={[unknownHint]} />
246+
</FirebaseUIProvider>
247+
);
248+
}).toThrow("Unknown multi-factor enrollment type: unknown");
249+
});
250+
251+
it("uses correct translation keys for buttons", () => {
252+
const ui = createMockUI({
253+
locale: registerLocale("test", {
254+
labels: {
255+
mfaTotpVerification: "Configure TOTP Authentication",
256+
mfaSmsVerification: "Configure SMS Authentication",
257+
},
258+
}),
259+
});
260+
261+
render(
262+
<FirebaseUIProvider ui={ui}>
263+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP, FactorId.PHONE]} />
264+
</FirebaseUIProvider>
265+
);
266+
267+
expect(screen.getByRole("button", { name: "Configure TOTP Authentication" })).toBeInTheDocument();
268+
expect(screen.getByRole("button", { name: "Configure SMS Authentication" })).toBeInTheDocument();
269+
});
270+
271+
it("renders with correct CSS classes", () => {
272+
const ui = createMockUI();
273+
274+
const { container } = render(
275+
<FirebaseUIProvider ui={ui}>
276+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP, FactorId.PHONE]} />
277+
</FirebaseUIProvider>
278+
);
279+
280+
const contentDiv = container.querySelector(".space-y-2");
281+
expect(contentDiv).toBeInTheDocument();
282+
});
283+
284+
it("handles mixed hint types correctly", () => {
285+
const ui = createMockUI({
286+
locale: registerLocale("test", {
287+
labels: {
288+
mfaTotpVerification: "Set up TOTP",
289+
mfaSmsVerification: "Set up SMS",
290+
},
291+
}),
292+
});
293+
294+
render(
295+
<FirebaseUIProvider ui={ui}>
296+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP, FactorId.PHONE]} />
297+
</FirebaseUIProvider>
298+
);
299+
300+
expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument();
301+
expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument();
302+
303+
fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" }));
304+
305+
expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument();
306+
expect(screen.queryByTestId("sms-multi-factor-enrollment-form")).not.toBeInTheDocument();
307+
});
308+
309+
it("maintains state correctly when switching between hints", () => {
310+
const ui = createMockUI({
311+
locale: registerLocale("test", {
312+
labels: {
313+
mfaTotpVerification: "Set up TOTP",
314+
mfaSmsVerification: "Set up SMS",
315+
},
316+
}),
317+
});
318+
319+
const { rerender } = render(
320+
<FirebaseUIProvider ui={ui}>
321+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP, FactorId.PHONE]} />
322+
</FirebaseUIProvider>
323+
);
324+
325+
fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" }));
326+
expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument();
327+
328+
rerender(
329+
<FirebaseUIProvider ui={ui}>
330+
<MultiFactorAuthEnrollmentForm hints={[FactorId.TOTP, FactorId.PHONE]} />
331+
</FirebaseUIProvider>
332+
);
333+
334+
expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument();
335+
});
336+
});

0 commit comments

Comments
 (0)