From ef5fd2f608d8d57bc2276df8bb1a3ce871da11bc Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 13 Nov 2025 00:33:21 +0000 Subject: [PATCH 1/6] feat(react): add useOnUserAuthenticated hook and screen implementations --- examples/react/src/routes.ts | 6 - .../email-link-auth-screen-w-oauth.tsx | 3 +- .../src/screens/email-link-auth-screen.tsx | 3 +- ...forgot-password-auth-screen-w-handlers.tsx | 0 examples/react/src/screens/oauth-screen.tsx | 8 +- .../src/screens/phone-auth-screen-w-oauth.tsx | 3 +- .../react/src/screens/phone-auth-screen.tsx | 3 +- .../sign-in-auth-screen-w-handlers.tsx | 4 + .../screens/sign-in-auth-screen-w-oauth.tsx | 3 +- .../react/src/screens/sign-in-auth-screen.tsx | 3 +- .../sign-up-auth-screen-w-handlers.tsx | 3 +- .../screens/sign-up-auth-screen-w-oauth.tsx | 3 +- .../react/src/screens/sign-up-auth-screen.tsx | 3 +- .../screens/email-link-auth-screen.test.tsx | 97 ++++++- .../auth/screens/email-link-auth-screen.tsx | 15 +- .../src/auth/screens/oauth-screen.test.tsx | 97 ++++++- .../react/src/auth/screens/oauth-screen.tsx | 10 +- .../auth/screens/phone-auth-screen.test.tsx | 113 ++++++-- .../src/auth/screens/phone-auth-screen.tsx | 15 +- .../auth/screens/sign-in-auth-screen.test.tsx | 99 ++++++- .../src/auth/screens/sign-in-auth-screen.tsx | 15 +- .../auth/screens/sign-up-auth-screen.test.tsx | 99 ++++++- .../src/auth/screens/sign-up-auth-screen.tsx | 17 +- packages/react/src/hooks.test.tsx | 254 +++++++++++++++++- packages/react/src/hooks.ts | 15 +- packages/react/tests/utils.tsx | 11 +- 26 files changed, 799 insertions(+), 103 deletions(-) delete mode 100644 examples/react/src/screens/forgot-password-auth-screen-w-handlers.tsx diff --git a/examples/react/src/routes.ts b/examples/react/src/routes.ts index 90af4a284..f3b958586 100644 --- a/examples/react/src/routes.ts +++ b/examples/react/src/routes.ts @@ -67,12 +67,6 @@ export const routes = [ path: "/screens/forgot-password-auth-screen", component: ForgotPasswordAuthScreenPage, }, - { - name: "Forgot Password Screen (with handlers)", - description: "A screen allowing a user to reset their password, with forgot password and register handlers.", - path: "/screens/forgot-password-auth-screen-w-handlers", - component: ForgotPasswordAuthScreenPage, - }, { name: "OAuth Screen", description: "A screen which allows a user to sign in with OAuth only.", diff --git a/examples/react/src/screens/email-link-auth-screen-w-oauth.tsx b/examples/react/src/screens/email-link-auth-screen-w-oauth.tsx index 5725e66ae..b918a5a0d 100644 --- a/examples/react/src/screens/email-link-auth-screen-w-oauth.tsx +++ b/examples/react/src/screens/email-link-auth-screen-w-oauth.tsx @@ -35,8 +35,7 @@ export default function EmailLinkAuthScreenWithOAuthPage() { onEmailSent={() => { alert("Email has been sent - please check your email"); }} - onSignIn={(credential) => { - console.log(credential); + onSignIn={() => { navigate("/"); }} > diff --git a/examples/react/src/screens/email-link-auth-screen.tsx b/examples/react/src/screens/email-link-auth-screen.tsx index b803ea13e..1a7d7a1cf 100644 --- a/examples/react/src/screens/email-link-auth-screen.tsx +++ b/examples/react/src/screens/email-link-auth-screen.tsx @@ -27,8 +27,7 @@ export default function EmailLinkAuthScreenPage() { onEmailSent={() => { alert("Email has been sent"); }} - onSignIn={(credential) => { - console.log(credential); + onSignIn={() => { navigate("/"); }} /> diff --git a/examples/react/src/screens/forgot-password-auth-screen-w-handlers.tsx b/examples/react/src/screens/forgot-password-auth-screen-w-handlers.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/react/src/screens/oauth-screen.tsx b/examples/react/src/screens/oauth-screen.tsx index 6fb8521fc..b85a1fb30 100644 --- a/examples/react/src/screens/oauth-screen.tsx +++ b/examples/react/src/screens/oauth-screen.tsx @@ -26,13 +26,19 @@ import { OAuthScreen, TwitterSignInButton, } from "@invertase/firebaseui-react"; +import { useNavigate } from "react-router"; export default function OAuthScreenPage() { const [themed, setThemed] = useState(false); + const navigate = useNavigate(); return ( <> - + { + navigate("/"); + }} + > diff --git a/examples/react/src/screens/phone-auth-screen-w-oauth.tsx b/examples/react/src/screens/phone-auth-screen-w-oauth.tsx index 3d765569e..a8f892732 100644 --- a/examples/react/src/screens/phone-auth-screen-w-oauth.tsx +++ b/examples/react/src/screens/phone-auth-screen-w-oauth.tsx @@ -32,8 +32,7 @@ export default function PhoneAuthScreenWithOAuthPage() { return ( { - console.log(credential); + onSignIn={() => { navigate("/"); }} > diff --git a/examples/react/src/screens/phone-auth-screen.tsx b/examples/react/src/screens/phone-auth-screen.tsx index 4bd364a66..c244f99f6 100644 --- a/examples/react/src/screens/phone-auth-screen.tsx +++ b/examples/react/src/screens/phone-auth-screen.tsx @@ -24,8 +24,7 @@ export default function PhoneAuthScreenPage() { return ( { - console.log(credential); + onSignIn={() => { navigate("/"); }} /> diff --git a/examples/react/src/screens/sign-in-auth-screen-w-handlers.tsx b/examples/react/src/screens/sign-in-auth-screen-w-handlers.tsx index 7999a07d2..a881d1f3e 100644 --- a/examples/react/src/screens/sign-in-auth-screen-w-handlers.tsx +++ b/examples/react/src/screens/sign-in-auth-screen-w-handlers.tsx @@ -21,6 +21,7 @@ import { useNavigate } from "react-router"; export default function SignInAuthScreenWithHandlersPage() { const navigate = useNavigate(); + return ( { @@ -29,6 +30,9 @@ export default function SignInAuthScreenWithHandlersPage() { onSignUpClick={() => { navigate("/screens/sign-up-auth-screen"); }} + onSignIn={() => { + navigate("/"); + }} /> ); } diff --git a/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx b/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx index 4909fcaf3..d3c8a9c28 100644 --- a/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx +++ b/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx @@ -30,8 +30,7 @@ export default function SignInAuthScreenWithOAuthPage() { return ( { - console.log(credential); + onSignIn={() => { navigate("/"); }} > diff --git a/examples/react/src/screens/sign-in-auth-screen.tsx b/examples/react/src/screens/sign-in-auth-screen.tsx index af5df82b5..3392f9a1b 100644 --- a/examples/react/src/screens/sign-in-auth-screen.tsx +++ b/examples/react/src/screens/sign-in-auth-screen.tsx @@ -22,8 +22,7 @@ export default function SignInAuthScreenPage() { return ( { - console.log(credential); + onSignIn={() => { navigate("/"); }} /> diff --git a/examples/react/src/screens/sign-up-auth-screen-w-handlers.tsx b/examples/react/src/screens/sign-up-auth-screen-w-handlers.tsx index fbe456b66..2f8d3ddab 100644 --- a/examples/react/src/screens/sign-up-auth-screen-w-handlers.tsx +++ b/examples/react/src/screens/sign-up-auth-screen-w-handlers.tsx @@ -27,8 +27,7 @@ export default function SignUpAuthScreenWithHandlersPage() { onSignInClick={() => { navigate("/screens/sign-in-auth-screen"); }} - onSignUp={(credential) => { - console.log(credential); + onSignUp={() => { navigate("/"); }} /> diff --git a/examples/react/src/screens/sign-up-auth-screen-w-oauth.tsx b/examples/react/src/screens/sign-up-auth-screen-w-oauth.tsx index 3762afd90..9497c778d 100644 --- a/examples/react/src/screens/sign-up-auth-screen-w-oauth.tsx +++ b/examples/react/src/screens/sign-up-auth-screen-w-oauth.tsx @@ -32,8 +32,7 @@ export default function SignUpAuthScreenWithOAuthPage() { return ( { - console.log(credential); + onSignUp={() => { navigate("/"); }} > diff --git a/examples/react/src/screens/sign-up-auth-screen.tsx b/examples/react/src/screens/sign-up-auth-screen.tsx index f284aebc5..d0a902c79 100644 --- a/examples/react/src/screens/sign-up-auth-screen.tsx +++ b/examples/react/src/screens/sign-up-auth-screen.tsx @@ -24,8 +24,7 @@ export default function SignUpAuthScreenPage() { return ( { - console.log(credential); + onSignUp={() => { navigate("/"); }} /> diff --git a/packages/react/src/auth/screens/email-link-auth-screen.test.tsx b/packages/react/src/auth/screens/email-link-auth-screen.test.tsx index b20df6d42..52734863d 100644 --- a/packages/react/src/auth/screens/email-link-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/email-link-auth-screen.test.tsx @@ -15,11 +15,11 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; import { EmailLinkAuthScreen } from "~/auth/screens/email-link-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import type { MultiFactorResolver } from "firebase/auth"; +import type { MultiFactorResolver, User } from "firebase/auth"; vi.mock("~/auth/forms/email-link-auth-form", () => ({ EmailLinkAuthForm: () =>
Email Link Form
, @@ -201,7 +201,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -212,11 +223,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/src/auth/screens/email-link-auth-screen.tsx b/packages/react/src/auth/screens/email-link-auth-screen.tsx index 103e1e85e..7072b9c90 100644 --- a/packages/react/src/auth/screens/email-link-auth-screen.tsx +++ b/packages/react/src/auth/screens/email-link-auth-screen.tsx @@ -15,15 +15,20 @@ */ import type { PropsWithChildren } from "react"; +import type { User } from "firebase/auth"; import { getTranslation } from "@invertase/firebaseui-core"; import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; +import { useOnUserAuthenticated, useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; import { EmailLinkAuthForm, type EmailLinkAuthFormProps } from "../forms/email-link-auth-form"; import { RedirectError } from "~/components/redirect-error"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; -export type EmailLinkAuthScreenProps = PropsWithChildren; +export type EmailLinkAuthScreenProps = PropsWithChildren< + Pick & { + onSignIn?: (user: User) => void; + } +>; export function EmailLinkAuthScreen({ children, onEmailSent, onSignIn }: EmailLinkAuthScreenProps) { const ui = useUI(); @@ -32,8 +37,10 @@ export function EmailLinkAuthScreen({ children, onEmailSent, onSignIn }: EmailLi const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignIn); + if (mfaResolver) { - return ; + return ; } return ( @@ -44,7 +51,7 @@ export function EmailLinkAuthScreen({ children, onEmailSent, onSignIn }: EmailLi {subtitleText} - + {children ? ( <> {getTranslation(ui, "messages", "dividerOr")} diff --git a/packages/react/src/auth/screens/oauth-screen.test.tsx b/packages/react/src/auth/screens/oauth-screen.test.tsx index 3d9e3aee3..4afcedab1 100644 --- a/packages/react/src/auth/screens/oauth-screen.test.tsx +++ b/packages/react/src/auth/screens/oauth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; import { OAuthScreen } from "~/auth/screens/oauth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("~/components/policies", async (originalModule) => { const module = await originalModule(); @@ -230,7 +230,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -241,11 +252,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "oauth-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + OAuth Provider + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "oauth-mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + OAuth Provider + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/src/auth/screens/oauth-screen.tsx b/packages/react/src/auth/screens/oauth-screen.tsx index 4fb263d5e..c3591e370 100644 --- a/packages/react/src/auth/screens/oauth-screen.tsx +++ b/packages/react/src/auth/screens/oauth-screen.tsx @@ -15,16 +15,16 @@ */ import { getTranslation } from "@invertase/firebaseui-core"; -import { type UserCredential } from "firebase/auth"; +import { type User } from "firebase/auth"; import { type PropsWithChildren } from "react"; -import { useUI } from "~/hooks"; +import { useOnUserAuthenticated, useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; import { Policies } from "~/components/policies"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; import { RedirectError } from "~/components/redirect-error"; export type OAuthScreenProps = PropsWithChildren<{ - onSignIn?: (credential: UserCredential) => void; + onSignIn?: (user: User) => void; }>; export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { @@ -34,8 +34,10 @@ export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignIn); + if (mfaResolver) { - return ; + return ; } return ( diff --git a/packages/react/src/auth/screens/phone-auth-screen.test.tsx b/packages/react/src/auth/screens/phone-auth-screen.test.tsx index 07ecb4686..c804d378c 100644 --- a/packages/react/src/auth/screens/phone-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/phone-auth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { PhoneAuthScreen } from "~/auth/screens/phone-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("~/auth/forms/phone-auth-form", () => ({ PhoneAuthForm: ({ resendDelay }: { resendDelay?: number }) => ( @@ -99,19 +99,6 @@ describe("", () => { expect(screen.getByTestId("phone-auth-form")).toBeDefined(); }); - // it("passes resendDelay prop to PhoneAuthForm", () => { - // const ui = createMockUI(); - - // render( - // - // - // - // ); - - // const phoneForm = screen.getByTestId("phone-auth-form"); - // expect(phoneForm).toBeDefined(); - // expect(phoneForm.getAttribute("data-resend-delay")).toBe("60"); - // }); it("renders a divider with children when present", () => { const ui = createMockUI({ @@ -256,7 +243,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -267,13 +265,86 @@ describe("", () => { ); - // Simulate nested MFA form success - const trigger = screen.getByTestId("mfa-on-success"); - trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + // Simulate the MFA flow success - this would trigger auth state change + const mockUser = { + uid: "phone-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "phone-mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/src/auth/screens/phone-auth-screen.tsx b/packages/react/src/auth/screens/phone-auth-screen.tsx index 8e234d0be..53436370b 100644 --- a/packages/react/src/auth/screens/phone-auth-screen.tsx +++ b/packages/react/src/auth/screens/phone-auth-screen.tsx @@ -17,13 +17,16 @@ import type { PropsWithChildren } from "react"; import { getTranslation } from "@invertase/firebaseui-core"; import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; +import { useOnUserAuthenticated, useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; -import { PhoneAuthForm, type PhoneAuthFormProps } from "../forms/phone-auth-form"; +import { PhoneAuthForm } from "../forms/phone-auth-form"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; import { RedirectError } from "~/components/redirect-error"; +import type { User } from "firebase/auth"; -export type PhoneAuthScreenProps = PropsWithChildren; +export type PhoneAuthScreenProps = PropsWithChildren<{ + onSignIn?: (user: User) => void; +}>; export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { const ui = useUI(); @@ -32,8 +35,10 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(props.onSignIn); + if (mfaResolver) { - return ; + return ; } return ( @@ -44,7 +49,7 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { {subtitleText} - + {children ? ( <> {getTranslation(ui, "messages", "dividerOr")} diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx index 2e7154f19..4a9b176ed 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import { SignInAuthScreen } from "~/auth/screens/sign-in-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("~/auth/forms/sign-in-auth-form", () => ({ SignInAuthForm: ({ @@ -285,7 +285,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -296,12 +307,86 @@ describe("", () => { ); - // Simulate the MFA child reporting success with a credential - fireEvent.click(screen.getByTestId("mfa-on-success")); + // Simulate the MFA child reporting success - this would trigger auth state change + const mockUser = { + uid: "mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.tsx index 857bf4738..0da74d2ff 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.tsx @@ -15,26 +15,29 @@ */ import type { PropsWithChildren } from "react"; +import type { User } from "firebase/auth"; import { getTranslation } from "@invertase/firebaseui-core"; import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; +import { useOnUserAuthenticated, useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; import { SignInAuthForm, type SignInAuthFormProps } from "../forms/sign-in-auth-form"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; import { RedirectError } from "~/components/redirect-error"; -export type SignInAuthScreenProps = PropsWithChildren; +export type SignInAuthScreenProps = PropsWithChildren> & { + onSignIn?: (user: User) => void; +}; -export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps) { +export function SignInAuthScreen({ children, onSignIn, ...props }: SignInAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignIn); - if (mfaResolver) { - return ; + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx index c77148c60..2c7d21b56 100644 --- a/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import { SignUpAuthScreen } from "~/auth/screens/sign-up-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("~/auth/forms/sign-up-auth-form", () => ({ SignUpAuthForm: ({ onSignInClick }: { onSignInClick?: () => void }) => ( @@ -259,7 +259,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignUp = vi.fn(); @@ -270,13 +281,85 @@ describe("", () => { ); - // Simulate nested MFA form success - const trigger = screen.getByTestId("mfa-on-success"); - trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const mockUser = { + uid: "signup-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignUp).toHaveBeenCalledTimes(1); + expect(onSignUp).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignUp when user authenticates via useOnUserAuthenticated hook", () => { + const onSignUp = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignUp).toHaveBeenCalledTimes(1); - expect(onSignUp).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "signup-mfa-user" }) }) + expect(onSignUp).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignUp for anonymous users", () => { + const onSignUp = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignUp).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/src/auth/screens/sign-up-auth-screen.tsx b/packages/react/src/auth/screens/sign-up-auth-screen.tsx index 1a9de7329..5dac1981c 100644 --- a/packages/react/src/auth/screens/sign-up-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-up-auth-screen.tsx @@ -14,27 +14,30 @@ * limitations under the License. */ -import { type PropsWithChildren } from "react"; +import type { PropsWithChildren } from "react"; +import type { User } from "firebase/auth"; import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; +import { useOnUserAuthenticated, useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; import { SignUpAuthForm, type SignUpAuthFormProps } from "../forms/sign-up-auth-form"; import { getTranslation } from "@invertase/firebaseui-core"; import { RedirectError } from "~/components/redirect-error"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; -export type SignUpAuthScreenProps = PropsWithChildren; +export type SignUpAuthScreenProps = PropsWithChildren> & { + onSignUp?: (user: User) => void; +}; -export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) { +export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signUp"); const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); - const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignUp); - if (mfaResolver) { - return ; + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/react/src/hooks.test.tsx b/packages/react/src/hooks.test.tsx index ce2fed504..622be7ab6 100644 --- a/packages/react/src/hooks.test.tsx +++ b/packages/react/src/hooks.test.tsx @@ -26,10 +26,11 @@ import { usePhoneAuthNumberFormSchema, usePhoneAuthVerifyFormSchema, useRecaptchaVerifier, + useOnUserAuthenticated, } from "./hooks"; import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale, enUs } from "@invertase/firebaseui-translations"; -import type { RecaptchaVerifier } from "firebase/auth"; +import type { RecaptchaVerifier, User } from "firebase/auth"; // Mock RecaptchaVerifier from firebase/auth const mockRender = vi.fn(); @@ -929,3 +930,254 @@ describe("useRecaptchaVerifier", () => { expect(result.current).toBe(mockVerifier); }); }); + +describe("useOnUserAuthenticated", () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it("calls callback when a non-anonymous user is authenticated", () => { + const mockCallback = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + let unsubscribe: (() => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + unsubscribe = vi.fn(); + return unsubscribe; + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + const { unmount } = renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + expect(authStateChangeCallback).toBeDefined(); + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith(mockUser); + + unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("does not call callback when user is anonymous", () => { + const mockCallback = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it("does not call callback when user is null", () => { + const mockCallback = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + authStateChangeCallback!(null); + }); + + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it("works without a callback", () => { + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + renderHook(() => useOnUserAuthenticated(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + }); + + it("unsubscribes from auth state changes on unmount", () => { + const mockCallback = vi.fn(); + let unsubscribe: (() => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn(() => { + unsubscribe = vi.fn(); + return unsubscribe; + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + const { unmount } = renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + expect(unsubscribe).toBeDefined(); + + unmount(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("resubscribes when auth instance changes", () => { + const mockCallback = vi.fn(); + const mockAuth1 = { + onAuthStateChanged: vi.fn(() => vi.fn()), + }; + const mockAuth2 = { + onAuthStateChanged: vi.fn(() => vi.fn()), + }; + + const mockUI1 = createMockUI({ + auth: mockAuth1 as any, + }); + const mockUI2 = createMockUI({ + auth: mockAuth2 as any, + }); + + const { rerender } = renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI1 }), + }); + + expect(mockAuth1.onAuthStateChanged).toHaveBeenCalledTimes(1); + expect(mockAuth2.onAuthStateChanged).not.toHaveBeenCalled(); + + rerender(); + // Note: The hook depends on auth, but since we're using the same mockUI instance, + // we need to create a new wrapper with a different UI + const { rerender: rerender2 } = renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI2 }), + }); + + rerender2(); + + // The effect should re-run when auth changes, but since we're using a new wrapper, + // we need to check that the new auth instance's onAuthStateChanged is called + expect(mockAuth2.onAuthStateChanged).toHaveBeenCalledTimes(1); + }); + + it("resubscribes when callback changes", () => { + const mockCallback1 = vi.fn(); + const mockCallback2 = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + const unsubscribeFunctions: (() => void)[] = []; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + const unsubscribe = vi.fn(); + unsubscribeFunctions.push(unsubscribe); + return unsubscribe; + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + const { rerender } = renderHook(({ callback }) => useOnUserAuthenticated(callback), { + initialProps: { callback: mockCallback1 }, + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + expect(unsubscribeFunctions).toHaveLength(1); + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(mockCallback1).toHaveBeenCalledTimes(1); + expect(mockCallback2).not.toHaveBeenCalled(); + + rerender({ callback: mockCallback2 }); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(2); + expect(unsubscribeFunctions).toHaveLength(2); + expect(unsubscribeFunctions[0]).toHaveBeenCalledTimes(1); + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(mockCallback1).toHaveBeenCalledTimes(1); + expect(mockCallback2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index 390c7639d..b86791d49 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -15,7 +15,7 @@ */ import { useContext, useMemo, useEffect, useRef } from "react"; -import type { RecaptchaVerifier } from "firebase/auth"; +import type { RecaptchaVerifier, User } from "firebase/auth"; import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, @@ -53,6 +53,19 @@ const ui = initializeUI(...); return ui; } +export function useOnUserAuthenticated(callback?: (user: User) => void) { + const ui = useUI(); + const auth = ui.auth; + + useEffect(() => { + return auth.onAuthStateChanged((user) => { + if (user && !user.isAnonymous) { + callback?.(user); + } + }); + }, [auth, callback]); +} + export function useRedirectError() { const ui = useUI(); return useMemo(() => { diff --git a/packages/react/tests/utils.tsx b/packages/react/tests/utils.tsx index 2b62c8138..a9956ed2d 100644 --- a/packages/react/tests/utils.tsx +++ b/packages/react/tests/utils.tsx @@ -3,14 +3,21 @@ import type { Auth } from "firebase/auth"; import { enUs } from "@invertase/firebaseui-translations"; import { Behavior, FirebaseUI, FirebaseUIOptions, FirebaseUIStore, initializeUI } from "@invertase/firebaseui-core"; import { FirebaseUIProvider } from "../src/context"; +import { vi } from "vitest"; export function createMockUI(overrides?: Partial): FirebaseUIStore { + const defaultAuth = { + onAuthStateChanged: vi.fn(() => vi.fn()), + } as unknown as Auth; + + const { auth, ...restOverrides } = overrides || {}; + return initializeUI({ app: {} as FirebaseApp, - auth: {} as Auth, + auth: auth ?? defaultAuth, locale: enUs, behaviors: [] as Behavior[], - ...overrides, + ...restOverrides, }); } From 7954e8036600926234e35e1b42d449eb45773338 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 13 Nov 2025 00:40:01 +0000 Subject: [PATCH 2/6] feat(shadcn): implement useOnUserAuthenticated into screens --- .../email-link-auth-screen.test.tsx | 101 ++++++++++++++++-- .../src/components/email-link-auth-screen.tsx | 11 +- .../src/components/oauth-screen.test.tsx | 97 +++++++++++++++-- .../shadcn/src/components/oauth-screen.tsx | 13 +-- .../src/components/phone-auth-screen.test.tsx | 97 +++++++++++++++-- .../src/components/phone-auth-screen.tsx | 20 ++-- .../components/sign-in-auth-screen.test.tsx | 97 +++++++++++++++-- .../src/components/sign-in-auth-screen.tsx | 10 +- .../components/sign-up-auth-screen.test.tsx | 101 ++++++++++++++++-- .../src/components/sign-up-auth-screen.tsx | 11 +- packages/shadcn/tests/utils.tsx | 46 ++++---- 11 files changed, 521 insertions(+), 83 deletions(-) diff --git a/packages/shadcn/src/components/email-link-auth-screen.test.tsx b/packages/shadcn/src/components/email-link-auth-screen.test.tsx index 29b98b0c0..3bc4493b5 100644 --- a/packages/shadcn/src/components/email-link-auth-screen.test.tsx +++ b/packages/shadcn/src/components/email-link-auth-screen.test.tsx @@ -15,12 +15,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { EmailLinkAuthScreen } from "./email-link-auth-screen"; import { createMockUI } from "../../tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("./email-link-auth-form", () => ({ EmailLinkAuthForm: ({ onEmailSent, onSignIn }: any) => ( @@ -116,16 +116,14 @@ describe("", () => { }); const onEmailSentMock = vi.fn(); - const onSignInMock = vi.fn(); render( - + ); expect(screen.getByTestId("onEmailSent-prop")).toBeInTheDocument(); - expect(screen.getByTestId("onSignIn-prop")).toBeInTheDocument(); }); it("should not render separator when no children", () => { @@ -246,7 +244,18 @@ describe("", () => { session: null, hints: [], }; - const mockUI = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -257,11 +266,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "email-link-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "email-link-mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/shadcn/src/components/email-link-auth-screen.tsx b/packages/shadcn/src/components/email-link-auth-screen.tsx index 88828712c..171a55de7 100644 --- a/packages/shadcn/src/components/email-link-auth-screen.tsx +++ b/packages/shadcn/src/components/email-link-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type EmailLinkAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type EmailLinkAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -11,15 +11,16 @@ import { RedirectError } from "@/components/redirect-error"; export type { EmailLinkAuthScreenProps }; -export function EmailLinkAuthScreen({ children, ...props }: EmailLinkAuthScreenProps) { +export function EmailLinkAuthScreen({ children, onSignIn, ...props }: EmailLinkAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/shadcn/src/components/oauth-screen.test.tsx b/packages/shadcn/src/components/oauth-screen.test.tsx index 855ad6280..bb49c5799 100644 --- a/packages/shadcn/src/components/oauth-screen.test.tsx +++ b/packages/shadcn/src/components/oauth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { OAuthScreen } from "@/components/oauth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "../../tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("@/components/policies", () => ({ Policies: () =>
Policies
, @@ -228,7 +228,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -239,11 +250,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "oauth-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + OAuth Provider + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "oauth-mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + OAuth Provider + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/shadcn/src/components/oauth-screen.tsx b/packages/shadcn/src/components/oauth-screen.tsx index 1281d74e1..a586527d2 100644 --- a/packages/shadcn/src/components/oauth-screen.tsx +++ b/packages/shadcn/src/components/oauth-screen.tsx @@ -1,16 +1,16 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { type UserCredential } from "firebase/auth"; +import { type User } from "firebase/auth"; import { type PropsWithChildren } from "react"; -import { useUI } from "@invertase/firebaseui-react"; +import { useUI, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; import { Policies } from "@/components/policies"; import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; import { RedirectError } from "@/components/redirect-error"; export type OAuthScreenProps = PropsWithChildren<{ - onSignIn?: (credential: UserCredential) => void; + onSignIn?: (user: User) => void; }>; export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { @@ -18,10 +18,11 @@ export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/shadcn/src/components/phone-auth-screen.test.tsx b/packages/shadcn/src/components/phone-auth-screen.test.tsx index edac411f8..d789517f3 100644 --- a/packages/shadcn/src/components/phone-auth-screen.test.tsx +++ b/packages/shadcn/src/components/phone-auth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { PhoneAuthScreen } from "@/components/phone-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "../../tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("@/components/phone-auth-form", () => ({ PhoneAuthForm: ({ resendDelay }: { resendDelay?: number }) => ( @@ -239,7 +239,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -250,11 +261,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "phone-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "phone-mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/shadcn/src/components/phone-auth-screen.tsx b/packages/shadcn/src/components/phone-auth-screen.tsx index ad31fae73..7908c58c8 100644 --- a/packages/shadcn/src/components/phone-auth-screen.tsx +++ b/packages/shadcn/src/components/phone-auth-screen.tsx @@ -2,24 +2,28 @@ import type { PropsWithChildren } from "react"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI } from "@invertase/firebaseui-react"; +import { useUI, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { PhoneAuthForm, type PhoneAuthFormProps } from "@/components/phone-auth-form"; +import { PhoneAuthForm } from "@/components/phone-auth-form"; import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; import { RedirectError } from "@/components/redirect-error"; +import type { User } from "firebase/auth"; -export type PhoneAuthScreenProps = PropsWithChildren; +export type PhoneAuthScreenProps = PropsWithChildren<{ + onSignIn?: (user: User) => void; +}>; -export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { +export function PhoneAuthScreen({ children, onSignIn }: PhoneAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( @@ -30,7 +34,7 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { {subtitleText} - + {children ? ( <> diff --git a/packages/shadcn/src/components/sign-in-auth-screen.test.tsx b/packages/shadcn/src/components/sign-in-auth-screen.test.tsx index cf5abfdd7..b9ed03064 100644 --- a/packages/shadcn/src/components/sign-in-auth-screen.test.tsx +++ b/packages/shadcn/src/components/sign-in-auth-screen.test.tsx @@ -15,12 +15,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { SignInAuthScreen } from "./sign-in-auth-screen"; import { createMockUI } from "../../tests/utils"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("./sign-in-auth-form", () => ({ SignInAuthForm: ({ onSignIn, onForgotPasswordClick, onRegisterClick }: any) => ( @@ -248,7 +248,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -259,11 +270,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/shadcn/src/components/sign-in-auth-screen.tsx b/packages/shadcn/src/components/sign-in-auth-screen.tsx index 322c34848..3397fac0d 100644 --- a/packages/shadcn/src/components/sign-in-auth-screen.tsx +++ b/packages/shadcn/src/components/sign-in-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type SignInAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type SignInAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -10,16 +10,16 @@ import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-a export type { SignInAuthScreenProps }; -export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps) { +export function SignInAuthScreen({ children, onSignIn, ...props }: SignInAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignIn); - if (mfaResolver) { - return ; + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/shadcn/src/components/sign-up-auth-screen.test.tsx b/packages/shadcn/src/components/sign-up-auth-screen.test.tsx index 5e7437845..afbaf6567 100644 --- a/packages/shadcn/src/components/sign-up-auth-screen.test.tsx +++ b/packages/shadcn/src/components/sign-up-auth-screen.test.tsx @@ -15,12 +15,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { SignUpAuthScreen } from "./sign-up-auth-screen"; import { createMockUI } from "../../tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("./sign-up-auth-form", () => ({ SignUpAuthForm: ({ onSignUp, onSignInClick }: any) => ( @@ -115,16 +115,14 @@ describe("", () => { }), }); - const onSignUpMock = vi.fn(); const onSignInClickMock = vi.fn(); render( - + ); - expect(screen.getByTestId("onSignUp-prop")).toBeInTheDocument(); expect(screen.getByTestId("onSignInClick-prop")).toBeInTheDocument(); }); @@ -246,7 +244,18 @@ describe("", () => { session: null, hints: [], }; - const mockUI = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignUp = vi.fn(); @@ -257,11 +266,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "signup-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignUp).toHaveBeenCalledTimes(1); - expect(onSignUp).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "signup-mfa-user" }) }) + expect(onSignUp).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignUp when user authenticates via useOnUserAuthenticated hook", () => { + const onSignUp = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignUp).toHaveBeenCalledTimes(1); + expect(onSignUp).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignUp for anonymous users", () => { + const onSignUp = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignUp).not.toHaveBeenCalled(); }); }); diff --git a/packages/shadcn/src/components/sign-up-auth-screen.tsx b/packages/shadcn/src/components/sign-up-auth-screen.tsx index 23838f3f7..f358a163e 100644 --- a/packages/shadcn/src/components/sign-up-auth-screen.tsx +++ b/packages/shadcn/src/components/sign-up-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type SignUpAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type SignUpAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -10,15 +10,16 @@ import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-a export type { SignUpAuthScreenProps }; -export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) { +export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signUp"); const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignUp); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/shadcn/tests/utils.tsx b/packages/shadcn/tests/utils.tsx index 31e8cedc0..0d232fbce 100644 --- a/packages/shadcn/tests/utils.tsx +++ b/packages/shadcn/tests/utils.tsx @@ -7,36 +7,46 @@ import { FirebaseUIStore } from "@invertase/firebaseui-core"; import { vi } from "vitest"; export function createMockUI(overrides?: Partial) { + const defaultAuth = { + currentUser: null, + onAuthStateChanged: vi.fn(() => vi.fn()), + } as unknown as Auth; + + const { auth, ...restOverrides } = overrides || {}; + return initializeUI({ app: {} as FirebaseApp, - auth: { - currentUser: null, - } as unknown as Auth, + auth: auth ?? defaultAuth, locale: enUs, behaviors: [] as Behavior[], - ...overrides, + ...restOverrides, }); } export function createMockUIWithUser(overrides?: Partial) { + const defaultAuth = { + currentUser: { + uid: "test-user-id", + email: "test@example.com", + _onReload: vi.fn(), + _multiFactor: { + enrolledFactors: [], + enroll: vi.fn(), + unenroll: vi.fn(), + getSession: vi.fn(), + }, + }, + onAuthStateChanged: vi.fn(() => vi.fn()), + } as unknown as Auth; + + const { auth, ...restOverrides } = overrides || {}; + return initializeUI({ app: {} as FirebaseApp, - auth: { - currentUser: { - uid: "test-user-id", - email: "test@example.com", - _onReload: vi.fn(), - _multiFactor: { - enrolledFactors: [], - enroll: vi.fn(), - unenroll: vi.fn(), - getSession: vi.fn(), - }, - }, - } as unknown as Auth, + auth: auth ?? defaultAuth, locale: enUs, behaviors: [] as Behavior[], - ...overrides, + ...restOverrides, }); } From e4457a05ebf193623daad017cf17bf2220fa68aa Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 13 Nov 2025 00:40:34 +0000 Subject: [PATCH 3/6] chore: fix bad type --- packages/shadcn/src/components/sign-in-auth-screen.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shadcn/src/components/sign-in-auth-screen.test.tsx b/packages/shadcn/src/components/sign-in-auth-screen.test.tsx index b9ed03064..dca816c20 100644 --- a/packages/shadcn/src/components/sign-in-auth-screen.test.tsx +++ b/packages/shadcn/src/components/sign-in-auth-screen.test.tsx @@ -144,11 +144,11 @@ describe("", () => { it("should forward props to SignInAuthForm", () => { const mockUI = createMockUI(); const onForgotPasswordClickMock = vi.fn(); - const onRegisterClickMock = vi.fn(); + const onSignUpClickMock = vi.fn(); render( - + ); From 4e0a2c83a82f8973160b1b4f9561f40b142473e6 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 13 Nov 2025 01:18:28 +0000 Subject: [PATCH 4/6] feat(angular): add injectUserAuthenticated provider --- ...t.ts => email-link-auth-screen-w-oauth.ts} | 6 +- .../email-link-auth-screen-w-oauth/index.ts | 1 - ...component.ts => email-link-auth-screen.ts} | 6 +- .../screens/email-link-auth-screen/index.ts | 1 - ...forgot-password-auth-screen-w-handlers.ts} | 0 .../index.ts | 1 - ...nent.ts => forgot-password-auth-screen.ts} | 0 .../forgot-password-auth-screen/index.ts | 1 - ....component.ts => mfa-enrollment-screen.ts} | 0 .../screens/mfa-enrollment-screen/index.ts | 1 - ...th-screen.component.ts => oauth-screen.ts} | 6 +- .../src/app/screens/oauth-screen/index.ts | 1 - ...ponent.ts => phone-auth-screen-w-oauth.ts} | 8 +- .../phone-auth-screen-w-oauth/index.ts | 1 - ...reen.component.ts => phone-auth-screen.ts} | 6 +- .../app/screens/phone-auth-screen/index.ts | 1 - ...t.ts => sign-in-auth-screen-w-handlers.ts} | 9 +- .../sign-in-auth-screen-w-handlers/index.ts | 1 - ...nent.ts => sign-in-auth-screen-w-oauth.ts} | 6 +- .../sign-in-auth-screen-w-oauth/index.ts | 1 - ...en.component.ts => sign-in-auth-screen.ts} | 6 +- .../app/screens/sign-in-auth-screen/index.ts | 1 - ...t.ts => sign-up-auth-screen-w-handlers.ts} | 5 +- .../sign-up-auth-screen-w-handlers/index.ts | 1 - ...nent.ts => sign-up-auth-screen-w-oauth.ts} | 6 +- .../sign-up-auth-screen-w-oauth/index.ts | 1 - ...en.component.ts => sign-up-auth-screen.ts} | 6 +- .../app/screens/sign-up-auth-screen/index.ts | 1 - .../screens/email-link-auth-screen.spec.ts | 147 +++++++++++++++-- .../auth/screens/email-link-auth-screen.ts | 16 +- .../src/lib/auth/screens/oauth-screen.spec.ts | 153 ++++++++++++++++-- .../src/lib/auth/screens/oauth-screen.ts | 14 +- .../auth/screens/phone-auth-screen.spec.ts | 149 +++++++++++++++-- .../src/lib/auth/screens/phone-auth-screen.ts | 16 +- .../auth/screens/sign-in-auth-screen.spec.ts | 153 ++++++++++++++++-- .../lib/auth/screens/sign-in-auth-screen.ts | 19 ++- .../auth/screens/sign-up-auth-screen.spec.ts | 149 +++++++++++++++-- .../lib/auth/screens/sign-up-auth-screen.ts | 16 +- packages/angular/src/lib/provider.ts | 19 ++- .../angular/src/lib/tests/test-helpers.ts | 2 + 40 files changed, 803 insertions(+), 134 deletions(-) rename examples/angular/src/app/screens/{email-link-auth-screen-w-oauth/email-link-auth-screen-w-oauth.component.ts => email-link-auth-screen-w-oauth.ts} (89%) delete mode 100644 examples/angular/src/app/screens/email-link-auth-screen-w-oauth/index.ts rename examples/angular/src/app/screens/{email-link-auth-screen/email-link-auth-screen.component.ts => email-link-auth-screen.ts} (87%) delete mode 100644 examples/angular/src/app/screens/email-link-auth-screen/index.ts rename examples/angular/src/app/screens/{forgot-password-auth-screen-w-handlers/forgot-password-auth-screen-w-handlers.component.ts => forgot-password-auth-screen-w-handlers.ts} (100%) delete mode 100644 examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/index.ts rename examples/angular/src/app/screens/{forgot-password-auth-screen/forgot-password-auth-screen.component.ts => forgot-password-auth-screen.ts} (100%) delete mode 100644 examples/angular/src/app/screens/forgot-password-auth-screen/index.ts rename examples/angular/src/app/screens/{mfa-enrollment-screen/mfa-enrollment-screen.component.ts => mfa-enrollment-screen.ts} (100%) delete mode 100644 examples/angular/src/app/screens/mfa-enrollment-screen/index.ts rename examples/angular/src/app/screens/{oauth-screen/oauth-screen.component.ts => oauth-screen.ts} (91%) delete mode 100644 examples/angular/src/app/screens/oauth-screen/index.ts rename examples/angular/src/app/screens/{phone-auth-screen-w-oauth/phone-auth-screen-w-oauth.component.ts => phone-auth-screen-w-oauth.ts} (83%) delete mode 100644 examples/angular/src/app/screens/phone-auth-screen-w-oauth/index.ts rename examples/angular/src/app/screens/{phone-auth-screen/phone-auth-screen.component.ts => phone-auth-screen.ts} (84%) delete mode 100644 examples/angular/src/app/screens/phone-auth-screen/index.ts rename examples/angular/src/app/screens/{sign-in-auth-screen-w-handlers/sign-in-auth-screen-w-handlers.component.ts => sign-in-auth-screen-w-handlers.ts} (84%) delete mode 100644 examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/index.ts rename examples/angular/src/app/screens/{sign-in-auth-screen-w-oauth/sign-in-auth-screen-w-oauth.component.ts => sign-in-auth-screen-w-oauth.ts} (90%) delete mode 100644 examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/index.ts rename examples/angular/src/app/screens/{sign-in-auth-screen/sign-in-auth-screen.component.ts => sign-in-auth-screen.ts} (83%) delete mode 100644 examples/angular/src/app/screens/sign-in-auth-screen/index.ts rename examples/angular/src/app/screens/{sign-up-auth-screen-w-handlers/sign-up-auth-screen-w-handlers.component.ts => sign-up-auth-screen-w-handlers.ts} (86%) delete mode 100644 examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/index.ts rename examples/angular/src/app/screens/{sign-up-auth-screen-w-oauth/sign-up-auth-screen-w-oauth.component.ts => sign-up-auth-screen-w-oauth.ts} (90%) delete mode 100644 examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/index.ts rename examples/angular/src/app/screens/{sign-up-auth-screen/sign-up-auth-screen.component.ts => sign-up-auth-screen.ts} (83%) delete mode 100644 examples/angular/src/app/screens/sign-up-auth-screen/index.ts diff --git a/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/email-link-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth.ts similarity index 89% rename from examples/angular/src/app/screens/email-link-auth-screen-w-oauth/email-link-auth-screen-w-oauth.component.ts rename to examples/angular/src/app/screens/email-link-auth-screen-w-oauth.ts index 331551c38..8fb5c0263 100644 --- a/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/email-link-auth-screen-w-oauth.component.ts +++ b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth.ts @@ -17,7 +17,6 @@ import { Component, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; import { EmailLinkAuthScreenComponent, GoogleSignInButtonComponent } from "@invertase/firebaseui-angular"; -import type { UserCredential } from "firebase/auth"; import { Router } from "@angular/router"; @Component({ @@ -25,7 +24,7 @@ import { Router } from "@angular/router"; standalone: true, imports: [CommonModule, EmailLinkAuthScreenComponent, GoogleSignInButtonComponent], template: ` - + `, @@ -38,8 +37,7 @@ export class EmailLinkAuthScreenWithOAuthComponent { alert("email sent - please check your email"); } - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/index.ts deleted file mode 100644 index 13f2e186d..000000000 --- a/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./email-link-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/email-link-auth-screen/email-link-auth-screen.component.ts b/examples/angular/src/app/screens/email-link-auth-screen.ts similarity index 87% rename from examples/angular/src/app/screens/email-link-auth-screen/email-link-auth-screen.component.ts rename to examples/angular/src/app/screens/email-link-auth-screen.ts index 55dcefb66..3a702d5d5 100644 --- a/examples/angular/src/app/screens/email-link-auth-screen/email-link-auth-screen.component.ts +++ b/examples/angular/src/app/screens/email-link-auth-screen.ts @@ -17,14 +17,13 @@ import { Component, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; import { EmailLinkAuthScreenComponent } from "@invertase/firebaseui-angular"; -import type { UserCredential } from "firebase/auth"; import { Router } from "@angular/router"; @Component({ selector: "app-email-link-auth-screen", standalone: true, imports: [CommonModule, EmailLinkAuthScreenComponent], - template: ` `, + template: ` `, styles: [], }) export class EmailLinkAuthScreenWrapperComponent { @@ -34,8 +33,7 @@ export class EmailLinkAuthScreenWrapperComponent { alert("email sent - please check your email"); } - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/email-link-auth-screen/index.ts b/examples/angular/src/app/screens/email-link-auth-screen/index.ts deleted file mode 100644 index 3d995dddc..000000000 --- a/examples/angular/src/app/screens/email-link-auth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./email-link-auth-screen.component"; diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/forgot-password-auth-screen-w-handlers.component.ts b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers.ts similarity index 100% rename from examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/forgot-password-auth-screen-w-handlers.component.ts rename to examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers.ts diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/index.ts b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/index.ts deleted file mode 100644 index 227203450..000000000 --- a/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./forgot-password-auth-screen-w-handlers.component"; diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts b/examples/angular/src/app/screens/forgot-password-auth-screen.ts similarity index 100% rename from examples/angular/src/app/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts rename to examples/angular/src/app/screens/forgot-password-auth-screen.ts diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen/index.ts b/examples/angular/src/app/screens/forgot-password-auth-screen/index.ts deleted file mode 100644 index 6cc32654d..000000000 --- a/examples/angular/src/app/screens/forgot-password-auth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./forgot-password-auth-screen.component"; diff --git a/examples/angular/src/app/screens/mfa-enrollment-screen/mfa-enrollment-screen.component.ts b/examples/angular/src/app/screens/mfa-enrollment-screen.ts similarity index 100% rename from examples/angular/src/app/screens/mfa-enrollment-screen/mfa-enrollment-screen.component.ts rename to examples/angular/src/app/screens/mfa-enrollment-screen.ts diff --git a/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts b/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts deleted file mode 100644 index 1402c2ecd..000000000 --- a/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./mfa-enrollment-screen.component"; diff --git a/examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts b/examples/angular/src/app/screens/oauth-screen.ts similarity index 91% rename from examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts rename to examples/angular/src/app/screens/oauth-screen.ts index 80690c2dd..1424a5e70 100644 --- a/examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts +++ b/examples/angular/src/app/screens/oauth-screen.ts @@ -25,7 +25,6 @@ import { MicrosoftSignInButtonComponent, TwitterSignInButtonComponent, } from "@invertase/firebaseui-angular"; -import type { UserCredential } from "firebase/auth"; import { Router } from "@angular/router"; @Component({ @@ -42,7 +41,7 @@ import { Router } from "@angular/router"; TwitterSignInButtonComponent, ], template: ` - + @@ -63,8 +62,7 @@ export class OAuthScreenWrapperComponent { themed = signal(false); private router = inject(Router); - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/oauth-screen/index.ts b/examples/angular/src/app/screens/oauth-screen/index.ts deleted file mode 100644 index 6fed7e762..000000000 --- a/examples/angular/src/app/screens/oauth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./oauth-screen.component"; diff --git a/examples/angular/src/app/screens/phone-auth-screen-w-oauth/phone-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/phone-auth-screen-w-oauth.ts similarity index 83% rename from examples/angular/src/app/screens/phone-auth-screen-w-oauth/phone-auth-screen-w-oauth.component.ts rename to examples/angular/src/app/screens/phone-auth-screen-w-oauth.ts index 9fac00203..f5e50ef68 100644 --- a/examples/angular/src/app/screens/phone-auth-screen-w-oauth/phone-auth-screen-w-oauth.component.ts +++ b/examples/angular/src/app/screens/phone-auth-screen-w-oauth.ts @@ -17,7 +17,6 @@ import { Component, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; import { PhoneAuthScreenComponent, GoogleSignInButtonComponent, ContentComponent } from "@invertase/firebaseui-angular"; -import type { UserCredential } from "firebase/auth"; import { Router } from "@angular/router"; @Component({ @@ -25,9 +24,9 @@ import { Router } from "@angular/router"; standalone: true, imports: [CommonModule, PhoneAuthScreenComponent, GoogleSignInButtonComponent, ContentComponent], template: ` - + - + `, @@ -36,8 +35,7 @@ import { Router } from "@angular/router"; export class PhoneAuthScreenWithOAuthComponent { private router = inject(Router); - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/phone-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/phone-auth-screen-w-oauth/index.ts deleted file mode 100644 index 3d0a30e0d..000000000 --- a/examples/angular/src/app/screens/phone-auth-screen-w-oauth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./phone-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.ts b/examples/angular/src/app/screens/phone-auth-screen.ts similarity index 84% rename from examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.ts rename to examples/angular/src/app/screens/phone-auth-screen.ts index add17ca3f..e0ff32d21 100644 --- a/examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.ts +++ b/examples/angular/src/app/screens/phone-auth-screen.ts @@ -17,21 +17,19 @@ import { Component, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; import { PhoneAuthScreenComponent } from "@invertase/firebaseui-angular"; -import type { UserCredential } from "firebase/auth"; import { Router } from "@angular/router"; @Component({ selector: "app-phone-auth-screen", standalone: true, imports: [CommonModule, PhoneAuthScreenComponent], - template: ` `, + template: ` `, styles: [], }) export class PhoneAuthScreenWrapperComponent { private router = inject(Router); - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/phone-auth-screen/index.ts b/examples/angular/src/app/screens/phone-auth-screen/index.ts deleted file mode 100644 index ae65a8ce7..000000000 --- a/examples/angular/src/app/screens/phone-auth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./phone-auth-screen.component"; diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/sign-in-auth-screen-w-handlers.component.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers.ts similarity index 84% rename from examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/sign-in-auth-screen-w-handlers.component.ts rename to examples/angular/src/app/screens/sign-in-auth-screen-w-handlers.ts index ce7a7d296..063c52c62 100644 --- a/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/sign-in-auth-screen-w-handlers.component.ts +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers.ts @@ -24,11 +24,7 @@ import { SignInAuthScreenComponent } from "@invertase/firebaseui-angular"; standalone: true, imports: [CommonModule, SignInAuthScreenComponent], template: ` - + `, styles: [], }) @@ -43,8 +39,7 @@ export class SignInAuthScreenWithHandlersComponent { this.router.navigate(["/screens/sign-up-auth-screen"]); } - onSignIn(credential: unknown) { - console.log(credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/index.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/index.ts deleted file mode 100644 index d498e8f0b..000000000 --- a/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-in-auth-screen-w-handlers.component"; diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/sign-in-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth.ts similarity index 90% rename from examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/sign-in-auth-screen-w-oauth.component.ts rename to examples/angular/src/app/screens/sign-in-auth-screen-w-oauth.ts index dd0048828..56cf346ba 100644 --- a/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/sign-in-auth-screen-w-oauth.component.ts +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth.ts @@ -26,7 +26,6 @@ import { MicrosoftSignInButtonComponent, TwitterSignInButtonComponent, } from "@invertase/firebaseui-angular"; -import type { UserCredential } from "firebase/auth"; import { Router } from "@angular/router"; @Component({ @@ -44,7 +43,7 @@ import { Router } from "@angular/router"; TwitterSignInButtonComponent, ], template: ` - + @@ -60,8 +59,7 @@ import { Router } from "@angular/router"; export class SignInAuthScreenWithOAuthComponent { private router = inject(Router); - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/index.ts deleted file mode 100644 index 2697e1510..000000000 --- a/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-in-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts b/examples/angular/src/app/screens/sign-in-auth-screen.ts similarity index 83% rename from examples/angular/src/app/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts rename to examples/angular/src/app/screens/sign-in-auth-screen.ts index 2475549dd..b87cee771 100644 --- a/examples/angular/src/app/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts +++ b/examples/angular/src/app/screens/sign-in-auth-screen.ts @@ -17,21 +17,19 @@ import { Component, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; import { SignInAuthScreenComponent } from "@invertase/firebaseui-angular"; -import type { UserCredential } from "firebase/auth"; import { Router } from "@angular/router"; @Component({ selector: "app-sign-in-auth-screen", standalone: true, imports: [CommonModule, SignInAuthScreenComponent], - template: ` `, + template: ` `, styles: [], }) export class SignInAuthScreenWrapperComponent { private router = inject(Router); - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/sign-in-auth-screen/index.ts b/examples/angular/src/app/screens/sign-in-auth-screen/index.ts deleted file mode 100644 index 744e4f844..000000000 --- a/examples/angular/src/app/screens/sign-in-auth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-in-auth-screen.component"; diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/sign-up-auth-screen-w-handlers.component.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers.ts similarity index 86% rename from examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/sign-up-auth-screen-w-handlers.component.ts rename to examples/angular/src/app/screens/sign-up-auth-screen-w-handlers.ts index 8aa94af46..f3b0b73d9 100644 --- a/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/sign-up-auth-screen-w-handlers.component.ts +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers.ts @@ -23,7 +23,7 @@ import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; selector: "app-sign-up-auth-screen-w-handlers", standalone: true, imports: [CommonModule, SignUpAuthScreenComponent], - template: ` `, + template: ``, styles: [], }) export class SignUpAuthScreenWithHandlersComponent { @@ -33,8 +33,7 @@ export class SignUpAuthScreenWithHandlersComponent { this.router.navigate(["/screens/sign-in-auth-screen"]); } - onSignUp(credential: unknown) { - console.log(credential); + onSignUp() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/index.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/index.ts deleted file mode 100644 index 64db728c0..000000000 --- a/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-up-auth-screen-w-handlers.component"; diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/sign-up-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth.ts similarity index 90% rename from examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/sign-up-auth-screen-w-oauth.component.ts rename to examples/angular/src/app/screens/sign-up-auth-screen-w-oauth.ts index c172ead67..450120e07 100644 --- a/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/sign-up-auth-screen-w-oauth.component.ts +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth.ts @@ -26,7 +26,6 @@ import { MicrosoftSignInButtonComponent, TwitterSignInButtonComponent, } from "@invertase/firebaseui-angular"; -import type { UserCredential } from "firebase/auth"; import { Router } from "@angular/router"; @Component({ @@ -44,7 +43,7 @@ import { Router } from "@angular/router"; TwitterSignInButtonComponent, ], template: ` - + @@ -60,8 +59,7 @@ import { Router } from "@angular/router"; export class SignUpAuthScreenWithOAuthComponent { private router = inject(Router); - onSignUp(credential: UserCredential) { - console.log("sign up", credential); + onSignUp() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/index.ts deleted file mode 100644 index cc1567d01..000000000 --- a/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-up-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts b/examples/angular/src/app/screens/sign-up-auth-screen.ts similarity index 83% rename from examples/angular/src/app/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts rename to examples/angular/src/app/screens/sign-up-auth-screen.ts index 6671ecba7..d235c6800 100644 --- a/examples/angular/src/app/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts +++ b/examples/angular/src/app/screens/sign-up-auth-screen.ts @@ -18,20 +18,18 @@ import { Component, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; import { Router } from "@angular/router"; -import type { UserCredential } from "firebase/auth"; @Component({ selector: "app-sign-up-auth-screen", standalone: true, imports: [CommonModule, SignUpAuthScreenComponent], - template: ` `, + template: ` `, styles: [], }) export class SignUpAuthScreenWrapperComponent { private router = inject(Router); - onSignUp(credential: UserCredential) { - console.log("sign up", credential); + onSignUp() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/sign-up-auth-screen/index.ts b/examples/angular/src/app/screens/sign-up-auth-screen/index.ts deleted file mode 100644 index 41aa28348..000000000 --- a/examples/angular/src/app/screens/sign-up-auth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-up-auth-screen.component"; diff --git a/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts index ef9e77d37..b85ef04cf 100644 --- a/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts @@ -16,6 +16,8 @@ import { render, screen, fireEvent } from "@testing-library/angular"; import { Component, EventEmitter } from "@angular/core"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; import { EmailLinkAuthScreenComponent } from "./email-link-auth-screen"; import { @@ -74,8 +76,25 @@ class TestHostWithContentComponent {} class TestHostWithoutContentComponent {} describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + beforeEach(() => { - const { injectTranslation, injectUI } = require("../../../provider"); + authStateSubject = new Subject(); + + const { injectTranslation, injectUI, injectUserAuthenticated } = require("../../../provider"); + + // Mock injectUserAuthenticated to store the callback and set up subscription + injectUserAuthenticated.mockImplementation((callback: (user: User) => void) => { + userAuthenticatedCallback = callback; + const subscription = authStateSubject.subscribe((user) => { + if (user && !user.isAnonymous && userAuthenticatedCallback) { + userAuthenticatedCallback(user); + } + }); + return subscription; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -94,6 +113,13 @@ describe("", () => { })); }); + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + it("renders with correct title and subtitle", async () => { await render(TestHostWithoutContentComponent, { imports: [ @@ -234,7 +260,7 @@ describe("", () => { expect(container.querySelector("fui-multi-factor-auth-assertion-screen")).toBeInTheDocument(); }); - it("calls signIn output when MFA flow succeeds", async () => { + it("emits signIn when MFA flow succeeds and user authenticates", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => () => ({ multiFactorResolver: { auth: {}, session: null, hints: [] }, @@ -258,15 +284,116 @@ describe("", () => { const component = fixture.debugElement.query((el) => el.name === "fui-email-link-auth-screen").componentInstance; const signInSpy = jest.spyOn(component.signIn, "emit"); - // Simulate MFA success by directly calling the onSuccess handler - const mfaComponent = fixture.debugElement.query( - (el) => el.name === "fui-multi-factor-auth-assertion-screen" - ).componentInstance; - mfaComponent.onSuccess.emit({ user: { uid: "mfa-user" } }); + // Simulate user authenticating after MFA flow succeeds + const mockUser = { + uid: "mfa-user", + email: "email-link@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable (simulating auth state change after MFA) + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); expect(signInSpy).toHaveBeenCalledTimes(1); - expect(signInSpy).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) - ); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("emits signIn when a non-anonymous user authenticates", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-email-link-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate a user authenticating + const mockUser = { + uid: "test-user-123", + email: "test@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("does not emit signIn for anonymous users", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-email-link-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate an anonymous user authenticating + const mockAnonymousUser = { + uid: "anonymous-user-123", + isAnonymous: true, + } as User; + + // Emit the anonymous user through the authState observable + authStateSubject.next(mockAnonymousUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); + }); + + it("does not emit signIn when user is null", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-email-link-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Emit null (no user) through the authState observable + authStateSubject.next(null); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts b/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts index 343ebad09..f0a744659 100644 --- a/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts @@ -23,11 +23,11 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; -import { injectTranslation, injectUI } from "../../provider"; +import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { EmailLinkAuthFormComponent } from "../forms/email-link-auth-form"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; -import { UserCredential } from "@angular/fire/auth"; +import { User } from "@angular/fire/auth"; @Component({ selector: "fui-email-link-auth-screen", @@ -48,7 +48,7 @@ import { UserCredential } from "@angular/fire/auth"; ], template: ` @if (mfaResolver()) { - + } @else {
@@ -57,7 +57,7 @@ import { UserCredential } from "@angular/fire/auth"; {{ subtitleText() }} - + @@ -74,6 +74,12 @@ export class EmailLinkAuthScreenComponent { titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); + constructor() { + injectUserAuthenticated((user) => { + this.signIn.emit(user); + }); + } + @Output() emailSent = new EventEmitter(); - @Output() signIn = new EventEmitter(); + @Output() signIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts index 1b87973fa..83e808db1 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts @@ -17,6 +17,8 @@ import { render, screen } from "@testing-library/angular"; import { Component, EventEmitter } from "@angular/core"; import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; import { OAuthScreenComponent } from "./oauth-screen"; import { @@ -35,6 +37,7 @@ jest.mock("../../../provider", () => ({ injectPolicies: jest.fn(), injectRedirectError: jest.fn(), injectUI: jest.fn(), + injectUserAuthenticated: jest.fn(), })); @Component({ @@ -92,8 +95,25 @@ class MockMultiFactorAuthAssertionScreenComponent { } describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + beforeEach(() => { - const { injectTranslation, injectPolicies, injectRedirectError, injectUI } = require("../../../provider"); + authStateSubject = new Subject(); + + const { injectTranslation, injectPolicies, injectRedirectError, injectUI, injectUserAuthenticated } = require("../../../provider"); + + // Mock injectUserAuthenticated to store the callback and set up subscription + injectUserAuthenticated.mockImplementation((callback: (user: User) => void) => { + userAuthenticatedCallback = callback; + const subscription = authStateSubject.subscribe((user) => { + if (user && !user.isAnonymous && userAuthenticatedCallback) { + userAuthenticatedCallback(user); + } + }); + return subscription; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -122,6 +142,13 @@ describe("", () => { }); }); + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + it("renders with correct title and subtitle", async () => { await render(TestHostWithoutContentComponent, { imports: [ @@ -339,7 +366,7 @@ describe("", () => { expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); - it("emits onSignIn with credential when MFA flow succeeds", async () => { + it("emits onSignIn when MFA flow succeeds and user authenticates", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ @@ -371,14 +398,122 @@ describe("", () => { const component = fixture.debugElement.query((el) => el.name === "fui-oauth-screen").componentInstance; const onSignInSpy = jest.spyOn(component.onSignIn, "emit"); - const mfaScreenComponent = fixture.debugElement.query( - (el) => el.name === "fui-multi-factor-auth-assertion-screen" - ).componentInstance; - mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-oauth-mfa-user" } }); + // Simulate user authenticating after MFA flow succeeds + const mockUser = { + uid: "angular-oauth-mfa-user", + email: "oauth@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable (simulating auth state change after MFA) + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); expect(onSignInSpy).toHaveBeenCalledTimes(1); - expect(onSignInSpy).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "angular-oauth-mfa-user" }) }) - ); + expect(onSignInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("emits onSignIn when a non-anonymous user authenticates", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-oauth-screen").componentInstance; + const onSignInSpy = jest.spyOn(component.onSignIn, "emit"); + + // Simulate a user authenticating + const mockUser = { + uid: "test-user-123", + email: "test@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(onSignInSpy).toHaveBeenCalledTimes(1); + expect(onSignInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("does not emit onSignIn for anonymous users", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-oauth-screen").componentInstance; + const onSignInSpy = jest.spyOn(component.onSignIn, "emit"); + + // Simulate an anonymous user authenticating + const mockAnonymousUser = { + uid: "anonymous-user-123", + isAnonymous: true, + } as User; + + // Emit the anonymous user through the authState observable + authStateSubject.next(mockAnonymousUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(onSignInSpy).not.toHaveBeenCalled(); + }); + + it("does not emit onSignIn when user is null", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-oauth-screen").componentInstance; + const onSignInSpy = jest.spyOn(component.onSignIn, "emit"); + + // Emit null (no user) through the authState observable + authStateSubject.next(null); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(onSignInSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.ts b/packages/angular/src/lib/auth/screens/oauth-screen.ts index cb30c6c9d..3fa0a9808 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.ts @@ -23,11 +23,11 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; -import { injectTranslation, injectUI } from "../../provider"; +import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { PoliciesComponent } from "../../components/policies"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; -import { type UserCredential } from "firebase/auth"; +import { type User } from "@angular/fire/auth"; @Component({ selector: "fui-oauth-screen", @@ -48,7 +48,7 @@ import { type UserCredential } from "firebase/auth"; ], template: ` @if (mfaResolver()) { - + } @else {
@@ -76,5 +76,11 @@ export class OAuthScreenComponent { titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); - @Output() onSignIn = new EventEmitter(); + constructor() { + injectUserAuthenticated((user) => { + this.onSignIn.emit(user); + }); + } + + @Output() onSignIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts index 138ea477d..d64cde28e 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts @@ -17,6 +17,8 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; import { PhoneAuthScreenComponent } from "./phone-auth-screen"; import { @@ -64,8 +66,25 @@ class TestHostWithContentComponent {} class TestHostWithoutContentComponent {} describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + beforeEach(() => { - const { injectTranslation, injectUI } = require("../../../provider"); + authStateSubject = new Subject(); + + const { injectTranslation, injectUI, injectUserAuthenticated } = require("../../../provider"); + + // Mock injectUserAuthenticated to store the callback and set up subscription + injectUserAuthenticated.mockImplementation((callback: (user: User) => void) => { + userAuthenticatedCallback = callback; + const subscription = authStateSubject.subscribe((user) => { + if (user && !user.isAnonymous && userAuthenticatedCallback) { + userAuthenticatedCallback(user); + } + }); + return subscription; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -85,6 +104,13 @@ describe("", () => { }); }); + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + it("renders with correct title and subtitle", async () => { await render(TestHostWithoutContentComponent, { imports: [ @@ -270,7 +296,7 @@ describe("", () => { expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); - it("emits signIn with credential when MFA flow succeeds", async () => { + it("emits signIn when MFA flow succeeds and user authenticates", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ @@ -305,14 +331,119 @@ describe("", () => { const component = fixture.debugElement.query((el) => el.name === "fui-phone-auth-screen").componentInstance; const signInSpy = jest.spyOn(component.signIn, "emit"); - const mfaScreenComponent = fixture.debugElement.query( - (el) => el.name === "fui-multi-factor-auth-assertion-screen" - ).componentInstance; - mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-phone-mfa-user" } }); + // Simulate user authenticating after MFA flow succeeds + const mockUser = { + uid: "angular-phone-mfa-user", + email: "phone@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable (simulating auth state change after MFA) + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); expect(signInSpy).toHaveBeenCalledTimes(1); - expect(signInSpy).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "angular-phone-mfa-user" }) }) - ); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("emits signIn when a non-anonymous user authenticates", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-phone-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate a user authenticating + const mockUser = { + uid: "test-user-123", + email: "test@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("does not emit signIn for anonymous users", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-phone-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate an anonymous user authenticating + const mockAnonymousUser = { + uid: "anonymous-user-123", + isAnonymous: true, + } as User; + + // Emit the anonymous user through the authState observable + authStateSubject.next(mockAnonymousUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); + }); + + it("does not emit signIn when user is null", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-phone-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Emit null (no user) through the authState observable + authStateSubject.next(null); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts index 64ed89364..96a5bde86 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts @@ -23,11 +23,11 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; -import { injectTranslation, injectUI } from "../../provider"; +import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { PhoneAuthFormComponent } from "../forms/phone-auth-form"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; -import { UserCredential } from "@angular/fire/auth"; +import { User } from "@angular/fire/auth"; @Component({ selector: "fui-phone-auth-screen", @@ -48,7 +48,7 @@ import { UserCredential } from "@angular/fire/auth"; ], template: ` @if (mfaResolver()) { - + } @else {
@@ -57,7 +57,7 @@ import { UserCredential } from "@angular/fire/auth"; {{ subtitleText() }} - + @@ -74,5 +74,11 @@ export class PhoneAuthScreenComponent { titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); - @Output() signIn = new EventEmitter(); + constructor() { + injectUserAuthenticated((user) => { + this.signIn.emit(user); + }); + } + + @Output() signIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts index 77e1c0d28..33a4580b8 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts @@ -17,6 +17,8 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; import { SignInAuthScreenComponent } from "./sign-in-auth-screen"; import { @@ -64,8 +66,29 @@ class TestHostWithContentComponent {} class TestHostWithoutContentComponent {} describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + beforeEach(() => { - const { injectTranslation, injectUI } = require("../../../provider"); + authStateSubject = new Subject(); + + // Store the callback so we can trigger it later + const { injectTranslation, injectUI, injectUserAuthenticated } = require("../../../provider"); + + // Mock injectUserAuthenticated to store the callback and set up subscription + injectUserAuthenticated.mockImplementation((callback: (user: User) => void) => { + userAuthenticatedCallback = callback; + // Set up subscription similar to the real implementation + const subscription = authStateSubject.subscribe((user) => { + if (user && !user.isAnonymous && userAuthenticatedCallback) { + userAuthenticatedCallback(user); + } + }); + // Note: In the real implementation, this is cleaned up in an effect's onCleanup + // For testing, we'll manage it manually + return subscription; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -85,6 +108,13 @@ describe("", () => { }); }); + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + it("renders with correct title and subtitle", async () => { await render(TestHostWithoutContentComponent, { imports: [ @@ -270,7 +300,7 @@ describe("", () => { expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); - it("emits signIn with credential when MFA flow succeeds", async () => { + it("emits signIn when MFA flow succeeds and user authenticates", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ @@ -305,14 +335,119 @@ describe("", () => { const component = fixture.debugElement.query((el) => el.name === "fui-sign-in-auth-screen").componentInstance; const signInSpy = jest.spyOn(component.signIn, "emit"); - const mfaScreenComponent = fixture.debugElement.query( - (el) => el.name === "fui-multi-factor-auth-assertion-screen" - ).componentInstance; - mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-mfa-user" } }); + // Simulate user authenticating after MFA flow succeeds + const mockUser = { + uid: "angular-mfa-user", + email: "mfa@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable (simulating auth state change after MFA) + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); expect(signInSpy).toHaveBeenCalledTimes(1); - expect(signInSpy).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "angular-mfa-user" }) }) - ); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("emits signIn when a non-anonymous user authenticates", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-in-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate a user authenticating + const mockUser = { + uid: "test-user-123", + email: "test@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("does not emit signIn for anonymous users", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-in-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate an anonymous user authenticating + const mockAnonymousUser = { + uid: "anonymous-user-123", + isAnonymous: true, + } as User; + + // Emit the anonymous user through the authState observable + authStateSubject.next(mockAnonymousUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); + }); + + it("does not emit signIn when user is null", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-in-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Emit null (no user) through the authState observable + authStateSubject.next(null); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts index 02efd57b2..14b0dc36d 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { Component, Output, EventEmitter, computed } from "@angular/core"; +import { Component, Output, EventEmitter, computed, inject, effect } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { injectTranslation, injectUI } from "../../provider"; +import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { SignInAuthFormComponent } from "../forms/sign-in-auth-form"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; @@ -28,7 +28,7 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; -import { UserCredential } from "@angular/fire/auth"; +import { Auth, authState, User, UserCredential } from "@angular/fire/auth"; @Component({ selector: "fui-sign-in-auth-screen", standalone: true, @@ -48,7 +48,7 @@ import { UserCredential } from "@angular/fire/auth"; ], template: ` @if (mfaResolver()) { - + } @else {
@@ -57,7 +57,7 @@ import { UserCredential } from "@angular/fire/auth"; {{ subtitleText() }} - + @@ -70,11 +70,16 @@ export class SignInAuthScreenComponent { private ui = injectUI(); mfaResolver = computed(() => this.ui().multiFactorResolver); - titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); + constructor() { + injectUserAuthenticated((user) => { + this.signIn.emit(user); + }); + } + @Output() forgotPassword = new EventEmitter(); @Output() signUp = new EventEmitter(); - @Output() signIn = new EventEmitter(); + @Output() signIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts index 02c113fc9..687205d0e 100644 --- a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts @@ -17,6 +17,8 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; import { SignUpAuthScreenComponent } from "./sign-up-auth-screen"; import { @@ -64,8 +66,25 @@ class TestHostWithContentComponent {} class TestHostWithoutContentComponent {} describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + beforeEach(() => { - const { injectTranslation, injectUI } = require("../../../provider"); + authStateSubject = new Subject(); + + const { injectTranslation, injectUI, injectUserAuthenticated } = require("../../../provider"); + + // Mock injectUserAuthenticated to store the callback and set up subscription + injectUserAuthenticated.mockImplementation((callback: (user: User) => void) => { + userAuthenticatedCallback = callback; + const subscription = authStateSubject.subscribe((user) => { + if (user && !user.isAnonymous && userAuthenticatedCallback) { + userAuthenticatedCallback(user); + } + }); + return subscription; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -85,6 +104,13 @@ describe("", () => { }); }); + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + it("renders with correct title and subtitle", async () => { await render(TestHostWithoutContentComponent, { imports: [ @@ -269,7 +295,7 @@ describe("", () => { expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); - it("emits signUp with credential when MFA flow succeeds", async () => { + it("emits signUp when MFA flow succeeds and user authenticates", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ @@ -304,14 +330,119 @@ describe("", () => { const component = fixture.debugElement.query((el) => el.name === "fui-sign-up-auth-screen").componentInstance; const signUpSpy = jest.spyOn(component.signUp, "emit"); - const mfaScreenComponent = fixture.debugElement.query( - (el) => el.name === "fui-multi-factor-auth-assertion-screen" - ).componentInstance; - mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-signup-mfa-user" } }); + // Simulate user authenticating after MFA flow succeeds + const mockUser = { + uid: "angular-signup-mfa-user", + email: "signup@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable (simulating auth state change after MFA) + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); expect(signUpSpy).toHaveBeenCalledTimes(1); - expect(signUpSpy).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "angular-signup-mfa-user" }) }) - ); + expect(signUpSpy).toHaveBeenCalledWith(mockUser); + }); + + it("emits signUp when a non-anonymous user authenticates", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-up-auth-screen").componentInstance; + const signUpSpy = jest.spyOn(component.signUp, "emit"); + + // Simulate a user authenticating + const mockUser = { + uid: "test-user-123", + email: "test@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signUpSpy).toHaveBeenCalledTimes(1); + expect(signUpSpy).toHaveBeenCalledWith(mockUser); + }); + + it("does not emit signUp for anonymous users", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-up-auth-screen").componentInstance; + const signUpSpy = jest.spyOn(component.signUp, "emit"); + + // Simulate an anonymous user authenticating + const mockAnonymousUser = { + uid: "anonymous-user-123", + isAnonymous: true, + } as User; + + // Emit the anonymous user through the authState observable + authStateSubject.next(mockAnonymousUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signUpSpy).not.toHaveBeenCalled(); + }); + + it("does not emit signUp when user is null", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-up-auth-screen").componentInstance; + const signUpSpy = jest.spyOn(component.signUp, "emit"); + + // Emit null (no user) through the authState observable + authStateSubject.next(null); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signUpSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts index 48e16a14e..f479917a1 100644 --- a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts @@ -16,9 +16,9 @@ import { Component, Output, EventEmitter, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { UserCredential } from "@angular/fire/auth"; +import { User } from "@angular/fire/auth"; -import { injectTranslation, injectUI } from "../../provider"; +import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { SignUpAuthFormComponent } from "../forms/sign-up-auth-form"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; @@ -49,7 +49,7 @@ import { ], template: ` @if (mfaResolver()) { - + } @else {
@@ -58,7 +58,7 @@ import { {{ subtitleText() }} - + @@ -75,6 +75,12 @@ export class SignUpAuthScreenComponent { titleText = injectTranslation("labels", "signUp"); subtitleText = injectTranslation("prompts", "enterDetailsToCreate"); - @Output() signUp = new EventEmitter(); + constructor() { + injectUserAuthenticated((user) => { + this.signUp.emit(user); + }); + } + + @Output() signUp = new EventEmitter(); @Output() signIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/provider.ts b/packages/angular/src/lib/provider.ts index 815dc6c3e..0c1d1b480 100644 --- a/packages/angular/src/lib/provider.ts +++ b/packages/angular/src/lib/provider.ts @@ -30,7 +30,7 @@ import { } from "@angular/core"; import { isPlatformBrowser } from "@angular/common"; import { FirebaseApps } from "@angular/fire/app"; -import { Auth } from "@angular/fire/auth"; +import { Auth, authState, User } from "@angular/fire/auth"; import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, @@ -95,6 +95,23 @@ export function injectUI() { return ui.asReadonly(); } +export function injectUserAuthenticated(onAuthenticated: (user: User) => void) { + const auth = inject(Auth); + const state = authState(auth); + + effect((onCleanup) => { + const subscription = state.subscribe((user) => { + if (user && !user.isAnonymous) { + onAuthenticated(user); + } + }); + + onCleanup(() => { + subscription.unsubscribe(); + }); + }); +} + export function injectRecaptchaVerifier(element: () => ElementRef) { const ui = injectUI(); const platformId = inject(PLATFORM_ID); diff --git a/packages/angular/src/lib/tests/test-helpers.ts b/packages/angular/src/lib/tests/test-helpers.ts index 4d6a977c0..f606320d8 100644 --- a/packages/angular/src/lib/tests/test-helpers.ts +++ b/packages/angular/src/lib/tests/test-helpers.ts @@ -287,6 +287,8 @@ export const injectRecaptchaVerifier = jest.fn().mockImplementation(() => { }); }); +export const injectUserAuthenticated = jest.fn(); + export const RecaptchaVerifier = jest.fn().mockImplementation(() => ({ clear: jest.fn(), render: jest.fn(), From e3cbd7992b4e133555cce268cccc7a6453f04440 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 13 Nov 2025 01:21:36 +0000 Subject: [PATCH 5/6] chore: Update shadcn example --- .../src/components/email-link-auth-screen.tsx | 11 +- .../shadcn/src/components/oauth-screen.tsx | 13 +- .../src/components/phone-auth-screen.tsx | 20 ++- .../src/components/sign-in-auth-screen.tsx | 10 +- .../src/components/sign-up-auth-screen.tsx | 11 +- examples/shadcn/src/components/ui/alert.tsx | 39 +++-- examples/shadcn/src/components/ui/button.tsx | 30 ++-- examples/shadcn/src/components/ui/card.tsx | 62 +++++-- examples/shadcn/src/components/ui/form.tsx | 111 +++++++----- .../shadcn/src/components/ui/input-otp.tsx | 39 +++-- examples/shadcn/src/components/ui/input.tsx | 8 +- examples/shadcn/src/components/ui/label.tsx | 17 +- examples/shadcn/src/components/ui/select.tsx | 75 +++++--- .../shadcn/src/components/ui/separator.tsx | 10 +- examples/shadcn/src/index.css | 2 +- .../email-link-auth-screen-w-oauth.tsx | 9 +- .../src/screens/email-link-auth-screen.tsx | 14 +- examples/shadcn/src/screens/oauth-screen.tsx | 8 +- .../src/screens/phone-auth-screen-w-oauth.tsx | 3 +- .../shadcn/src/screens/phone-auth-screen.tsx | 3 +- .../sign-up-auth-screen-w-handlers.tsx | 2 +- .../screens/sign-up-auth-screen-w-oauth.tsx | 3 +- pnpm-lock.yaml | 160 +++++++++--------- 23 files changed, 408 insertions(+), 252 deletions(-) diff --git a/examples/shadcn/src/components/email-link-auth-screen.tsx b/examples/shadcn/src/components/email-link-auth-screen.tsx index 88828712c..171a55de7 100644 --- a/examples/shadcn/src/components/email-link-auth-screen.tsx +++ b/examples/shadcn/src/components/email-link-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type EmailLinkAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type EmailLinkAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -11,15 +11,16 @@ import { RedirectError } from "@/components/redirect-error"; export type { EmailLinkAuthScreenProps }; -export function EmailLinkAuthScreen({ children, ...props }: EmailLinkAuthScreenProps) { +export function EmailLinkAuthScreen({ children, onSignIn, ...props }: EmailLinkAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/examples/shadcn/src/components/oauth-screen.tsx b/examples/shadcn/src/components/oauth-screen.tsx index 1281d74e1..a586527d2 100644 --- a/examples/shadcn/src/components/oauth-screen.tsx +++ b/examples/shadcn/src/components/oauth-screen.tsx @@ -1,16 +1,16 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { type UserCredential } from "firebase/auth"; +import { type User } from "firebase/auth"; import { type PropsWithChildren } from "react"; -import { useUI } from "@invertase/firebaseui-react"; +import { useUI, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; import { Policies } from "@/components/policies"; import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; import { RedirectError } from "@/components/redirect-error"; export type OAuthScreenProps = PropsWithChildren<{ - onSignIn?: (credential: UserCredential) => void; + onSignIn?: (user: User) => void; }>; export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { @@ -18,10 +18,11 @@ export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/examples/shadcn/src/components/phone-auth-screen.tsx b/examples/shadcn/src/components/phone-auth-screen.tsx index ad31fae73..7908c58c8 100644 --- a/examples/shadcn/src/components/phone-auth-screen.tsx +++ b/examples/shadcn/src/components/phone-auth-screen.tsx @@ -2,24 +2,28 @@ import type { PropsWithChildren } from "react"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI } from "@invertase/firebaseui-react"; +import { useUI, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { PhoneAuthForm, type PhoneAuthFormProps } from "@/components/phone-auth-form"; +import { PhoneAuthForm } from "@/components/phone-auth-form"; import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; import { RedirectError } from "@/components/redirect-error"; +import type { User } from "firebase/auth"; -export type PhoneAuthScreenProps = PropsWithChildren; +export type PhoneAuthScreenProps = PropsWithChildren<{ + onSignIn?: (user: User) => void; +}>; -export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { +export function PhoneAuthScreen({ children, onSignIn }: PhoneAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( @@ -30,7 +34,7 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { {subtitleText} - + {children ? ( <> diff --git a/examples/shadcn/src/components/sign-in-auth-screen.tsx b/examples/shadcn/src/components/sign-in-auth-screen.tsx index 322c34848..3397fac0d 100644 --- a/examples/shadcn/src/components/sign-in-auth-screen.tsx +++ b/examples/shadcn/src/components/sign-in-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type SignInAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type SignInAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -10,16 +10,16 @@ import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-a export type { SignInAuthScreenProps }; -export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps) { +export function SignInAuthScreen({ children, onSignIn, ...props }: SignInAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignIn); - if (mfaResolver) { - return ; + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/examples/shadcn/src/components/sign-up-auth-screen.tsx b/examples/shadcn/src/components/sign-up-auth-screen.tsx index 23838f3f7..f358a163e 100644 --- a/examples/shadcn/src/components/sign-up-auth-screen.tsx +++ b/examples/shadcn/src/components/sign-up-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type SignUpAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type SignUpAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -10,15 +10,16 @@ import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-a export type { SignUpAuthScreenProps }; -export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) { +export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signUp"); const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignUp); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/examples/shadcn/src/components/ui/alert.tsx b/examples/shadcn/src/components/ui/alert.tsx index c6f7846fd..14213546e 100644 --- a/examples/shadcn/src/components/ui/alert.tsx +++ b/examples/shadcn/src/components/ui/alert.tsx @@ -1,7 +1,7 @@ -import * as React from "react"; -import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils" const alertVariants = cva( "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", @@ -17,23 +17,40 @@ const alertVariants = cva( variant: "default", }, } -); +) -function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps) { - return
; +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) } function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { return (
- ); + ) } -function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { return (
) )} {...props} /> - ); + ) } -export { Alert, AlertTitle, AlertDescription }; +export { Alert, AlertTitle, AlertDescription } diff --git a/examples/shadcn/src/components/ui/button.tsx b/examples/shadcn/src/components/ui/button.tsx index 1ee147901..21409a066 100644 --- a/examples/shadcn/src/components/ui/button.tsx +++ b/examples/shadcn/src/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -14,8 +14,10 @@ const buttonVariants = cva( "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { @@ -32,7 +34,7 @@ const buttonVariants = cva( size: "default", }, } -); +) function Button({ className, @@ -42,11 +44,17 @@ function Button({ ...props }: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean; + asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; + const Comp = asChild ? Slot : "button" - return ; + return ( + + ) } -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/examples/shadcn/src/components/ui/card.tsx b/examples/shadcn/src/components/ui/card.tsx index 9939da87c..681ad980f 100644 --- a/examples/shadcn/src/components/ui/card.tsx +++ b/examples/shadcn/src/components/ui/card.tsx @@ -1,15 +1,18 @@ -import * as React from "react"; +import * as React from "react" -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils" function Card({ className, ...props }: React.ComponentProps<"div">) { return (
- ); + ) } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -22,35 +25,68 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) { )} {...props} /> - ); + ) } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return
; + return ( +
+ ) } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return
; + return ( +
+ ) } function CardAction({ className, ...props }: React.ComponentProps<"div">) { return (
- ); + ) } function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return
; + return ( +
+ ) } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return ( -
- ); +
+ ) } -export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/examples/shadcn/src/components/ui/form.tsx b/examples/shadcn/src/components/ui/form.tsx index cbf278836..7d7474cc9 100644 --- a/examples/shadcn/src/components/ui/form.tsx +++ b/examples/shadcn/src/components/ui/form.tsx @@ -1,6 +1,6 @@ -import * as React from "react"; -import * as LabelPrimitive from "@radix-ui/react-label"; -import { Slot } from "@radix-ui/react-slot"; +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" import { Controller, FormProvider, @@ -9,21 +9,23 @@ import { type ControllerProps, type FieldPath, type FieldValues, -} from "react-hook-form"; +} from "react-hook-form" -import { cn } from "@/lib/utils"; -import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" -const Form = FormProvider; +const Form = FormProvider type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, > = { - name: TName; -}; + name: TName +} -const FormFieldContext = React.createContext({} as FormFieldContextValue); +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) const FormField = < TFieldValues extends FieldValues = FieldValues, @@ -35,21 +37,21 @@ const FormField = < - ); -}; + ) +} const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext); - const itemContext = React.useContext(FormItemContext); - const { getFieldState } = useFormContext(); - const formState = useFormState({ name: fieldContext.name }); - const fieldState = getFieldState(fieldContext.name, formState); + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) if (!fieldContext) { - throw new Error("useFormField should be used within "); + throw new Error("useFormField should be used within ") } - const { id } = itemContext; + const { id } = itemContext return { id, @@ -58,27 +60,36 @@ const useFormField = () => { formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState, - }; -}; + } +} type FormItemContextValue = { - id: string; -}; + id: string +} -const FormItemContext = React.createContext({} as FormItemContextValue); +const FormItemContext = React.createContext( + {} as FormItemContextValue +) function FormItem({ className, ...props }: React.ComponentProps<"div">) { - const id = React.useId(); + const id = React.useId() return ( -
+
- ); + ) } -function FormLabel({ className, ...props }: React.ComponentProps) { - const { error, formItemId } = useFormField(); +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() return (