Skip to content

Commit 1bcf2ee

Browse files
authored
feat(auth): implement ProtectedRoute with signup completion checks (#1236)
* feat(auth): implement ProtectedRoute with signup completion checks - Added ProtectedRoute component to manage user authentication and redirect logic based on signup completion status. - Integrated useAuthCheck and useHasCompletedSignup hooks to determine user state and redirect to appropriate routes. - Created unit tests for ProtectedRoute to validate redirect behavior for authenticated and unauthenticated users. - Updated routing constants to include ONBOARDING path for better navigation handling. - Enhanced onboarding flow to ensure users are guided correctly based on their signup status. * refactor(auth): simplify ProtectedRoute tests and enhance loading state handling - Removed the renderWithRouter utility in ProtectedRoute tests, directly using the render function for clarity. - Added a new test case to ensure that the ProtectedRoute does not redirect when hasCompletedSignup is null, addressing the loading state scenario. - Updated ProtectedRoute component to wait for hasCompletedSignup to be determined before performing any redirects, improving user experience during authentication checks.
1 parent 55f691b commit 1bcf2ee

File tree

7 files changed

+507
-41
lines changed

7 files changed

+507
-41
lines changed
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import "@testing-library/jest-dom";
2+
import { waitFor } from "@testing-library/react";
3+
import { render } from "@web/__tests__/__mocks__/mock.render";
4+
import { AUTH_FAILURE_REASONS } from "@web/common/constants/auth.constants";
5+
import { ROOT_ROUTES } from "@web/common/constants/routes";
6+
import { STORAGE_KEYS } from "@web/common/constants/storage.constants";
7+
import { ProtectedRoute } from "./ProtectedRoute";
8+
import { useAuthCheck } from "./useAuthCheck";
9+
import { useHasCompletedSignup } from "./useHasCompletedSignup";
10+
11+
// Mock dependencies
12+
jest.mock("./useAuthCheck");
13+
jest.mock("./useHasCompletedSignup");
14+
15+
const mockUseAuthCheck = useAuthCheck as jest.MockedFunction<
16+
typeof useAuthCheck
17+
>;
18+
const mockUseHasCompletedSignup = useHasCompletedSignup as jest.MockedFunction<
19+
typeof useHasCompletedSignup
20+
>;
21+
22+
// Mock navigate function
23+
const mockNavigate = jest.fn();
24+
25+
jest.mock("react-router-dom", () => ({
26+
...jest.requireActual("react-router-dom"),
27+
useNavigate: () => mockNavigate,
28+
}));
29+
30+
describe("ProtectedRoute", () => {
31+
beforeEach(() => {
32+
jest.clearAllMocks();
33+
mockNavigate.mockClear();
34+
localStorage.clear();
35+
});
36+
37+
describe("Redirect Logic", () => {
38+
it("redirects to /onboarding when not authenticated and hasCompletedSignup is false", async () => {
39+
mockUseAuthCheck.mockReturnValue({
40+
isAuthenticated: false,
41+
isCheckingAuth: false,
42+
isGoogleTokenActive: false,
43+
isSessionActive: false,
44+
});
45+
mockUseHasCompletedSignup.mockReturnValue({
46+
hasCompletedSignup: false,
47+
markSignupCompleted: jest.fn(),
48+
});
49+
50+
render(
51+
<ProtectedRoute>
52+
<div>Protected Content</div>
53+
</ProtectedRoute>,
54+
);
55+
56+
await waitFor(() => {
57+
expect(mockNavigate).toHaveBeenCalledWith(ROOT_ROUTES.ONBOARDING);
58+
});
59+
});
60+
61+
it("redirects to /login when not authenticated and hasCompletedSignup is true (Google token expired)", async () => {
62+
mockUseAuthCheck.mockReturnValue({
63+
isAuthenticated: false,
64+
isCheckingAuth: false,
65+
isGoogleTokenActive: false,
66+
isSessionActive: false,
67+
});
68+
mockUseHasCompletedSignup.mockReturnValue({
69+
hasCompletedSignup: true,
70+
markSignupCompleted: jest.fn(),
71+
});
72+
73+
render(
74+
<ProtectedRoute>
75+
<div>Protected Content</div>
76+
</ProtectedRoute>,
77+
);
78+
79+
await waitFor(() => {
80+
expect(mockNavigate).toHaveBeenCalledWith(
81+
`${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.GAUTH_SESSION_EXPIRED}`,
82+
);
83+
});
84+
});
85+
86+
it("redirects to /login when not authenticated and hasCompletedSignup is true (user session expired)", async () => {
87+
mockUseAuthCheck.mockReturnValue({
88+
isAuthenticated: false,
89+
isCheckingAuth: false,
90+
isGoogleTokenActive: true,
91+
isSessionActive: false,
92+
});
93+
mockUseHasCompletedSignup.mockReturnValue({
94+
hasCompletedSignup: true,
95+
markSignupCompleted: jest.fn(),
96+
});
97+
98+
render(
99+
<ProtectedRoute>
100+
<div>Protected Content</div>
101+
</ProtectedRoute>,
102+
);
103+
104+
await waitFor(() => {
105+
expect(mockNavigate).toHaveBeenCalledWith(
106+
`${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.USER_SESSION_EXPIRED}`,
107+
);
108+
});
109+
});
110+
111+
it("does not redirect when authenticated", async () => {
112+
mockUseAuthCheck.mockReturnValue({
113+
isAuthenticated: true,
114+
isCheckingAuth: false,
115+
isGoogleTokenActive: true,
116+
isSessionActive: true,
117+
});
118+
mockUseHasCompletedSignup.mockReturnValue({
119+
hasCompletedSignup: true,
120+
markSignupCompleted: jest.fn(),
121+
});
122+
123+
const { getByText } = render(
124+
<ProtectedRoute>
125+
<div>Protected Content</div>
126+
</ProtectedRoute>,
127+
);
128+
129+
await waitFor(() => {
130+
expect(getByText("Protected Content")).toBeInTheDocument();
131+
});
132+
133+
expect(mockNavigate).not.toHaveBeenCalled();
134+
});
135+
136+
it("does not redirect when hasCompletedSignup is null (loading state)", async () => {
137+
mockUseAuthCheck.mockReturnValue({
138+
isAuthenticated: false,
139+
isCheckingAuth: false,
140+
isGoogleTokenActive: false,
141+
isSessionActive: false,
142+
});
143+
mockUseHasCompletedSignup.mockReturnValue({
144+
hasCompletedSignup: null,
145+
markSignupCompleted: jest.fn(),
146+
});
147+
148+
render(
149+
<ProtectedRoute>
150+
<div>Protected Content</div>
151+
</ProtectedRoute>,
152+
);
153+
154+
// Wait a bit to ensure redirect doesn't happen
155+
await new Promise((resolve) => setTimeout(resolve, 100));
156+
157+
// Should not redirect while loading
158+
expect(mockNavigate).not.toHaveBeenCalled();
159+
});
160+
});
161+
162+
describe("localStorage Integration", () => {
163+
it("checks localStorage for hasCompletedSignup when determining redirect", async () => {
164+
localStorage.setItem(STORAGE_KEYS.HAS_COMPLETED_SIGNUP, "true");
165+
166+
mockUseAuthCheck.mockReturnValue({
167+
isAuthenticated: false,
168+
isCheckingAuth: false,
169+
isGoogleTokenActive: false,
170+
isSessionActive: false,
171+
});
172+
mockUseHasCompletedSignup.mockReturnValue({
173+
hasCompletedSignup: true,
174+
markSignupCompleted: jest.fn(),
175+
});
176+
177+
render(
178+
<ProtectedRoute>
179+
<div>Protected Content</div>
180+
</ProtectedRoute>,
181+
);
182+
183+
await waitFor(() => {
184+
expect(mockUseHasCompletedSignup).toHaveBeenCalled();
185+
expect(mockNavigate).toHaveBeenCalledWith(
186+
expect.stringContaining(ROOT_ROUTES.LOGIN),
187+
);
188+
});
189+
});
190+
191+
it("redirects to /onboarding when localStorage indicates user hasn't completed signup", async () => {
192+
localStorage.setItem(STORAGE_KEYS.HAS_COMPLETED_SIGNUP, "false");
193+
194+
mockUseAuthCheck.mockReturnValue({
195+
isAuthenticated: false,
196+
isCheckingAuth: false,
197+
isGoogleTokenActive: false,
198+
isSessionActive: false,
199+
});
200+
mockUseHasCompletedSignup.mockReturnValue({
201+
hasCompletedSignup: false,
202+
markSignupCompleted: jest.fn(),
203+
});
204+
205+
render(
206+
<ProtectedRoute>
207+
<div>Protected Content</div>
208+
</ProtectedRoute>,
209+
);
210+
211+
await waitFor(() => {
212+
expect(mockNavigate).toHaveBeenCalledWith(ROOT_ROUTES.ONBOARDING);
213+
});
214+
});
215+
});
216+
});

packages/web/src/auth/ProtectedRoute.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,44 @@ import { AUTH_FAILURE_REASONS } from "@web/common/constants/auth.constants";
44
import { ROOT_ROUTES } from "@web/common/constants/routes";
55
import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader";
66
import { useAuthCheck } from "./useAuthCheck";
7+
import { useHasCompletedSignup } from "./useHasCompletedSignup";
78

89
export const ProtectedRoute = ({ children }: { children: ReactNode }) => {
910
const navigate = useNavigate();
1011

1112
const { isAuthenticated, isCheckingAuth, isGoogleTokenActive } =
1213
useAuthCheck();
14+
const { hasCompletedSignup } = useHasCompletedSignup();
1315

1416
useEffect(() => {
1517
const handleAuthCheck = () => {
1618
if (isAuthenticated === false) {
17-
if (isGoogleTokenActive === false) {
18-
navigate(
19-
`${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.GAUTH_SESSION_EXPIRED}`,
20-
);
19+
// Wait for hasCompletedSignup to be determined (not null) before redirecting
20+
if (hasCompletedSignup === null) {
21+
return;
22+
}
23+
24+
// Check if user has completed signup to determine redirect destination
25+
if (hasCompletedSignup === true) {
26+
// User has completed signup before, redirect to /login
27+
if (isGoogleTokenActive === false) {
28+
navigate(
29+
`${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.GAUTH_SESSION_EXPIRED}`,
30+
);
31+
} else {
32+
navigate(
33+
`${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.USER_SESSION_EXPIRED}`,
34+
);
35+
}
2136
} else {
22-
navigate(
23-
`${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.USER_SESSION_EXPIRED}`,
24-
);
37+
// User hasn't completed signup, redirect to /onboarding
38+
navigate(ROOT_ROUTES.ONBOARDING);
2539
}
2640
}
2741
};
2842

2943
void handleAuthCheck();
30-
}, [isAuthenticated, isGoogleTokenActive, navigate]);
44+
}, [isAuthenticated, isGoogleTokenActive, hasCompletedSignup, navigate]);
3145

3246
return (
3347
<>

packages/web/src/common/constants/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export const ROOT_ROUTES = {
22
API: "/api",
33
LOGIN: "/login",
44
LOGOUT: "/logout",
5+
ONBOARDING: "/onboarding",
56
ROOT: "/",
67
DAY: "/day",
78
NOW: "/now",

packages/web/src/views/CmdPalette/CmdPalette.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useState } from "react";
1+
import { useEffect, useState } from "react";
22
import CommandPalette, {
33
filterItems,
44
getItemIndex,
@@ -149,6 +149,12 @@ const CmdPalette = ({
149149
},
150150
},
151151

152+
{
153+
id: "redo-onboarding",
154+
children: "Re-do onboarding",
155+
icon: "ArrowPathIcon",
156+
onClick: () => window.open(ROOT_ROUTES.ONBOARDING, "_blank"),
157+
},
152158
{
153159
id: "log-out",
154160
children: "Log Out [z]",

0 commit comments

Comments
 (0)