Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/backend/src/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ class AuthController {
: await this.signup(gUser, gRefreshToken);

const sUserId = supertokens.convertToRecipeUserId(cUserId);
await Session.createNewSession(req, res, "public", sUserId);
await Session.createNewSession(req, res, "public", sUserId, {
email,
});

const result: Result_Auth_Compass = {
cUserId,
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/common/types/supertokens.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export interface SupertokensAccessTokenPayload {
parentRefreshTokenHash1: string | null;
antiCsrfToken: string | null;
iss: string;
email?: string;
}
2 changes: 1 addition & 1 deletion packages/web/src/auth/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { ReactNode, useEffect } from "react";
import { ReactNode, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { AUTH_FAILURE_REASONS } from "@web/common/constants/auth.constants";
import { ROOT_ROUTES } from "@web/common/constants/routes";
Expand Down
246 changes: 246 additions & 0 deletions packages/web/src/auth/UserProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { usePostHog } from "posthog-js/react";
import Session from "supertokens-auth-react/recipe/session";
import "@testing-library/jest-dom";
import { render, waitFor } from "@testing-library/react";
import { UserProvider } from "./UserProvider";

// Mock PostHog
jest.mock("posthog-js/react");
const mockUsePostHog = usePostHog as jest.MockedFunction<typeof usePostHog>;

// Mock SuperTokens Session
jest.mock("supertokens-auth-react/recipe/session");
const mockSession = Session as jest.Mocked<typeof Session>;

// Mock AbsoluteOverflowLoader
jest.mock("@web/components/AbsoluteOverflowLoader", () => ({
AbsoluteOverflowLoader: () => <div>Loading...</div>,
}));

describe("UserProvider", () => {
const mockIdentify = jest.fn();
const mockGetAccessTokenPayloadSecurely = jest.fn();

beforeEach(() => {
jest.clearAllMocks();

// Default mock implementation
mockSession.getAccessTokenPayloadSecurely =
mockGetAccessTokenPayloadSecurely;
});

describe("PostHog Integration", () => {
it("should call posthog.identify when PostHog is enabled and user data is available", async () => {
const testUserId = "test-user-123";
const testEmail = "test@example.com";

// Mock session with userId and email
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
sub: testUserId,
email: testEmail,
});

// Mock PostHog as enabled
mockUsePostHog.mockReturnValue({
identify: mockIdentify,
} as any);

render(
<UserProvider>
<div>Test Child</div>
</UserProvider>,
);

// Wait for async data fetch and PostHog identify to be called
await waitFor(() => {
expect(mockIdentify).toHaveBeenCalledWith(testEmail, {
email: testEmail,
userId: testUserId,
});
});

// Verify it was called exactly once
expect(mockIdentify).toHaveBeenCalledTimes(1);
});

it("should NOT call posthog.identify when PostHog is disabled", async () => {
const testUserId = "test-user-123";
const testEmail = "test@example.com";

// Mock session with userId and email
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
sub: testUserId,
email: testEmail,
});

// Mock PostHog as disabled (returns undefined/null)
mockUsePostHog.mockReturnValue(undefined as any);

render(
<UserProvider>
<div>Test Child</div>
</UserProvider>,
);

// Wait a bit to ensure no identify call happens
await waitFor(() => {
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
});

// Verify identify was never called
expect(mockIdentify).not.toHaveBeenCalled();
});

it("should NOT call posthog.identify when email is missing from session", async () => {
const testUserId = "test-user-123";

// Mock session with userId but NO email
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
sub: testUserId,
// email is missing
});

// Mock PostHog as enabled
mockUsePostHog.mockReturnValue({
identify: mockIdentify,
} as any);

render(
<UserProvider>
<div>Test Child</div>
</UserProvider>,
);

// Wait for data fetch
await waitFor(() => {
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
});

// Verify identify was not called because email is missing
expect(mockIdentify).not.toHaveBeenCalled();
});

it("should NOT call posthog.identify when userId is missing", async () => {
const testEmail = "test@example.com";

// Mock session with email but NO userId (sub)
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
email: testEmail,
// sub is missing
});

// Mock PostHog as enabled
mockUsePostHog.mockReturnValue({
identify: mockIdentify,
} as any);

render(
<UserProvider>
<div>Test Child</div>
</UserProvider>,
);

// The component should show loading state because userId is null
// and identify should never be called
await waitFor(() => {
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
});

expect(mockIdentify).not.toHaveBeenCalled();
});

