Skip to content

Commit f97c551

Browse files
committed
feat(shadcn): Assertion flows
1 parent 55b7ddb commit f97c551

11 files changed

+1173
-414
lines changed

packages/react/src/auth/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,14 @@ export {
7676
MultiFactorAuthEnrollmentForm,
7777
type MultiFactorAuthEnrollmentFormProps,
7878
} from "./forms/multi-factor-auth-enrollment-form";
79+
80+
export {
81+
useSmsMultiFactorAssertionPhoneFormAction,
82+
useSmsMultiFactorAssertionVerifyFormAction,
83+
SmsMultiFactorAssertionForm,
84+
type SmsMultiFactorAssertionFormProps,
85+
} from "./forms/mfa/sms-multi-factor-assertion-form";
86+
export {
87+
useTotpMultiFactorAssertionFormAction,
88+
TotpMultiFactorAssertionForm,
89+
} from "./forms/mfa/totp-multi-factor-assertion-form";

packages/shadcn/registry-spec.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,48 @@
404404
"type": "registry:component"
405405
}
406406
]
407+
},
408+
{
409+
"name": "multi-factor-auth-assertion-form",
410+
"type": "registry:block",
411+
"title": "Multi-Factor Auth Assertion Form",
412+
"description": "A form allowing users to complete multi-factor authentication during sign-in with TOTP or SMS options.",
413+
"dependencies": ["{{ DEP | @firebase-ui/react }}"],
414+
"registryDependencies": ["button", "{{ DOMAIN }}/sms-multi-factor-assertion-form.json", "{{ DOMAIN }}/totp-multi-factor-assertion-form.json"],
415+
"files": [
416+
{
417+
"path": "src/registry/multi-factor-auth-assertion-form.tsx",
418+
"type": "registry:component"
419+
}
420+
]
421+
},
422+
{
423+
"name": "sms-multi-factor-assertion-form",
424+
"type": "registry:block",
425+
"title": "SMS Multi-Factor Assertion Form",
426+
"description": "A form allowing users to complete SMS-based multi-factor authentication during sign-in.",
427+
"dependencies": ["{{ DEP | @firebase-ui/react }}"],
428+
"registryDependencies": ["form", "input", "button", "input-otp"],
429+
"files": [
430+
{
431+
"path": "src/registry/sms-multi-factor-assertion-form.tsx",
432+
"type": "registry:component"
433+
}
434+
]
435+
},
436+
{
437+
"name": "totp-multi-factor-assertion-form",
438+
"type": "registry:block",
439+
"title": "TOTP Multi-Factor Assertion Form",
440+
"description": "A form allowing users to complete TOTP-based multi-factor authentication during sign-in.",
441+
"dependencies": ["{{ DEP | @firebase-ui/react }}"],
442+
"registryDependencies": ["form", "button", "input-otp"],
443+
"files": [
444+
{
445+
"path": "src/registry/totp-multi-factor-assertion-form.tsx",
446+
"type": "registry:component"
447+
}
448+
]
407449
}
408450
]
409451
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
3+
import { MultiFactorAuthAssertionForm } from "./multi-factor-auth-assertion-form";
4+
import { createFirebaseUIProvider, createMockUI } from "~/tests/utils";
5+
import { registerLocale } from "@firebase-ui/translations";
6+
import { FactorId, MultiFactorResolver, PhoneMultiFactorGenerator, TotpMultiFactorGenerator } from "firebase/auth";
7+
8+
vi.mock("@/registry/sms-multi-factor-assertion-form", () => ({
9+
SmsMultiFactorAssertionForm: ({ hint }: { hint: any }) => (
10+
<div data-testid="sms-assertion-form">
11+
<div data-testid="sms-hint-factor-id">{hint.factorId}</div>
12+
</div>
13+
),
14+
}));
15+
16+
vi.mock("@/registry/totp-multi-factor-assertion-form", () => ({
17+
TotpMultiFactorAssertionForm: ({ hint }: { hint: any }) => (
18+
<div data-testid="totp-assertion-form">
19+
<div data-testid="totp-hint-factor-id">{hint.factorId}</div>
20+
</div>
21+
),
22+
}));
23+
24+
describe("<MultiFactorAuthAssertionForm />", () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks();
27+
});
28+
29+
afterEach(() => {
30+
cleanup();
31+
});
32+
33+
it("throws error when no multiFactorResolver is present", () => {
34+
const ui = createMockUI();
35+
36+
expect(() => {
37+
render(
38+
createFirebaseUIProvider({
39+
children: <MultiFactorAuthAssertionForm />,
40+
ui: ui,
41+
})
42+
);
43+
}).toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver");
44+
});
45+
46+
it("auto-selects single hint and renders corresponding form", () => {
47+
const mockResolver: MultiFactorResolver = {
48+
hints: [
49+
{
50+
uid: "test-uid",
51+
factorId: PhoneMultiFactorGenerator.FACTOR_ID,
52+
displayName: "Test Phone",
53+
},
54+
],
55+
} as MultiFactorResolver;
56+
57+
const ui = createMockUI({
58+
multiFactorResolver: mockResolver,
59+
});
60+
61+
render(
62+
createFirebaseUIProvider({
63+
children: <MultiFactorAuthAssertionForm />,
64+
ui: ui,
65+
})
66+
);
67+
68+
expect(screen.getByTestId("sms-assertion-form")).toBeInTheDocument();
69+
expect(screen.getByTestId("sms-hint-factor-id")).toHaveTextContent(PhoneMultiFactorGenerator.FACTOR_ID);
70+
});
71+
72+
it("shows buttons for multiple hints and allows selection", () => {
73+
const mockResolver: MultiFactorResolver = {
74+
hints: [
75+
{
76+
uid: "test-uid-1",
77+
factorId: PhoneMultiFactorGenerator.FACTOR_ID,
78+
displayName: "Test Phone",
79+
},
80+
{
81+
uid: "test-uid-2",
82+
factorId: TotpMultiFactorGenerator.FACTOR_ID,
83+
displayName: "Test TOTP",
84+
},
85+
],
86+
} as MultiFactorResolver;
87+
88+
const ui = createMockUI({
89+
multiFactorResolver: mockResolver,
90+
locale: registerLocale("test", {
91+
labels: {
92+
mfaTotpVerification: "Set up TOTP",
93+
mfaSmsVerification: "Set up SMS",
94+
},
95+
}),
96+
});
97+
98+
render(
99+
createFirebaseUIProvider({
100+
children: <MultiFactorAuthAssertionForm />,
101+
ui: ui,
102+
})
103+
);
104+
105+
expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument();
106+
expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument();
107+
108+
fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" }));
109+
110+
expect(screen.getByTestId("totp-assertion-form")).toBeInTheDocument();
111+
expect(screen.getByTestId("totp-hint-factor-id")).toHaveTextContent(TotpMultiFactorGenerator.FACTOR_ID);
112+
expect(screen.queryByTestId("sms-assertion-form")).not.toBeInTheDocument();
113+
});
114+
115+
it("renders SMS form when SMS hint is selected", () => {
116+
const mockResolver: MultiFactorResolver = {
117+
hints: [
118+
{
119+
uid: "test-uid-1",
120+
factorId: PhoneMultiFactorGenerator.FACTOR_ID,
121+
displayName: "Test Phone",
122+
},
123+
{
124+
uid: "test-uid-2",
125+
factorId: TotpMultiFactorGenerator.FACTOR_ID,
126+
displayName: "Test TOTP",
127+
},
128+
],
129+
} as MultiFactorResolver;
130+
131+
const ui = createMockUI({
132+
multiFactorResolver: mockResolver,
133+
locale: registerLocale("test", {
134+
labels: {
135+
mfaTotpVerification: "Set up TOTP",
136+
mfaSmsVerification: "Set up SMS",
137+
},
138+
}),
139+
});
140+
141+
render(
142+
createFirebaseUIProvider({
143+
children: <MultiFactorAuthAssertionForm />,
144+
ui: ui,
145+
})
146+
);
147+
148+
fireEvent.click(screen.getByRole("button", { name: "Set up SMS" }));
149+
150+
expect(screen.getByTestId("sms-assertion-form")).toBeInTheDocument();
151+
expect(screen.getByTestId("sms-hint-factor-id")).toHaveTextContent(PhoneMultiFactorGenerator.FACTOR_ID);
152+
expect(screen.queryByTestId("totp-assertion-form")).not.toBeInTheDocument();
153+
});
154+
155+
it("shows selection message when multiple hints are available", () => {
156+
const mockResolver: MultiFactorResolver = {
157+
hints: [
158+
{
159+
uid: "test-uid-1",
160+
factorId: PhoneMultiFactorGenerator.FACTOR_ID,
161+
displayName: "Test Phone",
162+
},
163+
{
164+
uid: "test-uid-2",
165+
factorId: TotpMultiFactorGenerator.FACTOR_ID,
166+
displayName: "Test TOTP",
167+
},
168+
],
169+
} as MultiFactorResolver;
170+
171+
const ui = createMockUI({
172+
multiFactorResolver: mockResolver,
173+
});
174+
175+
render(
176+
createFirebaseUIProvider({
177+
children: <MultiFactorAuthAssertionForm />,
178+
ui: ui,
179+
})
180+
);
181+
182+
expect(screen.getByText("Select a multi-factor authentication method")).toBeInTheDocument();
183+
});
184+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth";
4+
import { type ComponentProps, useState } from "react";
5+
import { getTranslation } from "@firebase-ui/core";
6+
import { useUI } from "@firebase-ui/react";
7+
8+
import { SmsMultiFactorAssertionForm } from "./sms-multi-factor-assertion-form";
9+
import { TotpMultiFactorAssertionForm } from "./totp-multi-factor-assertion-form";
10+
import { Button } from "@/components/ui/button";
11+
12+
export function MultiFactorAuthAssertionForm() {
13+
const ui = useUI();
14+
const resolver = ui.multiFactorResolver;
15+
16+
if (!resolver) {
17+
throw new Error("MultiFactorAuthAssertionForm requires a multi-factor resolver");
18+
}
19+
20+
// If only a single hint is provided, select it by default to improve UX.
21+
const [hint, setHint] = useState<MultiFactorInfo | undefined>(
22+
resolver.hints.length === 1 ? resolver.hints[0] : undefined
23+
);
24+
25+
if (hint) {
26+
if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) {
27+
return <SmsMultiFactorAssertionForm hint={hint} />;
28+
}
29+
30+
if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) {
31+
return <TotpMultiFactorAssertionForm hint={hint} />;
32+
}
33+
}
34+
35+
return (
36+
<div className="space-y-2">
37+
<p className="text-sm text-muted-foreground">Select a multi-factor authentication method</p>
38+
{resolver.hints.map((hint) => {
39+
if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) {
40+
return <TotpButton key={hint.factorId} onClick={() => setHint(hint)} />;
41+
}
42+
43+
if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) {
44+
return <SmsButton key={hint.factorId} onClick={() => setHint(hint)} />;
45+
}
46+
47+
return null;
48+
})}
49+
</div>
50+
);
51+
}
52+
53+
function TotpButton(props: ComponentProps<typeof Button>) {
54+
const ui = useUI();
55+
const labelText = getTranslation(ui, "labels", "mfaTotpVerification");
56+
return <Button {...props}>{labelText}</Button>;
57+
}
58+
59+
function SmsButton(props: ComponentProps<typeof Button>) {
60+
const ui = useUI();
61+
const labelText = getTranslation(ui, "labels", "mfaSmsVerification");
62+
return <Button {...props}>{labelText}</Button>;
63+
}

