Skip to content

Commit 49a52e2

Browse files
committed
Merge branch '@invertase/v7-development' of https://github.com/firebase/firebaseui-web into @invertase/sign-in-with-custom-token
2 parents 6142c8a + 7695806 commit 49a52e2

File tree

56 files changed

+4099
-121
lines changed

Some content is hidden

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

56 files changed

+4099
-121
lines changed

examples/react/src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ function App() {
8282
Password Reset Screen
8383
</NavLink>
8484
</li>
85+
<li>
86+
<NavLink to="/screens/mfa-enrollment-screen" className="text-blue-500 hover:underline">
87+
MFA Enrollment Screen
88+
</NavLink>
89+
</li>
8590
</ul>
8691
</div>
8792
</div>

examples/react/src/main.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ import OAuthScreenPage from "./screens/oauth-screen";
4747
/** Password Reset */
4848
import ForgotPasswordPage from "./screens/forgot-password-screen";
4949

50+
/** MFA Enrollment */
51+
import MultiFactorAuthEnrollmentScreenPage from "./screens/mfa-enrollment-screen";
52+
5053
const root = document.getElementById("root")!;
5154

5255
ReactDOM.createRoot(root).render(
@@ -72,6 +75,7 @@ ReactDOM.createRoot(root).render(
7275
<Route path="/screens/sign-up-auth-screen-w-oauth" element={<SignUpAuthScreenWithOAuthPage />} />
7376
<Route path="/screens/oauth-screen" element={<OAuthScreenPage />} />
7477
<Route path="/screens/forgot-password-screen" element={<ForgotPasswordPage />} />
78+
<Route path="/screens/mfa-enrollment-screen" element={<MultiFactorAuthEnrollmentScreenPage />} />
7579
</Routes>
7680
</FirebaseUIProvider>
7781
</BrowserRouter>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
"use client";
18+
19+
import { MultiFactorAuthEnrollmentScreen } from "@firebase-ui/react";
20+
import { FactorId } from "firebase/auth";
21+
22+
export default function MultiFactorAuthEnrollmentScreenPage() {
23+
return (
24+
<MultiFactorAuthEnrollmentScreen
25+
hints={[FactorId.TOTP, FactorId.PHONE]}
26+
onEnrollment={() => {
27+
console.log("Enrollment successful");
28+
}}
29+
/>
30+
);
31+
}

packages/core/src/auth.ts

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,17 @@ import {
2525
EmailAuthProvider,
2626
linkWithCredential,
2727
PhoneAuthProvider,
28+
TotpMultiFactorGenerator,
29+
multiFactor,
2830
type ActionCodeSettings,
2931
type ApplicationVerifier,
3032
type AuthProvider,
3133
type UserCredential,
3234
type AuthCredential,
3335
type TotpSecret,
34-
type PhoneInfoOptions,
36+
type MultiFactorAssertion,
37+
type MultiFactorUser,
38+
type MultiFactorInfo,
3539
} from "firebase/auth";
3640
import QRCode from "qrcode-generator";
3741
import { type FirebaseUI } from "./config";
@@ -55,13 +59,18 @@ async function handlePendingCredential(_ui: FirebaseUI, user: UserCredential): P
5559
}
5660
}
5761

62+
function setPendingState(ui: FirebaseUI) {
63+
ui.setRedirectError(undefined);
64+
ui.setState("pending");
65+
}
66+
5867
export async function signInWithEmailAndPassword(
5968
ui: FirebaseUI,
6069
email: string,
6170
password: string
6271
): Promise<UserCredential> {
6372
try {
64-
ui.setState("pending");
73+
setPendingState(ui);
6574
const credential = EmailAuthProvider.credential(email, password);
6675

6776
if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) {
@@ -88,7 +97,7 @@ export async function createUserWithEmailAndPassword(
8897
displayName?: string
8998
): Promise<UserCredential> {
9099
try {
91-
ui.setState("pending");
100+
setPendingState(ui);
92101
const credential = EmailAuthProvider.credential(email, password);
93102

94103
if (hasBehavior(ui, "requireDisplayName") && !displayName) {
@@ -123,13 +132,38 @@ export async function createUserWithEmailAndPassword(
123132

124133
export async function verifyPhoneNumber(
125134
ui: FirebaseUI,
126-
phoneNumber: PhoneInfoOptions | string,
127-
appVerifier: ApplicationVerifier
135+
phoneNumber: string,
136+
appVerifier: ApplicationVerifier,
137+
mfaUser?: MultiFactorUser,
138+
mfaHint?: MultiFactorInfo
128139
): Promise<string> {
129140
try {
130-
ui.setState("pending");
141+
setPendingState(ui);
131142
const provider = new PhoneAuthProvider(ui.auth);
132-
return await provider.verifyPhoneNumber(phoneNumber, appVerifier);
143+
144+
if (mfaHint && ui.multiFactorResolver) {
145+
// MFA assertion flow
146+
return await provider.verifyPhoneNumber(
147+
{
148+
multiFactorHint: mfaHint,
149+
session: ui.multiFactorResolver.session,
150+
},
151+
appVerifier
152+
);
153+
} else if (mfaUser) {
154+
// MFA enrollment flow
155+
const session = await mfaUser.getSession();
156+
return await provider.verifyPhoneNumber(
157+
{
158+
phoneNumber,
159+
session,
160+
},
161+
appVerifier
162+
);
163+
} else {
164+
// Regular phone auth flow
165+
return await provider.verifyPhoneNumber(phoneNumber, appVerifier);
166+
}
133167
} catch (error) {
134168
handleFirebaseError(ui, error);
135169
} finally {
@@ -143,7 +177,7 @@ export async function confirmPhoneNumber(
143177
verificationCode: string
144178
): Promise<UserCredential> {
145179
try {
146-
ui.setState("pending");
180+
setPendingState(ui);
147181
const currentUser = ui.auth.currentUser;
148182
const credential = PhoneAuthProvider.credential(verificationId, verificationCode);
149183

@@ -166,7 +200,7 @@ export async function confirmPhoneNumber(
166200

167201
export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Promise<void> {
168202
try {
169-
ui.setState("pending");
203+
setPendingState(ui);
170204
await _sendPasswordResetEmail(ui.auth, email);
171205
} catch (error) {
172206
handleFirebaseError(ui, error);
@@ -177,7 +211,7 @@ export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Pro
177211

178212
export async function sendSignInLinkToEmail(ui: FirebaseUI, email: string): Promise<void> {
179213
try {
180-
ui.setState("pending");
214+
setPendingState(ui);
181215
const actionCodeSettings = {
182216
url: window.location.href,
183217
// TODO(ehesp): Check this...
@@ -201,7 +235,7 @@ export async function signInWithEmailLink(ui: FirebaseUI, email: string, link: s
201235

202236
export async function signInWithCredential(ui: FirebaseUI, credential: AuthCredential): Promise<UserCredential> {
203237
try {
204-
ui.setState("pending");
238+
setPendingState(ui);
205239
if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) {
206240
const userCredential = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential);
207241

@@ -235,7 +269,7 @@ export async function signInWithCustomToken(ui: FirebaseUI, customToken: string)
235269

236270
export async function signInAnonymously(ui: FirebaseUI): Promise<UserCredential> {
237271
try {
238-
ui.setState("pending");
272+
setPendingState(ui);
239273
const result = await _signInAnonymously(ui.auth);
240274
return handlePendingCredential(ui, result);
241275
} catch (error) {
@@ -247,7 +281,7 @@ export async function signInAnonymously(ui: FirebaseUI): Promise<UserCredential>
247281

248282
export async function signInWithProvider(ui: FirebaseUI, provider: AuthProvider): Promise<UserCredential | never> {
249283
try {
250-
ui.setState("pending");
284+
setPendingState(ui);
251285
if (hasBehavior(ui, "autoUpgradeAnonymousProvider")) {
252286
const credential = await getBehavior(ui, "autoUpgradeAnonymousProvider")(ui, provider);
253287

@@ -280,7 +314,7 @@ export async function completeEmailLinkSignIn(ui: FirebaseUI, currentUrl: string
280314
const email = window.localStorage.getItem("emailForSignIn");
281315
if (!email) return null;
282316

283-
ui.setState("pending");
317+
setPendingState(ui);
284318
const result = await signInWithEmailLink(ui, email, currentUrl);
285319
return handlePendingCredential(ui, result);
286320
} catch (error) {
@@ -305,3 +339,44 @@ export function generateTotpQrCode(ui: FirebaseUI, secret: TotpSecret, accountNa
305339
qr.make();
306340
return qr.createDataURL();
307341
}
342+
343+
export async function signInWithMultiFactorAssertion(ui: FirebaseUI, assertion: MultiFactorAssertion) {
344+
try {
345+
setPendingState(ui);
346+
const result = await ui.multiFactorResolver?.resolveSignIn(assertion);
347+
ui.setMultiFactorResolver(undefined);
348+
return result;
349+
} catch (error) {
350+
handleFirebaseError(ui, error);
351+
} finally {
352+
ui.setState("idle");
353+
}
354+
}
355+
356+
export async function enrollWithMultiFactorAssertion(
357+
ui: FirebaseUI,
358+
assertion: MultiFactorAssertion,
359+
displayName?: string
360+
): Promise<void> {
361+
try {
362+
setPendingState(ui);
363+
await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName);
364+
} catch (error) {
365+
handleFirebaseError(ui, error);
366+
} finally {
367+
ui.setState("idle");
368+
}
369+
}
370+
371+
export async function generateTotpSecret(ui: FirebaseUI): Promise<TotpSecret> {
372+
try {
373+
setPendingState(ui);
374+
const mfaUser = multiFactor(ui.auth.currentUser!);
375+
const session = await mfaUser.getSession();
376+
return await TotpMultiFactorGenerator.generateSecret(session);
377+
} catch (error) {
378+
handleFirebaseError(ui, error);
379+
} finally {
380+
ui.setState("idle");
381+
}
382+
}

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
});

0 commit comments

Comments
 (0)