From d9eb81eb5d26a1d19a3efca418722862fb787973 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 15:59:11 +0100 Subject: [PATCH 01/43] refactor(core): Align current API with new spec --- packages/core/src/auth.ts | 57 +++++++++++---------------- packages/core/src/behaviors.ts | 35 +++++++--------- packages/core/src/config.ts | 10 ++--- packages/core/src/country-data.ts | 8 +++- packages/core/src/errors.ts | 1 + packages/core/src/index.ts | 2 - packages/core/src/state.ts | 10 +---- packages/core/src/styles.css | 15 ------- packages/core/src/types.ts | 22 ----------- packages/core/tests/unit/auth.test.ts | 4 +- 10 files changed, 49 insertions(+), 115 deletions(-) delete mode 100644 packages/core/src/styles.css delete mode 100644 packages/core/src/types.ts diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 0365c91a..4549809a 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -25,7 +25,6 @@ import { AuthProvider, ConfirmationResult, EmailAuthProvider, - getAuth, linkWithCredential, PhoneAuthProvider, RecaptchaVerifier, @@ -43,7 +42,7 @@ async function handlePendingCredential(ui: FirebaseUIConfiguration, user: UserCr try { const pendingCred = JSON.parse(pendingCredString); - ui.setState("linking"); + ui.setState("pending"); const result = await linkWithCredential(user.user, pendingCred); ui.setState("idle"); window.sessionStorage.removeItem("pendingCred"); @@ -60,7 +59,6 @@ export async function signInWithEmailAndPassword( password: string ): Promise { try { - const auth = getAuth(ui.app); const credential = EmailAuthProvider.credential(email, password); if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { @@ -71,8 +69,8 @@ export async function signInWithEmailAndPassword( } } - ui.setState("signing-in"); - const result = await signInWithCredential(auth, credential); + ui.setState("pending"); + const result = await signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -87,7 +85,6 @@ export async function createUserWithEmailAndPassword( password: string ): Promise { try { - const auth = getAuth(ui.app); const credential = EmailAuthProvider.credential(email, password); if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { @@ -98,8 +95,8 @@ export async function createUserWithEmailAndPassword( } } - ui.setState("creating-user"); - const result = await _createUserWithEmailAndPassword(auth, email, password); + ui.setState("pending"); + const result = await _createUserWithEmailAndPassword(ui.auth, email, password); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -114,9 +111,8 @@ export async function signInWithPhoneNumber( recaptchaVerifier: RecaptchaVerifier ): Promise { try { - const auth = getAuth(ui.app); - ui.setState("signing-in"); - return await _signInWithPhoneNumber(auth, phoneNumber, recaptchaVerifier); + ui.setState("pending"); + return await _signInWithPhoneNumber(ui.auth, phoneNumber, recaptchaVerifier); } catch (error) { handleFirebaseError(ui, error); } finally { @@ -130,8 +126,7 @@ export async function confirmPhoneNumber( verificationCode: string ): Promise { try { - const auth = getAuth(ui.app); - const currentUser = auth.currentUser; + const currentUser = ui.auth.currentUser; const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode); if (currentUser?.isAnonymous && hasBehavior(ui, "autoUpgradeAnonymousCredential")) { @@ -142,8 +137,8 @@ export async function confirmPhoneNumber( } } - ui.setState("signing-in"); - const result = await signInWithCredential(auth, credential); + ui.setState("pending"); + const result = await signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -154,9 +149,8 @@ export async function confirmPhoneNumber( export async function sendPasswordResetEmail(ui: FirebaseUIConfiguration, email: string): Promise { try { - const auth = getAuth(ui.app); - ui.setState("sending-password-reset-email"); - await _sendPasswordResetEmail(auth, email); + ui.setState("pending"); + await _sendPasswordResetEmail(ui.auth, email); } catch (error) { handleFirebaseError(ui, error); } finally { @@ -166,16 +160,14 @@ export async function sendPasswordResetEmail(ui: FirebaseUIConfiguration, email: export async function sendSignInLinkToEmail(ui: FirebaseUIConfiguration, email: string): Promise { try { - const auth = getAuth(ui.app); - const actionCodeSettings = { url: window.location.href, // TODO(ehesp): Check this... handleCodeInApp: true, } satisfies ActionCodeSettings; - ui.setState("sending-sign-in-link-to-email"); - await _sendSignInLinkToEmail(auth, email, actionCodeSettings); + ui.setState("pending"); + await _sendSignInLinkToEmail(ui.auth, email, actionCodeSettings); window.localStorage.setItem("emailForSignIn", email); } catch (error) { handleFirebaseError(ui, error); @@ -190,7 +182,6 @@ export async function signInWithEmailLink( link: string ): Promise { try { - const auth = ui.getAuth(); const credential = EmailAuthProvider.credentialWithLink(email, link); if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { @@ -200,8 +191,8 @@ export async function signInWithEmailLink( } } - ui.setState("signing-in"); - const result = await signInWithCredential(auth, credential); + ui.setState("pending"); + const result = await signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -212,9 +203,8 @@ export async function signInWithEmailLink( export async function signInAnonymously(ui: FirebaseUIConfiguration): Promise { try { - const auth = getAuth(ui.app); - ui.setState("signing-in"); - const result = await _signInAnonymously(auth); + ui.setState("pending"); + const result = await _signInAnonymously(ui.auth); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -225,16 +215,14 @@ export async function signInAnonymously(ui: FirebaseUIConfiguration): Promise { try { - const auth = getAuth(ui.app); - if (hasBehavior(ui, "autoUpgradeAnonymousProvider")) { await getBehavior(ui, "autoUpgradeAnonymousProvider")(ui, provider); // If we get to here, the user is not anonymous, otherwise they // have been redirected to the provider's sign in page. } - ui.setState("signing-in"); - await signInWithRedirect(auth, provider); + ui.setState("pending"); + await signInWithRedirect(ui.auth, provider); // We don't modify state here since the user is redirected. // If we support popups, we'd need to modify state here. } catch (error) { @@ -249,15 +237,14 @@ export async function completeEmailLinkSignIn( currentUrl: string ): Promise { try { - const auth = ui.getAuth(); - if (!_isSignInWithEmailLink(auth, currentUrl)) { + if (!_isSignInWithEmailLink(ui.auth, currentUrl)) { return null; } const email = window.localStorage.getItem("emailForSignIn"); if (!email) return null; - ui.setState("signing-in"); + ui.setState("pending"); const result = await signInWithEmailLink(ui, email, currentUrl); ui.setState("idle"); return handlePendingCredential(ui, result); diff --git a/packages/core/src/behaviors.ts b/packages/core/src/behaviors.ts index 6461bc1f..c14662b9 100644 --- a/packages/core/src/behaviors.ts +++ b/packages/core/src/behaviors.ts @@ -66,22 +66,17 @@ export function autoAnonymousLogin(): Behavior<"autoAnonymousLogin"> { return { autoAnonymousLogin: async (ui) => { - const auth = ui.getAuth(); - - const user = await new Promise((resolve) => { - const unsubscribe = onAuthStateChanged(auth, (user) => { - ui.setState("signing-in"); - if (!user) { - signInAnonymously(auth); - return; - } - - unsubscribe(); - resolve(user); - }); - }); + const auth = ui.auth; + + await auth.authStateReady(); + + if (!auth.currentUser) { + ui.setState("loading"); + await signInAnonymously(auth); + } + ui.setState("idle"); - return user; + return auth.currentUser!; }, }; } @@ -91,28 +86,26 @@ export function autoUpgradeAnonymousUsers(): Behavior< > { return { autoUpgradeAnonymousCredential: async (ui, credential) => { - const auth = ui.getAuth(); - const currentUser = auth.currentUser; + const currentUser = ui.auth.currentUser; // Check if the user is anonymous. If not, we can't upgrade them. if (!currentUser?.isAnonymous) { return; } - ui.setState("linking"); + ui.setState("pending"); const result = await linkWithCredential(currentUser, credential); ui.setState("idle"); return result; }, autoUpgradeAnonymousProvider: async (ui, provider) => { - const auth = ui.getAuth(); - const currentUser = auth.currentUser; + const currentUser = ui.auth.currentUser; if (!currentUser?.isAnonymous) { return; } - ui.setState("linking"); + ui.setState("pending"); await linkWithRedirect(currentUser, provider); // We don't modify state here since the user is redirected. // If we support popups, we'd need to modify state here. diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 0a7e210c..a8a9704a 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -23,20 +23,19 @@ import { FirebaseUIState } from "./state"; type FirebaseUIConfigurationOptions = { app: FirebaseApp; + auth?: Auth; locale?: RegisteredLocale; behaviors?: Partial>[]; - recaptchaMode?: "normal" | "invisible"; }; export type FirebaseUIConfiguration = { app: FirebaseApp; - getAuth: () => Auth; + auth: Auth; setLocale: (locale: RegisteredLocale) => void; state: FirebaseUIState; setState: (state: FirebaseUIState) => void; locale: RegisteredLocale; behaviors: Partial>; - recaptchaMode: "normal" | "invisible"; }; export const $config = map>>({}); @@ -59,19 +58,18 @@ export function initializeUI(config: FirebaseUIConfigurationOptions, name: strin name, deepMap({ app: config.app, - getAuth: () => getAuth(config.app), + auth: config.auth || getAuth(config.app), locale: config.locale ?? enUs, setLocale: (locale: RegisteredLocale) => { const current = $config.get()[name]!; current.setKey(`locale`, locale); }, - state: behaviors?.autoAnonymousLogin ? "signing-in" : "loading", + state: behaviors?.autoAnonymousLogin ? "loading" : "idle", setState: (state: FirebaseUIState) => { const current = $config.get()[name]!; current.setKey(`state`, state); }, behaviors: behaviors ?? {}, - recaptchaMode: config.recaptchaMode ?? "normal", }) ); diff --git a/packages/core/src/country-data.ts b/packages/core/src/country-data.ts index 4e555481..a8948919 100644 --- a/packages/core/src/country-data.ts +++ b/packages/core/src/country-data.ts @@ -13,8 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { CountryData } from "./types"; +export interface CountryData { + name: string; + dialCode: string; + code: string; + emoji: string; +}; export const countryData: CountryData[] = [ { name: "United States", dialCode: "+1", code: "US", emoji: "πŸ‡ΊπŸ‡Έ" }, diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index e662e38a..8687234b 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -38,6 +38,7 @@ export function handleFirebaseError( enableHandleExistingCredential?: boolean; } ): never { + // TODO(ehesp): Type error as unknown, check instance of FirebaseError if (error?.code === "auth/account-exists-with-different-credential") { if (opts?.enableHandleExistingCredential && error.credential) { window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential)); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd31f6c6..77a0a7b5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,7 +19,5 @@ export * from "./behaviors"; export * from "./config"; export * from "./errors"; export * from "./schemas"; -export * from "./types"; export * from "./country-data"; export * from "./translations"; -export type { CountryData } from "./types"; diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 1eccca82..dcc381b8 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -14,12 +14,4 @@ * limitations under the License. */ -export type FirebaseUIState = - | "loading" - | "idle" - | "signing-in" - | "signing-out" - | "linking" - | "creating-user" - | "sending-password-reset-email" - | "sending-sign-in-link-to-email"; +export type FirebaseUIState = "idle" | "pending" | "loading"; diff --git a/packages/core/src/styles.css b/packages/core/src/styles.css deleted file mode 100644 index 2ef08e38..00000000 --- a/packages/core/src/styles.css +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts deleted file mode 100644 index 4e1b0b48..00000000 --- a/packages/core/src/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface CountryData { - name: string; - dialCode: string; - code: string; - emoji: string; -} diff --git a/packages/core/tests/unit/auth.test.ts b/packages/core/tests/unit/auth.test.ts index d417fe75..1cbebc9e 100644 --- a/packages/core/tests/unit/auth.test.ts +++ b/packages/core/tests/unit/auth.test.ts @@ -91,18 +91,16 @@ describe("Firebase UI Auth", () => { (EmailAuthProvider.credential as any).mockReturnValue(mockCredential); (EmailAuthProvider.credentialWithLink as any).mockReturnValue(mockCredential); (PhoneAuthProvider.credential as any).mockReturnValue(mockCredential); - (getAuth as any).mockReturnValue(mockAuth); // Create a mock FirebaseUIConfiguration mockUi = { app: { name: "test" } as any, - getAuth: () => mockAuth, + auth: mockAuth, setLocale: vi.fn(), state: "idle", setState: vi.fn(), locale: enUs, behaviors: {}, - recaptchaMode: "normal", }; }); From 342411a33e2b361235f04f0e91915c26e59c783e Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 16:28:45 +0100 Subject: [PATCH 02/43] refactor: Reorganize test files --- packages/react/{tests => }/setup-test.ts | 0 .../auth/forms/email-link-form.test.tsx | 2 +- .../auth/forms/email-password-form.test.tsx | 2 +- .../auth/forms/forgot-password-form.test.tsx | 2 +- .../auth/forms/phone-form.test.tsx | 0 .../auth/forms/register-form.test.tsx | 2 +- .../auth/oauth/google-sign-in-button.test.tsx | 0 .../auth/oauth/oauth-button.test.tsx | 0 .../screens/email-link-auth-screen.test.tsx | 0 .../auth/screens/oauth-screen.test.tsx | 0 .../screens/password-reset-screen.test.tsx | 0 .../auth/screens/phone-auth-screen.test.tsx | 0 .../auth/screens/sign-in-auth-screen.test.tsx | 0 .../auth/screens/sign-up-auth-screen.test.tsx | 0 .../unit => src}/components/button.test.tsx | 2 +- .../unit => src}/components/card.test.tsx | 2 +- .../components/country-selector.test.tsx | 2 +- .../unit => src}/components/divider.test.tsx | 2 +- .../components/field-info.test.tsx | 2 +- .../components/terms-and-privacy.test.tsx | 2 +- .../context.test.tsx} | 2 +- .../{tests/unit/hooks => src}/hooks.test.tsx | 15 +++---------- packages/react/src/hooks.ts | 21 +++++++------------ packages/react/src/index.ts | 1 + .../{tests/unit => src}/utils/cn.test.tsx | 2 +- packages/react/tests/tsconfig.json | 2 +- packages/react/tsconfig.test.json | 3 +-- packages/react/vitest.config.ts | 6 +++--- 28 files changed, 28 insertions(+), 44 deletions(-) rename packages/react/{tests => }/setup-test.ts (100%) rename packages/react/{tests/unit => src}/auth/forms/email-link-form.test.tsx (99%) rename packages/react/{tests/unit => src}/auth/forms/email-password-form.test.tsx (98%) rename packages/react/{tests/unit => src}/auth/forms/forgot-password-form.test.tsx (98%) rename packages/react/{tests/unit => src}/auth/forms/phone-form.test.tsx (100%) rename packages/react/{tests/unit => src}/auth/forms/register-form.test.tsx (98%) rename packages/react/{tests/unit => src}/auth/oauth/google-sign-in-button.test.tsx (100%) rename packages/react/{tests/unit => src}/auth/oauth/oauth-button.test.tsx (100%) rename packages/react/{tests/unit => src}/auth/screens/email-link-auth-screen.test.tsx (100%) rename packages/react/{tests/unit => src}/auth/screens/oauth-screen.test.tsx (100%) rename packages/react/{tests/unit => src}/auth/screens/password-reset-screen.test.tsx (100%) rename packages/react/{tests/unit => src}/auth/screens/phone-auth-screen.test.tsx (100%) rename packages/react/{tests/unit => src}/auth/screens/sign-in-auth-screen.test.tsx (100%) rename packages/react/{tests/unit => src}/auth/screens/sign-up-auth-screen.test.tsx (100%) rename packages/react/{tests/unit => src}/components/button.test.tsx (98%) rename packages/react/{tests/unit => src}/components/card.test.tsx (98%) rename packages/react/{tests/unit => src}/components/country-selector.test.tsx (97%) rename packages/react/{tests/unit => src}/components/divider.test.tsx (97%) rename packages/react/{tests/unit => src}/components/field-info.test.tsx (98%) rename packages/react/{tests/unit => src}/components/terms-and-privacy.test.tsx (97%) rename packages/react/{tests/unit/context/config-provider.test.tsx => src/context.test.tsx} (96%) rename packages/react/{tests/unit/hooks => src}/hooks.test.tsx (80%) rename packages/react/{tests/unit => src}/utils/cn.test.tsx (97%) diff --git a/packages/react/tests/setup-test.ts b/packages/react/setup-test.ts similarity index 100% rename from packages/react/tests/setup-test.ts rename to packages/react/setup-test.ts diff --git a/packages/react/tests/unit/auth/forms/email-link-form.test.tsx b/packages/react/src/auth/forms/email-link-form.test.tsx similarity index 99% rename from packages/react/tests/unit/auth/forms/email-link-form.test.tsx rename to packages/react/src/auth/forms/email-link-form.test.tsx index c062b49d..7ab1e7ba 100644 --- a/packages/react/tests/unit/auth/forms/email-link-form.test.tsx +++ b/packages/react/src/auth/forms/email-link-form.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; -import { EmailLinkForm } from "../../../../src/auth/forms/email-link-form"; +import { EmailLinkForm } from "./email-link-form"; // Mock Firebase UI Core vi.mock("@firebase-ui/core", async (importOriginal) => { diff --git a/packages/react/tests/unit/auth/forms/email-password-form.test.tsx b/packages/react/src/auth/forms/email-password-form.test.tsx similarity index 98% rename from packages/react/tests/unit/auth/forms/email-password-form.test.tsx rename to packages/react/src/auth/forms/email-password-form.test.tsx index 50749073..c6f10b2b 100644 --- a/packages/react/tests/unit/auth/forms/email-password-form.test.tsx +++ b/packages/react/src/auth/forms/email-password-form.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { EmailPasswordForm } from "../../../../src/auth/forms/email-password-form"; +import { EmailPasswordForm } from "./email-password-form"; import { act } from "react"; // Mock the dependencies diff --git a/packages/react/tests/unit/auth/forms/forgot-password-form.test.tsx b/packages/react/src/auth/forms/forgot-password-form.test.tsx similarity index 98% rename from packages/react/tests/unit/auth/forms/forgot-password-form.test.tsx rename to packages/react/src/auth/forms/forgot-password-form.test.tsx index faf13695..b7b5ba1a 100644 --- a/packages/react/tests/unit/auth/forms/forgot-password-form.test.tsx +++ b/packages/react/src/auth/forms/forgot-password-form.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { ForgotPasswordForm } from "../../../../src/auth/forms/forgot-password-form"; +import { ForgotPasswordForm } from "./forgot-password-form"; import { act } from "react"; // Mock the dependencies diff --git a/packages/react/tests/unit/auth/forms/phone-form.test.tsx b/packages/react/src/auth/forms/phone-form.test.tsx similarity index 100% rename from packages/react/tests/unit/auth/forms/phone-form.test.tsx rename to packages/react/src/auth/forms/phone-form.test.tsx diff --git a/packages/react/tests/unit/auth/forms/register-form.test.tsx b/packages/react/src/auth/forms/register-form.test.tsx similarity index 98% rename from packages/react/tests/unit/auth/forms/register-form.test.tsx rename to packages/react/src/auth/forms/register-form.test.tsx index 7cf02749..e4e9f8bc 100644 --- a/packages/react/tests/unit/auth/forms/register-form.test.tsx +++ b/packages/react/src/auth/forms/register-form.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { RegisterForm } from "../../../../src/auth/forms/register-form"; +import { RegisterForm } from "./register-form"; import { act } from "react"; // Mock the dependencies diff --git a/packages/react/tests/unit/auth/oauth/google-sign-in-button.test.tsx b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx similarity index 100% rename from packages/react/tests/unit/auth/oauth/google-sign-in-button.test.tsx rename to packages/react/src/auth/oauth/google-sign-in-button.test.tsx diff --git a/packages/react/tests/unit/auth/oauth/oauth-button.test.tsx b/packages/react/src/auth/oauth/oauth-button.test.tsx similarity index 100% rename from packages/react/tests/unit/auth/oauth/oauth-button.test.tsx rename to packages/react/src/auth/oauth/oauth-button.test.tsx diff --git a/packages/react/tests/unit/auth/screens/email-link-auth-screen.test.tsx b/packages/react/src/auth/screens/email-link-auth-screen.test.tsx similarity index 100% rename from packages/react/tests/unit/auth/screens/email-link-auth-screen.test.tsx rename to packages/react/src/auth/screens/email-link-auth-screen.test.tsx diff --git a/packages/react/tests/unit/auth/screens/oauth-screen.test.tsx b/packages/react/src/auth/screens/oauth-screen.test.tsx similarity index 100% rename from packages/react/tests/unit/auth/screens/oauth-screen.test.tsx rename to packages/react/src/auth/screens/oauth-screen.test.tsx diff --git a/packages/react/tests/unit/auth/screens/password-reset-screen.test.tsx b/packages/react/src/auth/screens/password-reset-screen.test.tsx similarity index 100% rename from packages/react/tests/unit/auth/screens/password-reset-screen.test.tsx rename to packages/react/src/auth/screens/password-reset-screen.test.tsx diff --git a/packages/react/tests/unit/auth/screens/phone-auth-screen.test.tsx b/packages/react/src/auth/screens/phone-auth-screen.test.tsx similarity index 100% rename from packages/react/tests/unit/auth/screens/phone-auth-screen.test.tsx rename to packages/react/src/auth/screens/phone-auth-screen.test.tsx diff --git a/packages/react/tests/unit/auth/screens/sign-in-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx similarity index 100% rename from packages/react/tests/unit/auth/screens/sign-in-auth-screen.test.tsx rename to packages/react/src/auth/screens/sign-in-auth-screen.test.tsx diff --git a/packages/react/tests/unit/auth/screens/sign-up-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx similarity index 100% rename from packages/react/tests/unit/auth/screens/sign-up-auth-screen.test.tsx rename to packages/react/src/auth/screens/sign-up-auth-screen.test.tsx diff --git a/packages/react/tests/unit/components/button.test.tsx b/packages/react/src/components/button.test.tsx similarity index 98% rename from packages/react/tests/unit/components/button.test.tsx rename to packages/react/src/components/button.test.tsx index cde025b6..24a0db56 100644 --- a/packages/react/tests/unit/components/button.test.tsx +++ b/packages/react/src/components/button.test.tsx @@ -17,7 +17,7 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { Button } from "../../../src/components/button"; +import { Button } from "./button"; describe("Button Component", () => { it("renders with default variant (primary)", () => { diff --git a/packages/react/tests/unit/components/card.test.tsx b/packages/react/src/components/card.test.tsx similarity index 98% rename from packages/react/tests/unit/components/card.test.tsx rename to packages/react/src/components/card.test.tsx index 1ae9a20f..5053e278 100644 --- a/packages/react/tests/unit/components/card.test.tsx +++ b/packages/react/src/components/card.test.tsx @@ -17,7 +17,7 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { Card, CardHeader, CardTitle, CardSubtitle } from "../../../src/components/card"; +import { Card, CardHeader, CardTitle, CardSubtitle } from "./card"; describe("Card Components", () => { describe("Card", () => { diff --git a/packages/react/tests/unit/components/country-selector.test.tsx b/packages/react/src/components/country-selector.test.tsx similarity index 97% rename from packages/react/tests/unit/components/country-selector.test.tsx rename to packages/react/src/components/country-selector.test.tsx index 63fe462c..6b118661 100644 --- a/packages/react/tests/unit/components/country-selector.test.tsx +++ b/packages/react/src/components/country-selector.test.tsx @@ -17,7 +17,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { CountrySelector } from "../../../src/components/country-selector"; +import { CountrySelector } from "./country-selector"; import { countryData } from "@firebase-ui/core"; describe("CountrySelector Component", () => { diff --git a/packages/react/tests/unit/components/divider.test.tsx b/packages/react/src/components/divider.test.tsx similarity index 97% rename from packages/react/tests/unit/components/divider.test.tsx rename to packages/react/src/components/divider.test.tsx index aa9bc0dc..92d041ee 100644 --- a/packages/react/tests/unit/components/divider.test.tsx +++ b/packages/react/src/components/divider.test.tsx @@ -17,7 +17,7 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { Divider } from "../../../src/components/divider"; +import { Divider } from "./divider"; describe("Divider Component", () => { it("renders a divider with no text", () => { diff --git a/packages/react/tests/unit/components/field-info.test.tsx b/packages/react/src/components/field-info.test.tsx similarity index 98% rename from packages/react/tests/unit/components/field-info.test.tsx rename to packages/react/src/components/field-info.test.tsx index d019266c..5cb72a01 100644 --- a/packages/react/tests/unit/components/field-info.test.tsx +++ b/packages/react/src/components/field-info.test.tsx @@ -17,7 +17,7 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { FieldInfo } from "../../../src/components/field-info"; +import { FieldInfo } from "./field-info"; import { FieldApi } from "@tanstack/react-form"; describe("FieldInfo Component", () => { diff --git a/packages/react/tests/unit/components/terms-and-privacy.test.tsx b/packages/react/src/components/terms-and-privacy.test.tsx similarity index 97% rename from packages/react/tests/unit/components/terms-and-privacy.test.tsx rename to packages/react/src/components/terms-and-privacy.test.tsx index 03724a0c..24c43cff 100644 --- a/packages/react/tests/unit/components/terms-and-privacy.test.tsx +++ b/packages/react/src/components/terms-and-privacy.test.tsx @@ -17,7 +17,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { Policies, PolicyProvider } from "../../../src/components/policies"; +import { Policies, PolicyProvider } from "./policies"; // Mock useUI hook vi.mock("~/hooks", () => ({ diff --git a/packages/react/tests/unit/context/config-provider.test.tsx b/packages/react/src/context.test.tsx similarity index 96% rename from packages/react/tests/unit/context/config-provider.test.tsx rename to packages/react/src/context.test.tsx index 3fed05ae..795b9ba2 100644 --- a/packages/react/tests/unit/context/config-provider.test.tsx +++ b/packages/react/src/context.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect } from "vitest"; import { render, act } from "@testing-library/react"; -import { FirebaseUIProvider, FirebaseUIContext } from "../../../src/context"; +import { FirebaseUIProvider, FirebaseUIContext } from "./context"; import { map } from "nanostores"; import { useContext } from "react"; import { FirebaseUI, FirebaseUIConfiguration } from "@firebase-ui/core"; diff --git a/packages/react/tests/unit/hooks/hooks.test.tsx b/packages/react/src/hooks.test.tsx similarity index 80% rename from packages/react/tests/unit/hooks/hooks.test.tsx rename to packages/react/src/hooks.test.tsx index 75d82f08..028e545c 100644 --- a/packages/react/tests/unit/hooks/hooks.test.tsx +++ b/packages/react/src/hooks.test.tsx @@ -16,9 +16,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook } from "@testing-library/react"; -import { useUI, useAuth } from "../../../src/hooks"; +import { useUI } from "./hooks"; import { getAuth } from "firebase/auth"; -import { FirebaseUIContext } from "../../../src/context"; +import { FirebaseUIContext } from "./context"; // Mock Firebase vi.mock("firebase/auth", () => ({ @@ -59,20 +59,11 @@ describe("Hooks", () => { vi.clearAllMocks(); }); - describe("useConfig", () => { + describe("useUI", () => { it("returns the config from context", () => { const { result } = renderHook(() => useUI(), { wrapper }); expect(result.current).toEqual(mockConfig); }); }); - - describe("useAuth", () => { - it("returns the authentication instance from Firebase", () => { - const { result } = renderHook(() => useAuth(), { wrapper }); - - expect(getAuth).toHaveBeenCalledWith(mockApp); - expect(result.current).toBeDefined(); - }); - }); }); diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index b3b148f0..3575142a 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -14,25 +14,18 @@ * limitations under the License. */ -import { useContext, useMemo } from "react"; -import { getAuth } from "firebase/auth"; +import { useContext } from "react"; import { FirebaseUIContext } from "./context"; -import { FirebaseUIConfiguration } from "@firebase-ui/core"; /** * Get the UI configuration from the context. */ export function useUI() { - return useContext(FirebaseUIContext); -} + const ui = useContext(FirebaseUIContext); -/** - * Get the auth instance from the UI configuration. - * If no UI configuration is provided, use the auth instance from the context. - */ -export function useAuth(ui?: FirebaseUIConfiguration | undefined) { - const contextUI = useUI(); - const config = ui ?? contextUI; - const auth = useMemo(() => ui?.getAuth() ?? getAuth(config.app), [config.app]); - return auth; + if (!ui) { + throw new Error("No FirebaseUI context found. Your application must be wrapped in a component."); + } + + return ui; } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index be57f55f..dd8e33f3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -17,4 +17,5 @@ export * from "./auth"; export * from "./hooks"; export * from "./components"; +// TODO(ehesp):Why is this exported as ConfigProvider? export { FirebaseUIProvider as ConfigProvider } from "./context"; diff --git a/packages/react/tests/unit/utils/cn.test.tsx b/packages/react/src/utils/cn.test.tsx similarity index 97% rename from packages/react/tests/unit/utils/cn.test.tsx rename to packages/react/src/utils/cn.test.tsx index 00fd6e89..af9d7cd6 100644 --- a/packages/react/tests/unit/utils/cn.test.tsx +++ b/packages/react/src/utils/cn.test.tsx @@ -15,7 +15,7 @@ */ import { describe, it, expect } from "vitest"; -import { cn } from "../../../src/utils/cn"; +import { cn } from "./cn"; describe("cn utility", () => { it("merges class names correctly", () => { diff --git a/packages/react/tests/tsconfig.json b/packages/react/tests/tsconfig.json index 40a3ad97..8dacfaa4 100644 --- a/packages/react/tests/tsconfig.json +++ b/packages/react/tests/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../tsconfig.test.json", - "include": ["./**/*.tsx", "./**/*.ts"], + "include": ["./**/*.tsx", "./**/*.ts", "../src/components/button.test.tsx", "../src/hooks.test.tsx", "../src/context.test.tsx", "../setup-test.ts", "../src/auth/forms/email-link-form.test.tsx", "../src/auth/forms/email-password-form.test.tsx", "../src/auth/forms/forgot-password-form.test.tsx", "../src/auth/forms/register-form.test.tsx", "../src/auth/oauth/google-sign-in-button.test.tsx", "../src/auth/screens/password-reset-screen.test.tsx",, "../src/auth/screens/oauth-screen.test.tsx" "../src/auth/screens/email-link-auth-screen.test.tsx", "../src/auth/screens/sign-in-auth-screen.test.tsx", "../src/auth/screens/phone-auth-screen.test.tsx"], "compilerOptions": { "jsx": "react-jsx", "esModuleInterop": true, diff --git a/packages/react/tsconfig.test.json b/packages/react/tsconfig.test.json index f8f8130f..a068a888 100644 --- a/packages/react/tsconfig.test.json +++ b/packages/react/tsconfig.test.json @@ -7,8 +7,7 @@ "baseUrl": ".", "paths": { "~/*": ["./src/*"], - "@firebase-ui/core": ["../firebaseui-core/src/index.ts"], - "@firebase-ui/core/*": ["../firebaseui-core/src/*"] + "@firebase-ui/core": ["../firebaseui-core/src/index.ts"] } }, "include": ["src", "tests"] diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index 4026e0c0..322eefdb 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -23,20 +23,20 @@ export default defineConfig({ // Use the same environment as the package environment: "jsdom", // Include TypeScript files - include: ["tests/**/*.{test,spec}.{js,ts,jsx,tsx}"], + include: ["**/*.{test,spec}.{js,ts,jsx,tsx}"], // Exclude build output and node_modules exclude: ["node_modules/**/*", "dist/**/*"], // Enable globals for jest-dom to work correctly globals: true, // Use the setup file for all tests - setupFiles: ["./tests/setup-test.ts"], + setupFiles: ["./setup-test.ts"], // Mock modules mockReset: false, // Use tsconfig.test.json for TypeScript typecheck: { enabled: true, tsconfig: "./tsconfig.test.json", - include: ["tests/**/*.{ts,tsx}"], + include: ["**/*.{ts,tsx}"], }, // Increase test timeout for Firebase operations testTimeout: 15000, From a2a6013756a6a791b3a0101c32fad2ce6da19e7c Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 16:30:22 +0100 Subject: [PATCH 03/43] feat: Add CardContent component --- packages/react/src/components/card.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/react/src/components/card.tsx b/packages/react/src/components/card.tsx index 361dde8f..6045ff13 100644 --- a/packages/react/src/components/card.tsx +++ b/packages/react/src/components/card.tsx @@ -50,3 +50,11 @@ export function CardSubtitle({ children, className, ...props }: HTMLAttributes ); } + +export function CardContent({ children, className, ...props }: HTMLAttributes) { + return ( +
+ {children} +
+ ); +} From ff9b668d611b2cf93d70ae789279a6782b892879 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 16:39:37 +0100 Subject: [PATCH 04/43] feat(react): Support allowedCountries prop --- packages/core/src/country-data.ts | 18 ++++++++--------- .../react/src/components/country-selector.tsx | 20 +++++++++++-------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/core/src/country-data.ts b/packages/core/src/country-data.ts index a8948919..a28559b2 100644 --- a/packages/core/src/country-data.ts +++ b/packages/core/src/country-data.ts @@ -13,14 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export interface CountryData { - name: string; - dialCode: string; - code: string; - emoji: string; -}; -export const countryData: CountryData[] = [ +export const countryData = [ { name: "United States", dialCode: "+1", code: "US", emoji: "πŸ‡ΊπŸ‡Έ" }, { name: "United Kingdom", dialCode: "+44", code: "GB", emoji: "πŸ‡¬πŸ‡§" }, { name: "Afghanistan", dialCode: "+93", code: "AF", emoji: "πŸ‡¦πŸ‡«" }, @@ -269,13 +263,17 @@ export const countryData: CountryData[] = [ { name: "Zambia", dialCode: "+260", code: "ZM", emoji: "πŸ‡ΏπŸ‡²" }, { name: "Zimbabwe", dialCode: "+263", code: "ZW", emoji: "πŸ‡ΏπŸ‡Ό" }, { name: "Γ…land Islands", dialCode: "+358", code: "AX", emoji: "πŸ‡¦πŸ‡½" }, -]; +] as const; -export function getCountryByDialCode(dialCode: string): CountryData | undefined { +export type CountryData = (typeof countryData)[number]; + +export type CountryCodes = CountryData["code"]; + +export function getCountryByDialCode(dialCode: string): (typeof countryData)[number] | undefined { return countryData.find((country) => country.dialCode === dialCode); } -export function getCountryByCode(code: string): CountryData | undefined { +export function getCountryByCode(code: string): (typeof countryData)[number] | undefined { return countryData.find((country) => country.code === code.toUpperCase()); } diff --git a/packages/react/src/components/country-selector.tsx b/packages/react/src/components/country-selector.tsx index ba8ebe8a..6af10a4a 100644 --- a/packages/react/src/components/country-selector.tsx +++ b/packages/react/src/components/country-selector.tsx @@ -16,18 +16,22 @@ "use client"; -import { CountryData, countryData } from "@firebase-ui/core"; +import { CountryCodes, CountryData, countryData } from "@firebase-ui/core"; +import { ComponentProps } from "react"; import { cn } from "~/utils/cn"; -interface CountrySelectorProps { +export type CountrySelectorProps = ComponentProps<"div"> & { value: CountryData; onChange: (country: CountryData) => void; - className?: string; -} + allowedCountries?: CountryCodes[]; +}; + +export function CountrySelector({ value, onChange, allowedCountries, className, ...props }: CountrySelectorProps) { + + const countries = allowedCountries ? countryData.filter((c) => allowedCountries.includes(c.code)) : countryData; -export function CountrySelector({ value, onChange, className }: CountrySelectorProps) { return ( -
+
{value.emoji}
@@ -36,13 +40,13 @@ export function CountrySelector({ value, onChange, className }: CountrySelectorP className="fui-country-selector__select" value={value.code} onChange={(e) => { - const country = countryData.find((c) => c.code === e.target.value); + const country = countries.find((c) => c.code === e.target.value); if (country) { onChange(country); } }} > - {countryData.map((country) => ( + {countries.map((country) => ( From 0ec2f6d2a41b9817bd9dd67fb91283ce2c0f2b2f Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 16:39:55 +0100 Subject: [PATCH 05/43] chore: Improve card child props --- packages/react/src/components/card.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react/src/components/card.tsx b/packages/react/src/components/card.tsx index 6045ff13..aebfcb90 100644 --- a/packages/react/src/components/card.tsx +++ b/packages/react/src/components/card.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import type { HTMLAttributes, PropsWithChildren } from "react"; +import type { ComponentProps, PropsWithChildren } from "react"; import { cn } from "~/utils/cn"; -type CardProps = PropsWithChildren>; +type CardProps = PropsWithChildren>; export function Card({ children, className, ...props }: CardProps) { return ( @@ -35,7 +35,7 @@ export function CardHeader({ children, className, ...props }: CardProps) { ); } -export function CardTitle({ children, className, ...props }: HTMLAttributes) { +export function CardTitle({ children, className, ...props }: ComponentProps<"h2">) { return (

{children} @@ -43,7 +43,7 @@ export function CardTitle({ children, className, ...props }: HTMLAttributes) { +export function CardSubtitle({ children, className, ...props }: ComponentProps<"p">) { return (

{children} @@ -51,7 +51,7 @@ export function CardSubtitle({ children, className, ...props }: HTMLAttributes) { +export function CardContent({ children, className, ...props }: ComponentProps<"div">) { return (

{children} From 5d1bd7d4da4fe23e51331d2928b554fbf223564d Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 16:42:35 +0100 Subject: [PATCH 06/43] refactor: Update element props api usage --- packages/react/src/components/button.tsx | 6 +++--- packages/react/src/components/divider.tsx | 4 ++-- packages/react/src/components/field-info.tsx | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/react/src/components/button.tsx b/packages/react/src/components/button.tsx index 7cb67264..b3a6bb56 100644 --- a/packages/react/src/components/button.tsx +++ b/packages/react/src/components/button.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ButtonHTMLAttributes } from "react"; +import { ComponentProps } from "react"; import { Slot } from "@radix-ui/react-slot"; import { cn } from "~/utils/cn"; @@ -25,10 +25,10 @@ const buttonVariants = { type ButtonVariant = keyof typeof buttonVariants; -interface ButtonProps extends ButtonHTMLAttributes { +export type ButtonProps = ComponentProps<"button"> & { variant?: ButtonVariant; asChild?: boolean; -} +}; export function Button({ className, variant = "primary", asChild, ...props }: ButtonProps) { const Comp = asChild ? Slot : "button"; diff --git a/packages/react/src/components/divider.tsx b/packages/react/src/components/divider.tsx index 171031f8..4bebb986 100644 --- a/packages/react/src/components/divider.tsx +++ b/packages/react/src/components/divider.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import { HTMLAttributes } from "react"; +import { ComponentProps, PropsWithChildren } from "react"; import { cn } from "~/utils/cn"; -type DividerProps = HTMLAttributes; +export type DividerProps = PropsWithChildren>; export function Divider({ className, children, ...props }: DividerProps) { if (!children) { diff --git a/packages/react/src/components/field-info.tsx b/packages/react/src/components/field-info.tsx index 3b060eed..024b282d 100644 --- a/packages/react/src/components/field-info.tsx +++ b/packages/react/src/components/field-info.tsx @@ -15,12 +15,12 @@ */ import type { FieldApi } from "@tanstack/react-form"; -import { HTMLAttributes } from "react"; +import { ComponentProps } from "react"; import { cn } from "~/utils/cn"; -interface FieldInfoProps extends HTMLAttributes { +export type FieldInfoProps = ComponentProps<"div"> & { field: FieldApi; -} +}; export function FieldInfo({ field, className, ...props }: FieldInfoProps) { return ( From 9e8314c6a9a16f64a5ac897958752a6ab803eebb Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 16:46:58 +0100 Subject: [PATCH 07/43] feat(react): Add provider prop to GoogleSignInButton --- .../auth/oauth/google-sign-in-button.test.tsx | 11 +++- .../src/auth/oauth/google-sign-in-button.tsx | 50 +++++++++++-------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/packages/react/src/auth/oauth/google-sign-in-button.test.tsx b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx index a3ae3dcc..c3739996 100644 --- a/packages/react/src/auth/oauth/google-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx @@ -16,7 +16,7 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; -import { GoogleSignInButton } from "~/auth/oauth/google-sign-in-button"; +import { GoogleIcon, GoogleSignInButton } from "~/auth/oauth/google-sign-in-button"; // Mock hooks vi.mock("~/hooks", () => ({ @@ -64,3 +64,12 @@ describe("GoogleSignInButton", () => { expect(screen.getByText("foo bar")).toBeInTheDocument(); }); }); + +it("exports a valid GoogleIcon component which is an svg", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + expect(svg).toHaveClass("fui-provider__icon"); +}); + diff --git a/packages/react/src/auth/oauth/google-sign-in-button.tsx b/packages/react/src/auth/oauth/google-sign-in-button.tsx index 3b0e04e3..551a416b 100644 --- a/packages/react/src/auth/oauth/google-sign-in-button.tsx +++ b/packages/react/src/auth/oauth/google-sign-in-button.tsx @@ -21,30 +21,40 @@ import { GoogleAuthProvider } from "firebase/auth"; import { useUI } from "~/hooks"; import { OAuthButton } from "./oauth-button"; -export function GoogleSignInButton() { +export type GoogleSignInButtonProps = { + provider?: GoogleAuthProvider; +}; + +export function GoogleSignInButton({ provider }: GoogleSignInButtonProps) { const ui = useUI(); return ( - - - - - - - + + {getTranslation(ui, "labels", "signInWithGoogle")} ); } + +export function GoogleIcon() { + return ( + + + + + + + ); +} From 69db89c23d285ae976652626cc5355982ea1cf03 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 16:50:45 +0100 Subject: [PATCH 08/43] refactor(react): Align EmailLinkAuth{Form,Screen} components with API spec --- ...test.tsx => email-link-auth-form.test.tsx} | 2 +- ...link-form.tsx => email-link-auth-form.tsx} | 7 ++++-- .../auth/screens/email-link-auth-screen.tsx | 24 ++++++++++--------- .../auth/email-link-auth.integration.test.tsx | 2 +- 4 files changed, 20 insertions(+), 15 deletions(-) rename packages/react/src/auth/forms/{email-link-form.test.tsx => email-link-auth-form.test.tsx} (99%) rename packages/react/src/auth/forms/{email-link-form.tsx => email-link-auth-form.tsx} (95%) diff --git a/packages/react/src/auth/forms/email-link-form.test.tsx b/packages/react/src/auth/forms/email-link-auth-form.test.tsx similarity index 99% rename from packages/react/src/auth/forms/email-link-form.test.tsx rename to packages/react/src/auth/forms/email-link-auth-form.test.tsx index 7ab1e7ba..e944c3e9 100644 --- a/packages/react/src/auth/forms/email-link-form.test.tsx +++ b/packages/react/src/auth/forms/email-link-auth-form.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; -import { EmailLinkForm } from "./email-link-form"; +import { EmailLinkForm } from "./email-link-auth-form"; // Mock Firebase UI Core vi.mock("@firebase-ui/core", async (importOriginal) => { diff --git a/packages/react/src/auth/forms/email-link-form.tsx b/packages/react/src/auth/forms/email-link-auth-form.tsx similarity index 95% rename from packages/react/src/auth/forms/email-link-form.tsx rename to packages/react/src/auth/forms/email-link-auth-form.tsx index 7bb8f34b..8dea3f1d 100644 --- a/packages/react/src/auth/forms/email-link-form.tsx +++ b/packages/react/src/auth/forms/email-link-auth-form.tsx @@ -30,9 +30,11 @@ import { Button } from "../../components/button"; import { FieldInfo } from "../../components/field-info"; import { Policies } from "../../components/policies"; -interface EmailLinkFormProps {} +export type EmailLinkAuthFormProps = { + onEmailSent?: () => void; +}; -export function EmailLinkForm(_: EmailLinkFormProps) { +export function EmailLinkAuthForm({ onEmailSent }: EmailLinkAuthFormProps) { const ui = useUI(); const [formError, setFormError] = useState(null); @@ -54,6 +56,7 @@ export function EmailLinkForm(_: EmailLinkFormProps) { try { await sendSignInLinkToEmail(ui, value.email); setEmailSent(true); + onEmailSent?.(); } catch (error) { if (error instanceof FirebaseUIError) { setFormError(error.message); diff --git a/packages/react/src/auth/screens/email-link-auth-screen.tsx b/packages/react/src/auth/screens/email-link-auth-screen.tsx index 195b694c..024a168f 100644 --- a/packages/react/src/auth/screens/email-link-auth-screen.tsx +++ b/packages/react/src/auth/screens/email-link-auth-screen.tsx @@ -18,12 +18,12 @@ import type { PropsWithChildren } from "react"; import { getTranslation } from "@firebase-ui/core"; import { Divider } from "~/components/divider"; import { useUI } from "~/hooks"; -import { Card, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; -import { EmailLinkForm } from "../forms/email-link-form"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; +import { EmailLinkAuthForm, type EmailLinkAuthFormProps } from "../forms/email-link-auth-form"; -export type EmailLinkAuthScreenProps = PropsWithChildren; +export type EmailLinkAuthScreenProps = PropsWithChildren; -export function EmailLinkAuthScreen({ children }: EmailLinkAuthScreenProps) { +export function EmailLinkAuthScreen({ children, onEmailSent }: EmailLinkAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); @@ -36,13 +36,15 @@ export function EmailLinkAuthScreen({ children }: EmailLinkAuthScreenProps) { {titleText} {subtitleText} - - {children ? ( - <> - {getTranslation(ui, "messages", "dividerOr")} -
{children}
- - ) : null} + + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
{children}
+ + ) : null} +
); diff --git a/packages/react/tests/integration/auth/email-link-auth.integration.test.tsx b/packages/react/tests/integration/auth/email-link-auth.integration.test.tsx index ea0efaf0..92a20f27 100644 --- a/packages/react/tests/integration/auth/email-link-auth.integration.test.tsx +++ b/packages/react/tests/integration/auth/email-link-auth.integration.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, afterAll } from "vitest"; import { fireEvent, waitFor, act, render } from "@testing-library/react"; -import { EmailLinkForm } from "../../../src/auth/forms/email-link-form"; +import { EmailLinkForm } from "../../../src/auth/forms/email-link-auth-form"; import { initializeApp } from "firebase/app"; import { getAuth, connectAuthEmulator, deleteUser } from "firebase/auth"; import { initializeUI } from "@firebase-ui/core"; From a36637138930c9e53c263b81807c4592e6bfdf6b Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 16:51:41 +0100 Subject: [PATCH 09/43] refactor: Align OAuthScreen with API spec --- packages/react/src/auth/screens/oauth-screen.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react/src/auth/screens/oauth-screen.tsx b/packages/react/src/auth/screens/oauth-screen.tsx index 70f758cb..1e6617d9 100644 --- a/packages/react/src/auth/screens/oauth-screen.tsx +++ b/packages/react/src/auth/screens/oauth-screen.tsx @@ -16,7 +16,7 @@ import { getTranslation } from "@firebase-ui/core"; import { useUI } from "~/hooks"; -import { Card, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; import { PropsWithChildren } from "react"; import { Policies } from "~/components/policies"; @@ -36,8 +36,10 @@ export function OAuthScreen({ children }: OAuthScreenProps) { {titleText} {subtitleText} - {children} - + + {children} + +

); From e765077d5ccc6ef5407d120d130092e64216f144 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 16:55:22 +0100 Subject: [PATCH 10/43] refactor(react): Align ForgotPasswordAuth{Screen,Form} with API spec --- ...test.tsx => forgot-password-auth-form.test.tsx} | 2 +- ...word-form.tsx => forgot-password-auth-form.tsx} | 6 ++++-- packages/react/src/auth/index.ts | 2 +- ...st.tsx => forgot-password-auth-screen.test.tsx} | 2 +- ...-screen.tsx => forgot-password-auth-screen.tsx} | 14 +++++++------- .../auth/forgot-password.integration.test.tsx | 2 +- 6 files changed, 15 insertions(+), 13 deletions(-) rename packages/react/src/auth/forms/{forgot-password-form.test.tsx => forgot-password-auth-form.test.tsx} (99%) rename packages/react/src/auth/forms/{forgot-password-form.tsx => forgot-password-auth-form.tsx} (95%) rename packages/react/src/auth/screens/{password-reset-screen.test.tsx => forgot-password-auth-screen.test.tsx} (96%) rename packages/react/src/auth/screens/{password-reset-screen.tsx => forgot-password-auth-screen.tsx} (70%) diff --git a/packages/react/src/auth/forms/forgot-password-form.test.tsx b/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx similarity index 99% rename from packages/react/src/auth/forms/forgot-password-form.test.tsx rename to packages/react/src/auth/forms/forgot-password-auth-form.test.tsx index b7b5ba1a..740992eb 100644 --- a/packages/react/src/auth/forms/forgot-password-form.test.tsx +++ b/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { ForgotPasswordForm } from "./forgot-password-form"; +import { ForgotPasswordForm } from "./forgot-password-auth-form"; import { act } from "react"; // Mock the dependencies diff --git a/packages/react/src/auth/forms/forgot-password-form.tsx b/packages/react/src/auth/forms/forgot-password-auth-form.tsx similarity index 95% rename from packages/react/src/auth/forms/forgot-password-form.tsx rename to packages/react/src/auth/forms/forgot-password-auth-form.tsx index e75225ca..bb8fb090 100644 --- a/packages/react/src/auth/forms/forgot-password-form.tsx +++ b/packages/react/src/auth/forms/forgot-password-auth-form.tsx @@ -30,11 +30,12 @@ import { Button } from "../../components/button"; import { FieldInfo } from "../../components/field-info"; import { Policies } from "../../components/policies"; -interface ForgotPasswordFormProps { +export type ForgotPasswordAuthFormProps = { + onPasswordSent?: () => void; onBackToSignInClick?: () => void; } -export function ForgotPasswordForm({ onBackToSignInClick }: ForgotPasswordFormProps) { +export function ForgotPasswordAuthForm({ onBackToSignInClick, onPasswordSent }: ForgotPasswordAuthFormProps) { const ui = useUI(); const [formError, setFormError] = useState(null); @@ -55,6 +56,7 @@ export function ForgotPasswordForm({ onBackToSignInClick }: ForgotPasswordFormPr try { await sendPasswordResetEmail(ui, value.email); setEmailSent(true); + onPasswordSent?.(); } catch (error) { if (error instanceof FirebaseUIError) { setFormError(error.message); diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts index aea7ad5f..a45f1114 100644 --- a/packages/react/src/auth/index.ts +++ b/packages/react/src/auth/index.ts @@ -24,7 +24,7 @@ export { SignUpAuthScreen, type SignUpAuthScreenProps } from "./screens/sign-up- export { OAuthScreen, type OAuthScreenProps } from "./screens/oauth-screen"; -export { PasswordResetScreen, type PasswordResetScreenProps } from "./screens/password-reset-screen"; +export { ForgotPasswordAuthScreen, type ForgotPasswordAuthScreenProps } from "./screens/forgot-password-auth-screen"; /** Export forms */ export { EmailPasswordForm, type EmailPasswordFormProps } from "./forms/email-password-form"; diff --git a/packages/react/src/auth/screens/password-reset-screen.test.tsx b/packages/react/src/auth/screens/forgot-password-auth-screen.test.tsx similarity index 96% rename from packages/react/src/auth/screens/password-reset-screen.test.tsx rename to packages/react/src/auth/screens/forgot-password-auth-screen.test.tsx index 322303b3..78206f47 100644 --- a/packages/react/src/auth/screens/password-reset-screen.test.tsx +++ b/packages/react/src/auth/screens/forgot-password-auth-screen.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, fireEvent } from "@testing-library/react"; -import { PasswordResetScreen } from "~/auth/screens/password-reset-screen"; +import { PasswordResetScreen } from "~/auth/screens/forgot-password-auth-screen"; import * as hooks from "~/hooks"; // Mock the hooks diff --git a/packages/react/src/auth/screens/password-reset-screen.tsx b/packages/react/src/auth/screens/forgot-password-auth-screen.tsx similarity index 70% rename from packages/react/src/auth/screens/password-reset-screen.tsx rename to packages/react/src/auth/screens/forgot-password-auth-screen.tsx index a999a19c..932b0074 100644 --- a/packages/react/src/auth/screens/password-reset-screen.tsx +++ b/packages/react/src/auth/screens/forgot-password-auth-screen.tsx @@ -16,14 +16,12 @@ import { getTranslation } from "@firebase-ui/core"; import { useUI } from "~/hooks"; -import { Card, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; -import { ForgotPasswordForm } from "../forms/forgot-password-form"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; +import { ForgotPasswordAuthForm, type ForgotPasswordAuthFormProps } from "../forms/forgot-password-auth-form"; -export type PasswordResetScreenProps = { - onBackToSignInClick?: () => void; -}; +export type ForgotPasswordAuthScreenProps = ForgotPasswordAuthFormProps; -export function PasswordResetScreen({ onBackToSignInClick }: PasswordResetScreenProps) { +export function ForgotPasswordAuthScreen(props: ForgotPasswordAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "resetPassword"); @@ -36,7 +34,9 @@ export function PasswordResetScreen({ onBackToSignInClick }: PasswordResetScreen {titleText} {subtitleText} - + + +
); diff --git a/packages/react/tests/integration/auth/forgot-password.integration.test.tsx b/packages/react/tests/integration/auth/forgot-password.integration.test.tsx index 8f9ec69c..4361be1a 100644 --- a/packages/react/tests/integration/auth/forgot-password.integration.test.tsx +++ b/packages/react/tests/integration/auth/forgot-password.integration.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, afterAll, beforeEach } from "vitest"; import { fireEvent, waitFor, act, render } from "@testing-library/react"; -import { ForgotPasswordForm } from "../../../src/auth/forms/forgot-password-form"; +import { ForgotPasswordForm } from "../../../src/auth/forms/forgot-password-auth-form"; import { initializeApp } from "firebase/app"; import { getAuth, From 4edd8f1431ded38b47e5879964049d78bd02f6da Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 17:02:14 +0100 Subject: [PATCH 11/43] refactor: Align PhoneAuth{Screen,Form} with API spec --- ...form.test.tsx => phone-auth-form.test.tsx} | 14 +++++----- .../{phone-form.tsx => phone-auth-form.tsx} | 24 ++++++++++------- .../src/auth/screens/phone-auth-screen.tsx | 26 +++++++++---------- 3 files changed, 34 insertions(+), 30 deletions(-) rename packages/react/src/auth/forms/{phone-form.test.tsx => phone-auth-form.test.tsx} (97%) rename packages/react/src/auth/forms/{phone-form.tsx => phone-auth-form.tsx} (93%) diff --git a/packages/react/src/auth/forms/phone-form.test.tsx b/packages/react/src/auth/forms/phone-auth-form.test.tsx similarity index 97% rename from packages/react/src/auth/forms/phone-form.test.tsx rename to packages/react/src/auth/forms/phone-auth-form.test.tsx index 2b673f0c..e4df0d6f 100644 --- a/packages/react/src/auth/forms/phone-form.test.tsx +++ b/packages/react/src/auth/forms/phone-auth-form.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { PhoneForm } from "../../../../src/auth/forms/phone-form"; +import { PhoneAuthForm } from "./phone-auth-form"; import { act } from "react"; // Mock Firebase Auth @@ -156,7 +156,7 @@ vi.mock("../../../../src/components/country-selector", () => ({ // Import the actual functions after mocking import { signInWithPhoneNumber } from "@firebase-ui/core"; -describe("PhoneForm", () => { +describe("PhoneAuthForm", () => { beforeEach(() => { vi.clearAllMocks(); // Reset the global state @@ -165,7 +165,7 @@ describe("PhoneForm", () => { }); it("renders the phone number form initially", () => { - render(); + render(); expect(screen.getByRole("textbox", { name: /phone number/i })).toBeInTheDocument(); expect(screen.getByTestId("country-selector")).toBeInTheDocument(); @@ -174,7 +174,7 @@ describe("PhoneForm", () => { }); it("attempts to send verification code when phone number is submitted", async () => { - render(); + render(); // Get the submit button const submitButton = screen.getByTestId("submit-button"); @@ -208,7 +208,7 @@ describe("PhoneForm", () => { (mockError as any).code = "auth/invalid-phone-number"; (signInWithPhoneNumber as unknown as ReturnType).mockRejectedValueOnce(mockError); - render(); + render(); // Get the submit button const submitButton = screen.getByTestId("submit-button"); @@ -236,7 +236,7 @@ describe("PhoneForm", () => { }); it("validates on blur for the first time", async () => { - render(); + render(); const phoneInput = screen.getByRole("textbox", { name: /phone number/i }); @@ -249,7 +249,7 @@ describe("PhoneForm", () => { }); it("validates on input after first blur", async () => { - render(); + render(); const phoneInput = screen.getByRole("textbox", { name: /phone number/i }); diff --git a/packages/react/src/auth/forms/phone-form.tsx b/packages/react/src/auth/forms/phone-auth-form.tsx similarity index 93% rename from packages/react/src/auth/forms/phone-form.tsx rename to packages/react/src/auth/forms/phone-auth-form.tsx index fe99010a..7310c953 100644 --- a/packages/react/src/auth/forms/phone-form.tsx +++ b/packages/react/src/auth/forms/phone-auth-form.tsx @@ -30,7 +30,7 @@ import { useForm } from "@tanstack/react-form"; import { ConfirmationResult, RecaptchaVerifier } from "firebase/auth"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; -import { useAuth, useUI } from "~/hooks"; +import { useUI } from "~/hooks"; import { Button } from "../../components/button"; import { CountrySelector } from "../../components/country-selector"; import { FieldInfo } from "../../components/field-info"; @@ -46,6 +46,7 @@ interface PhoneNumberFormProps { function PhoneNumberForm({ onSubmit, formError, recaptchaVerifier, recaptchaContainerRef }: PhoneNumberFormProps) { const ui = useUI(); + // TODO(ehesp): How does this support allowed countries? const [selectedCountry, setSelectedCountry] = useState(countryData[0]); const [firstValidationOccured, setFirstValidationOccured] = useState(false); @@ -71,6 +72,8 @@ function PhoneNumberForm({ onSubmit, formError, recaptchaVerifier, recaptchaCont }, }); + // TODO(ehesp): Country data onChange types are not matching + return (
setSelectedCountry(country as CountryData)} className="fui-phone-input__country-selector" /> (null); const [confirmationResult, setConfirmationResult] = useState(null); @@ -310,8 +312,9 @@ export function PhoneForm({ resendDelay = 30 }: PhoneFormProps) { useEffect(() => { if (!recaptchaContainerRef.current) return; - const verifier = new RecaptchaVerifier(auth, recaptchaContainerRef.current, { - size: ui.recaptchaMode ?? "normal", + const verifier = new RecaptchaVerifier(ui.auth, recaptchaContainerRef.current, { + // size: ui.recaptchaMode ?? "normal", TODO(ehesp): Get this from the useRecaptchaVerifier hook once implemented + size: "normal", }); setRecaptchaVerifier(verifier); @@ -320,7 +323,7 @@ export function PhoneForm({ resendDelay = 30 }: PhoneFormProps) { verifier.clear(); setRecaptchaVerifier(null); }; - }, [auth, ui.recaptchaMode]); + }, [ui]); const handlePhoneSubmit = async (number: string) => { setFormError(null); @@ -356,8 +359,9 @@ export function PhoneForm({ resendDelay = 30 }: PhoneFormProps) { recaptchaVerifier.clear(); } - const verifier = new RecaptchaVerifier(auth, recaptchaContainerRef.current, { - size: ui.recaptchaMode ?? "normal", + const verifier = new RecaptchaVerifier(ui.auth, recaptchaContainerRef.current, { + // size: ui.recaptchaMode ?? "normal", // TODO(ehesp): Get this from the useRecaptchaVerifier hook once implemented + size: "normal", }); setRecaptchaVerifier(verifier); diff --git a/packages/react/src/auth/screens/phone-auth-screen.tsx b/packages/react/src/auth/screens/phone-auth-screen.tsx index 79806dc7..31027b90 100644 --- a/packages/react/src/auth/screens/phone-auth-screen.tsx +++ b/packages/react/src/auth/screens/phone-auth-screen.tsx @@ -18,14 +18,12 @@ import type { PropsWithChildren } from "react"; import { getTranslation } from "@firebase-ui/core"; import { Divider } from "~/components/divider"; import { useUI } from "~/hooks"; -import { Card, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; -import { PhoneForm } from "../forms/phone-form"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; +import { PhoneAuthForm, type PhoneAuthFormProps } from "../forms/phone-auth-form"; -export type PhoneAuthScreenProps = PropsWithChildren<{ - resendDelay?: number; -}>; +export type PhoneAuthScreenProps = PropsWithChildren; -export function PhoneAuthScreen({ children, resendDelay }: PhoneAuthScreenProps) { +export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); @@ -38,13 +36,15 @@ export function PhoneAuthScreen({ children, resendDelay }: PhoneAuthScreenProps) {titleText} {subtitleText} - - {children ? ( - <> - {getTranslation(ui, "messages", "dividerOr")} -
{children}
- - ) : null} + + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
{children}
+ + ) : null} +
); From f04c478ce264b552434267a9b8d61b1791b70f0b Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 17:06:57 +0100 Subject: [PATCH 12/43] refactor(react): Align SignInAuth{Screen,Form} with API spec --- ...rm.test.tsx => sign-in-auth-form.test.tsx} | 14 +++++----- ...assword-form.tsx => sign-in-auth-form.tsx} | 9 ++++--- packages/react/src/auth/index.ts | 3 +-- .../src/auth/screens/sign-in-auth-screen.tsx | 27 +++++++++---------- .../email-password-auth.integration.test.tsx | 2 +- 5 files changed, 28 insertions(+), 27 deletions(-) rename packages/react/src/auth/forms/{email-password-form.test.tsx => sign-in-auth-form.test.tsx} (95%) rename packages/react/src/auth/forms/{email-password-form.tsx => sign-in-auth-form.tsx} (93%) diff --git a/packages/react/src/auth/forms/email-password-form.test.tsx b/packages/react/src/auth/forms/sign-in-auth-form.test.tsx similarity index 95% rename from packages/react/src/auth/forms/email-password-form.test.tsx rename to packages/react/src/auth/forms/sign-in-auth-form.test.tsx index c6f10b2b..f996ce5c 100644 --- a/packages/react/src/auth/forms/email-password-form.test.tsx +++ b/packages/react/src/auth/forms/sign-in-auth-form.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { EmailPasswordForm } from "./email-password-form"; +import { SignInAuthForm } from "./sign-in-auth-form"; import { act } from "react"; // Mock the dependencies @@ -108,13 +108,13 @@ vi.mock("../../../../src/components/button", () => ({ // Import the actual functions after mocking import { signInWithEmailAndPassword } from "@firebase-ui/core"; -describe("EmailPasswordForm", () => { +describe("SignInAuthForm", () => { beforeEach(() => { vi.clearAllMocks(); }); it("renders the form correctly", () => { - render(); + render(); expect(screen.getByRole("textbox", { name: /email address/i })).toBeInTheDocument(); expect(screen.getByTestId("policies")).toBeInTheDocument(); @@ -122,7 +122,7 @@ describe("EmailPasswordForm", () => { }); it("submits the form when the button is clicked", async () => { - render(); + render(); // Get the submit button const submitButton = screen.getByTestId("submit-button"); @@ -151,7 +151,7 @@ describe("EmailPasswordForm", () => { const mockError = new Error("Invalid credentials"); (signInWithEmailAndPassword as Mock).mockRejectedValueOnce(mockError); - render(); + render(); // Get the submit button const submitButton = screen.getByTestId("submit-button"); @@ -176,7 +176,7 @@ describe("EmailPasswordForm", () => { }); it("validates on blur for the first time", async () => { - render(); + render(); const emailInput = screen.getByRole("textbox", { name: /email address/i }); const passwordInput = screen.getByDisplayValue("password123"); @@ -191,7 +191,7 @@ describe("EmailPasswordForm", () => { }); it("validates on input after first blur", async () => { - render(); + render(); const emailInput = screen.getByRole("textbox", { name: /email address/i }); const passwordInput = screen.getByDisplayValue("password123"); diff --git a/packages/react/src/auth/forms/email-password-form.tsx b/packages/react/src/auth/forms/sign-in-auth-form.tsx similarity index 93% rename from packages/react/src/auth/forms/email-password-form.tsx rename to packages/react/src/auth/forms/sign-in-auth-form.tsx index 6b3cfbfd..d8945462 100644 --- a/packages/react/src/auth/forms/email-password-form.tsx +++ b/packages/react/src/auth/forms/sign-in-auth-form.tsx @@ -29,13 +29,15 @@ import { useUI } from "~/hooks"; import { Button } from "../../components/button"; import { FieldInfo } from "../../components/field-info"; import { Policies } from "../../components/policies"; +import { UserCredential } from "firebase/auth"; -export interface EmailPasswordFormProps { +export type SignInAuthFormProps = { + onSignIn?: (credential: UserCredential) => void; onForgotPasswordClick?: () => void; onRegisterClick?: () => void; } -export function EmailPasswordForm({ onForgotPasswordClick, onRegisterClick }: EmailPasswordFormProps) { +export function SignInAuthForm({ onSignIn, onForgotPasswordClick, onRegisterClick }: SignInAuthFormProps) { const ui = useUI(); const [formError, setFormError] = useState(null); @@ -56,7 +58,8 @@ export function EmailPasswordForm({ onForgotPasswordClick, onRegisterClick }: Em onSubmit: async ({ value }) => { setFormError(null); try { - await signInWithEmailAndPassword(ui, value.email, value.password); + const credential = await signInWithEmailAndPassword(ui, value.email, value.password); + onSignIn?.(credential); } catch (error) { if (error instanceof FirebaseUIError) { setFormError(error.message); diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts index a45f1114..bd9ec449 100644 --- a/packages/react/src/auth/index.ts +++ b/packages/react/src/auth/index.ts @@ -26,8 +26,7 @@ export { OAuthScreen, type OAuthScreenProps } from "./screens/oauth-screen"; export { ForgotPasswordAuthScreen, type ForgotPasswordAuthScreenProps } from "./screens/forgot-password-auth-screen"; -/** Export forms */ -export { EmailPasswordForm, type EmailPasswordFormProps } from "./forms/email-password-form"; +export { SignInAuthForm, type SignInAuthFormProps } from "./forms/sign-in-auth-form"; export { RegisterForm, type RegisterFormProps } from "./forms/register-form"; diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.tsx index 5b8cb5c0..6db02ae1 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.tsx @@ -18,15 +18,12 @@ import type { PropsWithChildren } from "react"; import { getTranslation } from "@firebase-ui/core"; import { Divider } from "~/components/divider"; import { useUI } from "~/hooks"; -import { Card, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; -import { EmailPasswordForm } from "../forms/email-password-form"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; +import { SignInAuthForm, type SignInAuthFormProps } from "../forms/sign-in-auth-form"; -export type SignInAuthScreenProps = PropsWithChildren<{ - onForgotPasswordClick?: () => void; - onRegisterClick?: () => void; -}>; +export type SignInAuthScreenProps = PropsWithChildren; -export function SignInAuthScreen({ onForgotPasswordClick, onRegisterClick, children }: SignInAuthScreenProps) { +export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); @@ -39,13 +36,15 @@ export function SignInAuthScreen({ onForgotPasswordClick, onRegisterClick, child {titleText} {subtitleText} - - {children ? ( - <> - {getTranslation(ui, "messages", "dividerOr")} -
{children}
- - ) : null} + + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
{children}
+ + ) : null} +
); diff --git a/packages/react/tests/integration/auth/email-password-auth.integration.test.tsx b/packages/react/tests/integration/auth/email-password-auth.integration.test.tsx index 5a0a05f9..d5484055 100644 --- a/packages/react/tests/integration/auth/email-password-auth.integration.test.tsx +++ b/packages/react/tests/integration/auth/email-password-auth.integration.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { screen, fireEvent, waitFor, act, render } from "@testing-library/react"; -import { EmailPasswordForm } from "../../../src/auth/forms/email-password-form"; +import { EmailPasswordForm } from "../../../src/auth/forms/sign-in-auth-form"; import { initializeApp } from "firebase/app"; import { getAuth, From 28573f29e7ac96ef924f9332a9fd13013b40dfbd Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 17:09:37 +0100 Subject: [PATCH 13/43] refactor(react): Align SignUpAuth{Screen,Form} with API spec --- ...rm.test.tsx => sign-up-auth-form.test.tsx} | 14 +++++----- ...egister-form.tsx => sign-up-auth-form.tsx} | 9 ++++--- packages/react/src/auth/index.ts | 2 +- .../src/auth/screens/sign-up-auth-screen.tsx | 26 +++++++++---------- .../auth/register.integration.test.tsx | 2 +- 5 files changed, 28 insertions(+), 25 deletions(-) rename packages/react/src/auth/forms/{register-form.test.tsx => sign-up-auth-form.test.tsx} (95%) rename packages/react/src/auth/forms/{register-form.tsx => sign-up-auth-form.tsx} (93%) diff --git a/packages/react/src/auth/forms/register-form.test.tsx b/packages/react/src/auth/forms/sign-up-auth-form.test.tsx similarity index 95% rename from packages/react/src/auth/forms/register-form.test.tsx rename to packages/react/src/auth/forms/sign-up-auth-form.test.tsx index e4e9f8bc..b48fab46 100644 --- a/packages/react/src/auth/forms/register-form.test.tsx +++ b/packages/react/src/auth/forms/sign-up-auth-form.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { RegisterForm } from "./register-form"; +import { SignUpAuthForm } from "./sign-up-auth-form"; import { act } from "react"; // Mock the dependencies @@ -113,7 +113,7 @@ describe("RegisterForm", () => { }); it("renders the form correctly", () => { - render(); + render(); expect(screen.getByRole("textbox", { name: /email address/i })).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); @@ -122,7 +122,7 @@ describe("RegisterForm", () => { }); it("submits the form when the button is clicked", async () => { - render(); + render(); // Get the submit button const submitButton = screen.getByTestId("submit-button"); @@ -151,7 +151,7 @@ describe("RegisterForm", () => { const mockError = new Error("Email already in use"); (createUserWithEmailAndPassword as Mock).mockRejectedValueOnce(mockError); - render(); + render(); // Get the submit button const submitButton = screen.getByTestId("submit-button"); @@ -180,7 +180,7 @@ describe("RegisterForm", () => { }); it("validates on blur for the first time", async () => { - render(); + render(); const emailInput = screen.getByRole("textbox", { name: /email address/i }); const passwordInput = screen.getByDisplayValue("password123"); @@ -195,7 +195,7 @@ describe("RegisterForm", () => { }); it("validates on input after first blur", async () => { - render(); + render(); const emailInput = screen.getByRole("textbox", { name: /email address/i }); const passwordInput = screen.getByDisplayValue("password123"); @@ -219,7 +219,7 @@ describe("RegisterForm", () => { // TODO: Fix this test it.skip("displays back to sign in button when provided", () => { const onBackToSignInClickMock = vi.fn(); - render(); + render(); const backButton = document.querySelector(".fui-form__action")!; expect(backButton).toBeInTheDocument(); diff --git a/packages/react/src/auth/forms/register-form.tsx b/packages/react/src/auth/forms/sign-up-auth-form.tsx similarity index 93% rename from packages/react/src/auth/forms/register-form.tsx rename to packages/react/src/auth/forms/sign-up-auth-form.tsx index 0eca7935..15a5b4da 100644 --- a/packages/react/src/auth/forms/register-form.tsx +++ b/packages/react/src/auth/forms/sign-up-auth-form.tsx @@ -29,12 +29,14 @@ import { useUI } from "~/hooks"; import { Button } from "../../components/button"; import { FieldInfo } from "../../components/field-info"; import { Policies } from "../../components/policies"; +import { type UserCredential } from "firebase/auth"; -export interface RegisterFormProps { +export type SignUpAuthFormProps = { + onSignUp?: (credential: UserCredential) => void; onBackToSignInClick?: () => void; } -export function RegisterForm({ onBackToSignInClick }: RegisterFormProps) { +export function SignUpAuthForm({ onBackToSignInClick, onSignUp }: SignUpAuthFormProps) { const ui = useUI(); const [formError, setFormError] = useState(null); @@ -53,7 +55,8 @@ export function RegisterForm({ onBackToSignInClick }: RegisterFormProps) { onSubmit: async ({ value }) => { setFormError(null); try { - await createUserWithEmailAndPassword(ui, value.email, value.password); + const credential = await createUserWithEmailAndPassword(ui, value.email, value.password); + onSignUp?.(credential); } catch (error) { if (error instanceof FirebaseUIError) { setFormError(error.message); diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts index bd9ec449..9da89b92 100644 --- a/packages/react/src/auth/index.ts +++ b/packages/react/src/auth/index.ts @@ -28,7 +28,7 @@ export { ForgotPasswordAuthScreen, type ForgotPasswordAuthScreenProps } from "./ export { SignInAuthForm, type SignInAuthFormProps } from "./forms/sign-in-auth-form"; -export { RegisterForm, type RegisterFormProps } from "./forms/register-form"; +export { SignUpAuthForm, type SignUpAuthFormProps } from "./forms/sign-up-auth-form"; /** Export Buttons */ export { GoogleSignInButton } from "./oauth/google-sign-in-button"; diff --git a/packages/react/src/auth/screens/sign-up-auth-screen.tsx b/packages/react/src/auth/screens/sign-up-auth-screen.tsx index 04837be3..9419e8a3 100644 --- a/packages/react/src/auth/screens/sign-up-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-up-auth-screen.tsx @@ -17,15 +17,13 @@ import { PropsWithChildren } from "react"; import { Divider } from "~/components/divider"; import { useUI } from "~/hooks"; -import { Card, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; -import { RegisterForm } from "../forms/register-form"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; +import { SignUpAuthForm, type SignUpAuthFormProps } from "../forms/sign-up-auth-form"; import { getTranslation } from "@firebase-ui/core"; -export type SignUpAuthScreenProps = PropsWithChildren<{ - onBackToSignInClick?: () => void; -}>; +export type SignUpAuthScreenProps = PropsWithChildren; -export function SignUpAuthScreen({ onBackToSignInClick, children }: SignUpAuthScreenProps) { +export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "register"); @@ -38,13 +36,15 @@ export function SignUpAuthScreen({ onBackToSignInClick, children }: SignUpAuthSc {titleText} {subtitleText} - - {children ? ( - <> - {getTranslation(ui, "messages", "dividerOr")} -
{children}
- - ) : null} + + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
{children}
+ + ) : null} +
); diff --git a/packages/react/tests/integration/auth/register.integration.test.tsx b/packages/react/tests/integration/auth/register.integration.test.tsx index 7132a80c..73d4164b 100644 --- a/packages/react/tests/integration/auth/register.integration.test.tsx +++ b/packages/react/tests/integration/auth/register.integration.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, afterAll, beforeEach } from "vitest"; import { screen, fireEvent, waitFor, act, render } from "@testing-library/react"; -import { RegisterForm } from "../../../src/auth/forms/register-form"; +import { RegisterForm } from "../../../src/auth/forms/sign-up-auth-form"; import { initializeApp } from "firebase/app"; import { getAuth, connectAuthEmulator, deleteUser, signOut, signInWithEmailAndPassword } from "firebase/auth"; import { initializeUI } from "@firebase-ui/core"; From 2486915a7720c4eee4c3e0fca267757834016bd1 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 19:18:09 +0100 Subject: [PATCH 14/43] chore(react): Align polciies/toc naming --- .../components/{terms-and-privacy.test.tsx => policies.test.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react/src/components/{terms-and-privacy.test.tsx => policies.test.tsx} (100%) diff --git a/packages/react/src/components/terms-and-privacy.test.tsx b/packages/react/src/components/policies.test.tsx similarity index 100% rename from packages/react/src/components/terms-and-privacy.test.tsx rename to packages/react/src/components/policies.test.tsx From 74c37e4a695a70ccd01ac3f93748a4d499e66558 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 11 Sep 2025 19:18:25 +0100 Subject: [PATCH 15/43] refactor: Sync country data and phone auth changes --- packages/core/src/country-data.ts | 13 +++++++---- .../react/src/auth/forms/phone-auth-form.tsx | 9 ++++---- .../src/components/country-selector.test.tsx | 8 +++---- .../react/src/components/country-selector.tsx | 23 +++++++++++-------- 4 files changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/core/src/country-data.ts b/packages/core/src/country-data.ts index a28559b2..65e623ae 100644 --- a/packages/core/src/country-data.ts +++ b/packages/core/src/country-data.ts @@ -267,17 +267,22 @@ export const countryData = [ export type CountryData = (typeof countryData)[number]; -export type CountryCodes = CountryData["code"]; +export type CountryCode = CountryData["code"]; -export function getCountryByDialCode(dialCode: string): (typeof countryData)[number] | undefined { +export function getCountryByDialCode(dialCode: string): CountryData | undefined { return countryData.find((country) => country.dialCode === dialCode); } -export function getCountryByCode(code: string): (typeof countryData)[number] | undefined { +export function getCountryByCode(code: CountryCode): CountryData | undefined { return countryData.find((country) => country.code === code.toUpperCase()); } -export function formatPhoneNumberWithCountry(phoneNumber: string, countryDialCode: string): string { +export function formatPhoneNumberWithCountry(phoneNumber: string, countryCode: CountryCode): string { + const countryData = getCountryByCode(countryCode); + if (!countryData) { + return phoneNumber; + } + const countryDialCode = countryData.dialCode; // Remove any existing dial code if present const cleanNumber = phoneNumber.replace(/^\+\d+/, "").trim(); return `${countryDialCode}${cleanNumber}`; diff --git a/packages/react/src/auth/forms/phone-auth-form.tsx b/packages/react/src/auth/forms/phone-auth-form.tsx index 7310c953..1661f84a 100644 --- a/packages/react/src/auth/forms/phone-auth-form.tsx +++ b/packages/react/src/auth/forms/phone-auth-form.tsx @@ -18,7 +18,7 @@ import { confirmPhoneNumber, - CountryData, + CountryCode, countryData, createPhoneFormSchema, FirebaseUIError, @@ -47,7 +47,8 @@ function PhoneNumberForm({ onSubmit, formError, recaptchaVerifier, recaptchaCont const ui = useUI(); // TODO(ehesp): How does this support allowed countries? - const [selectedCountry, setSelectedCountry] = useState(countryData[0]); + // TODO(ehesp): How does this support default country? + const [selectedCountry, setSelectedCountry] = useState(countryData[0].code); const [firstValidationOccured, setFirstValidationOccured] = useState(false); const phoneFormSchema = useMemo( @@ -67,7 +68,7 @@ function PhoneNumberForm({ onSubmit, formError, recaptchaVerifier, recaptchaCont onSubmit: phoneFormSchema, }, onSubmit: async ({ value }) => { - const formattedNumber = formatPhoneNumberWithCountry(value.phoneNumber, selectedCountry.dialCode); + const formattedNumber = formatPhoneNumberWithCountry(value.phoneNumber, selectedCountry); await onSubmit(formattedNumber); }, }); @@ -94,7 +95,7 @@ function PhoneNumberForm({ onSubmit, formError, recaptchaVerifier, recaptchaCont
setSelectedCountry(country as CountryData)} + onChange={(code) => setSelectedCountry(code as CountryCode)} className="fui-phone-input__country-selector" /> { }); it("renders with the selected country", () => { - render(); + render(); // Check if the country flag emoji is displayed expect(screen.getByText(defaultCountry.emoji)).toBeInTheDocument(); @@ -43,7 +43,7 @@ describe("CountrySelector Component", () => { }); it("applies custom className", () => { - render(); + render(); const selector = screen.getByRole("combobox").closest(".fui-country-selector"); expect(selector).toHaveClass("fui-country-selector"); @@ -51,7 +51,7 @@ describe("CountrySelector Component", () => { }); it("calls onChange when a different country is selected", () => { - render(); + render(); const select = screen.getByRole("combobox"); @@ -72,7 +72,7 @@ describe("CountrySelector Component", () => { }); it("renders all countries in the dropdown", () => { - render(); + render(); const select = screen.getByRole("combobox"); const options = select.querySelectorAll("option"); diff --git a/packages/react/src/components/country-selector.tsx b/packages/react/src/components/country-selector.tsx index 6af10a4a..a8a073ab 100644 --- a/packages/react/src/components/country-selector.tsx +++ b/packages/react/src/components/country-selector.tsx @@ -16,33 +16,38 @@ "use client"; -import { CountryCodes, CountryData, countryData } from "@firebase-ui/core"; +import { CountryCode, countryData, getCountryByCode } from "@firebase-ui/core"; import { ComponentProps } from "react"; import { cn } from "~/utils/cn"; export type CountrySelectorProps = ComponentProps<"div"> & { - value: CountryData; - onChange: (country: CountryData) => void; - allowedCountries?: CountryCodes[]; + value: CountryCode; + onChange: (code: CountryCode) => void; + allowedCountries?: CountryCode[]; }; export function CountrySelector({ value, onChange, allowedCountries, className, ...props }: CountrySelectorProps) { + const country = getCountryByCode(value); const countries = allowedCountries ? countryData.filter((c) => allowedCountries.includes(c.code)) : countryData; + if (!country) { + return null; + } + return (
- {value.emoji} + {country.emoji}
- {value.dialCode} + {country.dialCode}