From 0b10559b4f13c07b18b3a742ca1c89793ae888b7 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Wed, 12 Nov 2025 23:57:46 +0000 Subject: [PATCH 1/4] feat(react): add onSignIn callback to oauth buttons --- .../auth/oauth/apple-sign-in-button.test.tsx | 41 +++++- .../src/auth/oauth/apple-sign-in-button.tsx | 7 +- .../oauth/facebook-sign-in-button.test.tsx | 41 +++++- .../auth/oauth/facebook-sign-in-button.tsx | 7 +- .../auth/oauth/github-sign-in-button.test.tsx | 41 +++++- .../src/auth/oauth/github-sign-in-button.tsx | 7 +- .../auth/oauth/google-sign-in-button.test.tsx | 41 +++++- .../src/auth/oauth/google-sign-in-button.tsx | 7 +- .../oauth/microsoft-sign-in-button.test.tsx | 41 +++++- .../auth/oauth/microsoft-sign-in-button.tsx | 7 +- .../src/auth/oauth/oauth-button.test.tsx | 139 +++++++++++++++--- .../react/src/auth/oauth/oauth-button.tsx | 14 +- .../oauth/twitter-sign-in-button.test.tsx | 41 +++++- .../src/auth/oauth/twitter-sign-in-button.tsx | 7 +- 14 files changed, 389 insertions(+), 52 deletions(-) diff --git a/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx b/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx index 34685c3c3..6165c5d96 100644 --- a/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx @@ -14,11 +14,13 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; import { AppleLogo, AppleSignInButton } from "./apple-sign-in-button"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; import { OAuthProvider } from "firebase/auth"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; vi.mock("firebase/auth", async () => { const actual = await vi.importActual("firebase/auth"); @@ -33,6 +35,14 @@ vi.mock("firebase/auth", async () => { }; }); +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + afterEach(() => { cleanup(); }); @@ -160,6 +170,35 @@ describe("", () => { expect(button).toHaveClass("fui-provider__button"); expect(button.getAttribute("type")).toBe("button"); }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); }); describe("", () => { diff --git a/packages/react/src/auth/oauth/apple-sign-in-button.tsx b/packages/react/src/auth/oauth/apple-sign-in-button.tsx index 4df4731e0..6d91226d6 100644 --- a/packages/react/src/auth/oauth/apple-sign-in-button.tsx +++ b/packages/react/src/auth/oauth/apple-sign-in-button.tsx @@ -17,7 +17,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { OAuthProvider } from "firebase/auth"; +import { OAuthProvider, type UserCredential } from "firebase/auth"; import { useUI } from "~/hooks"; import { OAuthButton } from "./oauth-button"; import AppleSvgLogo from "~/components/logos/apple/Logo"; @@ -26,13 +26,14 @@ import { cn } from "~/utils/cn"; export type AppleSignInButtonProps = { provider?: OAuthProvider; themed?: boolean; + onSignIn?: (credential: UserCredential) => void; }; -export function AppleSignInButton({ provider, themed }: AppleSignInButtonProps) { +export function AppleSignInButton({ provider, ...props }: AppleSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithApple")} diff --git a/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx b/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx index 3bf7e25dc..595042cbb 100644 --- a/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx @@ -14,10 +14,12 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; import { FacebookLogo, FacebookSignInButton } from "./facebook-sign-in-button"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; vi.mock("firebase/auth", async () => { const actual = await vi.importActual("firebase/auth"); @@ -32,6 +34,14 @@ vi.mock("firebase/auth", async () => { }; }); +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + afterEach(() => { cleanup(); }); @@ -161,6 +171,35 @@ describe("", () => { expect(button).toHaveClass("fui-provider__button"); expect(button.getAttribute("type")).toBe("button"); }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); }); describe("", () => { diff --git a/packages/react/src/auth/oauth/facebook-sign-in-button.tsx b/packages/react/src/auth/oauth/facebook-sign-in-button.tsx index 456647fb9..be4bfc794 100644 --- a/packages/react/src/auth/oauth/facebook-sign-in-button.tsx +++ b/packages/react/src/auth/oauth/facebook-sign-in-button.tsx @@ -17,7 +17,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { FacebookAuthProvider } from "firebase/auth"; +import { FacebookAuthProvider, type UserCredential } from "firebase/auth"; import { useUI } from "~/hooks"; import { OAuthButton } from "./oauth-button"; import FacebookSvgLogo from "~/components/logos/facebook/Logo"; @@ -26,13 +26,14 @@ import { cn } from "~/utils/cn"; export type FacebookSignInButtonProps = { provider?: FacebookAuthProvider; themed?: boolean; + onSignIn?: (credential: UserCredential) => void; }; -export function FacebookSignInButton({ provider, themed }: FacebookSignInButtonProps) { +export function FacebookSignInButton({ provider, ...props }: FacebookSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithFacebook")} diff --git a/packages/react/src/auth/oauth/github-sign-in-button.test.tsx b/packages/react/src/auth/oauth/github-sign-in-button.test.tsx index 851100d6d..7fe80b333 100644 --- a/packages/react/src/auth/oauth/github-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/github-sign-in-button.test.tsx @@ -14,10 +14,12 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; import { GitHubLogo, GitHubSignInButton } from "./github-sign-in-button"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; vi.mock("firebase/auth", async () => { const actual = await vi.importActual("firebase/auth"); @@ -32,6 +34,14 @@ vi.mock("firebase/auth", async () => { }; }); +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + afterEach(() => { cleanup(); }); @@ -161,6 +171,35 @@ describe("", () => { expect(button).toHaveClass("fui-provider__button"); expect(button.getAttribute("type")).toBe("button"); }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); }); describe("", () => { diff --git a/packages/react/src/auth/oauth/github-sign-in-button.tsx b/packages/react/src/auth/oauth/github-sign-in-button.tsx index 7fbf31984..e9e52206e 100644 --- a/packages/react/src/auth/oauth/github-sign-in-button.tsx +++ b/packages/react/src/auth/oauth/github-sign-in-button.tsx @@ -17,7 +17,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { GithubAuthProvider } from "firebase/auth"; +import { GithubAuthProvider, type UserCredential } from "firebase/auth"; import { useUI } from "~/hooks"; import { OAuthButton } from "./oauth-button"; import GitHubSvgLogo from "~/components/logos/github/Logo"; @@ -26,13 +26,14 @@ import { cn } from "~/utils/cn"; export type GitHubSignInButtonProps = { provider?: GithubAuthProvider; themed?: boolean; + onSignIn?: (credential: UserCredential) => void; }; -export function GitHubSignInButton({ provider, themed }: GitHubSignInButtonProps) { +export function GitHubSignInButton({ provider, ...props }: GitHubSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithGitHub")} 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 41170a713..c9d1f18a5 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 @@ -14,10 +14,12 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; import { GoogleLogo, GoogleSignInButton } from "./google-sign-in-button"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; vi.mock("firebase/auth", async () => { const actual = await vi.importActual("firebase/auth"); @@ -32,6 +34,14 @@ vi.mock("firebase/auth", async () => { }; }); +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + afterEach(() => { cleanup(); }); @@ -161,6 +171,35 @@ describe("", () => { expect(button).toHaveClass("fui-provider__button"); expect(button.getAttribute("type")).toBe("button"); }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); }); describe("", () => { 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 cbaca2bc7..4d33ea2d9 100644 --- a/packages/react/src/auth/oauth/google-sign-in-button.tsx +++ b/packages/react/src/auth/oauth/google-sign-in-button.tsx @@ -17,7 +17,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { GoogleAuthProvider } from "firebase/auth"; +import { GoogleAuthProvider, type UserCredential } from "firebase/auth"; import { useUI } from "~/hooks"; import { OAuthButton } from "./oauth-button"; import GoogleSvgLogo from "~/components/logos/google/Logo"; @@ -26,13 +26,14 @@ import { cn } from "~/utils/cn"; export type GoogleSignInButtonProps = { provider?: GoogleAuthProvider; themed?: boolean | "neutral"; + onSignIn?: (credential: UserCredential) => void; }; -export function GoogleSignInButton({ provider, themed }: GoogleSignInButtonProps) { +export function GoogleSignInButton({ provider, ...props }: GoogleSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithGoogle")} diff --git a/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx b/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx index 03bd497ef..3ed992392 100644 --- a/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx @@ -14,11 +14,13 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; import { MicrosoftLogo, MicrosoftSignInButton } from "./microsoft-sign-in-button"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; import { OAuthProvider } from "firebase/auth"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; vi.mock("firebase/auth", async () => { const actual = await vi.importActual("firebase/auth"); @@ -33,6 +35,14 @@ vi.mock("firebase/auth", async () => { }; }); +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + afterEach(() => { cleanup(); }); @@ -160,6 +170,35 @@ describe("", () => { expect(button).toHaveClass("fui-provider__button"); expect(button.getAttribute("type")).toBe("button"); }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); }); describe("", () => { diff --git a/packages/react/src/auth/oauth/microsoft-sign-in-button.tsx b/packages/react/src/auth/oauth/microsoft-sign-in-button.tsx index f706d4a25..2e85ad512 100644 --- a/packages/react/src/auth/oauth/microsoft-sign-in-button.tsx +++ b/packages/react/src/auth/oauth/microsoft-sign-in-button.tsx @@ -17,7 +17,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { OAuthProvider } from "firebase/auth"; +import { OAuthProvider, type UserCredential } from "firebase/auth"; import { useUI } from "~/hooks"; import { OAuthButton } from "./oauth-button"; import MicrosoftSvgLogo from "~/components/logos/microsoft/Logo"; @@ -26,13 +26,14 @@ import { cn } from "~/utils/cn"; export type MicrosoftSignInButtonProps = { provider?: OAuthProvider; themed?: boolean; + onSignIn?: (credential: UserCredential) => void; }; -export function MicrosoftSignInButton({ provider, themed }: MicrosoftSignInButtonProps) { +export function MicrosoftSignInButton({ provider, ...props }: MicrosoftSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithMicrosoft")} diff --git a/packages/react/src/auth/oauth/oauth-button.test.tsx b/packages/react/src/auth/oauth/oauth-button.test.tsx index e08ad2a39..209c85ae4 100644 --- a/packages/react/src/auth/oauth/oauth-button.test.tsx +++ b/packages/react/src/auth/oauth/oauth-button.test.tsx @@ -14,7 +14,7 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, fireEvent, cleanup, renderHook, act } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, renderHook, act, waitFor } from "@testing-library/react"; import { OAuthButton, useSignInWithProvider } from "./oauth-button"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { enUs, registerLocale } from "@invertase/firebaseui-translations"; @@ -132,6 +132,58 @@ describe("", () => { expect(mockSignInWithProvider).toHaveBeenCalledWith(expect.anything(), mockGoogleProvider); }); + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI(); + + render( + + + Sign in with Google + + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); + + it("does not call onSignIn callback when sign-in fails", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const onSignIn = vi.fn(); + const ui = createMockUI(); + const mockError = new FirebaseUIError( + ui.get(), + new FirebaseError("auth/user-not-found", "No account found with this email address") + ); + mockSignInWithProvider.mockRejectedValue(mockError); + + render( + + + Sign in with Google + + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).not.toHaveBeenCalled(); + }); + }); + it("displays FirebaseUIError message when FirebaseUIError occurs", async () => { const { FirebaseUIError } = await import("@invertase/firebaseui-core"); const mockSignInWithProvider = vi.mocked(signInWithProvider); @@ -151,12 +203,11 @@ describe("", () => { const button = screen.getByTestId("oauth-button"); fireEvent.click(button); - // Next tick - wait for the mock to resolve - await new Promise((resolve) => setTimeout(resolve, 0)); - - const errorMessage = screen.getByText("No account found with this email address"); - expect(errorMessage).toBeDefined(); - expect(errorMessage.className).toContain("fui-error"); + await waitFor(() => { + const errorMessage = screen.getByText("No account found with this email address"); + expect(errorMessage).toBeDefined(); + expect(errorMessage.className).toContain("fui-error"); + }); }); it("displays unknown error message when non-Firebase error occurs", async () => { @@ -184,14 +235,13 @@ describe("", () => { const button = screen.getByTestId("oauth-button"); fireEvent.click(button); - // Wait for error to appear - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(consoleErrorSpy).toHaveBeenCalledWith(regularError); + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith(regularError); - const errorMessage = screen.getByText("unknownError"); - expect(errorMessage).toBeDefined(); - expect(errorMessage.className).toContain("fui-error"); + const errorMessage = screen.getByText("unknownError"); + expect(errorMessage).toBeDefined(); + expect(errorMessage.className).toContain("fui-error"); + }); // Restore console.error consoleErrorSpy.mockRestore(); @@ -217,19 +267,19 @@ describe("", () => { // First click - should show error fireEvent.click(button); - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedError = enUs.translations.errors!.wrongPassword!; - // The error message will be the translated message for auth/wrong-password - const errorMessage = screen.getByText(expectedError); - expect(errorMessage).toBeDefined(); + await waitFor(() => { + // The error message will be the translated message for auth/wrong-password + const errorMessage = screen.getByText(expectedError); + expect(errorMessage).toBeDefined(); + }); // Second click - should clear error fireEvent.click(button); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(screen.queryByText(expectedError)).toBeNull(); + await waitFor(() => { + expect(screen.queryByText(expectedError)).toBeNull(); + }); }); }); @@ -270,6 +320,51 @@ describe("useSignInWithProvider", () => { expect(mockSignInWithProvider).toHaveBeenCalledWith(ui.get(), mockGoogleProvider); }); + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider, onSignIn), { wrapper }); + + await act(async () => { + await result.current.callback(); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + + it("does not call onSignIn callback when sign-in fails", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const onSignIn = vi.fn(); + const ui = createMockUI(); + const mockError = new FirebaseUIError( + ui.get(), + new FirebaseError("auth/user-not-found", "No account found with this email address") + ); + mockSignInWithProvider.mockRejectedValue(mockError); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider, onSignIn), { wrapper }); + + await act(async () => { + await result.current.callback(); + }); + + expect(onSignIn).not.toHaveBeenCalled(); + }); + it("sets error state when FirebaseUIError occurs", async () => { const { FirebaseUIError } = await import("@invertase/firebaseui-core"); const mockSignInWithProvider = vi.mocked(signInWithProvider); diff --git a/packages/react/src/auth/oauth/oauth-button.tsx b/packages/react/src/auth/oauth/oauth-button.tsx index b188ca894..82a2c80ea 100644 --- a/packages/react/src/auth/oauth/oauth-button.tsx +++ b/packages/react/src/auth/oauth/oauth-button.tsx @@ -17,7 +17,7 @@ "use client"; import { FirebaseUIError, getTranslation, signInWithProvider } from "@invertase/firebaseui-core"; -import type { AuthProvider } from "firebase/auth"; +import type { AuthProvider, UserCredential } from "firebase/auth"; import type { PropsWithChildren } from "react"; import { useCallback, useState } from "react"; import { Button } from "~/components/button"; @@ -26,16 +26,18 @@ import { useUI } from "~/hooks"; export type OAuthButtonProps = PropsWithChildren<{ provider: AuthProvider; themed?: boolean | string; + onSignIn?: (credential: UserCredential) => void; }>; -export function useSignInWithProvider(provider: AuthProvider) { +export function useSignInWithProvider(provider: AuthProvider, onSignIn?: (credential: UserCredential) => void) { const ui = useUI(); const [error, setError] = useState(null); const callback = useCallback(async () => { setError(null); try { - await signInWithProvider(ui, provider); + const credential = await signInWithProvider(ui, provider); + onSignIn?.(credential); } catch (error) { if (error instanceof FirebaseUIError) { setError(error.message); @@ -44,15 +46,15 @@ export function useSignInWithProvider(provider: AuthProvider) { console.error(error); setError(getTranslation(ui, "errors", "unknownError")); } - }, [ui, provider, setError]); + }, [ui, provider, setError, onSignIn]); return { error, callback }; } -export function OAuthButton({ provider, children, themed }: OAuthButtonProps) { +export function OAuthButton({ provider, children, themed, onSignIn }: OAuthButtonProps) { const ui = useUI(); - const { error, callback } = useSignInWithProvider(provider); + const { error, callback } = useSignInWithProvider(provider, onSignIn); return (
diff --git a/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx b/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx index 5a014cde4..3d940db6b 100644 --- a/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx @@ -14,10 +14,12 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; import { TwitterLogo, TwitterSignInButton } from "./twitter-sign-in-button"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; vi.mock("firebase/auth", async () => { const actual = await vi.importActual("firebase/auth"); @@ -32,6 +34,14 @@ vi.mock("firebase/auth", async () => { }; }); +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + afterEach(() => { cleanup(); }); @@ -161,6 +171,35 @@ describe("", () => { expect(button).toHaveClass("fui-provider__button"); expect(button.getAttribute("type")).toBe("button"); }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); }); describe("", () => { diff --git a/packages/react/src/auth/oauth/twitter-sign-in-button.tsx b/packages/react/src/auth/oauth/twitter-sign-in-button.tsx index 39627e3dd..492f43d51 100644 --- a/packages/react/src/auth/oauth/twitter-sign-in-button.tsx +++ b/packages/react/src/auth/oauth/twitter-sign-in-button.tsx @@ -17,7 +17,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { TwitterAuthProvider } from "firebase/auth"; +import { TwitterAuthProvider, type UserCredential } from "firebase/auth"; import { useUI } from "~/hooks"; import { OAuthButton } from "./oauth-button"; import TwitterSvgLogo from "~/components/logos/twitter/Logo"; @@ -26,13 +26,14 @@ import { cn } from "~/utils/cn"; export type TwitterSignInButtonProps = { provider?: TwitterAuthProvider; themed?: boolean; + onSignIn?: (credential: UserCredential) => void; }; -export function TwitterSignInButton({ provider, themed }: TwitterSignInButtonProps) { +export function TwitterSignInButton({ provider, ...props }: TwitterSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithTwitter")} From ad95ae45b2aa21d0f0947fc1a632b448362f8259 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 13 Nov 2025 00:01:05 +0000 Subject: [PATCH 2/4] feat(shadcn): add oauth button onSignIn prop --- .../components/apple-sign-in-button.test.tsx | 22 ++++- .../src/components/apple-sign-in-button.tsx | 4 +- .../facebook-sign-in-button.test.tsx | 22 ++++- .../components/facebook-sign-in-button.tsx | 4 +- .../components/github-sign-in-button.test.tsx | 22 ++++- .../src/components/github-sign-in-button.tsx | 4 +- .../components/google-sign-in-button.test.tsx | 22 ++++- .../src/components/google-sign-in-button.tsx | 4 +- .../microsoft-sign-in-button.test.tsx | 22 ++++- .../components/microsoft-sign-in-button.tsx | 4 +- .../src/components/oauth-button.test.tsx | 95 ++++++++++++++----- .../shadcn/src/components/oauth-button.tsx | 4 +- .../twitter-sign-in-button.test.tsx | 22 ++++- .../src/components/twitter-sign-in-button.tsx | 4 +- 14 files changed, 213 insertions(+), 42 deletions(-) diff --git a/packages/shadcn/src/components/apple-sign-in-button.test.tsx b/packages/shadcn/src/components/apple-sign-in-button.test.tsx index 1ed2f6574..490b11362 100644 --- a/packages/shadcn/src/components/apple-sign-in-button.test.tsx +++ b/packages/shadcn/src/components/apple-sign-in-button.test.tsx @@ -23,10 +23,11 @@ import { OAuthProvider } from "firebase/auth"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; vi.mock("./oauth-button", () => ({ - OAuthButton: ({ provider, children, themed }: any) => ( + OAuthButton: ({ provider, children, themed, onSignIn }: any) => (
{provider.providerId}
{String(themed)}
+
{onSignIn ? "present" : "absent"}
{children}
), @@ -192,4 +193,23 @@ describe("", () => { expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); }); + + it("passes onSignIn prop to OAuthButton", () => { + const onSignIn = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("onSignIn")).toHaveTextContent("present"); + }); }); diff --git a/packages/shadcn/src/components/apple-sign-in-button.tsx b/packages/shadcn/src/components/apple-sign-in-button.tsx index ad310636c..beebd8c1d 100644 --- a/packages/shadcn/src/components/apple-sign-in-button.tsx +++ b/packages/shadcn/src/components/apple-sign-in-button.tsx @@ -8,11 +8,11 @@ import { OAuthButton } from "@/components/oauth-button"; export type { AppleSignInButtonProps }; -export function AppleSignInButton({ provider, themed }: AppleSignInButtonProps) { +export function AppleSignInButton({ provider, ...props }: AppleSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithApple")} diff --git a/packages/shadcn/src/components/facebook-sign-in-button.test.tsx b/packages/shadcn/src/components/facebook-sign-in-button.test.tsx index 1e9f911ed..daebce5fb 100644 --- a/packages/shadcn/src/components/facebook-sign-in-button.test.tsx +++ b/packages/shadcn/src/components/facebook-sign-in-button.test.tsx @@ -23,10 +23,11 @@ import { FacebookAuthProvider } from "firebase/auth"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; vi.mock("./oauth-button", () => ({ - OAuthButton: ({ provider, children, themed }: any) => ( + OAuthButton: ({ provider, children, themed, onSignIn }: any) => (
{provider.providerId}
{String(themed)}
+
{onSignIn ? "present" : "absent"}
{children}
), @@ -192,4 +193,23 @@ describe("", () => { expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); }); + + it("passes onSignIn prop to OAuthButton", () => { + const onSignIn = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("onSignIn")).toHaveTextContent("present"); + }); }); diff --git a/packages/shadcn/src/components/facebook-sign-in-button.tsx b/packages/shadcn/src/components/facebook-sign-in-button.tsx index 3bd0bc52c..04aba6ac8 100644 --- a/packages/shadcn/src/components/facebook-sign-in-button.tsx +++ b/packages/shadcn/src/components/facebook-sign-in-button.tsx @@ -8,11 +8,11 @@ import { OAuthButton } from "@/components/oauth-button"; export type { FacebookSignInButtonProps }; -export function FacebookSignInButton({ provider, themed }: FacebookSignInButtonProps) { +export function FacebookSignInButton({ provider, ...props }: FacebookSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithFacebook")} diff --git a/packages/shadcn/src/components/github-sign-in-button.test.tsx b/packages/shadcn/src/components/github-sign-in-button.test.tsx index 2a4701bdb..7a35558b1 100644 --- a/packages/shadcn/src/components/github-sign-in-button.test.tsx +++ b/packages/shadcn/src/components/github-sign-in-button.test.tsx @@ -23,10 +23,11 @@ import { GithubAuthProvider } from "firebase/auth"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; vi.mock("./oauth-button", () => ({ - OAuthButton: ({ provider, children, themed }: any) => ( + OAuthButton: ({ provider, children, themed, onSignIn }: any) => (
{provider.providerId}
{String(themed)}
+
{onSignIn ? "present" : "absent"}
{children}
), @@ -192,4 +193,23 @@ describe("", () => { expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); }); + + it("passes onSignIn prop to OAuthButton", () => { + const onSignIn = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("onSignIn")).toHaveTextContent("present"); + }); }); diff --git a/packages/shadcn/src/components/github-sign-in-button.tsx b/packages/shadcn/src/components/github-sign-in-button.tsx index a2b92a65b..a1f1ca130 100644 --- a/packages/shadcn/src/components/github-sign-in-button.tsx +++ b/packages/shadcn/src/components/github-sign-in-button.tsx @@ -8,11 +8,11 @@ import { OAuthButton } from "@/components/oauth-button"; export type { GitHubSignInButtonProps }; -export function GitHubSignInButton({ provider, themed }: GitHubSignInButtonProps) { +export function GitHubSignInButton({ provider, ...props }: GitHubSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithGitHub")} diff --git a/packages/shadcn/src/components/google-sign-in-button.test.tsx b/packages/shadcn/src/components/google-sign-in-button.test.tsx index 8e9837e30..d7765f1c8 100644 --- a/packages/shadcn/src/components/google-sign-in-button.test.tsx +++ b/packages/shadcn/src/components/google-sign-in-button.test.tsx @@ -23,10 +23,11 @@ import { GoogleAuthProvider } from "firebase/auth"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; vi.mock("./oauth-button", () => ({ - OAuthButton: ({ provider, children, themed }: any) => ( + OAuthButton: ({ provider, children, themed, onSignIn }: any) => (
{provider.providerId}
{themed}
+
{onSignIn ? "present" : "absent"}
{children}
), @@ -192,4 +193,23 @@ describe("", () => { expect(screen.getByTestId("themed")).toHaveTextContent(""); }); + + it("passes onSignIn prop to OAuthButton", () => { + const onSignIn = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("onSignIn")).toHaveTextContent("present"); + }); }); diff --git a/packages/shadcn/src/components/google-sign-in-button.tsx b/packages/shadcn/src/components/google-sign-in-button.tsx index 4d0796c70..2e648fa60 100644 --- a/packages/shadcn/src/components/google-sign-in-button.tsx +++ b/packages/shadcn/src/components/google-sign-in-button.tsx @@ -8,11 +8,11 @@ import { OAuthButton } from "@/components/oauth-button"; export type { GoogleSignInButtonProps }; -export function GoogleSignInButton({ provider, themed }: GoogleSignInButtonProps) { +export function GoogleSignInButton({ provider, ...props }: GoogleSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithGoogle")} diff --git a/packages/shadcn/src/components/microsoft-sign-in-button.test.tsx b/packages/shadcn/src/components/microsoft-sign-in-button.test.tsx index 22cfa0080..e7b9667f8 100644 --- a/packages/shadcn/src/components/microsoft-sign-in-button.test.tsx +++ b/packages/shadcn/src/components/microsoft-sign-in-button.test.tsx @@ -23,10 +23,11 @@ import { OAuthProvider } from "firebase/auth"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; vi.mock("./oauth-button", () => ({ - OAuthButton: ({ provider, children, themed }: any) => ( + OAuthButton: ({ provider, children, themed, onSignIn }: any) => (
{provider.providerId}
{String(themed)}
+
{onSignIn ? "present" : "absent"}
{children}
), @@ -192,4 +193,23 @@ describe("", () => { expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); }); + + it("passes onSignIn prop to OAuthButton", () => { + const onSignIn = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("onSignIn")).toHaveTextContent("present"); + }); }); diff --git a/packages/shadcn/src/components/microsoft-sign-in-button.tsx b/packages/shadcn/src/components/microsoft-sign-in-button.tsx index f5288b6a1..916c4c2ad 100644 --- a/packages/shadcn/src/components/microsoft-sign-in-button.tsx +++ b/packages/shadcn/src/components/microsoft-sign-in-button.tsx @@ -8,11 +8,11 @@ import { OAuthButton } from "@/components/oauth-button"; export type { MicrosoftSignInButtonProps }; -export function MicrosoftSignInButton({ provider, themed }: MicrosoftSignInButtonProps) { +export function MicrosoftSignInButton({ provider, ...props }: MicrosoftSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithMicrosoft")} diff --git a/packages/shadcn/src/components/oauth-button.test.tsx b/packages/shadcn/src/components/oauth-button.test.tsx index 41731f813..ccbddf858 100644 --- a/packages/shadcn/src/components/oauth-button.test.tsx +++ b/packages/shadcn/src/components/oauth-button.test.tsx @@ -15,7 +15,7 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; import { OAuthButton } from "./oauth-button"; import { createMockUI } from "../../tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; @@ -141,6 +141,58 @@ describe("", () => { expect(mockSignInWithProvider).toHaveBeenCalledWith(expect.anything(), mockGoogleProvider); }); + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI(); + + render( + + + Sign in with Google + + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); + + it("does not call onSignIn callback when sign-in fails", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const onSignIn = vi.fn(); + const ui = createMockUI(); + const mockError = new FirebaseUIError( + ui.get(), + new FirebaseError("auth/user-not-found", "No account found with this email address") + ); + mockSignInWithProvider.mockRejectedValue(mockError); + + render( + + + Sign in with Google + + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).not.toHaveBeenCalled(); + }); + }); + it("displays FirebaseUIError message when FirebaseUIError occurs", async () => { const { FirebaseUIError } = await import("@invertase/firebaseui-core"); const mockSignInWithProvider = vi.mocked(signInWithProvider); @@ -160,14 +212,13 @@ describe("", () => { const button = screen.getByTestId("oauth-button"); fireEvent.click(button); - // Next tick - wait for the mock to resolve - await new Promise((resolve) => setTimeout(resolve, 0)); - - const errorMessage = screen.getByText("No account found with this email address"); - expect(errorMessage).toBeDefined(); + await waitFor(() => { + const errorMessage = screen.getByText("No account found with this email address"); + expect(errorMessage).toBeDefined(); - // Make sure we use the shadcn theme name, rather than a "text-red-500" - expect(errorMessage.className).toContain("text-destructive"); + // Make sure we use the shadcn theme name, rather than a "text-red-500" + expect(errorMessage.className).toContain("text-destructive"); + }); }); it("displays unknown error message when non-Firebase error occurs", async () => { @@ -195,16 +246,15 @@ describe("", () => { const button = screen.getByTestId("oauth-button"); fireEvent.click(button); - // Wait for error to appear - await new Promise((resolve) => setTimeout(resolve, 0)); + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith(regularError); - expect(consoleErrorSpy).toHaveBeenCalledWith(regularError); + const errorMessage = screen.getByText("unknownError"); + expect(errorMessage).toBeDefined(); - const errorMessage = screen.getByText("unknownError"); - expect(errorMessage).toBeDefined(); - - // Make sure we use the shadcn theme name, rather than a "text-red-500" - expect(errorMessage.className).toContain("text-destructive"); + // Make sure we use the shadcn theme name, rather than a "text-red-500" + expect(errorMessage.className).toContain("text-destructive"); + }); // Restore console.error consoleErrorSpy.mockRestore(); @@ -232,16 +282,17 @@ describe("", () => { // First click - should show error fireEvent.click(button); - await new Promise((resolve) => setTimeout(resolve, 0)); - const errorMessage = screen.getByText("Incorrect password"); - expect(errorMessage).toBeDefined(); + await waitFor(() => { + const errorMessage = screen.getByText("Incorrect password"); + expect(errorMessage).toBeDefined(); + }); // Second click - should clear error fireEvent.click(button); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(screen.queryByText("Incorrect password")).toBeNull(); + await waitFor(() => { + expect(screen.queryByText("Incorrect password")).toBeNull(); + }); }); it("does not display error message initially", () => { diff --git a/packages/shadcn/src/components/oauth-button.tsx b/packages/shadcn/src/components/oauth-button.tsx index 3707c2012..548d32a64 100644 --- a/packages/shadcn/src/components/oauth-button.tsx +++ b/packages/shadcn/src/components/oauth-button.tsx @@ -5,10 +5,10 @@ import { Button } from "@/components/ui/button"; export type { OAuthButtonProps }; -export function OAuthButton({ provider, children, themed }: OAuthButtonProps) { +export function OAuthButton({ provider, children, themed, onSignIn }: OAuthButtonProps) { const ui = useUI(); - const { error, callback } = useSignInWithProvider(provider); + const { error, callback } = useSignInWithProvider(provider, onSignIn); return (
diff --git a/packages/shadcn/src/components/twitter-sign-in-button.test.tsx b/packages/shadcn/src/components/twitter-sign-in-button.test.tsx index 0f7493347..053580c4e 100644 --- a/packages/shadcn/src/components/twitter-sign-in-button.test.tsx +++ b/packages/shadcn/src/components/twitter-sign-in-button.test.tsx @@ -23,10 +23,11 @@ import { TwitterAuthProvider } from "firebase/auth"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; vi.mock("./oauth-button", () => ({ - OAuthButton: ({ provider, children, themed }: any) => ( + OAuthButton: ({ provider, children, themed, onSignIn }: any) => (
{provider.providerId}
{String(themed)}
+
{onSignIn ? "present" : "absent"}
{children}
), @@ -192,4 +193,23 @@ describe("", () => { expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); }); + + it("passes onSignIn prop to OAuthButton", () => { + const onSignIn = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("onSignIn")).toHaveTextContent("present"); + }); }); diff --git a/packages/shadcn/src/components/twitter-sign-in-button.tsx b/packages/shadcn/src/components/twitter-sign-in-button.tsx index 7d4cc39ad..7a3ee30ff 100644 --- a/packages/shadcn/src/components/twitter-sign-in-button.tsx +++ b/packages/shadcn/src/components/twitter-sign-in-button.tsx @@ -8,11 +8,11 @@ import { OAuthButton } from "@/components/oauth-button"; export type { TwitterSignInButtonProps }; -export function TwitterSignInButton({ provider, themed }: TwitterSignInButtonProps) { +export function TwitterSignInButton({ provider, ...props }: TwitterSignInButtonProps) { const ui = useUI(); return ( - + {getTranslation(ui, "labels", "signInWithTwitter")} From ef4d8f9e6fc4b62d27fcd2c4f17850d83d025029 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 13 Nov 2025 00:06:32 +0000 Subject: [PATCH 3/4] feat(angular): add oauth button signIn output signal --- .../auth/oauth/apple-sign-in-button.spec.ts | 12 +++++ .../lib/auth/oauth/apple-sign-in-button.ts | 7 +-- .../oauth/facebook-sign-in-button.spec.ts | 12 +++++ .../lib/auth/oauth/facebook-sign-in-button.ts | 9 ++-- .../auth/oauth/github-sign-in-button.spec.ts | 12 +++++ .../lib/auth/oauth/github-sign-in-button.ts | 7 +-- .../auth/oauth/google-sign-in-button.spec.ts | 12 +++++ .../lib/auth/oauth/google-sign-in-button.ts | 9 ++-- .../oauth/microsoft-sign-in-button.spec.ts | 12 +++++ .../auth/oauth/microsoft-sign-in-button.ts | 9 ++-- .../src/lib/auth/oauth/oauth-button.spec.ts | 49 ++++++++++++++++++- .../src/lib/auth/oauth/oauth-button.ts | 8 +-- .../auth/oauth/twitter-sign-in-button.spec.ts | 12 +++++ .../lib/auth/oauth/twitter-sign-in-button.ts | 9 ++-- 14 files changed, 153 insertions(+), 26 deletions(-) diff --git a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts index acdb77964..36731ddf1 100644 --- a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts @@ -93,4 +93,16 @@ describe("", () => { const button = screen.getByRole("button"); expect(button).toHaveAttribute("data-provider", "apple.com"); }); + + it("has signIn output", async () => { + const { fixture } = await render(TestAppleSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-apple-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); }); diff --git a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts index 8681b5765..8d760d5cf 100644 --- a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { Component, input } from "@angular/core"; +import { Component, input, output } from "@angular/core"; import { CommonModule } from "@angular/common"; import { OAuthButtonComponent } from "./oauth-button"; import { injectTranslation, injectUI } from "../../provider"; -import { OAuthProvider } from "@angular/fire/auth"; +import { OAuthProvider, UserCredential } from "@angular/fire/auth"; import { AppleLogoComponent } from "../../components/logos/apple"; @Component({ @@ -29,7 +29,7 @@ import { AppleLogoComponent } from "../../components/logos/apple"; style: "display: block;", }, template: ` - + {{ signInWithAppleLabel() }} @@ -39,6 +39,7 @@ export class AppleSignInButtonComponent { ui = injectUI(); signInWithAppleLabel = injectTranslation("labels", "signInWithApple"); themed = input(false); + signIn = output(); private defaultProvider = new OAuthProvider("apple.com"); diff --git a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts index 24a732cf3..46a1ca8dc 100644 --- a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts @@ -93,4 +93,16 @@ describe("", () => { const button = screen.getByRole("button"); expect(button).toHaveAttribute("data-provider", "facebook.com"); }); + + it("has signIn output", async () => { + const { fixture } = await render(TestFacebookSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-facebook-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); }); diff --git a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts index 06443abe2..901451921 100644 --- a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { Component, input } from "@angular/core"; +import { Component, input, output } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { FacebookAuthProvider } from "@angular/fire/auth"; +import { FacebookAuthProvider, UserCredential } from "@angular/fire/auth"; import { OAuthButtonComponent } from "./oauth-button"; import { injectTranslation, injectUI } from "../../provider"; import { FacebookLogoComponent } from "../../components/logos/facebook"; @@ -29,7 +29,7 @@ import { FacebookLogoComponent } from "../../components/logos/facebook"; style: "display: block;", }, template: ` - + {{ signInWithFacebookLabel() }} @@ -39,7 +39,8 @@ export class FacebookSignInButtonComponent { ui = injectUI(); signInWithFacebookLabel = injectTranslation("labels", "signInWithFacebook"); themed = input(false); - + signIn = output(); + private defaultProvider = new FacebookAuthProvider(); provider = input(); diff --git a/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts index 061e15e9a..d1a5c3a54 100644 --- a/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts @@ -93,4 +93,16 @@ describe("", () => { const button = screen.getByRole("button"); expect(button).toHaveAttribute("data-provider", "github.com"); }); + + it("has signIn output", async () => { + const { fixture } = await render(TestGithubSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-github-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); }); diff --git a/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts index 6e253e175..049d6f54e 100644 --- a/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { Component, input } from "@angular/core"; +import { Component, input, output } from "@angular/core"; import { CommonModule } from "@angular/common"; import { OAuthButtonComponent } from "./oauth-button"; import { injectTranslation } from "../../provider"; -import { GithubAuthProvider } from "@angular/fire/auth"; +import { GithubAuthProvider, UserCredential } from "@angular/fire/auth"; import { GithubLogoComponent } from "../../components/logos/github"; @Component({ @@ -29,7 +29,7 @@ import { GithubLogoComponent } from "../../components/logos/github"; style: "display: block;", }, template: ` - + {{ signInWithGitHubLabel() }} @@ -38,6 +38,7 @@ import { GithubLogoComponent } from "../../components/logos/github"; export class GitHubSignInButtonComponent { signInWithGitHubLabel = injectTranslation("labels", "signInWithGitHub"); themed = input(false); + signIn = output(); private defaultProvider = new GithubAuthProvider(); diff --git a/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts index 89267c092..25ec50e3e 100644 --- a/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts @@ -93,4 +93,16 @@ describe("", () => { const button = screen.getByRole("button"); expect(button).toHaveAttribute("data-provider", "google.com"); }); + + it("has signIn output", async () => { + const { fixture } = await render(TestGoogleSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-google-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); }); diff --git a/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts index a8201b0a6..fc9846e40 100644 --- a/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { Component, input } from "@angular/core"; +import { Component, input, output } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { GoogleAuthProvider } from "@angular/fire/auth"; +import { GoogleAuthProvider, UserCredential } from "@angular/fire/auth"; import { injectTranslation, injectUI } from "../../provider"; import { OAuthButtonComponent } from "./oauth-button"; import { GoogleLogoComponent } from "../../components/logos/google"; @@ -29,7 +29,7 @@ import { GoogleLogoComponent } from "../../components/logos/google"; style: "display: block;", }, template: ` - + {{ signInWithGoogleLabel() }} @@ -39,7 +39,8 @@ export class GoogleSignInButtonComponent { ui = injectUI(); signInWithGoogleLabel = injectTranslation("labels", "signInWithGoogle"); themed = input(false); - + signIn = output(); + private defaultProvider = new GoogleAuthProvider(); provider = input(); diff --git a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts index 512901edd..765be9910 100644 --- a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts @@ -93,4 +93,16 @@ describe("", () => { const button = screen.getByRole("button"); expect(button).toHaveAttribute("data-provider", "microsoft.com"); }); + + it("has signIn output", async () => { + const { fixture } = await render(TestMicrosoftSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-microsoft-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); }); diff --git a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts index 641aeeb98..992325da1 100644 --- a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { Component, input } from "@angular/core"; +import { Component, input, output } from "@angular/core"; import { CommonModule } from "@angular/common"; import { OAuthButtonComponent } from "./oauth-button"; import { injectTranslation } from "../../provider"; -import { OAuthProvider } from "@angular/fire/auth"; +import { OAuthProvider, UserCredential } from "@angular/fire/auth"; import { MicrosoftLogoComponent } from "../../components/logos/microsoft"; @Component({ @@ -29,7 +29,7 @@ import { MicrosoftLogoComponent } from "../../components/logos/microsoft"; style: "display: block;", }, template: ` - + {{ signInWithMicrosoftLabel() }} @@ -38,7 +38,8 @@ import { MicrosoftLogoComponent } from "../../components/logos/microsoft"; export class MicrosoftSignInButtonComponent { signInWithMicrosoftLabel = injectTranslation("labels", "signInWithMicrosoft"); themed = input(false); - + signIn = output(); + private defaultProvider = new OAuthProvider("microsoft.com"); provider = input(); diff --git a/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts index 32856f17e..5cc35bd75 100644 --- a/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts @@ -17,7 +17,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/angular"; import { Component } from "@angular/core"; import { OAuthButtonComponent } from "./oauth-button"; -import { AuthProvider } from "@angular/fire/auth"; +import { AuthProvider, UserCredential } from "@angular/fire/auth"; @Component({ template: ` Sign in with Google `, @@ -37,6 +37,19 @@ class TestOAuthButtonWithCustomProviderHostComponent { provider: AuthProvider = { providerId: "facebook.com" } as AuthProvider; } +@Component({ + template: ` Sign in with Google `, + standalone: true, + imports: [OAuthButtonComponent], +}) +class TestOAuthButtonWithSignInHostComponent { + provider: AuthProvider = { providerId: "google.com" } as AuthProvider; + signInCallback = jest.fn(); + handleSignIn(credential: UserCredential) { + this.signInCallback(credential); + } +} + describe("", () => { let mockSignInWithProvider: any; let mockFirebaseUIError: any; @@ -180,4 +193,38 @@ describe("", () => { expect(screen.queryByText("First error")).not.toBeInTheDocument(); }); }); + + it("should emit signIn when sign-in is successful", async () => { + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const { fixture } = await render(TestOAuthButtonWithSignInHostComponent, { + imports: [OAuthButtonComponent], + }); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(fixture.componentInstance.signInCallback).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.signInCallback).toHaveBeenCalledWith(mockCredential); + }); + }); + + it("should not emit signIn when sign-in fails", async () => { + mockSignInWithProvider.mockRejectedValue(new mockFirebaseUIError("Sign-in failed")); + + const { fixture } = await render(TestOAuthButtonWithSignInHostComponent, { + imports: [OAuthButtonComponent], + }); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText("Sign-in failed")).toBeInTheDocument(); + }); + + expect(fixture.componentInstance.signInCallback).not.toHaveBeenCalled(); + }); }); diff --git a/packages/angular/src/lib/auth/oauth/oauth-button.ts b/packages/angular/src/lib/auth/oauth/oauth-button.ts index 727c2c5f0..e0308bc2d 100644 --- a/packages/angular/src/lib/auth/oauth/oauth-button.ts +++ b/packages/angular/src/lib/auth/oauth/oauth-button.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { Component, input, signal, computed } from "@angular/core"; +import { Component, input, signal, computed, output } from "@angular/core"; import { CommonModule } from "@angular/common"; import { ButtonComponent } from "../../components/button"; import { injectUI } from "../../provider"; -import { AuthProvider } from "@angular/fire/auth"; +import { AuthProvider, UserCredential } from "@angular/fire/auth"; import { FirebaseUIError, signInWithProvider, getTranslation } from "@invertase/firebaseui-core"; @Component({ @@ -54,6 +54,7 @@ export class OAuthButtonComponent { provider = input.required(); themed = input(); error = signal(null); + signIn = output(); buttonVariant = computed(() => { return this.themed() ? "primary" : "secondary"; @@ -62,7 +63,8 @@ export class OAuthButtonComponent { async handleOAuthSignIn() { this.error.set(null); try { - await signInWithProvider(this.ui(), this.provider()); + const credential = await signInWithProvider(this.ui(), this.provider()); + this.signIn.emit(credential); } catch (error) { if (error instanceof FirebaseUIError) { this.error.set(error.message); diff --git a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts index 404ecd8ca..070c75f8b 100644 --- a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts @@ -93,4 +93,16 @@ describe("", () => { const button = screen.getByRole("button"); expect(button).toHaveAttribute("data-provider", "twitter.com"); }); + + it("has signIn output", async () => { + const { fixture } = await render(TestTwitterSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-twitter-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); }); diff --git a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts index db486aef9..6d89a1fec 100644 --- a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { Component, input } from "@angular/core"; +import { Component, input, output } from "@angular/core"; import { CommonModule } from "@angular/common"; import { OAuthButtonComponent } from "./oauth-button"; import { injectTranslation } from "../../provider"; -import { TwitterAuthProvider } from "@angular/fire/auth"; +import { TwitterAuthProvider, UserCredential } from "@angular/fire/auth"; import { TwitterLogoComponent } from "../../components/logos/twitter"; @Component({ @@ -29,7 +29,7 @@ import { TwitterLogoComponent } from "../../components/logos/twitter"; style: "display: block;", }, template: ` - + {{ signInWithTwitterLabel() }} @@ -38,7 +38,8 @@ import { TwitterLogoComponent } from "../../components/logos/twitter"; export class TwitterSignInButtonComponent { signInWithTwitterLabel = injectTranslation("labels", "signInWithTwitter"); themed = input(false); - + signIn = output(); + private defaultProvider = new TwitterAuthProvider(); provider = input(); From 436fd6266e874378cfd3caa5ed4be84e48e9c576 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 13 Nov 2025 00:06:57 +0000 Subject: [PATCH 4/4] chore: formatting --- .../angular/src/lib/auth/oauth/facebook-sign-in-button.ts | 2 +- packages/angular/src/lib/auth/oauth/google-sign-in-button.ts | 2 +- .../angular/src/lib/auth/oauth/microsoft-sign-in-button.ts | 2 +- packages/angular/src/lib/auth/oauth/oauth-button.spec.ts | 4 +++- packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts index 901451921..9d65c8030 100644 --- a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts @@ -40,7 +40,7 @@ export class FacebookSignInButtonComponent { signInWithFacebookLabel = injectTranslation("labels", "signInWithFacebook"); themed = input(false); signIn = output(); - + private defaultProvider = new FacebookAuthProvider(); provider = input(); diff --git a/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts index fc9846e40..3875734e2 100644 --- a/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts @@ -40,7 +40,7 @@ export class GoogleSignInButtonComponent { signInWithGoogleLabel = injectTranslation("labels", "signInWithGoogle"); themed = input(false); signIn = output(); - + private defaultProvider = new GoogleAuthProvider(); provider = input(); diff --git a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts index 992325da1..5472aeca8 100644 --- a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts @@ -39,7 +39,7 @@ export class MicrosoftSignInButtonComponent { signInWithMicrosoftLabel = injectTranslation("labels", "signInWithMicrosoft"); themed = input(false); signIn = output(); - + private defaultProvider = new OAuthProvider("microsoft.com"); provider = input(); diff --git a/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts index 5cc35bd75..67e2bac80 100644 --- a/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts @@ -38,7 +38,9 @@ class TestOAuthButtonWithCustomProviderHostComponent { } @Component({ - template: ` Sign in with Google `, + template: ` + Sign in with Google + `, standalone: true, imports: [OAuthButtonComponent], }) diff --git a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts index 6d89a1fec..56b8471ff 100644 --- a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts @@ -39,7 +39,7 @@ export class TwitterSignInButtonComponent { signInWithTwitterLabel = injectTranslation("labels", "signInWithTwitter"); themed = input(false); signIn = output(); - + private defaultProvider = new TwitterAuthProvider(); provider = input();