Skip to content

Commit db6e928

Browse files
committed
feat(react): Handle mfa error in screens
1 parent d3a35e2 commit db6e928

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1462
-188
lines changed

examples/react/src/firebase/config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,4 @@
1414
* limitations under the License.
1515
*/
1616

17-
export const firebaseConfig = {
18-
};
17+
export const firebaseConfig = {};

examples/react/src/screens/mfa-enrollment-screen.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import { MultiFactorAuthEnrollmentScreen } from "@firebase-ui/react";
2020
import { FactorId } from "firebase/auth";
2121

2222
export default function MultiFactorAuthEnrollmentScreenPage() {
23-
return <MultiFactorAuthEnrollmentScreen
24-
hints={[FactorId.TOTP, FactorId.PHONE]}
25-
onEnrollment={() => {
26-
console.log("Enrollment successful");
27-
}}
28-
/>;
23+
return (
24+
<MultiFactorAuthEnrollmentScreen
25+
hints={[FactorId.TOTP, FactorId.PHONE]}
26+
onEnrollment={() => {
27+
console.log("Enrollment successful");
28+
}}
29+
/>
30+
);
2931
}

packages/core/src/auth.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,18 @@ async function handlePendingCredential(_ui: FirebaseUI, user: UserCredential): P
5757
}
5858
}
5959

