Skip to content

Commit 5e8139e

Browse files
authored
feat(web): streamline login for returning users (#1167)
* feat(web): add HAS_COMPLETED_SIGNUP key to localStorage and update LoginView for signup tracking * feat(web): implement useHasCompletedSignup hook and integrate into LoginView for signup tracking * refactor(web): clean up imports in OnboardingStep component * test(web): add unit tests for OnboardingFlow component * refactor(web): simplify Onboarding component structure and improve readability * refactor(web): rename withProvider to withOnboardingProvider for clarity * feat(web): enhance onboarding flow for returning users and add loading state handling * feat(backend): include user email in session creation and update auth utilities * test(web): remove redundant test for new user signup in LoginView * test(web): add unit tests for UserProvider component with PostHog integration * refactor(web): remove unnecessary comments in OnboardingFlow component
1 parent 44a40de commit 5e8139e

24 files changed

+616
-114
lines changed

packages/backend/src/auth/controllers/auth.controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ class AuthController {
116116
: await this.signup(gUser, gRefreshToken);
117117

118118
const sUserId = supertokens.convertToRecipeUserId(cUserId);
119-
await Session.createNewSession(req, res, "public", sUserId);
119+
await Session.createNewSession(req, res, "public", sUserId, {
120+
email,
121+
});
120122

121123
const result: Result_Auth_Compass = {
122124
cUserId,

packages/backend/src/common/types/supertokens.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export interface SupertokensAccessTokenPayload {
99
parentRefreshTokenHash1: string | null;
1010
antiCsrfToken: string | null;
1111
iss: string;
12+
email?: string;
1213
}

packages/web/src/auth/ProtectedRoute.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { ReactNode, useEffect } from "react";
1+
import { ReactNode, useEffect } from "react";
22
import { useNavigate } from "react-router-dom";
33
import { AUTH_FAILURE_REASONS } from "@web/common/constants/auth.constants";
44
import { ROOT_ROUTES } from "@web/common/constants/routes";
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { usePostHog } from "posthog-js/react";
2+
import Session from "supertokens-auth-react/recipe/session";
3+
import "@testing-library/jest-dom";
4+
import { render, waitFor } from "@testing-library/react";
5+
import { UserProvider } from "./UserProvider";
6+
7+
// Mock PostHog
8+
jest.mock("posthog-js/react");
9+
const mockUsePostHog = usePostHog as jest.MockedFunction<typeof usePostHog>;
10+
11+
// Mock SuperTokens Session
12+
jest.mock("supertokens-auth-react/recipe/session");
13+
const mockSession = Session as jest.Mocked<typeof Session>;
14+
15+
// Mock AbsoluteOverflowLoader
16+
jest.mock("@web/components/AbsoluteOverflowLoader", () => ({
17+
AbsoluteOverflowLoader: () => <div>Loading...</div>,
18+
}));
19+
20+
describe("UserProvider", () => {
21+
const mockIdentify = jest.fn();
22+
const mockGetAccessTokenPayloadSecurely = jest.fn();
23+
24+
beforeEach(() => {
25+
jest.clearAllMocks();
26+
27+
// Default mock implementation
28+
mockSession.getAccessTokenPayloadSecurely =
29+
mockGetAccessTokenPayloadSecurely;
30+
});
31+
32+
describe("PostHog Integration", () => {
33+
it("should call posthog.identify when PostHog is enabled and user data is available", async () => {
34+
const testUserId = "test-user-123";
35+
const testEmail = "test@example.com";
36+
37+
// Mock session with userId and email
38+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
39+
sub: testUserId,
40+
email: testEmail,
41+
});
42+
43+
// Mock PostHog as enabled
44+
mockUsePostHog.mockReturnValue({
45+
identify: mockIdentify,
46+
} as any);
47+
48+
render(
49+
<UserProvider>
50+
<div>Test Child</div>
51+
</UserProvider>,
52+
);
53+
54+
// Wait for async data fetch and PostHog identify to be called
55+
await waitFor(() => {
56+
expect(mockIdentify).toHaveBeenCalledWith(testEmail, {
57+
email: testEmail,
58+
userId: testUserId,
59+
});
60+
});
61+
62+
// Verify it was called exactly once
63+
expect(mockIdentify).toHaveBeenCalledTimes(1);
64+
});
65+
66+
it("should NOT call posthog.identify when PostHog is disabled", async () => {
67+
const testUserId = "test-user-123";
68+
const testEmail = "test@example.com";
69+
70+
// Mock session with userId and email
71+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
72+
sub: testUserId,
73+
email: testEmail,
74+
});
75+
76+
// Mock PostHog as disabled (returns undefined/null)
77+
mockUsePostHog.mockReturnValue(undefined as any);
78+
79+
render(
80+
<UserProvider>
81+
<div>Test Child</div>
82+
</UserProvider>,
83+
);
84+
85+
// Wait a bit to ensure no identify call happens
86+
await waitFor(() => {
87+
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
88+
});
89+
90+
// Verify identify was never called
91+
expect(mockIdentify).not.toHaveBeenCalled();
92+
});
93+
94+
it("should NOT call posthog.identify when email is missing from session", async () => {
95+
const testUserId = "test-user-123";
96+
97+
// Mock session with userId but NO email
98+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
99+
sub: testUserId,
100+
// email is missing
101+
});
102+
103+
// Mock PostHog as enabled
104+
mockUsePostHog.mockReturnValue({
105+
identify: mockIdentify,
106+
} as any);
107+
108+
render(
109+
<UserProvider>
110+
<div>Test Child</div>
111+
</UserProvider>,
112+
);
113+
114+
// Wait for data fetch
115+
await waitFor(() => {
116+
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
117+
});
118+
119+
// Verify identify was not called because email is missing
120+
expect(mockIdentify).not.toHaveBeenCalled();
121+
});
122+
123+
it("should NOT call posthog.identify when userId is missing", async () => {
124+
const testEmail = "test@example.com";
125+
126+
// Mock session with email but NO userId (sub)
127+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
128+
email: testEmail,
129+
// sub is missing
130+
});
131+
132+
// Mock PostHog as enabled
133+
mockUsePostHog.mockReturnValue({
134+
identify: mockIdentify,
135+
} as any);
136+
137+
render(
138+
<UserProvider>
139+
<div>Test Child</div>
140+
</UserProvider>,
141+
);
142+
143+
// The component should show loading state because userId is null
144+
// and identify should never be called
145+
await waitFor(() => {
146+
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
147+
});
148+
149+
expect(mockIdentify).not.toHaveBeenCalled();
150+
});
151+
152+
it("should handle posthog.identify not being a function gracefully", async () => {
153+
const testUserId = "test-user-123";
154+
const testEmail = "test@example.com";
155+
156+
// Mock session with userId and email
157+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
158+
sub: testUserId,
159+
email: testEmail,
160+
});
161+
162+
// Mock PostHog with identify not being a function
163+
mockUsePostHog.mockReturnValue({
164+
identify: null,
165+
} as any);
166+
167+
// Should not throw an error
168+
expect(() => {
169+
render(
170+
<UserProvider>
171+
<div>Test Child</div>
172+
</UserProvider>,
173+
);
174+
}).not.toThrow();
175+
176+
await waitFor(() => {
177+
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
178+
});
179+
180+
// Verify identify was not called
181+
expect(mockIdentify).not.toHaveBeenCalled();
182+
});
183+
184+
it("should render children after user data is loaded", async () => {
185+
const testUserId = "test-user-123";
186+
const testEmail = "test@example.com";
187+
188+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
189+
sub: testUserId,
190+
email: testEmail,
191+
});
192+
193+
mockUsePostHog.mockReturnValue({
194+
identify: mockIdentify,
195+
} as any);
196+
197+
const { getByText } = render(
198+
<UserProvider>
199+
<div>Test Child Content</div>
200+
</UserProvider>,
201+
);
202+
203+
// Initially should show loading
204+
expect(getByText("Loading...")).toBeInTheDocument();
205+
206+
// After data loads, should show children
207+
await waitFor(() => {
208+
expect(getByText("Test Child Content")).toBeInTheDocument();
209+
});
210+
});
211+
212+
it("should handle session fetch errors gracefully", async () => {
213+
const consoleErrorSpy = jest
214+
.spyOn(console, "error")
215+
.mockImplementation(() => {});
216+
217+
// Mock session to throw an error
218+
mockGetAccessTokenPayloadSecurely.mockRejectedValue(
219+
new Error("Session error"),
220+
);
221+
222+
mockUsePostHog.mockReturnValue({
223+
identify: mockIdentify,
224+
} as any);
225+
226+
render(
227+
<UserProvider>
228+
<div>Test Child</div>
229+
</UserProvider>,
230+
);
231+
232+
// Should log error
233+
await waitFor(() => {
234+
expect(consoleErrorSpy).toHaveBeenCalledWith(
235+
"Failed to get user because:",
236+
expect.any(Error),
237+
);
238+
});
239+
240+
// Should not call identify
241+
expect(mockIdentify).not.toHaveBeenCalled();
242+
243+
consoleErrorSpy.mockRestore();
244+
});
245+
});
246+
});
Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,49 @@
1-
import React, {
1+
import { usePostHog } from "posthog-js/react";
2+
import {
23
ReactNode,
34
createContext,
4-
useContext,
5+
useEffect,
56
useLayoutEffect,
67
useState,
78
} from "react";
89
import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader";
9-
import { getUserId } from "./auth.util";
10+
import { getUserEmail, getUserId } from "./auth.util";
1011

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

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

1922
useLayoutEffect(() => {
20-
const fetchUserId = async () => {
23+
const fetchUserData = async () => {
2124
try {
2225
const uid = await getUserId();
26+
const userEmail = await getUserEmail();
2327
setUserId(uid);
28+
setEmail(userEmail);
2429
} catch (e) {
2530
console.error("Failed to get user because:", e);
2631
} finally {
2732
setIsLoadingUser(false);
2833
}
2934
};
3035

31-
void fetchUserId();
36+
void fetchUserData();
3237
}, []);
3338

39+
// Identify user in PostHog when userId and email are available
40+
// Only runs if PostHog is enabled (POSTHOG_HOST and POSTHOG_KEY are set)
41+
useEffect(() => {
42+
if (userId && email && posthog && typeof posthog.identify === "function") {
43+
posthog.identify(email, { email, userId });
44+
}
45+
}, [userId, email, posthog]);
46+
3447
if (isLoadingUser || userId === null) {
3548
return <AbsoluteOverflowLoader />;
3649
}
@@ -41,13 +54,3 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
4154
</UserContext.Provider>
4255
);
4356
};
44-
45-
export const useUser = () => {
46-
const context = useContext(UserContext);
47-
48-
if (context === undefined) {
49-
throw new Error("useUser must be used within a UserProvider");
50-
}
51-
52-
return context;
53-
};

packages/web/src/auth/auth.util.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Session from "supertokens-auth-react/recipe/session";
22

33
interface AccessTokenPayload {
44
sub: string;
5+
email?: string;
56
}
67

78
export const getUserId = async () => {
@@ -10,3 +11,17 @@ export const getUserId = async () => {
1011
const userId = accessTokenPayload["sub"];
1112
return userId;
1213
};
14+
15+
/**
16+
* Get the user's email from the session access token payload
17+
*/
18+
export const getUserEmail = async (): Promise<string | null> => {
19+
try {
20+
const accessTokenPayload =
21+
(await Session.getAccessTokenPayloadSecurely()) as AccessTokenPayload;
22+
return accessTokenPayload.email || null;
23+
} catch (error) {
24+
console.error("Failed to get user email:", error);
25+
return null;
26+
}
27+
};

0 commit comments

Comments
 (0)