it("should handle posthog.identify not being a function gracefully", async () => {
const testUserId = "test-user-123";
const testEmail = "test@example.com";

// Mock session with userId and email
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
sub: testUserId,
email: testEmail,
});

// Mock PostHog with identify not being a function
mockUsePostHog.mockReturnValue({
identify: null,
} as any);

// Should not throw an error
expect(() => {
render(
<UserProvider>
<div>Test Child</div>
</UserProvider>,
);
}).not.toThrow();

await waitFor(() => {
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
});

// Verify identify was not called
expect(mockIdentify).not.toHaveBeenCalled();
});

it("should render children after user data is loaded", async () => {
const testUserId = "test-user-123";
const testEmail = "test@example.com";

mockGetAccessTokenPayloadSecurely.mockResolvedValue({
sub: testUserId,
email: testEmail,
});

mockUsePostHog.mockReturnValue({
identify: mockIdentify,
} as any);

const { getByText } = render(
<UserProvider>
<div>Test Child Content</div>
</UserProvider>,
);

// Initially should show loading
expect(getByText("Loading...")).toBeInTheDocument();

// After data loads, should show children
await waitFor(() => {
expect(getByText("Test Child Content")).toBeInTheDocument();
});
});

it("should handle session fetch errors gracefully", async () => {
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});

// Mock session to throw an error
mockGetAccessTokenPayloadSecurely.mockRejectedValue(
new Error("Session error"),
);

mockUsePostHog.mockReturnValue({
identify: mockIdentify,
} as any);

render(
<UserProvider>
<div>Test Child</div>
</UserProvider>,
);

// Should log error
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to get user because:",
expect.any(Error),
);
});

// Should not call identify
expect(mockIdentify).not.toHaveBeenCalled();

consoleErrorSpy.mockRestore();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,36 +1,49 @@
import React, {
import { usePostHog } from "posthog-js/react";
import {
ReactNode,
createContext,
useContext,
useEffect,
useLayoutEffect,
useState,
} from "react";
import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader";
import { getUserId } from "./auth.util";
import { getUserEmail, getUserId } from "./auth.util";

const UserContext = createContext<
{ isLoadingUser: boolean; userId: string } | undefined
>(undefined);

export const UserProvider = ({ children }: { children: ReactNode }) => {
const [userId, setUserId] = useState<string | null>(null);
const [email, setEmail] = useState<string | null>(null);
const [isLoadingUser, setIsLoadingUser] = useState(false);
const posthog = usePostHog();

useLayoutEffect(() => {
const fetchUserId = async () => {
const fetchUserData = async () => {
try {
const uid = await getUserId();
const userEmail = await getUserEmail();
setUserId(uid);
setEmail(userEmail);
} catch (e) {
console.error("Failed to get user because:", e);
} finally {
setIsLoadingUser(false);
}
};

void fetchUserId();
void fetchUserData();
}, []);

// Identify user in PostHog when userId and email are available
// Only runs if PostHog is enabled (POSTHOG_HOST and POSTHOG_KEY are set)
useEffect(() => {
if (userId && email && posthog && typeof posthog.identify === "function") {
posthog.identify(email, { email, userId });
}
}, [userId, email, posthog]);

if (isLoadingUser || userId === null) {
return <AbsoluteOverflowLoader />;
}
Expand All @@ -41,13 +54,3 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
</UserContext.Provider>
);
};

export const useUser = () => {
const context = useContext(UserContext);

if (context === undefined) {
throw new Error("useUser must be used within a UserProvider");
}

return context;
};
15 changes: 15 additions & 0 deletions packages/web/src/auth/auth.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Session from "supertokens-auth-react/recipe/session";

interface AccessTokenPayload {
sub: string;
email?: string;
}

export const getUserId = async () => {
Expand All @@ -10,3 +11,17 @@ export const getUserId = async () => {
const userId = accessTokenPayload["sub"];
return userId;
};

/**
* Get the user's email from the session access token payload
*/
export const getUserEmail = async (): Promise<string | null> => {
try {
const accessTokenPayload =
(await Session.getAccessTokenPayloadSecurely()) as AccessTokenPayload;
return accessTokenPayload.email || null;
} catch (error) {
console.error("Failed to get user email:", error);
return null;
}
};
Loading