Skip to content

Commit 2de6ed1

Browse files
authored
Merge pull request #1244 from firebase/@invertase/bb-4.1
2 parents d50dbc4 + a54a6ff commit 2de6ed1

20 files changed

+465
-60
lines changed

examples/shadcn/src/components/multi-factor-auth-assertion-form.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
"use client";
22

3-
import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth";
3+
import {
4+
PhoneMultiFactorGenerator,
5+
TotpMultiFactorGenerator,
6+
type MultiFactorInfo,
7+
type UserCredential,
8+
} from "firebase/auth";
49
import { type ComponentProps, useState } from "react";
510
import { getTranslation } from "@firebase-ui/core";
611
import { useUI } from "@firebase-ui/react";
@@ -9,7 +14,11 @@ import { SmsMultiFactorAssertionForm } from "./sms-multi-factor-assertion-form";
914
import { TotpMultiFactorAssertionForm } from "./totp-multi-factor-assertion-form";
1015
import { Button } from "@/components/ui/button";
1116

12-
export function MultiFactorAuthAssertionForm() {
17+
export type MultiFactorAuthAssertionFormProps = {
18+
onSuccess?: (credential: UserCredential) => void;
19+
};
20+
21+
export function MultiFactorAuthAssertionForm(props: MultiFactorAuthAssertionFormProps) {
1322
const ui = useUI();
1423
const resolver = ui.multiFactorResolver;
1524

@@ -24,11 +33,25 @@ export function MultiFactorAuthAssertionForm() {
2433

2534
if (hint) {
2635
if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) {
27-
return <SmsMultiFactorAssertionForm hint={hint} />;
36+
return (
37+
<SmsMultiFactorAssertionForm
38+
hint={hint}
39+
onSuccess={(credential) => {
40+
props.onSuccess?.(credential);
41+
}}
42+
/>
43+
);
2844
}
2945

3046
if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) {
31-
return <TotpMultiFactorAssertionForm hint={hint} />;
47+
return (
48+
<TotpMultiFactorAssertionForm
49+
hint={hint}
50+
onSuccess={(credential) => {
51+
props.onSuccess?.(credential);
52+
}}
53+
/>
54+
);
3255
}
3356
}
3457

examples/shadcn/src/components/sms-multi-factor-assertion-form.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useRef, useState } from "react";
4-
import { type MultiFactorInfo } from "firebase/auth";
4+
import { type MultiFactorInfo, type UserCredential } from "firebase/auth";
55