packages/shadcn/src/registry/sign-in-auth-screen.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useUI, type SignInAuthScreenProps } from "@firebase-ui/react";
66
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
77
import { Separator } from "@/components/ui/separator";
88
import { SignInAuthForm } from "@/registry/sign-in-auth-form";
9+
import { MultiFactorAuthAssertionForm } from "@/registry/multi-factor-auth-assertion-form";
910

1011
export type { SignInAuthScreenProps };
1112

@@ -15,6 +16,8 @@ export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps)
1516
const titleText = getTranslation(ui, "labels", "signIn");
1617
const subtitleText = getTranslation(ui, "prompts", "signInToAccount");
1718

19+
const mfaResolver = ui.multiFactorResolver;
20+
1821
return (
1922
<div className="max-w-md mx-auto">
2023
<Card>
@@ -23,13 +26,19 @@ export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps)
2326
<CardDescription>{subtitleText}</CardDescription>
2427
</CardHeader>
2528
<CardContent>
26-
<SignInAuthForm {...props} />
27-
{children ? (
29+
{mfaResolver ? (
30+
<MultiFactorAuthAssertionForm />
31+
) : (
2832
<>
29-
<Separator>{getTranslation(ui, "messages", "dividerOr")}</Separator>
30-
<div className="space-y-2">{children}</div>
33+
<SignInAuthForm {...props} />
34+
{children ? (
35+
<>
36+
<Separator>{getTranslation(ui, "messages", "dividerOr")}</Separator>
37+
<div className="space-y-2">{children}</div>
38+
</>
39+
) : null}
3140
</>
32-
) : null}
41+
)}
3342
</CardContent>
3443
</Card>
3544
</div>

0 commit comments

Comments
 (0)