Skip to content

Commit 6094af4

Browse files
committed
Resolved issue as reported by coderabbit
1 parent 910ff96 commit 6094af4

6 files changed

Lines changed: 374 additions & 40 deletions

File tree

src/Routes/Router.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Home from "../pages/Home/Home.tsx";
1010
import Activity from "../pages/Activity.tsx";
1111
import PrivacyPolicy from "../pages/Privacy/PrivacyPolicy.tsx"; // ✅ Updated import path to match your new folder structure
1212
import Profile from "../pages/Profile/Profile.tsx";
13+
import ProtectedRoute from "../components/ProtectedRoute";
1314

1415
const Router = () => {
1516
return (
@@ -22,7 +23,7 @@ const Router = () => {
2223
<Route path="/contact" element={<Contact />} />
2324
<Route path="/contributors" element={<Contributors />} />
2425
<Route path="/contributor/:username" element={<ContributorProfile />} />
25-
<Route path="/profile" element={<Profile />} />
26+
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
2627
<Route path="/activity" element={<Activity />} />
2728

2829
{/* Privacy Policy page route */}

src/components/Navbar.tsx

Lines changed: 185 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,169 @@
11
import { NavLink, Link } from "react-router-dom";
2-
import { useState, useContext } from "react";
2+
import { useEffect, useMemo, useRef, useState, useContext } from "react";
33
import { ThemeContext } from "../context/ThemeContext";
4-
import { Moon, Sun, Menu, X, User } from "lucide-react";
4+
import { Moon, Sun, Menu, X, ChevronDown, BadgeInfo, LogOut, User } from "lucide-react";
5+
6+
type NavbarUser = {
7+
id?: string;
8+
username?: string;
9+
email?: string;
10+
};
11+
12+
const AUTH_STORAGE_KEY = "github_tracker_auth_user";
13+
14+
const readStoredUser = (): NavbarUser | null => {
15+
if (typeof window === "undefined") {
16+
return null;
17+
}
18+
19+
const storedUser = window.localStorage.getItem(AUTH_STORAGE_KEY);
20+
21+
if (!storedUser) {
22+
return null;
23+
}
24+
25+
try {
26+
const parsedUser = JSON.parse(storedUser) as NavbarUser;
27+
return parsedUser?.username ? parsedUser : null;
28+
} catch {
29+
return null;
30+
}
31+
};
32+
33+
type ProfileDropdownProps = {
34+
user: NavbarUser;
35+
onLogout: () => void;
36+
onCloseMenu?: () => void;
37+
mobile?: boolean;
38+
};
39+
40+
const ProfileDropdown: React.FC<ProfileDropdownProps> = ({ user, onLogout, onCloseMenu, mobile = false }) => {
41+
const [isOpen, setIsOpen] = useState(false);
42+
const profileMenuRef = useRef<HTMLDivElement | null>(null);
43+
const displayName = useMemo(() => user.username ?? "Profile", [user.username]);
44+
45+
useEffect(() => {
46+
const handleOutsideClick = (event: MouseEvent) => {
47+
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) {
48+
setIsOpen(false);
49+
}
50+
};
51+
52+
document.addEventListener("mousedown", handleOutsideClick);
53+
return () => document.removeEventListener("mousedown", handleOutsideClick);
54+
}, []);
55+
56+
const closeMenu = () => setIsOpen(false);
57+
58+
if (mobile) {
59+
return (
60+
<div className="mt-2 rounded-3xl border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/60 p-4">
61+
<div className="flex items-center gap-3">
62+
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-600 to-cyan-500 text-white font-semibold">
63+
{displayName.charAt(0).toUpperCase()}
64+
</div>
65+
<div>
66+
<p className="font-semibold text-slate-900 dark:text-white">{displayName}</p>
67+
<p className="text-sm text-slate-500 dark:text-slate-400">{user.email ?? "Signed in"}</p>
68+
</div>
69+
</div>
70+
71+
<div className="mt-4 flex flex-col gap-2">
72+
<Link
73+
to="/profile"
74+
onClick={onCloseMenu}
75+
className="flex items-center gap-2 rounded-2xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-white dark:text-slate-200 dark:hover:bg-white/5"
76+
>
77+
<User className="h-4 w-4" />
78+
View Profile
79+
</Link>
80+
<Link
81+
to={user.username ? `/contributor/${user.username}` : "/contributors"}
82+
onClick={onCloseMenu}
83+
className="flex items-center gap-2 rounded-2xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-white dark:text-slate-200 dark:hover:bg-white/5"
84+
>
85+
<BadgeInfo className="h-4 w-4" />
86+
Account Details
87+
</Link>
88+
<button
89+
onClick={onLogout}
90+
className="flex items-center gap-2 rounded-2xl px-4 py-3 text-sm font-medium text-red-600 transition hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-500/10"
91+
>
92+
<LogOut className="h-4 w-4" />
93+
Logout
94+
</button>
95+
</div>
96+
</div>
97+
);
98+
}
99+
100+
return (
101+
<div className="relative" ref={profileMenuRef}>
102+
<button
103+
onClick={() => setIsOpen((prev) => !prev)}
104+
className="flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-left transition hover:border-blue-300 hover:bg-blue-50 dark:hover:bg-gray-700"
105+
aria-haspopup="menu"
106+
aria-expanded={isOpen}
107+
>
108+
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-600 to-cyan-500 text-white shadow-md">
109+
{displayName.charAt(0).toUpperCase()}
110+
</span>
111+
<span className="hidden xl:block">
112+
<span className="block text-sm font-semibold text-slate-900 dark:text-white">{displayName}</span>
113+
<span className="block text-xs text-slate-500 dark:text-slate-400">Signed in</span>
114+
</span>
115+
<ChevronDown className={`h-4 w-4 text-slate-500 transition-transform ${isOpen ? "rotate-180" : ""}`} />
116+
</button>
117+
118+
{isOpen && (
119+
<div className="absolute right-0 mt-3 w-72 overflow-hidden rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
120+
<div className="px-5 py-4 border-b border-gray-100 dark:border-gray-800">
121+
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Account</p>
122+
<div className="mt-2 flex items-center gap-3">
123+
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-600 to-cyan-500 text-white font-semibold">
124+
{displayName.charAt(0).toUpperCase()}
125+
</div>
126+
<div>
127+
<p className="font-semibold text-slate-900 dark:text-white">{displayName}</p>
128+
<p className="text-sm text-slate-500 dark:text-slate-400">{user.email ?? "No email available"}</p>
129+
</div>
130+
</div>
131+
</div>
132+
133+
<div className="p-2">
134+
<Link
135+
to="/profile"
136+
onClick={closeMenu}
137+
className="flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-blue-50 hover:text-blue-700 dark:text-slate-300 dark:hover:bg-white/5 dark:hover:text-cyan-300"
138+
>
139+
<User className="h-4 w-4" />
140+
View Profile
141+
</Link>
142+
<Link
143+
to={user.username ? `/contributor/${user.username}` : "/contributors"}
144+
onClick={closeMenu}
145+
className="flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-blue-50 hover:text-blue-700 dark:text-slate-300 dark:hover:bg-white/5 dark:hover:text-cyan-300"
146+
>
147+
<BadgeInfo className="h-4 w-4" />
148+
Account Details
149+
</Link>
150+
<button
151+
onClick={onLogout}
152+
className="flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium text-red-600 transition hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-500/10"
153+
>
154+
<LogOut className="h-4 w-4" />
155+
Logout
156+
</button>
157+
</div>
158+
</div>
159+
)}
160+
</div>
161+
);
162+
};
5163

6164
const Navbar: React.FC = () => {
7165
const [isOpen, setIsOpen] = useState(false);
166+
const [user, setUser] = useState<NavbarUser | null>(() => readStoredUser());
8167

9168
const themeContext = useContext(ThemeContext);
10169

@@ -20,6 +179,13 @@ const Navbar: React.FC = () => {
20179
}`;
21180

22181
const closeMenu = () => setIsOpen(false);
182+
const handleLogout = () => {
183+
if (typeof window !== "undefined") {
184+
window.localStorage.removeItem(AUTH_STORAGE_KEY);
185+
}
186+
setUser(null);
187+
closeMenu();
188+
};
23189

24190
return (
25191
<nav className="sticky top-0 z-50 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 transition-colors duration-300 backdrop-blur">
@@ -42,12 +208,15 @@ const Navbar: React.FC = () => {
42208
<NavLink to="/contributors" className={navLinkStyles}>
43209
Contributors
44210
</NavLink>
45-
<NavLink to="/profile" className={navLinkStyles}>
46-
Profile
47-
</NavLink>
48-
<NavLink to="/login" className={navLinkStyles}>
49-
Login
50-
</NavLink>
211+
212+
{user ? (
213+
<ProfileDropdown user={user} onLogout={handleLogout} />
214+
) : (
215+
<NavLink to="/login" className={navLinkStyles}>
216+
Login
217+
</NavLink>
218+
)}
219+
51220
<button
52221
onClick={toggleTheme}
53222
className="ml-2 p-2 rounded-xl border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
@@ -99,12 +268,14 @@ const Navbar: React.FC = () => {
99268
<NavLink to="/contributors" className={navLinkStyles} onClick={closeMenu}>
100269
Contributors
101270
</NavLink>
102-
<NavLink to="/profile" className={navLinkStyles} onClick={closeMenu}>
103-
<span className="inline-flex items-center gap-2"><User className="h-4 w-4" />Profile</span>
104-
</NavLink>
105-
<NavLink to="/login" className={navLinkStyles} onClick={closeMenu}>
106-
Login
107-
</NavLink>
271+
272+
{user ? (
273+
<ProfileDropdown user={user} onLogout={handleLogout} onCloseMenu={closeMenu} mobile />
274+
) : (
275+
<NavLink to="/login" className={navLinkStyles} onClick={closeMenu}>
276+
Login
277+
</NavLink>
278+
)}
108279
</div>
109280
</div>
110281
)}

src/components/ProtectedRoute.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { ReactNode } from "react";
2+
import { Navigate } from "react-router-dom";
3+
4+
type ProtectedRouteProps = {
5+
children: ReactNode;
6+
};
7+
8+
const AUTH_STORAGE_KEY = "github_tracker_auth_user";
9+
10+
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
11+
const isAuthenticated = (() => {
12+
if (typeof window === "undefined") {
13+
return false;
14+
}
15+
16+
const storedUser = window.localStorage.getItem(AUTH_STORAGE_KEY);
17+
18+
if (!storedUser) {
19+
return false;
20+
}
21+
22+
try {
23+
const parsedUser = JSON.parse(storedUser) as { username?: string; email?: string };
24+
return Boolean(parsedUser?.username && parsedUser?.email);
25+
} catch {
26+
return false;
27+
}
28+
})();
29+
30+
if (!isAuthenticated) {
31+
return <Navigate to="/login" replace />;
32+
}
33+
34+
return <>{children}</>;
35+
};
36+
37+
export default ProtectedRoute;

src/components/__test__/Navbar.test.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const renderNavbar = (mode: 'light' | 'dark' = 'light') => {
1919
}
2020

2121
describe('Navbar', () => {
22+
beforeEach(() => {
23+
window.localStorage.clear()
24+
})
25+
2226
// --- Rendering ---
2327
it('renders the GitHub Tracker logo link', () => {
2428
renderNavbar()
@@ -33,6 +37,15 @@ describe('Navbar', () => {
3337
expect(screen.getByRole('link', { name: /login/i })).toBeInTheDocument()
3438
})
3539

40+
it('shows profile dropdown trigger when a user is stored', () => {
41+
window.localStorage.setItem('github_tracker_auth_user', JSON.stringify({ username: 'testuser', email: 'test@example.com' }))
42+
43+
renderNavbar()
44+
45+
expect(screen.queryByRole('link', { name: /login/i })).not.toBeInTheDocument()
46+
expect(screen.getByRole('button', { name: /testuser/i })).toBeInTheDocument()
47+
})
48+
3649
// --- Theme toggle ---
3750
it('shows Moon icon in light mode', () => {
3851
renderNavbar('light')
@@ -58,8 +71,8 @@ describe('Navbar', () => {
5871
renderNavbar()
5972
const hamburger = screen.getAllByRole('button')[1] // second button = hamburger
6073
fireEvent.click(hamburger)
61-
expect(screen.getByText('About')).toBeInTheDocument()
62-
expect(screen.getByText('Contact')).toBeInTheDocument()
74+
expect(screen.getByRole('link', { name: /login/i })).toBeInTheDocument()
75+
expect(screen.getByRole('link', { name: /contributors/i })).toBeInTheDocument()
6376
})
6477

6578
it('closes mobile menu when a nav link is clicked', () => {
@@ -73,9 +86,8 @@ describe('Navbar', () => {
7386

7487
it('calls toggleTheme from the mobile menu button', () => {
7588
const { toggleTheme } = renderNavbar('dark')
76-
const hamburger = screen.getAllByRole('button')[1]
77-
fireEvent.click(hamburger)
78-
fireEvent.click(screen.getByText(/light/i))
89+
const themeBtn = screen.getAllByRole('button')[0]
90+
fireEvent.click(themeBtn)
7991
expect(toggleTheme).toHaveBeenCalledTimes(1)
8092
})
8193

src/pages/Login/Login.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ const Login: React.FC = () => {
3434
setMessage(response.data.message);
3535

3636
if (response.data.message === 'Login successful') {
37+
if (typeof window !== "undefined" && response.data.user) {
38+
window.localStorage.setItem("github_tracker_auth_user", JSON.stringify(response.data.user));
39+
}
3740
navigate("/");
3841
}
3942
} catch (error: unknown) {

0 commit comments

Comments
 (0)