diff --git a/examples/react/index.html b/examples/react/index.html index d65098d3..b822bda2 100644 --- a/examples/react/index.html +++ b/examples/react/index.html @@ -24,7 +24,7 @@
- + diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index b7ccf94f..87ec9a84 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -22,7 +22,7 @@ import { getTranslation } from "./translations"; export class FirebaseUIError extends FirebaseError { constructor(ui: FirebaseUIConfiguration, error: FirebaseError) { const message = getTranslation(ui, "errors", ERROR_CODE_MAP[error.code as ErrorCode]); - super(error.code, message); + super(error.code, message || error.message); // Ensures that `instanceof FirebaseUIError` works, alongside `instanceof FirebaseError` Object.setPrototypeOf(this, FirebaseUIError.prototype); diff --git a/packages/react/src/auth/forms/phone-auth-form.test.tsx b/packages/react/src/auth/forms/phone-auth-form.test.tsx index 2c6ec744..0629f4b7 100644 --- a/packages/react/src/auth/forms/phone-auth-form.test.tsx +++ b/packages/react/src/auth/forms/phone-auth-form.test.tsx @@ -18,12 +18,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, renderHook, cleanup } from "@testing-library/react"; import { PhoneAuthForm, - usePhoneAuthFormAction, - usePhoneVerificationFormAction, - usePhoneResendAction, - useResendTimer, + usePhoneNumberFormAction, + usePhoneNumberForm, + useVerifyPhoneNumberFormAction, + useVerifyPhoneNumberForm, + PhoneNumberForm, } from "./phone-auth-form"; import { act } from "react"; +import type { UserCredential } from "firebase/auth"; vi.mock("firebase/auth", () => ({ RecaptchaVerifier: vi.fn().mockImplementation(() => ({ @@ -55,6 +57,18 @@ vi.mock("~/components/form", async (importOriginal) => { }; }); +vi.mock("~/hooks", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useRecaptchaVerifier: vi.fn().mockReturnValue({ + render: vi.fn(), + clear: vi.fn(), + verify: vi.fn(), + }), + }; +}); + import { signInWithPhoneNumber, confirmPhoneNumber } from "@firebase-ui/core"; import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@firebase-ui/translations"; @@ -82,182 +96,214 @@ vi.mock("~/components/country-selector", () => ({ )), })); -describe("useResendTimer", () => { +describe("usePhoneNumberFormAction", () => { beforeEach(() => { vi.clearAllMocks(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("should initialize with correct default values", () => { - const { result } = renderHook(() => useResendTimer(30)); - - expect(result.current.timeLeft).toBe(0); - expect(result.current.canResend).toBe(true); }); - it("should start timer and count down correctly", async () => { - const { result } = renderHook(() => useResendTimer(5)); + it("should return a callback which accepts phone number and recaptcha verifier", async () => { + const signInWithPhoneNumberMock = vi.mocked(signInWithPhoneNumber); + const mockUI = createMockUI(); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; - act(() => { - result.current.startTimer(); + const { result } = renderHook(() => usePhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), }); - expect(result.current.timeLeft).toBe(5); - expect(result.current.canResend).toBe(false); - - // Advance timer by 1 second - act(() => { - vi.advanceTimersByTime(1000); + await act(async () => { + await result.current({ phoneNumber: "1234567890", recaptchaVerifier: mockRecaptchaVerifier as any }); }); - expect(result.current.timeLeft).toBe(4); - expect(result.current.canResend).toBe(false); + expect(signInWithPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), "1234567890", mockRecaptchaVerifier); + }); - // Advance timer by 3 more seconds - act(() => { - vi.advanceTimersByTime(3000); - }); + it("should return a confirmation result on success", async () => { + const mockConfirmationResult = { confirm: vi.fn() } as any; + const signInWithPhoneNumberMock = vi.mocked(signInWithPhoneNumber).mockResolvedValue(mockConfirmationResult); + const mockUI = createMockUI(); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; - expect(result.current.timeLeft).toBe(1); - expect(result.current.canResend).toBe(false); + const { result } = renderHook(() => usePhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); - // Advance timer by final second - act(() => { - vi.advanceTimersByTime(1000); + await act(async () => { + const confirmationResult = await result.current({ + phoneNumber: "1234567890", + recaptchaVerifier: mockRecaptchaVerifier as any, + }); + expect(confirmationResult).toBe(mockConfirmationResult); }); - expect(result.current.timeLeft).toBe(0); - expect(result.current.canResend).toBe(true); + expect(signInWithPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), "1234567890", mockRecaptchaVerifier); }); - it("should handle multiple timer starts correctly", () => { - const { result } = renderHook(() => useResendTimer(3)); + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const signInWithPhoneNumberMock = vi.mocked(signInWithPhoneNumber).mockRejectedValue(new Error("Unknown error")); - // Start first timer - act(() => { - result.current.startTimer(); + const mockUI = createMockUI({ + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), }); - expect(result.current.timeLeft).toBe(3); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; - // Advance by 1 second - act(() => { - vi.advanceTimersByTime(1000); + const { result } = renderHook(() => usePhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), }); - expect(result.current.timeLeft).toBe(2); - - // Start timer again (should reset) - act(() => { - result.current.startTimer(); - }); + await expect(async () => { + await act(async () => { + await result.current({ phoneNumber: "1234567890", recaptchaVerifier: mockRecaptchaVerifier as any }); + }); + }).rejects.toThrow("Unknown error"); - expect(result.current.timeLeft).toBe(3); - expect(result.current.canResend).toBe(false); + expect(signInWithPhoneNumberMock).toHaveBeenCalledWith(mockUI.get(), "1234567890", mockRecaptchaVerifier); }); +}); - it("should clean up timer on unmount", () => { - const clearIntervalSpy = vi.spyOn(global, "clearInterval"); - - const { result, unmount } = renderHook(() => useResendTimer(10)); - - act(() => { - result.current.startTimer(); - }); - - unmount(); +describe("usePhoneNumberForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - expect(clearIntervalSpy).toHaveBeenCalled(); + afterEach(() => { + cleanup(); }); - it("should handle zero delay correctly", () => { - const { result } = renderHook(() => useResendTimer(0)); + it("should allow the form to be submitted with valid phone number", async () => { + const mockUI = createMockUI(); + const mockConfirmationResult = { confirm: vi.fn() } as any; + const signInWithPhoneNumberMock = vi.mocked(signInWithPhoneNumber).mockResolvedValue(mockConfirmationResult); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + + const { result } = renderHook( + () => + usePhoneNumberForm({ + recaptchaVerifier: mockRecaptchaVerifier as any, + onSuccess: vi.fn(), + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); act(() => { - result.current.startTimer(); + result.current.setFieldValue("phoneNumber", "1234567890"); }); - expect(result.current.timeLeft).toBe(0); - expect(result.current.canResend).toBe(false); // Timer is active but will complete immediately - - // Advance timer to trigger the interval callback - act(() => { - vi.advanceTimersByTime(1000); + await act(async () => { + await result.current.handleSubmit(); }); - expect(result.current.timeLeft).toBe(0); - expect(result.current.canResend).toBe(true); + expect(signInWithPhoneNumberMock).toHaveBeenCalledWith(mockUI.get(), "1234567890", mockRecaptchaVerifier); }); - it("should handle single second delay correctly", () => { - const { result } = renderHook(() => useResendTimer(1)); + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const signInWithPhoneNumberMock = vi.mocked(signInWithPhoneNumber); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + + const { result } = renderHook( + () => + usePhoneNumberForm({ + recaptchaVerifier: mockRecaptchaVerifier as any, + onSuccess: vi.fn(), + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); act(() => { - result.current.startTimer(); + result.current.setFieldValue("phoneNumber", "12345678901"); // too long }); - expect(result.current.timeLeft).toBe(1); - expect(result.current.canResend).toBe(false); - - // Advance by 1 second - act(() => { - vi.advanceTimersByTime(1000); + await act(async () => { + await result.current.handleSubmit(); }); - expect(result.current.timeLeft).toBe(0); - expect(result.current.canResend).toBe(true); + const fieldMeta = result.current.getFieldMeta("phoneNumber"); + expect(fieldMeta?.errors).toBeDefined(); + expect(fieldMeta?.errors.length).toBeGreaterThan(0); + expect(signInWithPhoneNumberMock).not.toHaveBeenCalled(); }); - it("should maintain correct state during countdown", () => { - const { result } = renderHook(() => useResendTimer(3)); + it("should call onSuccess callback when form submission succeeds", async () => { + const mockUI = createMockUI(); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + const mockConfirmationResult = { confirm: vi.fn() } as any; + const onSuccessMock = vi.fn(); + + vi.mocked(signInWithPhoneNumber).mockResolvedValue(mockConfirmationResult); + + const { result } = renderHook( + () => + usePhoneNumberForm({ + recaptchaVerifier: mockRecaptchaVerifier as any, + onSuccess: onSuccessMock, + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); act(() => { - result.current.startTimer(); + result.current.setFieldValue("phoneNumber", "1234567890"); }); - // Check initial state - expect(result.current.timeLeft).toBe(3); - expect(result.current.canResend).toBe(false); - - // Check state at each second - for (let i = 2; i >= 0; i--) { - act(() => { - vi.advanceTimersByTime(1000); - }); + await act(async () => { + await result.current.handleSubmit(); + }); - expect(result.current.timeLeft).toBe(i); - expect(result.current.canResend).toBe(i === 0); - } + expect(onSuccessMock).toHaveBeenCalledWith(mockConfirmationResult); }); }); -describe("usePhoneAuthFormAction", () => { +describe("useVerifyPhoneNumberFormAction", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("should return a callback which accepts phone number and recaptcha verifier", async () => { - const signInWithPhoneNumberMock = vi.mocked(signInWithPhoneNumber); + it("should return a callback which accepts confirmation result and code", async () => { + const confirmPhoneNumberMock = vi.mocked(confirmPhoneNumber); const mockUI = createMockUI(); - const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + const mockConfirmationResult = { confirm: vi.fn() }; + + const { result } = renderHook(() => useVerifyPhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ confirmation: mockConfirmationResult as any, code: "123456" }); + }); + + expect(confirmPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), mockConfirmationResult, "123456"); + }); + + it("should return a credential on success", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const confirmPhoneNumberMock = vi.mocked(confirmPhoneNumber).mockResolvedValue(mockCredential); + const mockUI = createMockUI(); + const mockConfirmationResult = { confirm: vi.fn() }; - const { result } = renderHook(() => usePhoneAuthFormAction(), { + const { result } = renderHook(() => useVerifyPhoneNumberFormAction(), { wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), }); await act(async () => { - await result.current({ phoneNumber: "+1234567890", recaptchaVerifier: mockRecaptchaVerifier as any }); + const credential = await result.current({ confirmation: mockConfirmationResult as any, code: "123456" }); + expect(credential).toBe(mockCredential); }); - expect(signInWithPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), "+1234567890", mockRecaptchaVerifier); + expect(confirmPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), mockConfirmationResult, "123456"); }); it("should throw an unknown error when its not a FirebaseUIError", async () => { - const signInWithPhoneNumberMock = vi.mocked(signInWithPhoneNumber).mockRejectedValue(new Error("Unknown error")); + const confirmPhoneNumberMock = vi.mocked(confirmPhoneNumber).mockRejectedValue(new Error("Unknown error")); const mockUI = createMockUI({ locale: registerLocale("es-ES", { @@ -267,67 +313,118 @@ describe("usePhoneAuthFormAction", () => { }), }); - const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + const mockConfirmationResult = { confirm: vi.fn() }; - const { result } = renderHook(() => usePhoneAuthFormAction(), { + const { result } = renderHook(() => useVerifyPhoneNumberFormAction(), { wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), }); await expect(async () => { await act(async () => { - await result.current({ phoneNumber: "+1234567890", recaptchaVerifier: mockRecaptchaVerifier as any }); + await result.current({ confirmation: mockConfirmationResult as any, code: "123456" }); }); - }).rejects.toThrow("unknownError"); + }).rejects.toThrow("Unknown error"); - expect(signInWithPhoneNumberMock).toHaveBeenCalledWith(mockUI.get(), "+1234567890", mockRecaptchaVerifier); + expect(confirmPhoneNumberMock).toHaveBeenCalledWith(mockUI.get(), mockConfirmationResult, "123456"); }); }); -describe("usePhoneVerificationFormAction", () => { +describe("useVerifyPhoneNumberForm", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("should return a callback which accepts confirmation result and code", async () => { - const confirmPhoneNumberMock = vi.mocked(confirmPhoneNumber); + afterEach(() => { + cleanup(); + }); + + it("should allow the form to be submitted with valid verification code", async () => { const mockUI = createMockUI(); - const mockConfirmationResult = { confirm: vi.fn() }; + const confirmPhoneNumberMock = vi.mocked(confirmPhoneNumber); + const mockConfirmationResult = { confirm: vi.fn() } as any; + + const { result } = renderHook( + () => + useVerifyPhoneNumberForm({ + confirmation: mockConfirmationResult, + onSuccess: vi.fn(), + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); - const { result } = renderHook(() => usePhoneVerificationFormAction(), { - wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + act(() => { + result.current.setFieldValue("verificationCode", "123456"); }); await act(async () => { - await result.current({ confirmationResult: mockConfirmationResult as any, code: "123456" }); + await result.current.handleSubmit(); }); - expect(confirmPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), mockConfirmationResult, "123456"); + expect(confirmPhoneNumberMock).toHaveBeenCalledWith(mockUI.get(), mockConfirmationResult, "123456"); }); -}); -describe("usePhoneResendAction", () => { - beforeEach(() => { - vi.clearAllMocks(); + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const confirmPhoneNumberMock = vi.mocked(confirmPhoneNumber); + const mockConfirmationResult = { confirm: vi.fn() } as any; + + const { result } = renderHook( + () => + useVerifyPhoneNumberForm({ + confirmation: mockConfirmationResult, + onSuccess: vi.fn(), + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); + + act(() => { + result.current.setFieldValue("verificationCode", "123"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(result.current.getFieldMeta("verificationCode")!.errors[0].length).toBeGreaterThan(0); + expect(confirmPhoneNumberMock).not.toHaveBeenCalled(); }); - it("should return a callback which accepts phone number and recaptcha verifier", async () => { - const signInWithPhoneNumberMock = vi.mocked(signInWithPhoneNumber).mockResolvedValue({ confirm: vi.fn() } as any); + it("should call onSuccess callback when form submission succeeds", async () => { const mockUI = createMockUI(); - const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + const mockConfirmationResult = { confirm: vi.fn() } as any; + const mockCredential = { credential: true } as unknown as UserCredential; + const onSuccessMock = vi.fn(); + + vi.mocked(confirmPhoneNumber).mockResolvedValue(mockCredential); + + const { result } = renderHook( + () => + useVerifyPhoneNumberForm({ + confirmation: mockConfirmationResult, + onSuccess: onSuccessMock, + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); - const { result } = renderHook(() => usePhoneResendAction(), { - wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + act(() => { + result.current.setFieldValue("verificationCode", "123456"); }); await act(async () => { - await result.current({ phoneNumber: "+1234567890", recaptchaVerifier: mockRecaptchaVerifier as any }); + await result.current.handleSubmit(); }); - expect(signInWithPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), "+1234567890", mockRecaptchaVerifier); + expect(onSuccessMock).toHaveBeenCalledWith(mockCredential); }); }); -describe("", () => { +describe("", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -336,42 +433,39 @@ describe("", () => { cleanup(); }); - it("should render the phone number form initially", () => { + it("should render the phone number form correctly", () => { const mockUI = createMockUI({ locale: registerLocale("test", { labels: { sendCode: "sendCode", + phoneNumber: "phoneNumber", }, }), }); const { container } = render( - + ); - // There should be only one form const form = container.querySelectorAll("form.fui-form"); expect(form.length).toBe(1); - // Make sure we have a phone number input - expect(screen.getByRole("textbox", { name: /phone number/i })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument(); expect(screen.getByTestId("country-selector")).toBeInTheDocument(); - // Ensure the "Send Code" button is present and is a submit button const sendCodeButton = screen.getByRole("button", { name: "sendCode" }); expect(sendCodeButton).toBeInTheDocument(); expect(sendCodeButton).toHaveAttribute("type", "submit"); }); - // TODO: Enable me once the phobe auth form is updated - it.skip("should trigger validation errors when the form is blurred", () => { + it("should trigger validation errors when the form is blurred", () => { const mockUI = createMockUI(); const { container } = render( - + ); @@ -384,6 +478,66 @@ describe("", () => { fireEvent.blur(input); }); - expect(screen.getByText("Please provide a phone number, The phone number is invalid")).toBeInTheDocument(); + expect(screen.getByText("Please provide a phone number")).toBeInTheDocument(); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the phone number form initially", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "sendCode", + phoneNumber: "phoneNumber", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument(); + expect(screen.getByTestId("country-selector")).toBeInTheDocument(); + + const sendCodeButton = screen.getByRole("button", { name: "sendCode" }); + expect(sendCodeButton).toBeInTheDocument(); + expect(sendCodeButton).toHaveAttribute("type", "submit"); + }); + + it("should render phone number form initially and handle form submission", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "sendCode", + phoneNumber: "phoneNumber", + }, + }), + }); + + const onSignInMock = vi.fn(); + + render( + + + + ); + + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "sendCode" })).toBeInTheDocument(); + expect(screen.getByTestId("country-selector")).toBeInTheDocument(); }); }); diff --git a/packages/react/src/auth/forms/phone-auth-form.tsx b/packages/react/src/auth/forms/phone-auth-form.tsx index 0cfd47ee..826cc4e5 100644 --- a/packages/react/src/auth/forms/phone-auth-form.tsx +++ b/packages/react/src/auth/forms/phone-auth-form.tsx @@ -25,108 +25,69 @@ import { getTranslation, signInWithPhoneNumber, } from "@firebase-ui/core"; -import { ConfirmationResult, RecaptchaVerifier } from "firebase/auth"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { usePhoneAuthFormSchema, useUI } from "~/hooks"; +import { ConfirmationResult, RecaptchaVerifier, UserCredential } from "firebase/auth"; +import { useCallback, useRef, useState } from "react"; +import { usePhoneAuthFormSchema, useRecaptchaVerifier, useUI } from "~/hooks"; import { form } from "~/components/form"; -import { CountrySelector } from "~/components/country-selector"; import { Policies } from "~/components/policies"; +import { CountrySelector } from "~/components/country-selector"; -export function usePhoneAuthFormAction() { - const ui = useUI(); - - return useCallback( - async ({ phoneNumber, recaptchaVerifier }: { phoneNumber: string; recaptchaVerifier: RecaptchaVerifier }) => { - try { - return await signInWithPhoneNumber(ui, phoneNumber, recaptchaVerifier); - } catch (error) { - if (error instanceof FirebaseUIError) { - throw new Error(error.message); - } - - console.error(error); - throw new Error(getTranslation(ui, "errors", "unknownError")); - } - }, - [ui] - ); -} - -export function usePhoneVerificationFormAction() { - const ui = useUI(); - - return useCallback( - async ({ confirmationResult, code }: { confirmationResult: ConfirmationResult; code: string }) => { - try { - return await confirmPhoneNumber(ui, confirmationResult, code); - } catch (error) { - if (error instanceof FirebaseUIError) { - throw new Error(error.message); - } - - console.error(error); - throw new Error(getTranslation(ui, "errors", "unknownError")); - } - }, - [ui] - ); -} - -export function usePhoneResendAction() { +export function usePhoneNumberFormAction() { const ui = useUI(); return useCallback( async ({ phoneNumber, recaptchaVerifier }: { phoneNumber: string; recaptchaVerifier: RecaptchaVerifier }) => { - try { - return await signInWithPhoneNumber(ui, phoneNumber, recaptchaVerifier); - } catch (error) { - if (error instanceof FirebaseUIError) { - throw new Error(error.message); - } - - console.error(error); - throw new Error(getTranslation(ui, "errors", "unknownError")); - } + return await signInWithPhoneNumber(ui, phoneNumber, recaptchaVerifier); }, [ui] ); } -interface PhoneNumberFormProps { - onSubmit: (phoneNumber: string) => Promise; - recaptchaVerifier: RecaptchaVerifier | null; - recaptchaContainerRef: React.RefObject; -} - -function PhoneNumberForm({ onSubmit, recaptchaVerifier, recaptchaContainerRef }: PhoneNumberFormProps) { - const ui = useUI(); - - // TODO(ehesp): How does this support allowed countries? - // TODO(ehesp): How does this support default country? - const [selectedCountry, setSelectedCountry] = useState(countryData[0].code); +type UsePhoneNumberForm = { + recaptchaVerifier: RecaptchaVerifier; + onSuccess: (confirmationResult: ConfirmationResult) => void; + formatPhoneNumber?: (phoneNumber: string) => string; +}; - const schema = usePhoneAuthFormSchema(); - const phoneFormSchema = schema.pick({ phoneNumber: true }); +export function usePhoneNumberForm({ recaptchaVerifier, onSuccess, formatPhoneNumber }: UsePhoneNumberForm) { + const action = usePhoneNumberFormAction(); + const schema = usePhoneAuthFormSchema().pick({ phoneNumber: true }); - const phoneForm = form.useAppForm({ + return form.useAppForm({ defaultValues: { phoneNumber: "", }, validators: { - onBlur: phoneFormSchema, - onSubmit: phoneFormSchema, + onBlur: schema, + onSubmit: schema, onSubmitAsync: async ({ value }) => { try { - const formattedNumber = formatPhoneNumberWithCountry(value.phoneNumber, selectedCountry); - return await onSubmit(formattedNumber); + const formatted = formatPhoneNumber ? formatPhoneNumber(value.phoneNumber) : value.phoneNumber; + const confirmationResult = await action({ phoneNumber: formatted, recaptchaVerifier }); + return onSuccess(confirmationResult); } catch (error) { - return error instanceof Error ? error.message : String(error); + return error instanceof FirebaseUIError ? error.message : String(error); } }, }, }); +} - // TODO(ehesp): Country data onChange types are not matching +type PhoneNumberFormProps = { + onSubmit: (confirmationResult: ConfirmationResult) => void; +}; + +export function PhoneNumberForm(props: PhoneNumberFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const form = usePhoneNumberForm({ + recaptchaVerifier: recaptchaVerifier!, + onSuccess: props.onSubmit, + formatPhoneNumber: (phoneNumber) => formatPhoneNumberWithCountry(phoneNumber, selectedCountry), + }); + + const [selectedCountry, setSelectedCountry] = useState(countryData[0].code); return (
{ e.preventDefault(); e.stopPropagation(); - await phoneForm.handleSubmit(); + await form.handleSubmit(); }} > - +
- + {(field) => ( - + } + /> )} - +
-
- -
- - {getTranslation(ui, "labels", "sendCode")} - - + {getTranslation(ui, "labels", "sendCode")} +
-
+
); } -export function useResendTimer(initialDelay: number) { - const [timeLeft, setTimeLeft] = useState(0); - const [isActive, setIsActive] = useState(false); - const timerRef = useRef(0); - - useEffect(() => { - return () => { - if (timerRef.current) { - clearInterval(timerRef.current); - } - }; - }, [initialDelay]); - - const startTimer = useCallback(() => { - if (timerRef.current) { - clearInterval(timerRef.current); - } - - setTimeLeft(initialDelay); - setIsActive(true); - - timerRef.current = window.setInterval(() => { - setTimeLeft((prev) => { - const next = prev <= 1 ? 0 : prev - 1; - if (prev <= 1) { - if (timerRef.current) { - clearInterval(timerRef.current); - } - setIsActive(false); - } - return next; - }); - }, 1000); - }, [initialDelay]); - - const canResend = !isActive && timeLeft === 0; - - return { timeLeft, canResend, startTimer }; -} +export function useVerifyPhoneNumberFormAction() { + const ui = useUI(); -interface VerificationFormProps { - onSubmit: (code: string) => Promise; - onResend: () => Promise; - isResending: boolean; - canResend: boolean; - timeLeft: number; - recaptchaContainerRef: React.RefObject; + return useCallback( + async ({ confirmation, code }: { confirmation: ConfirmationResult; code: string }) => { + return await confirmPhoneNumber(ui, confirmation, code); + }, + [ui] + ); } -function VerificationForm({ - onSubmit, - onResend, - isResending, - canResend, - timeLeft, - recaptchaContainerRef, -}: VerificationFormProps) { - const ui = useUI(); +type UseVerifyPhoneNumberForm = { + confirmation: ConfirmationResult; + onSuccess: (credential: UserCredential) => void; +}; - const schema = usePhoneAuthFormSchema(); - const verificationFormSchema = schema.pick({ verificationCode: true }); +export function useVerifyPhoneNumberForm({ confirmation, onSuccess }: UseVerifyPhoneNumberForm) { + const schema = usePhoneAuthFormSchema().pick({ verificationCode: true }); + const action = useVerifyPhoneNumberFormAction(); - const verificationForm = form.useAppForm({ + return form.useAppForm({ defaultValues: { verificationCode: "", }, validators: { - onBlur: verificationFormSchema, - onSubmit: verificationFormSchema, + onSubmit: schema, + onBlur: schema, onSubmitAsync: async ({ value }) => { try { - return await onSubmit(value.verificationCode); + const credential = await action({ confirmation, code: value.verificationCode }); + return onSuccess(credential); } catch (error) { - return error instanceof Error ? error.message : String(error); + return error instanceof FirebaseUIError ? error.message : String(error); } }, }, }); +} + +type VerifyPhoneNumberFormProps = { + onSuccess: (credential: UserCredential) => void; + confirmation: ConfirmationResult; +}; + +function VerifyPhoneNumberForm(props: VerifyPhoneNumberFormProps) { + const ui = useUI(); + const form = useVerifyPhoneNumberForm({ confirmation: props.confirmation, onSuccess: props.onSuccess }); return (
{ e.preventDefault(); e.stopPropagation(); - await verificationForm.handleSubmit(); + await form.handleSubmit(); }} > - -
- - {(field) => } - -
- +
-
+ + {(field) => } +
- - -
- - {getTranslation(ui, "labels", "verifyCode")} - - - {isResending - ? getTranslation(ui, "labels", "sending") - : !canResend - ? `${getTranslation(ui, "labels", "resendCode")} (${timeLeft}s)` - : getTranslation(ui, "labels", "resendCode")} - - + {getTranslation(ui, "labels", "verifyCode")} +
-
+
); } export type PhoneAuthFormProps = { - resendDelay?: number; + onSignIn?: (credential: UserCredential) => void; }; -export function PhoneAuthForm({ resendDelay = 30 }: PhoneAuthFormProps) { - const ui = useUI(); - - const [confirmationResult, setConfirmationResult] = useState(null); - const [recaptchaVerifier, setRecaptchaVerifier] = useState(null); - const [phoneNumber, setPhoneNumber] = useState(""); - const [isResending, setIsResending] = useState(false); - const recaptchaContainerRef = useRef(null); - const { timeLeft, canResend, startTimer } = useResendTimer(resendDelay); - - const phoneAuthAction = usePhoneAuthFormAction(); - const phoneVerificationAction = usePhoneVerificationFormAction(); - const phoneResendAction = usePhoneResendAction(); - - useEffect(() => { - if (!recaptchaContainerRef.current) return; - - 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); +export function PhoneAuthForm(props: PhoneAuthFormProps) { + const [result, setResult] = useState(null); - return () => { - verifier.clear(); - setRecaptchaVerifier(null); - }; - }, [ui]); - - const handlePhoneSubmit = async (number: string) => { - try { - if (!recaptchaVerifier) { - throw new Error("ReCAPTCHA not initialized"); - } - - const result = await phoneAuthAction({ phoneNumber: number, recaptchaVerifier }); - setPhoneNumber(number); - setConfirmationResult(result); - startTimer(); - } catch (error) { - // Error handling is now managed by the form system - console.error("Phone submission failed:", error); - throw error; - } - }; - - const handleResend = async () => { - if (isResending || !canResend || !phoneNumber || !recaptchaContainerRef.current) { - return; - } - - setIsResending(true); - - try { - if (recaptchaVerifier) { - recaptchaVerifier.clear(); - } - - 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); - - const result = await phoneResendAction({ phoneNumber, recaptchaVerifier: verifier }); - setConfirmationResult(result); - startTimer(); - } catch (error) { - console.error("Phone resend failed:", error); - // Error handling is now managed by the form system - } finally { - setIsResending(false); - } - }; - - const handleVerificationSubmit = async (code: string) => { - if (!confirmationResult) { - throw new Error("Confirmation result not initialized"); - } - - try { - await phoneVerificationAction({ confirmationResult, code }); - } catch (error) { - // Error handling is now managed by the form system - console.error("Phone verification failed:", error); - throw error; - } - }; + if (!result) { + return ; + } return ( -
- {confirmationResult ? ( - - ) : ( - - )} -
+ { + props.onSignIn?.(credential); + }} + /> ); } diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts index d3b32cbe..088b65fa 100644 --- a/packages/react/src/auth/index.ts +++ b/packages/react/src/auth/index.ts @@ -29,9 +29,10 @@ export { export { PhoneAuthForm, type PhoneAuthFormProps, - usePhoneAuthFormAction, - usePhoneVerificationFormAction, - usePhoneResendAction, + usePhoneNumberForm, + usePhoneNumberFormAction, + useVerifyPhoneNumberForm, + useVerifyPhoneNumberFormAction, } from "./forms/phone-auth-form"; export { SignInAuthForm, diff --git a/packages/react/src/components/form.tsx b/packages/react/src/components/form.tsx index 0d3eee63..f3d9fd6b 100644 --- a/packages/react/src/components/form.tsx +++ b/packages/react/src/components/form.tsx @@ -1,4 +1,4 @@ -import { ComponentProps, PropsWithChildren } from "react"; +import { ComponentProps, PropsWithChildren, ReactNode } from "react"; import { AnyFieldApi, createFormHook, createFormHookContexts } from "@tanstack/react-form"; import { Button } from "./button"; import { cn } from "~/utils/cn"; @@ -19,24 +19,27 @@ function FieldMetadata({ className, ...props }: ComponentProps<"div"> & { field: ); } -function Input(props: PropsWithChildren & { label: string }>) { +function Input(props: PropsWithChildren & { label: string; before?: ReactNode }>) { const field = useFieldContext(); return ( diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index 47c6a296..7984aa0b 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -14,15 +14,17 @@ * limitations under the License. */ -import { useContext, useMemo } from "react"; -import { FirebaseUIContext } from "./context"; +import { useContext, useMemo, useEffect } from "react"; import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, createPhoneAuthFormSchema, createSignInAuthFormSchema, createSignUpAuthFormSchema, + getBehavior, + hasBehavior, } from "@firebase-ui/core"; +import { FirebaseUIContext } from "./context"; /** * Get the UI configuration from the context. @@ -63,3 +65,21 @@ export function usePhoneAuthFormSchema() { const ui = useUI(); return useMemo(() => createPhoneAuthFormSchema(ui), [ui]); } + +export function useRecaptchaVerifier(ref: React.RefObject) { + const ui = useUI(); + + const verifier = useMemo(() => { + return ref.current && hasBehavior(ui, "recaptchaVerification") + ? getBehavior(ui, "recaptchaVerification")(ui, ref.current) + : null; + }, [ref, ui]); + + useEffect(() => { + if (verifier) { + verifier.render(); + } + }, [verifier]); + + return verifier; +} diff --git a/packages/styles/src/base.css b/packages/styles/src/base.css index 74268378..d03880b4 100644 --- a/packages/styles/src/base.css +++ b/packages/styles/src/base.css @@ -104,11 +104,11 @@ } :where(.fui-form fieldset), - :where(.fui-form fieldset > label) { + :where(.fui-form fieldset label) { @apply flex flex-col gap-2 text-text; } - :where(.fui-form fieldset > label > span) { + :where(.fui-form fieldset label span) { @apply inline-flex gap-3 text-sm font-medium; } @@ -116,16 +116,20 @@ @apply px-1 hover:underline text-xs text-text-muted; } - :where(.fui-form fieldset > label > input) { - @apply border-1 border-input rounded px-2 py-2 text-sm focus:outline-2 focus:outline-primary shadow-xs bg-transparent; + :where(.fui-form fieldset label input) { + @apply w-full border-1 border-input rounded px-2 py-2 text-sm focus:outline-2 focus:outline-primary shadow-xs bg-transparent; } - :where(.fui-form fieldset > label > input[aria-invalid="true"]) { - @apply outline-error outline-2; + :where(.fui-form fieldset label input[aria-invalid="true"]) { + @apply border-error outline-error outline-2; + } + + :where(.fui-form fieldset label div[data-input-group]) { + @apply flex items-center gap-2; } :where(.fui-form .fui-form__error) { - @apply text-error text-center text-xs; + @apply text-error text-left text-xs; } :where(.fui-success) { @@ -173,11 +177,11 @@ } :where(.fui-country-selector) { - @apply relative inline-block w-[80px]; + @apply relative inline-block w-[120px]; } :where(.fui-country-selector__wrapper) { - @apply relative flex items-center border-1 border-input rounded bg-transparent overflow-hidden; + @apply relative flex items-center outline-1 outline-input rounded bg-transparent overflow-hidden; } :where(.fui-country-selector__flag) { @@ -192,6 +196,14 @@ @apply absolute left-8 top-1/2 -translate-y-1/2 text-sm pointer-events-none text-text; } + :where(.fui-form fieldset label div[data-input-group]:has(input[aria-invalid="true"]) .fui-country-selector) { + @apply outline-error outline-2 rounded; + } + + :where(.fui-form fieldset label div[data-input-group]:has(input[aria-invalid="true"]) .fui-country-selector .fui-country-selector__wrapper) { + @apply outline-error outline-2 rounded; + } + :where(.fui-policies) { @apply text-text-muted text-center text-xs; }