diff --git a/docs/superpowers/plans/2026-07-29-posthog-user-identification.md b/docs/superpowers/plans/2026-07-29-posthog-user-identification.md new file mode 100644 index 0000000000..be050d84fd --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-posthog-user-identification.md @@ -0,0 +1,55 @@ +# PostHog User Identification TDD Plan + +> **For agentic workers:** Write and run the failing tests before changing production code. + +**Goal:** Associate web analytics with the authenticated Dofek user and clear that association after logout. + +**Behavior:** After authentication bootstrap returns a user, the web client identifies that user to PostHog with the stable database ID, name, and email. After a successful logout request, it resets PostHog before navigation completes. + +**Scope:** Web only, because PostHog is currently installed and initialized only in `packages/web`; adding a second mobile analytics SDK is out of scope. + +**Docs:** [PostHog user identification](https://posthog.com/docs/product-analytics/identify) + +--- + +## Current Evidence + +- `packages/web/src/lib/posthog.ts` initializes PostHog and captures page views but exposes no user identity lifecycle. +- `packages/web/src/lib/auth-context.tsx` is the canonical web authentication lifecycle and already receives the validated `AuthUser`. +- The current [PostHog identification guidance](https://posthog.com/docs/product-analytics/identify) says to identify as soon as the frontend knows the authenticated user and to reset on logout. + +## Test Strategy + +- Unit: Verify the PostHog adapter forwards the stable user ID and person properties to `identify()` and delegates logout cleanup to `reset()`. +- UI/auth lifecycle: Render `AuthProvider` with mocked auth and analytics adapters; verify authenticated bootstrap identifies, unauthenticated bootstrap does not identify, and successful logout resets. +- Platform parity: No mobile change because the mobile package does not use PostHog; this change completes the lifecycle of the existing web-only integration. + +## File Structure + +- Modify: `packages/web/src/lib/posthog.test.ts` — adapter regression tests. +- Modify: `packages/web/src/lib/posthog.ts` — typed identity and reset helpers. +- Create: `packages/web/src/lib/auth-context.test.tsx` — auth lifecycle regression tests. +- Modify: `packages/web/src/lib/auth-context.tsx` — connect authenticated bootstrap/logout to PostHog. + +## Tasks + +### Task 1: Add Failing Tests + +- [x] Add adapter tests for `identify()` and `reset()`. +- [x] Add auth-provider tests for authenticated, anonymous, and logout paths. +- [x] Run `pnpm exec vitest run packages/web/src/lib/posthog.test.ts packages/web/src/lib/auth-context.test.tsx`. +- [x] Confirm failures are caused by the missing identity lifecycle. + +### Task 2: Implement Minimal Fix + +- [x] Add typed PostHog identity/reset helpers. +- [x] Identify after the authenticated user is known. +- [x] Reset only after the logout request succeeds so a failed logout cannot desynchronize analytics from the still-live server session. +- [x] Run the focused tests and confirm they pass. + +### Task 3: Final Verification + +- [x] Run `pnpm lint`. +- [x] Run root, server, web, and mobile typechecks. +- [x] Run the full Docker-free test suite. +- [ ] Commit, push, open the linked PR, and monitor review and CI through merge. diff --git a/packages/web/src/lib/auth-context.test.tsx b/packages/web/src/lib/auth-context.test.tsx new file mode 100644 index 0000000000..5339958e1c --- /dev/null +++ b/packages/web/src/lib/auth-context.test.tsx @@ -0,0 +1,181 @@ +// @vitest-environment jsdom + +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthProvider, useAuth } from "./auth-context.tsx"; + +const mockCaptureException = vi.hoisted(() => vi.fn()); +const mockIdentifyPostHogUser = vi.hoisted(() => vi.fn()); +const mockResetPostHogUser = vi.hoisted(() => vi.fn()); +const mockRedirectToLogin = vi.hoisted(() => vi.fn()); + +vi.mock("./telemetry.ts", () => ({ + captureException: mockCaptureException, +})); + +vi.mock("./posthog.ts", () => ({ + identifyPostHogUser: mockIdentifyPostHogUser, + resetPostHogUser: mockResetPostHogUser, +})); + +vi.mock("./auth.ts", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + fetchCurrentUser: vi.fn(() => Promise.resolve(null)), + logout: vi.fn(() => Promise.resolve()), + redirectToLogin: mockRedirectToLogin, + }; +}); + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe("AuthProvider analytics identity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("identifies the authenticated user after bootstrap", async () => { + const { fetchCurrentUser } = await import("./auth.ts"); + const user = { + id: "user-123", + name: "Alice Example", + email: "alice@example.com", + }; + vi.mocked(fetchCurrentUser).mockResolvedValue(user); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(user); + }); + + expect(mockIdentifyPostHogUser).toHaveBeenCalledOnce(); + expect(mockIdentifyPostHogUser).toHaveBeenCalledWith(user); + expect(mockResetPostHogUser).not.toHaveBeenCalled(); + }); + + it("does not identify or reset an unauthenticated visitor during bootstrap", async () => { + const { fetchCurrentUser } = await import("./auth.ts"); + vi.mocked(fetchCurrentUser).mockResolvedValue(null); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockIdentifyPostHogUser).not.toHaveBeenCalled(); + expect(mockResetPostHogUser).not.toHaveBeenCalled(); + }); + + it("resets the analytics identity after logout succeeds", async () => { + const { fetchCurrentUser, logout } = await import("./auth.ts"); + vi.mocked(fetchCurrentUser).mockResolvedValue({ + id: "user-123", + name: "Alice Example", + email: "alice@example.com", + }); + vi.mocked(logout).mockResolvedValue(); + + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => { + expect(result.current.user).not.toBeNull(); + }); + + await act(async () => { + await result.current.logout(); + }); + + expect(mockResetPostHogUser).toHaveBeenCalledOnce(); + expect(vi.mocked(logout).mock.invocationCallOrder[0]).toBeLessThan( + mockResetPostHogUser.mock.invocationCallOrder[0] ?? 0, + ); + expect(mockResetPostHogUser.mock.invocationCallOrder[0]).toBeLessThan( + mockRedirectToLogin.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it("preserves the analytics identity when logout fails", async () => { + const { fetchCurrentUser, logout } = await import("./auth.ts"); + const error = new Error("Network unavailable"); + vi.mocked(fetchCurrentUser).mockResolvedValue({ + id: "user-123", + name: "Alice Example", + email: "alice@example.com", + }); + vi.mocked(logout).mockRejectedValue(error); + + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => { + expect(result.current.user).not.toBeNull(); + }); + + await expect( + act(async () => { + await result.current.logout(); + }), + ).rejects.toThrow("Network unavailable"); + + expect(mockResetPostHogUser).not.toHaveBeenCalled(); + expect(mockRedirectToLogin).not.toHaveBeenCalled(); + expect(mockCaptureException).toHaveBeenCalledWith(error, { source: "logout" }); + expect(result.current.user?.id).toBe("user-123"); + }); + + it("resets when an identified session becomes unauthenticated", async () => { + const { fetchCurrentUser } = await import("./auth.ts"); + vi.mocked(fetchCurrentUser) + .mockResolvedValueOnce({ + id: "user-123", + name: "Alice Example", + email: "alice@example.com", + }) + .mockResolvedValueOnce(null); + + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => { + expect(result.current.user?.id).toBe("user-123"); + }); + + await act(async () => { + await result.current.retryBootstrap(); + }); + + expect(result.current.user).toBeNull(); + expect(mockResetPostHogUser).toHaveBeenCalledOnce(); + }); + + it("resets before identifying a different authenticated user", async () => { + const { fetchCurrentUser } = await import("./auth.ts"); + vi.mocked(fetchCurrentUser) + .mockResolvedValueOnce({ + id: "user-123", + name: "Alice Example", + email: "alice@example.com", + }) + .mockResolvedValueOnce({ + id: "user-456", + name: "Bob Example", + email: "bob@example.com", + }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => { + expect(result.current.user?.id).toBe("user-123"); + }); + + await act(async () => { + await result.current.retryBootstrap(); + }); + + expect(result.current.user?.id).toBe("user-456"); + expect(mockResetPostHogUser).toHaveBeenCalledOnce(); + expect(mockResetPostHogUser.mock.invocationCallOrder[0]).toBeLessThan( + mockIdentifyPostHogUser.mock.invocationCallOrder[1] ?? 0, + ); + }); +}); diff --git a/packages/web/src/lib/auth-context.tsx b/packages/web/src/lib/auth-context.tsx index 2717f70de4..fa523a5623 100644 --- a/packages/web/src/lib/auth-context.tsx +++ b/packages/web/src/lib/auth-context.tsx @@ -1,6 +1,7 @@ -import { createContext, useCallback, useContext, useEffect, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import type { AuthUser } from "./auth.ts"; -import { logout as doLogout, fetchCurrentUser } from "./auth.ts"; +import { logout as doLogout, fetchCurrentUser, redirectToLogin } from "./auth.ts"; +import { identifyPostHogUser, resetPostHogUser } from "./posthog.ts"; import { captureException } from "./telemetry.ts"; interface AuthContextValue { @@ -23,6 +24,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const [bootstrapError, setBootstrapError] = useState(null); + const identifiedUserId = useRef(null); const retryBootstrap = useCallback(async () => { setIsLoading(true); @@ -30,6 +32,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const currentUser = await fetchCurrentUser(); setBootstrapError(null); setUser(currentUser); + if (currentUser) { + if (identifiedUserId.current && identifiedUserId.current !== currentUser.id) { + resetPostHogUser(); + } + identifyPostHogUser(currentUser); + identifiedUserId.current = currentUser.id; + } else if (identifiedUserId.current) { + resetPostHogUser(); + identifiedUserId.current = null; + } } catch (error: unknown) { captureException(error, { source: "auth-bootstrap" }); setBootstrapError(error instanceof Error ? error.message : String(error)); @@ -43,14 +55,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, [retryBootstrap]); const logout = useCallback(async () => { - setUser(null); - setBootstrapError(null); try { await doLogout(); } catch (error: unknown) { captureException(error, { source: "logout" }); throw error; } + setUser(null); + setBootstrapError(null); + resetPostHogUser(); + identifiedUserId.current = null; + redirectToLogin(); }, []); return ( diff --git a/packages/web/src/lib/auth.test.ts b/packages/web/src/lib/auth.test.ts index 41e21a1415..0b3295f33b 100644 --- a/packages/web/src/lib/auth.test.ts +++ b/packages/web/src/lib/auth.test.ts @@ -12,6 +12,7 @@ import { fetchCurrentUser, loginWithPassword, logout, + redirectToLogin, registerWithPassword, requestPasswordReset, } from "./auth.ts"; @@ -458,8 +459,8 @@ describe("logout", () => { vi.restoreAllMocks(); }); - it("posts to logout endpoint and redirects", async () => { - vi.mocked(fetch).mockResolvedValue(mockResponse({})); + it("posts to the logout endpoint without navigating", async () => { + vi.mocked(fetch).mockResolvedValue(mockResponse({ ok: true })); await logout(); @@ -467,6 +468,37 @@ describe("logout", () => { method: "POST", credentials: "include", }); + expect(window.location.href).toBe(""); + }); + + it("throws the server error and does not navigate when logout fails", async () => { + vi.mocked(fetch).mockResolvedValue( + mockResponse({ + ok: false, + status: 503, + statusText: "Service Unavailable", + json: () => Promise.resolve({ error: "Session store unavailable" }), + }), + ); + + await expect(logout()).rejects.toThrow("Session store unavailable"); + + expect(window.location.href).toBe(""); + }); +}); + +describe("redirectToLogin", () => { + beforeEach(() => { + vi.stubGlobal("window", { location: { href: "" } }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("navigates to login", () => { + redirectToLogin(); + expect(window.location.href).toBe("/login"); }); }); diff --git a/packages/web/src/lib/auth.ts b/packages/web/src/lib/auth.ts index 53427b6046..811db67a56 100644 --- a/packages/web/src/lib/auth.ts +++ b/packages/web/src/lib/auth.ts @@ -156,6 +156,14 @@ export async function confirmPasswordReset(token: string, password: string): Pro /** Log the user out. */ export async function logout(): Promise { - await fetch("/auth/logout", { method: "POST", credentials: "include" }); + const response = await fetch("/auth/logout", { method: "POST", credentials: "include" }); + if (!response.ok) { + throw new Error( + await getErrorMessage(response, `Logout failed: ${response.status} ${response.statusText}`), + ); + } +} + +export function redirectToLogin(): void { window.location.href = "/login"; } diff --git a/packages/web/src/lib/posthog.test.ts b/packages/web/src/lib/posthog.test.ts index 2aaf128f82..13086c00e7 100644 --- a/packages/web/src/lib/posthog.test.ts +++ b/packages/web/src/lib/posthog.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { capturePageView, initPostHog } from "./posthog.ts"; +import { capturePageView, identifyPostHogUser, initPostHog, resetPostHogUser } from "./posthog.ts"; vi.mock("posthog-js", () => ({ default: { init: vi.fn(), capture: vi.fn(), + identify: vi.fn(), + reset: vi.fn(), }, })); @@ -64,3 +66,47 @@ describe("capturePageView", () => { expect(posthog.capture).toHaveBeenCalledWith("$pageview"); }); }); + +describe("identifyPostHogUser", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("identifies the authenticated user with durable person properties", () => { + identifyPostHogUser({ + id: "user-123", + name: "Alice Example", + email: "alice@example.com", + }); + + expect(posthog.identify).toHaveBeenCalledWith("user-123", { + email: "alice@example.com", + name: "Alice Example", + }); + }); + + it("preserves a nullable email when identifying the user", () => { + identifyPostHogUser({ + id: "user-456", + name: "Private User", + email: null, + }); + + expect(posthog.identify).toHaveBeenCalledWith("user-456", { + email: null, + name: "Private User", + }); + }); +}); + +describe("resetPostHogUser", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("resets the browser identity", () => { + resetPostHogUser(); + + expect(posthog.reset).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/web/src/lib/posthog.ts b/packages/web/src/lib/posthog.ts index 77f4862e2d..e29d663957 100644 --- a/packages/web/src/lib/posthog.ts +++ b/packages/web/src/lib/posthog.ts @@ -1,4 +1,5 @@ import posthog from "posthog-js"; +import type { AuthUser } from "./auth.ts"; const API_KEY = "phc_GsvyihTLSXrWGKYYGz84m44nuT59kYEwEXNnI0JICtg"; @@ -13,3 +14,14 @@ export function initPostHog() { export function capturePageView() { posthog.capture("$pageview"); } + +export function identifyPostHogUser(user: AuthUser): void { + posthog.identify(user.id, { + email: user.email, + name: user.name, + }); +} + +export function resetPostHogUser(): void { + posthog.reset(); +}