60+
function setPendingState(ui: FirebaseUI) {
61+
ui.setRedirectError(undefined);
62+
ui.setState("pending");
63+
}
64+
6065
export async function signInWithEmailAndPassword(
6166
ui: FirebaseUI,
6267
email: string,
6368
password: string
6469
): Promise<UserCredential> {
6570
try {
66-
ui.setState("pending");
71+
setPendingState(ui);
6772
const credential = EmailAuthProvider.credential(email, password);
6873

6974
if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) {
@@ -90,7 +95,7 @@ export async function createUserWithEmailAndPassword(
9095
displayName?: string
9196
): Promise<UserCredential> {
9297
try {
93-
ui.setState("pending");
98+
setPendingState(ui);
9499
const credential = EmailAuthProvider.credential(email, password);
95100

96101
if (hasBehavior(ui, "requireDisplayName") && !displayName) {
@@ -130,7 +135,7 @@ export async function verifyPhoneNumber(
130135
mfaUser?: MultiFactorUser
131136
): Promise<string> {
132137
try {
133-
ui.setState("pending");
138+
setPendingState(ui);
134139
const provider = new PhoneAuthProvider(ui.auth);
135140
const session = await mfaUser?.getSession();
136141
return await provider.verifyPhoneNumber(
@@ -155,7 +160,7 @@ export async function confirmPhoneNumber(
155160
verificationCode: string
156161
): Promise<UserCredential> {
157162
try {
158-
ui.setState("pending");
163+
setPendingState(ui);
159164
const currentUser = ui.auth.currentUser;
160165
const credential = PhoneAuthProvider.credential(verificationId, verificationCode);
161166

@@ -178,7 +183,7 @@ export async function confirmPhoneNumber(
178183

179184
export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Promise<void> {
180185
try {
181-
ui.setState("pending");
186+
setPendingState(ui);
182187
await _sendPasswordResetEmail(ui.auth, email);
183188
} catch (error) {
184189
handleFirebaseError(ui, error);
@@ -189,7 +194,7 @@ export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Pro
189194

190195
export async function sendSignInLinkToEmail(ui: FirebaseUI, email: string): Promise<void> {
191196
try {
192-
ui.setState("pending");
197+
setPendingState(ui);
193198
const actionCodeSettings = {
194199
url: window.location.href,
195200
// TODO(ehesp): Check this...
@@ -213,7 +218,7 @@ export async function signInWithEmailLink(ui: FirebaseUI, email: string, link: s
213218

214219
export async function signInWithCredential(ui: FirebaseUI, credential: AuthCredential): Promise<UserCredential> {
215220
try {
216-
ui.setState("pending");
221+
setPendingState(ui);
217222
if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) {
218223
const userCredential = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential);
219224

@@ -235,7 +240,7 @@ export async function signInWithCredential(ui: FirebaseUI, credential: AuthCrede
235240

236241
export async function signInAnonymously(ui: FirebaseUI): Promise<UserCredential> {
237242
try {
238-
ui.setState("pending");
243+
setPendingState(ui);
239244
const result = await _signInAnonymously(ui.auth);
240245
return handlePendingCredential(ui, result);
241246
} catch (error) {
@@ -247,7 +252,7 @@ export async function signInAnonymously(ui: FirebaseUI): Promise<UserCredential>
247252

248253
export async function signInWithProvider(ui: FirebaseUI, provider: AuthProvider): Promise<UserCredential | never> {
249254
try {
250-
ui.setState("pending");
255+
setPendingState(ui);
251256
if (hasBehavior(ui, "autoUpgradeAnonymousProvider")) {
252257
const credential = await getBehavior(ui, "autoUpgradeAnonymousProvider")(ui, provider);
253258

@@ -280,7 +285,7 @@ export async function completeEmailLinkSignIn(ui: FirebaseUI, currentUrl: string
280285
const email = window.localStorage.getItem("emailForSignIn");
281286
if (!email) return null;
282287

283-
ui.setState("pending");
288+
setPendingState(ui);
284289
const result = await signInWithEmailLink(ui, email, currentUrl);
285290
return handlePendingCredential(ui, result);
286291
} catch (error) {
@@ -317,7 +322,7 @@ export async function enrollWithMultiFactorAssertion(
317322
displayName?: string
318323
): Promise<void> {
319324
try {
320-
ui.setState("pending");
325+
setPendingState(ui);
321326
await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName);
322327
} catch (error) {
323328
handleFirebaseError(ui, error);
@@ -328,7 +333,7 @@ export async function enrollWithMultiFactorAssertion(
328333

329334
export async function generateTotpSecret(ui: FirebaseUI): Promise<TotpSecret> {
330335
try {
331-
ui.setState("pending");
336+
setPendingState(ui);
332337
const mfaUser = multiFactor(ui.auth.currentUser!);
333338
const session = await mfaUser.getSession();
334339
return await TotpMultiFactorGenerator.generateSecret(session);

packages/core/src/config.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,4 +383,113 @@ describe("initializeUI", () => {
383383
ui.get().setMultiFactorResolver(undefined);
384384
expect(ui.get().multiFactorResolver).toBeUndefined();
385385
});
386+
387+
it("should have redirectError undefined by default", () => {
388+
const config = {
389+
app: {} as FirebaseApp,
390+
auth: {} as Auth,
391+
};
392+
393+
const ui = initializeUI(config);
394+
expect(ui.get().redirectError).toBeUndefined();
395+
});
396+
397+
it("should set and get redirectError correctly", () => {
398+
const config = {
399+
app: {} as FirebaseApp,
400+
auth: {} as Auth,
401+
};
402+
403+
const ui = initializeUI(config);
404+
const mockError = new Error("Test redirect error");
405+
406+
expect(ui.get().redirectError).toBeUndefined();
407+
ui.get().setRedirectError(mockError);
408+
expect(ui.get().redirectError).toBe(mockError);
409+
ui.get().setRedirectError(undefined);
410+
expect(ui.get().redirectError).toBeUndefined();
411+
});
412+
413+
it("should update redirectError multiple times", () => {
414+
const config = {
415+
app: {} as FirebaseApp,
416+
auth: {} as Auth,
417+
};
418+
419+
const ui = initializeUI(config);
420+
const mockError1 = new Error("First error");
421+
const mockError2 = new Error("Second error");
422+
423+
ui.get().setRedirectError(mockError1);
424+
expect(ui.get().redirectError).toBe(mockError1);
425+
ui.get().setRedirectError(mockError2);
426+
expect(ui.get().redirectError).toBe(mockError2);
427+
ui.get().setRedirectError(undefined);
428+
expect(ui.get().redirectError).toBeUndefined();
429+
});
430+
431+
it("should handle redirect error when getRedirectResult throws", async () => {
432+
Object.defineProperty(global, "window", {
433+
value: {},
434+
writable: true,
435+
configurable: true,
436+
});
437+
438+
const mockAuth = {
439+
currentUser: null,
440+
} as any;
441+
442+
const mockError = new Error("Redirect failed");
443+
const { getRedirectResult } = await import("firebase/auth");
444+
vi.mocked(getRedirectResult).mockClear();
445+
vi.mocked(getRedirectResult).mockRejectedValue(mockError);
446+
447+
const config = {
448+
app: {} as FirebaseApp,
449+
auth: mockAuth,
450+
};
451+
452+
const ui = initializeUI(config);
453+
454+
// Process next tick to make sure the promise is resolved
455+
await new Promise((resolve) => setTimeout(resolve, 0));
456+
457+
expect(getRedirectResult).toHaveBeenCalledTimes(1);
458+
expect(getRedirectResult).toHaveBeenCalledWith(mockAuth);
459+
expect(ui.get().redirectError).toBe(mockError);
460+
461+
delete (global as any).window;
462+
});
463+
464+
it("should convert non-Error objects to Error instances in redirect catch", async () => {
465+
Object.defineProperty(global, "window", {
466+
value: {},
467+
writable: true,
468+
configurable: true,
469+
});
470+
471+
const mockAuth = {
472+
currentUser: null,
473+
} as any;
474+
475+
const { getRedirectResult } = await import("firebase/auth");
476+
vi.mocked(getRedirectResult).mockClear();
477+
vi.mocked(getRedirectResult).mockRejectedValue("String error");
478+
479+
const config = {
480+
app: {} as FirebaseApp,
481+
auth: mockAuth,
482+
};
483+
484+
const ui = initializeUI(config);
485+
486+
// Process next tick to make sure the promise is resolved
487+
await new Promise((resolve) => setTimeout(resolve, 0));
488+
489+
expect(getRedirectResult).toHaveBeenCalledTimes(1);
490+
expect(ui.get().redirectError).toBeInstanceOf(Error);
491+
expect(ui.get().redirectError?.message).toBe("String error");
492+
493+
delete (global as any).window;
494+
});
386495
});

packages/core/src/config.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { deepMap, type DeepMapStore, map } from "nanostores";
2121
import { type Behavior, type Behaviors, defaultBehaviors } from "./behaviors";
2222
import type { InitBehavior, RedirectBehavior } from "./behaviors/utils";
2323
import { type FirebaseUIState } from "./state";
24+
import { handleFirebaseError } from "./errors";
2425

2526
export type FirebaseUIOptions = {
2627
app: FirebaseApp;
@@ -40,6 +41,8 @@ export type FirebaseUI = {
4041
behaviors: Behaviors;
4142
multiFactorResolver?: MultiFactorResolver;
4243
setMultiFactorResolver: (multiFactorResolver?: MultiFactorResolver) => void;
44+
redirectError?: Error;
45+
setRedirectError: (error?: Error) => void;
4346
};
4447

4548
export const $config = map<Record<string, DeepMapStore<FirebaseUI>>>({});
@@ -78,6 +81,11 @@ export function initializeUI(config: FirebaseUIOptions, name: string = "[DEFAULT
7881
const current = $config.get()[name]!;
7982
current.setKey(`multiFactorResolver`, resolver);
8083
},
84+
redirectError: undefined,
85+
setRedirectError: (error?: Error) => {
86+
const current = $config.get()[name]!;
87+
current.setKey(`redirectError`, error);
88+
},
8189
})
8290
);
8391

@@ -106,11 +114,17 @@ export function initializeUI(config: FirebaseUIOptions, name: string = "[DEFAULT
106114
});
107115
}
108116

109-
if (redirectBehaviors.length > 0) {
110-
getRedirectResult(ui.auth).then((result) => {
111-
Promise.all(redirectBehaviors.map((behavior) => behavior.handler(ui, result)));
117+
getRedirectResult(ui.auth)
118+
.then((result) => {
119+
return Promise.all(redirectBehaviors.map((behavior) => behavior.handler(ui, result)));
120+
})
121+
.catch((error) => {
122+
try {
123+
handleFirebaseError(ui, error);
124+
} catch (error) {
125+
ui.setRedirectError(error instanceof Error ? error : new Error(String(error)));
126+
}
112127
});
113-
}
114128
}
115129

116130
return store;

packages/core/tests/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export function createMockUI(overrides?: Partial<FirebaseUI>): FirebaseUI {
1616
behaviors: {},
1717
multiFactorResolver: undefined,
1818
setMultiFactorResolver: vi.fn(),
19+
redirectError: undefined,
20+
setRedirectError: vi.fn(),
1921
...overrides,
2022
};
2123
}

packages/react/src/auth/forms/email-link-auth-form.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ import { registerLocale } from "@firebase-ui/translations";
2929
import { FirebaseUIProvider } from "~/context";
3030
import type { UserCredential } from "firebase/auth";
3131

32+
vi.mock("firebase/auth", async () => {
33+
const actual = await vi.importActual("firebase/auth");
34+
return {
35+
...actual,
36+
getRedirectResult: vi.fn().mockResolvedValue(null),
37+
};
38+
});
39+
3240
vi.mock("@firebase-ui/core", async (importOriginal) => {
3341
const mod = await importOriginal<typeof import("@firebase-ui/core")>();
3442
return {

packages/react/src/auth/forms/forgot-password-auth-form.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ import { createFirebaseUIProvider, createMockUI } from "~/tests/utils";
2727
import { registerLocale } from "@firebase-ui/translations";
2828
import { FirebaseUIProvider } from "~/context";
2929

30+
vi.mock("firebase/auth", async () => {
31+
const actual = await vi.importActual("firebase/auth");
32+
return {
33+
...actual,
34+
getRedirectResult: vi.fn().mockResolvedValue(null),
35+
};
36+
});
37+
3038
vi.mock("@firebase-ui/core", async (importOriginal) => {
3139
const mod = await importOriginal<typeof import("@firebase-ui/core")>();
3240
return {

packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ export function MultiFactorEnrollmentVerifyPhoneNumberForm(props: MultiFactorEnr
198198
{(field) => <field.Input label={getTranslation(ui, "labels", "verificationCode")} type="text" />}
199199
</form.AppField>
200200
</fieldset>
201+
<fieldset>
202+
<form.SubmitButton>{getTranslation(ui, "labels", "verifyCode")}</form.SubmitButton>
203+
<form.ErrorMessage />
204+
</fieldset>
201205
</form.AppForm>
202206
</form>
203207
);

0 commit comments

Comments
 (0)