diff --git a/env/frontend.env b/env/frontend.env index b6d13aa462..dac69ec966 100644 --- a/env/frontend.env +++ b/env/frontend.env @@ -8,6 +8,8 @@ NEXT_PUBLIC_MITOL_API_BASE_URL=${MITOL_API_BASE_URL} NEXT_PUBLIC_CSRF_COOKIE_NAME=${CSRF_COOKIE_NAME} NEXT_PUBLIC_MITOL_SUPPORT_EMAIL=${MITOL_SUPPORT_EMAIL} +NEXT_PUBLIC_POSTHOG_API_KEY=${POSTHOG_PROJECT_API_KEY} + NEXT_PUBLIC_SITE_NAME="MIT Learn" NEXT_PUBLIC_MITOL_AXIOS_WITH_CREDENTIALS=true diff --git a/frontends/main/src/app/providers.tsx b/frontends/main/src/app/providers.tsx index f0e66a07ff..7c0506a702 100644 --- a/frontends/main/src/app/providers.tsx +++ b/frontends/main/src/app/providers.tsx @@ -5,20 +5,20 @@ import { getQueryClient } from "./getQueryClient" import { QueryClientProvider } from "@tanstack/react-query" import { ThemeProvider, NextJsAppRouterCacheProvider } from "ol-components" import { Provider as NiceModalProvider } from "@ebay/nice-modal-react" -import ConfiguredPostHogProvider from "@/components/ConfiguredPostHogProvider/ConfiguredPostHogProvider" +import ConfiguredPostHogProvider from "@/page-components/ConfiguredPostHogProvider/ConfiguredPostHogProvider" export default function Providers({ children }: { children: React.ReactNode }) { const queryClient = getQueryClient() return ( - - + + {children} - - + + ) } diff --git a/frontends/main/src/components/ConfiguredPostHogProvider/ConfiguredPostHogProvider.tsx b/frontends/main/src/components/ConfiguredPostHogProvider/ConfiguredPostHogProvider.tsx deleted file mode 100644 index e69b9afc5d..0000000000 --- a/frontends/main/src/components/ConfiguredPostHogProvider/ConfiguredPostHogProvider.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from "react" -import { PostHogProvider } from "posthog-js/react" - -const ConfiguredPostHogProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const apiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY || "" - const apiHost = - process.env.NEXT_PUBLIC_POSTHOG_API_HOST || "https://us.i.posthog.com" - const featureFlags = JSON.parse(process.env.FEATURE_FLAGS || "") - - const postHogOptions = { - api_host: apiHost, - bootstrap: { - featureFlags: featureFlags, - }, - } - - return apiKey ? ( - - {children} - - ) : ( - children - ) -} - -export default ConfiguredPostHogProvider diff --git a/frontends/main/src/page-components/ConfiguredPostHogProvider/ConfiguredPostHogProvider.test.tsx b/frontends/main/src/page-components/ConfiguredPostHogProvider/ConfiguredPostHogProvider.test.tsx new file mode 100644 index 0000000000..0015b1ea23 --- /dev/null +++ b/frontends/main/src/page-components/ConfiguredPostHogProvider/ConfiguredPostHogProvider.test.tsx @@ -0,0 +1,65 @@ +import React from "react" + +import { render, waitFor } from "@testing-library/react" +import { PosthogIdenifier } from "./ConfiguredPostHogProvider" +import { QueryClientProvider, QueryClient } from "@tanstack/react-query" + +// mock stuff +import { setMockResponse, urls } from "api/test-utils" +import type { User } from "api/hooks/user" +import { makeUserSettings } from "@/test-utils/factories" +import { usePostHog } from "posthog-js/react" +import type { PostHog } from "posthog-js" + +jest.mock("posthog-js/react", () => { + return { + __esModule: true, + usePostHog: jest.fn(), + } +}) +const mockUsePostHog = jest.mocked(usePostHog) +const posthog: Pick = { + identify: jest.fn(), + reset: jest.fn(), + get_property: jest.fn(), +} +mockUsePostHog.mockReturnValue(posthog as PostHog) +const mockPosthog = jest.mocked(posthog) + +describe("PosthogIdenifier", () => { + const setup = (user: Partial) => { + const queryClient = new QueryClient() + const userData = makeUserSettings(user) + + setMockResponse.get(urls.userMe.get(), userData) + render( + + + , + ) + return userData + } + test.each([ + { posthogUserState: "anonymous", resetCalls: 0 }, + { posthogUserState: "anything_else", resetCalls: 1 }, + ])( + "If user is NOT authenticated, calls `reset` if and only if not already anonymous", + async ({ posthogUserState, resetCalls }) => { + setup({ is_authenticated: false }) + mockPosthog.get_property.mockReturnValue(posthogUserState) + await waitFor(() => { + expect(mockPosthog.get_property).toHaveBeenCalledWith("$user_state") + }) + expect(mockPosthog.reset).toHaveBeenCalledTimes(resetCalls) + expect(mockPosthog.identify).not.toHaveBeenCalled() + }, + ) + + test("If authenticated, calls `identify` with user id and username", async () => { + const user = setup({ is_authenticated: true }) + await waitFor(() => { + expect(mockPosthog.identify).toHaveBeenCalledWith(String(user.id)) + }) + expect(mockPosthog.reset).not.toHaveBeenCalled() + }) +}) diff --git a/frontends/main/src/page-components/ConfiguredPostHogProvider/ConfiguredPostHogProvider.tsx b/frontends/main/src/page-components/ConfiguredPostHogProvider/ConfiguredPostHogProvider.tsx new file mode 100644 index 0000000000..b39e87ef77 --- /dev/null +++ b/frontends/main/src/page-components/ConfiguredPostHogProvider/ConfiguredPostHogProvider.tsx @@ -0,0 +1,57 @@ +import React, { useEffect } from "react" +import { PostHogProvider, usePostHog } from "posthog-js/react" +import { useUserMe } from "api/hooks/user" +import type { PostHogConfig } from "posthog-js" + +const PosthogIdenifier = () => { + const { data: user } = useUserMe() + const posthog = usePostHog() + /** + * Posthog docs recommend calling `posthog.identify` on signin and + * `posthog.reset` on signout. But signin and signout generally occur on the + * SSO server, which users could get to via other means. + * + * So instead, when page first loads: + * 1. Identify user (noop if user already identified) + * 2. If user is not authenticated AND posthog thinks they are not anonymous, + * then reset their posthog state. + */ + useEffect(() => { + if (!user) return + const anonymous = posthog.get_property("$user_state") === "anonymous" + if (user.is_authenticated && user.id) { + posthog.identify(String(user.id)) + } else if (!anonymous) { + posthog.reset() + } + }, [user, posthog]) + return null +} + +const ConfiguredPostHogProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const apiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY || "" + const apiHost = + process.env.NEXT_PUBLIC_POSTHOG_API_HOST || "https://us.i.posthog.com" + const featureFlags = JSON.parse(process.env.FEATURE_FLAGS || "") + + const postHogOptions: Partial = { + api_host: apiHost, + bootstrap: { + featureFlags: featureFlags, + }, + } + + return apiKey ? ( + + + {children} + + ) : ( + children + ) +} + +export default ConfiguredPostHogProvider +export { PosthogIdenifier } diff --git a/frontends/main/src/test-utils/index.tsx b/frontends/main/src/test-utils/index.tsx index 155f46fd02..877d8f684e 100644 --- a/frontends/main/src/test-utils/index.tsx +++ b/frontends/main/src/test-utils/index.tsx @@ -94,10 +94,6 @@ const renderWithProviders = ( return { view, queryClient, location } } -const renderTestApp = () => { - throw new Error("not supported") -} - /** * Assert that a functional component was called at some point with the given * props. @@ -231,7 +227,6 @@ const assertPartialMetas = (expected: Partial) => { } export { - renderTestApp, renderWithProviders, expectProps, expectLastProps,