diff --git a/packages/core/package.json b/packages/core/package.json index 9f5768df..10d03584 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,6 +52,7 @@ "zod": "catalog:" }, "devDependencies": { + "@types/google-one-tap": "^1.2.6", "@types/jsdom": "catalog:", "firebase": "catalog:", "jsdom": "catalog:", @@ -60,7 +61,7 @@ "tsup": "catalog:", "typescript": "catalog:", "vite": "catalog:", - "vitest-tsconfig-paths": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "vitest-tsconfig-paths": "catalog:" } } diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index bc036743..6f91fb3f 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -21,6 +21,7 @@ import { sendSignInLinkToEmail as _sendSignInLinkToEmail, signInAnonymously as _signInAnonymously, signInWithPhoneNumber as _signInWithPhoneNumber, + signInWithCredential as _signInWithCredential, ActionCodeSettings, ApplicationVerifier, AuthProvider, @@ -28,9 +29,9 @@ import { EmailAuthProvider, linkWithCredential, PhoneAuthProvider, - signInWithCredential, signInWithRedirect, UserCredential, + AuthCredential, } from "firebase/auth"; import { getBehavior, hasBehavior } from "./behaviors"; import { FirebaseUIConfiguration } from "./config"; @@ -63,14 +64,14 @@ export async function signInWithEmailAndPassword( if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { const result = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); - + if (result) { return handlePendingCredential(ui, result); } } ui.setState("pending"); - const result = await signInWithCredential(ui.auth, credential); + const result = await _signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -138,7 +139,7 @@ export async function confirmPhoneNumber( } ui.setState("pending"); - const result = await signInWithCredential(ui.auth, credential); + const result = await _signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -193,7 +194,7 @@ export async function signInWithEmailLink( } ui.setState("pending"); - const result = await signInWithCredential(ui.auth, credential); + const result = await _signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -235,6 +236,31 @@ export async function signInWithProvider(ui: FirebaseUIConfiguration, provider: } } +export async function signInWithCredential( + ui: FirebaseUIConfiguration, + credential: AuthCredential +): Promise { + try { + if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { + const userCredential = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); + + // If they got here, they're either not anonymous or they've been linked. + // If the credential has been linked, we don't need to sign them in, so return early. + if (userCredential) { + return handlePendingCredential(ui, userCredential); + } + } + + ui.setState("pending"); + const result = await _signInWithCredential(ui.auth, credential); + return handlePendingCredential(ui, result); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + export async function completeEmailLinkSignIn( ui: FirebaseUIConfiguration, currentUrl: string diff --git a/packages/core/src/behaviors.test.ts b/packages/core/src/behaviors.test.ts index 24433f25..f73146d1 100644 --- a/packages/core/src/behaviors.test.ts +++ b/packages/core/src/behaviors.test.ts @@ -1,13 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createMockUI } from "~/tests/utils"; -import { autoAnonymousLogin, autoUpgradeAnonymousUsers, getBehavior, hasBehavior, recaptchaVerification } from "./behaviors"; -import { Auth, signInAnonymously, User, UserCredential, linkWithCredential, linkWithRedirect, AuthCredential, AuthProvider, RecaptchaVerifier } from "firebase/auth"; +import { autoAnonymousLogin, autoUpgradeAnonymousUsers, getBehavior, hasBehavior, recaptchaVerification, oneTapSignIn } from "./behaviors"; +import { Auth, signInAnonymously, User, UserCredential, linkWithCredential, linkWithRedirect, AuthCredential, AuthProvider, RecaptchaVerifier, GoogleAuthProvider } from "firebase/auth"; +import { signInWithCredential } from "./auth"; vi.mock("firebase/auth", () => ({ signInAnonymously: vi.fn(), linkWithCredential: vi.fn(), linkWithRedirect: vi.fn(), RecaptchaVerifier: vi.fn(), + GoogleAuthProvider: { + credential: vi.fn(), + }, +})); + +vi.mock("./auth", () => ({ + signInWithCredential: vi.fn(), })); describe("hasBehavior", () => { @@ -62,11 +70,9 @@ describe("autoAnonymousLogin", () => { }); it('should sign the user in anonymously if they are not signed in', async () => { - const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); const mockUI = createMockUI({ auth: { currentUser: null, - authStateReady: mockAuthStateReady, } as unknown as Auth, }); @@ -74,18 +80,15 @@ describe("autoAnonymousLogin", () => { await autoAnonymousLogin().autoAnonymousLogin(mockUI); - expect(mockAuthStateReady).toHaveBeenCalled(); expect(signInAnonymously).toHaveBeenCalledWith(mockUI.auth); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["loading"], ["idle"]]); }); it('should not attempt to sign in anonymously if the user is already signed in', async () => { - const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); const mockUI = createMockUI({ auth: { currentUser: { uid: "test-user" } as User, - authStateReady: mockAuthStateReady, } as unknown as Auth, }); @@ -93,16 +96,16 @@ describe("autoAnonymousLogin", () => { await autoAnonymousLogin().autoAnonymousLogin(mockUI); - expect(mockAuthStateReady).toHaveBeenCalled(); expect(signInAnonymously).not.toHaveBeenCalled(); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([ ["idle"]]); }); it("should return noop behavior in SSR mode", async () => { - // Mock window as undefined to simulate SSR + const originalWindow = global.window; - // @ts-ignore + + // @ts-expect-error delete global.window; const behavior = autoAnonymousLogin(); @@ -319,4 +322,296 @@ describe("recaptchaVerification", () => { }); }); +describe("oneTapSignIn", () => { + beforeEach(() => { + vi.clearAllMocks(); + + Object.defineProperty(window, 'google', { + value: { + accounts: { + id: { + initialize: vi.fn(), + prompt: vi.fn(), + }, + }, + }, + writable: true, + }); + + Object.defineProperty(document, 'createElement', { + value: vi.fn(() => ({ + setAttribute: vi.fn(), + src: '', + async: false, + onload: null as (() => void) | null, + })), + writable: true, + }); + + Object.defineProperty(document, 'querySelector', { + value: vi.fn(), + writable: true, + }); + + Object.defineProperty(document.body, 'appendChild', { + value: vi.fn(), + writable: true, + }); + }); + + it("should initialize Google One Tap with default options", () => { + const mockScript = { + setAttribute: vi.fn(), + src: '', + async: false, + onload: null as (() => void) | null, + }; + + vi.mocked(document.createElement).mockReturnValue(mockScript as any); + vi.mocked(document.querySelector).mockReturnValue(null); + + const mockUI = createMockUI({ + auth: { + currentUser: null, + } as unknown as Auth, + }); + + const options = { + clientId: "test-client-id", + }; + + const behavior = oneTapSignIn(options); + behavior.oneTapSignIn(mockUI); + + expect(document.createElement).toHaveBeenCalledWith('script'); + expect(mockScript.setAttribute).toHaveBeenCalledWith('data-one-tap-sign-in', 'true'); + expect(mockScript.src).toBe('https://accounts.google.com/gsi/client'); + expect(mockScript.async).toBe(true); + expect(document.body.appendChild).toHaveBeenCalledWith(mockScript); + }); + + it("should initialize Google One Tap with custom options", () => { + const mockScript = { + setAttribute: vi.fn(), + src: '', + async: false, + onload: null as (() => void) | null, + }; + + vi.mocked(document.createElement).mockReturnValue(mockScript as any); + vi.mocked(document.querySelector).mockReturnValue(null); + + const mockUI = createMockUI({ + auth: { + currentUser: null, + } as unknown as Auth, + }); + + const options = { + clientId: "test-client-id", + autoSelect: true, + cancelOnTapOutside: false, + context: "signin" as const, + uxMode: "popup" as const, + logLevel: "debug" as const, + }; + + const behavior = oneTapSignIn(options); + behavior.oneTapSignIn(mockUI); + + // Simulate script load + const onload = mockScript.onload; + if (onload && typeof onload === 'function') { + onload(); + } + + expect(window.google.accounts.id.initialize).toHaveBeenCalledWith({ + client_id: "test-client-id", + auto_select: true, + cancel_on_tap_outside: false, + context: "signin", + ux_mode: "popup", + log_level: "debug", + callback: expect.any(Function), + }); + expect(window.google.accounts.id.prompt).toHaveBeenCalled(); + }); + + it("should handle callback and sign in with credential", async () => { + const mockScript = { + setAttribute: vi.fn(), + src: '', + async: false, + onload: null as (() => void) | null, + }; + + vi.mocked(document.createElement).mockReturnValue(mockScript as any); + vi.mocked(document.querySelector).mockReturnValue(null); + + const mockCredential = { + providerId: "google.com", + pendingToken: null, + buildRequest: vi.fn() + } as any; + const mockUserCredential = { user: { uid: "test-user" } } as UserCredential; + + vi.mocked(GoogleAuthProvider.credential).mockReturnValue(mockCredential); + vi.mocked(signInWithCredential).mockResolvedValue(mockUserCredential); + + const mockUI = createMockUI({ + auth: { + currentUser: null, + } as unknown as Auth, + }); + + const options = { + clientId: "test-client-id", + }; + + const behavior = oneTapSignIn(options); + behavior.oneTapSignIn(mockUI); + + const onload = mockScript.onload; + if (onload && typeof onload === 'function') { + onload(); + } + + const initializeCall = vi.mocked(window.google.accounts.id.initialize).mock.calls[0]; + const callback = initializeCall?.[0]?.callback; + + const mockResponse = { + credential: "test-credential-string", + select_by: "user" as const, + }; + + if (callback && typeof callback === 'function') { + callback(mockResponse); + } + + expect(GoogleAuthProvider.credential).toHaveBeenCalledWith("test-credential-string"); + expect(signInWithCredential).toHaveBeenCalledWith(mockUI, mockCredential); + }); + + it("should not initialize if user is already signed in (non-anonymous)", () => { + const mockUI = createMockUI({ + auth: { + currentUser: { uid: "test-user", isAnonymous: false } as User, + } as unknown as Auth, + }); + + const options = { + clientId: "test-client-id", + }; + + const behavior = oneTapSignIn(options); + behavior.oneTapSignIn(mockUI); + + expect(document.createElement).not.toHaveBeenCalled(); + }); + + it("should initialize if user is anonymous", () => { + const mockScript = { + setAttribute: vi.fn(), + src: '', + async: false, + onload: null as (() => void) | null, + }; + + vi.mocked(document.createElement).mockReturnValue(mockScript as any); + vi.mocked(document.querySelector).mockReturnValue(null); + + const mockUI = createMockUI({ + auth: { + currentUser: { uid: "anonymous-user", isAnonymous: true } as User, + } as unknown as Auth, + }); + + const options = { + clientId: "test-client-id", + }; + + const behavior = oneTapSignIn(options); + behavior.oneTapSignIn(mockUI); + + expect(document.createElement).toHaveBeenCalledWith('script'); + expect(document.body.appendChild).toHaveBeenCalledWith(mockScript); + }); + + it("should not initialize if script already exists", () => { + const mockExistingScript = { tagName: 'script' }; + vi.mocked(document.querySelector).mockReturnValue(mockExistingScript as any); + + const mockUI = createMockUI({ + auth: { + currentUser: null, + } as unknown as Auth, + }); + + const options = { + clientId: "test-client-id", + }; + + const behavior = oneTapSignIn(options); + behavior.oneTapSignIn(mockUI); + + expect(document.createElement).not.toHaveBeenCalled(); + }); + + it("should return early in SSR mode", () => { + const originalWindow = global.window; + + // @ts-expect-error + delete global.window; + + const mockUI = createMockUI(); + const options = { + clientId: "test-client-id", + }; + + const behavior = oneTapSignIn(options); + behavior.oneTapSignIn(mockUI); + + expect(document.createElement).not.toHaveBeenCalled(); + + global.window = originalWindow; + }); + + it("should work with hasBehavior and getBehavior", () => { + const mockScript = { + setAttribute: vi.fn(), + src: '', + async: false, + onload: null as (() => void) | null, + }; + + vi.mocked(document.createElement).mockReturnValue(mockScript as any); + vi.mocked(document.querySelector).mockReturnValue(null); + + const mockUI = createMockUI({ + auth: { + currentUser: null, + } as unknown as Auth, + behaviors: { + oneTapSignIn: oneTapSignIn({ clientId: "test-client-id" }).oneTapSignIn, + }, + }); + + expect(hasBehavior(mockUI, "oneTapSignIn")).toBe(true); + + const behavior = getBehavior(mockUI, "oneTapSignIn"); + behavior(mockUI); + + expect(document.createElement).toHaveBeenCalledWith('script'); + expect(document.body.appendChild).toHaveBeenCalledWith(mockScript); + }); + + it("should throw error when trying to get non-existent oneTapSignIn behavior", () => { + const mockUI = createMockUI(); + + expect(hasBehavior(mockUI, "oneTapSignIn")).toBe(false); + expect(() => getBehavior(mockUI, "oneTapSignIn")).toThrow("Behavior oneTapSignIn not found"); + }); +}); + + diff --git a/packages/core/src/behaviors.ts b/packages/core/src/behaviors.ts index 43d0b8af..e6631276 100644 --- a/packages/core/src/behaviors.ts +++ b/packages/core/src/behaviors.ts @@ -23,8 +23,11 @@ import { User, UserCredential, RecaptchaVerifier, + GoogleAuthProvider, } from "firebase/auth"; import { FirebaseUIConfiguration } from "./config"; +import { IdConfiguration } from "google-one-tap"; +import { signInWithCredential } from "./auth"; export type BehaviorHandlers = { autoAnonymousLogin: (ui: FirebaseUIConfiguration) => Promise; @@ -34,6 +37,7 @@ export type BehaviorHandlers = { ) => Promise; autoUpgradeAnonymousProvider: (ui: FirebaseUIConfiguration, provider: AuthProvider) => Promise; recaptchaVerification: (ui: FirebaseUIConfiguration, element: HTMLElement) => RecaptchaVerifier; + oneTapSignIn: (ui: FirebaseUIConfiguration) => void; }; export type Behavior = Pick; @@ -69,8 +73,6 @@ export function autoAnonymousLogin(): Behavior<"autoAnonymousLogin"> { autoAnonymousLogin: async (ui) => { const auth = ui.auth; - await auth.authStateReady(); - if (!auth.currentUser) { ui.setState("loading"); await signInAnonymously(auth); @@ -132,6 +134,60 @@ export function recaptchaVerification(options?: RecaptchaVerification): Behavior }; } +export type OneTapSignIn = { + clientId: IdConfiguration['client_id']; + autoSelect?: IdConfiguration['auto_select']; + cancelOnTapOutside?: IdConfiguration['cancel_on_tap_outside']; + context?: IdConfiguration['context']; + uxMode?: IdConfiguration['ux_mode']; + logLevel?: IdConfiguration['log_level']; +}; + +export function oneTapSignIn(options: OneTapSignIn): Behavior<"oneTapSignIn"> { + return { + oneTapSignIn: (ui) => { + if (typeof window === "undefined") { + return; + } + + // Only show one-tap if user is not signed in OR if they are anonymous. + // Don't show if user is already signed in with a real account. + if (ui.auth.currentUser && !ui.auth.currentUser.isAnonymous) { + return; + } + + // Prevent multiple instances of the script from being loaded, e.g. hot reload. + if (document.querySelector('script[data-one-tap-sign-in]')) { + return; + } + + const script = document.createElement('script'); + script.setAttribute('data-one-tap-sign-in', 'true'); + script.src = 'https://accounts.google.com/gsi/client'; + script.async = true; + + script.onload = () => { + window.google.accounts.id.initialize({ + client_id: options.clientId, + auto_select: options.autoSelect, + cancel_on_tap_outside: options.cancelOnTapOutside, + context: options.context, + ux_mode: options.uxMode, + log_level: options.logLevel, + callback: async (response) => { + const credential = GoogleAuthProvider.credential(response.credential); + await signInWithCredential(ui, credential); + }, + }); + + window.google.accounts.id.prompt(); + }; + + document.body.appendChild(script); + }, + } +} + export const defaultBehaviors = { ...recaptchaVerification(), }; \ No newline at end of file diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 7a1b9438..d0006465 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -1,15 +1,42 @@ import { FirebaseApp } from "firebase/app"; import { Auth } from "firebase/auth"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { initializeUI } from "./config"; import { enUs, registerLocale } from "@firebase-ui/translations"; import { autoUpgradeAnonymousUsers, defaultBehaviors } from "./behaviors"; describe('initializeUI', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call authStateReady when initializing UI', async () => { + const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); + const mockAuth = { + authStateReady: mockAuthStateReady, + } as unknown as Auth; + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + }; + + const ui = initializeUI(config); + + // Wait for the authStateReady promise to resolve + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockAuthStateReady).toHaveBeenCalled(); + expect(ui).toBeDefined(); + }); + it('should return a valid deep store with default values', () => { + const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); const config = { app: {} as FirebaseApp, - auth: {} as Auth, + auth: { + authStateReady: mockAuthStateReady, + } as unknown as Auth, }; const ui = initializeUI(config); @@ -23,9 +50,12 @@ describe('initializeUI', () => { }); it('should merge behaviors with defaultBehaviors', () => { + const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); const config = { app: {} as FirebaseApp, - auth: {} as Auth, + auth: { + authStateReady: mockAuthStateReady, + } as unknown as Auth, behaviors: [autoUpgradeAnonymousUsers()], }; @@ -42,9 +72,12 @@ describe('initializeUI', () => { }); it('should set state and update state when called', () => { + const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); const config = { app: {} as FirebaseApp, - auth: {} as Auth, + auth: { + authStateReady: mockAuthStateReady, + } as unknown as Auth, }; const ui = initializeUI(config); @@ -59,9 +92,12 @@ describe('initializeUI', () => { const testLocale1 = registerLocale('test1', {}); const testLocale2 = registerLocale('test2', {}); + const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); const config = { app: {} as FirebaseApp, - auth: {} as Auth, + auth: { + authStateReady: mockAuthStateReady, + } as unknown as Auth, }; const ui = initializeUI(config); @@ -73,9 +109,12 @@ describe('initializeUI', () => { }); it('should include defaultBehaviors even when no custom behaviors are provided', () => { + const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); const config = { app: {} as FirebaseApp, - auth: {} as Auth, + auth: { + authStateReady: mockAuthStateReady, + } as unknown as Auth, }; const ui = initializeUI(config); @@ -91,9 +130,12 @@ describe('initializeUI', () => { } }; + const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); const config = { app: {} as FirebaseApp, - auth: {} as Auth, + auth: { + authStateReady: mockAuthStateReady, + } as unknown as Auth, behaviors: [customRecaptchaVerification], }; @@ -111,9 +153,12 @@ describe('initializeUI', () => { } }; + const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); const config = { app: {} as FirebaseApp, - auth: {} as Auth, + auth: { + authStateReady: mockAuthStateReady, + } as unknown as Auth, behaviors: [behavior1, behavior2], }; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 9d1f3cb2..33baf857 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -80,14 +80,21 @@ export function initializeUI(config: FirebaseUIConfigurationOptions, name: strin }) ); - const ui = $config.get()[name]!; + const store = $config.get()[name]!; + const ui = store.get(); + + ui.auth.authStateReady().then(() => { + if (hasBehavior(ui, "autoAnonymousLogin")) { + getBehavior(ui, "autoAnonymousLogin")(ui); + } else { + store.setKey("state", "idle"); + } + + if (hasBehavior(ui, "oneTapSignIn")) { + getBehavior(ui, "oneTapSignIn")(ui); + } + }); - // TODO(ehesp): Should this belong here - if not, where should it be? - if (hasBehavior(ui.get(), "autoAnonymousLogin")) { - getBehavior(ui.get(), "autoAnonymousLogin")(ui.get()); - } else { - ui.setKey("state", "idle"); - } - return ui; + return store; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75048db8..d2079b4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -520,6 +520,9 @@ importers: specifier: 'catalog:' version: 4.1.11 devDependencies: + '@types/google-one-tap': + specifier: ^1.2.6 + version: 1.2.6 '@types/jsdom': specifier: 'catalog:' version: 21.1.7 @@ -3431,6 +3434,9 @@ packages: '@types/express@4.17.23': resolution: {integrity: sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==} + '@types/google-one-tap@1.2.6': + resolution: {integrity: sha512-REmJsXVHvKb/sgI8DF+7IesMbDbcsEokHBqxU01ENZ8d98UPWdRLhUCtxEm9bhNFFg6PJGy7PNFdvovp0hK3jA==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -11272,6 +11278,8 @@ snapshots: '@types/qs': 6.14.0 '@types/serve-static': 1.15.8 + '@types/google-one-tap@1.2.6': {} + '@types/http-errors@2.0.5': {} '@types/http-proxy@1.17.16':