66
import { FirebaseUIError, getTranslation } from "@firebase-ui/core";
77
import {
@@ -64,7 +64,7 @@ function SmsMultiFactorAssertionPhoneForm(props: SmsMultiFactorAssertionPhoneFor
6464

6565
type SmsMultiFactorAssertionVerifyFormProps = {
6666
verificationId: string;
67-
onSuccess: () => void;
67+
onSuccess: (credential: UserCredential) => void;
6868
};
6969

7070
function SmsMultiFactorAssertionVerifyForm(props: SmsMultiFactorAssertionVerifyFormProps) {
@@ -82,8 +82,11 @@ function SmsMultiFactorAssertionVerifyForm(props: SmsMultiFactorAssertionVerifyF
8282

8383
const onSubmit = async (values: { verificationId: string; verificationCode: string }) => {
8484
try {
85-
await action({ verificationId: values.verificationId, verificationCode: values.verificationCode });
86-
props.onSuccess();
85+
const credential = await action({
86+
verificationId: values.verificationId,
87+
verificationCode: values.verificationCode,
88+
});
89+
props.onSuccess(credential);
8790
} catch (error) {
8891
const message = error instanceof FirebaseUIError ? error.message : String(error);
8992
form.setError("root", { message });
@@ -126,7 +129,7 @@ function SmsMultiFactorAssertionVerifyForm(props: SmsMultiFactorAssertionVerifyF
126129

127130
export type SmsMultiFactorAssertionFormProps = {
128131
hint: MultiFactorInfo;
129-
onSuccess?: () => void;
132+
onSuccess?: (credential: UserCredential) => void;
130133
};
131134

132135
export function SmsMultiFactorAssertionForm(props: SmsMultiFactorAssertionFormProps) {
@@ -146,8 +149,8 @@ export function SmsMultiFactorAssertionForm(props: SmsMultiFactorAssertionFormPr
146149
return (
147150
<SmsMultiFactorAssertionVerifyForm
148151
verificationId={verification.verificationId}
149-
onSuccess={() => {
150-
props.onSuccess?.();
152+
onSuccess={(credential) => {
153+
props.onSuccess?.(credential);
151154
}}
152155
/>
153156
);

examples/shadcn/src/components/totp-multi-factor-assertion-form.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { type MultiFactorInfo } from "firebase/auth";
3+
import { type MultiFactorInfo, type UserCredential } from "firebase/auth";
44
import { FirebaseUIError, getTranslation } from "@firebase-ui/core";
55
import {
66
useMultiFactorTotpAuthVerifyFormSchema,
@@ -16,7 +16,7 @@ import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp
1616

1717
type TotpMultiFactorAssertionFormProps = {
1818
hint: MultiFactorInfo;
19-
onSuccess?: () => void;
19+
onSuccess?: (credential: UserCredential) => void;
2020
};
2121

2222
export function TotpMultiFactorAssertionForm(props: TotpMultiFactorAssertionFormProps) {
@@ -33,8 +33,8 @@ export function TotpMultiFactorAssertionForm(props: TotpMultiFactorAssertionForm
3333

3434
const onSubmit = async (values: { verificationCode: string }) => {
3535
try {
36-
await action({ verificationCode: values.verificationCode, hint: props.hint });
37-
props.onSuccess?.();
36+
const credential = await action({ verificationCode: values.verificationCode, hint: props.hint });
37+
props.onSuccess?.(credential);
3838
} catch (error) {
3939
const message = error instanceof FirebaseUIError ? error.message : String(error);
4040
form.setError("root", { message });

packages/shadcn/registry-spec.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,12 @@
443443
"title": "Sign Up Auth Screen",
444444
"description": "A screen allowing users to sign up with email and password.",
445445
"dependencies": ["{{ DEP | @firebase-ui/react }}"],
446-
"registryDependencies": ["separator", "card", "{{ DOMAIN }}/sign-up-auth-form.json"],
446+
"registryDependencies": [
447+
"separator",
448+
"card",
449+
"{{ DOMAIN }}/sign-up-auth-form.json",
450+
"{{ DOMAIN }}/multi-factor-auth-assertion-form.json"
451+
],
447452
"files": [
448453
{
449454
"path": "src/components/sign-up-auth-screen.tsx",

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

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@
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 "./email-link-auth-screen";
2020
import { createMockUI } from "../../tests/utils";
2121
import { registerLocale } from "@firebase-ui/translations";
2222
import { FirebaseUIProvider } from "@firebase-ui/react";
23+
import { MultiFactorResolver } from "firebase/auth";
2324

2425
vi.mock("./email-link-auth-form", () => ({
2526
EmailLinkAuthForm: ({ onEmailSent, onSignIn }: any) => (
@@ -31,6 +32,16 @@ vi.mock("./email-link-auth-form", () => ({
3132
),
3233
}));
3334

35+
vi.mock("@/components/multi-factor-auth-assertion-form", () => ({
36+
MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
37+
<div data-testid="mfa-assertion-form">
38+
<button data-testid="mfa-on-success" onClick={() => onSuccess?.({ user: { uid: "email-link-mfa-user" } })}>
39+
MFA Success
40+
</button>
41+
</div>
42+
),
43+
}));
44+
3445
describe("<EmailLinkAuthScreen />", () => {
3546
beforeEach(() => {
3647
vi.clearAllMocks();
@@ -144,4 +155,114 @@ describe("<EmailLinkAuthScreen />", () => {
144155
expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument();
145156
expect(screen.queryByText("or")).not.toBeInTheDocument();
146157
});
158+
159+
it("should render MultiFactorAuthAssertionForm when multiFactorResolver is present", () => {
160+
const mockResolver = {
161+
auth: {} as any,
162+
session: null,
163+
hints: [],
164+
};
165+
const mockUI = createMockUI({
166+
locale: registerLocale("test", {
167+
labels: {
168+
signIn: "Sign In",
169+
},
170+
prompts: {
171+
signInToAccount: "Sign in to your account",
172+
},
173+
}),
174+
});
175+
mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver);
176+
177+
render(
178+
<FirebaseUIProvider ui={mockUI}>
179+
<EmailLinkAuthScreen />
180+
</FirebaseUIProvider>
181+
);
182+
183+
expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument();
184+
expect(screen.queryByTestId("email-link-auth-form")).not.toBeInTheDocument();
185+
});
186+
187+
it("should not render EmailLinkAuthForm when MFA resolver exists", () => {
188+
const mockResolver = {
189+
auth: {} as any,
190+
session: null,
191+
hints: [],
192+
};
193+
const mockUI = createMockUI({
194+
locale: registerLocale("test", {
195+
labels: {
196+
signIn: "Sign In",
197+
},
198+
prompts: {
199+
signInToAccount: "Sign in to your account",
200+
},
201+
messages: {
202+
dividerOr: "or",
203+
},
204+
}),
205+
});
206+
mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver);
207+
208+
render(
209+
<FirebaseUIProvider ui={mockUI}>
210+
<EmailLinkAuthScreen>
211+
<div data-testid="child-component">Child Component</div>
212+
</EmailLinkAuthScreen>
213+
</FirebaseUIProvider>
214+
);
215+
216+
expect(screen.queryByTestId("email-link-auth-form")).not.toBeInTheDocument();
217+
expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument();
218+
expect(screen.queryByText("or")).not.toBeInTheDocument();
219+
expect(screen.queryByTestId("child-component")).not.toBeInTheDocument();
220+
});
221+
222+
it("should render EmailLinkAuthForm when MFA resolver is not present", () => {
223+
const mockUI = createMockUI({
224+
locale: registerLocale("test", {
225+
labels: {
226+
signIn: "Sign In",
227+
},
228+
prompts: {
229+
signInToAccount: "Sign in to your account",
230+
},
231+
}),
232+
});
233+
234+
render(
235+
<FirebaseUIProvider ui={mockUI}>
236+
<EmailLinkAuthScreen />
237+
</FirebaseUIProvider>
238+
);
239+
240+
expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument();
241+
expect(screen.queryByTestId("mfa-assertion-form")).not.toBeInTheDocument();
242+
});
243+
244+
it("calls onSignIn with credential when MFA flow succeeds", () => {
245+
const mockResolver = {
246+
auth: {} as any,
247+
session: null,
248+
hints: [],
249+
};
250+
const mockUI = createMockUI();
251+
mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver);
252+
253+
const onSignIn = vi.fn();
254+
255+
render(
256+
<FirebaseUIProvider ui={mockUI}>
257+
<EmailLinkAuthScreen onSignIn={onSignIn} />
258+
</FirebaseUIProvider>
259+
);
260+
261+
fireEvent.click(screen.getByTestId("mfa-on-success"));
262+
263+
expect(onSignIn).toHaveBeenCalledTimes(1);
264+
expect(onSignIn).toHaveBeenCalledWith(
265+
expect.objectContaining({ user: expect.objectContaining({ uid: "email-link-mfa-user" }) })
266+
);
267+
});
147268
});

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +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";
910

1011
export type { EmailLinkAuthScreenProps };
1112

@@ -14,6 +15,7 @@ export function EmailLinkAuthScreen({ children, ...props }: EmailLinkAuthScreenP
1415

1516
const titleText = getTranslation(ui, "labels", "signIn");
1617
const subtitleText = getTranslation(ui, "prompts", "signInToAccount");
18+
const mfaResolver = ui.multiFactorResolver;
1719

1820
return (
1921
<div className="max-w-md mx-auto">
@@ -23,13 +25,19 @@ export function EmailLinkAuthScreen({ children, ...props }: EmailLinkAuthScreenP
2325
<CardDescription>{subtitleText}</CardDescription>
2426
</CardHeader>
2527
<CardContent>
26-
<EmailLinkAuthForm {...props} />
27-
{children ? (
28+
{mfaResolver ? (
29+
<MultiFactorAuthAssertionForm onSuccess={(credential) => props.onSignIn?.(credential)} />
30+
) : (
2831
<>
29-
<Separator>{getTranslation(ui, "messages", "dividerOr")}</Separator>
30-
<div className="space-y-2">{children}</div>
32+
<EmailLinkAuthForm {...props} />
33+
{children ? (
34+
<>
35+
<Separator>{getTranslation(ui, "messages", "dividerOr")}</Separator>
36+
<div className="space-y-2">{children}</div>
37+
</>
38+
) : null}
3139
</>
32-
) : null}
40+
)}
3341
</CardContent>
3442
</Card>
3543
</div>

0 commit comments

Comments
 (0)