Skip to content

Commit dc4c88d

Browse files
committed
feat: implement dynamic navbar toggle and logout redirect flow
1 parent 4ae0ef6 commit dc4c88d

7 files changed

Lines changed: 221 additions & 33 deletions

File tree

backend/config/passportConfig.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ passport.serializeUser((user, done) => {
3838
passport.deserializeUser(async (id, done) => {
3939
try {
4040
const user = await User.findById(id);
41-
done(null, user);
41+
done(null, user ? {
42+
id: user._id.toString(),
43+
username: user.username,
44+
email: user.email
45+
} : null);
4246
} catch (err) {
4347
done(err, null);
4448
}

backend/routes/auth.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ router.post("/signup", validateRequest(signupSchema), async (req, res) => {
3030
}
3131
});
3232

33+
// Session status route
34+
router.get("/me", (req, res) => {
35+
const isAuthenticated = typeof req.isAuthenticated === "function" && req.isAuthenticated();
36+
37+
if (!isAuthenticated) {
38+
return res.status(200).json({ authenticated: false, user: null });
39+
}
40+
41+
return res.status(200).json({ authenticated: true, user: req.user });
42+
});
43+
3344
// Login route
3445
router.post("/login", validateRequest(loginSchema), passport.authenticate('local'), (req, res) => {
3546
res.status(200).json( { message: 'Login successful', user: req.user } );
@@ -40,10 +51,18 @@ router.get("/logout", (req, res) => {
4051

4152
req.logout((err) => {
4253

43-
if (err)
54+
if (err) {
4455
return res.status(500).json({ message: 'Logout failed', error: err.message });
45-
else
46-
res.status(200).json({ message: 'Logged out successfully' });
56+
}
57+
58+
req.session.destroy((sessionErr) => {
59+
if (sessionErr) {
60+
return res.status(500).json({ message: 'Logout failed', error: sessionErr.message });
61+
}
62+
63+
res.clearCookie('connect.sid');
64+
return res.status(200).json({ message: 'Logged out successfully' });
65+
});
4766
});
4867
});
4968

src/components/Navbar.tsx

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import { NavLink, Link } from "react-router-dom";
22
import { useState, useContext } from "react";
33
import { ThemeContext } from "../context/ThemeContext";
4-
import { Moon, Sun, Menu, X, Github } from "lucide-react";
4+
import { AuthContext } from "../context/AuthContext";
5+
import { Moon, Sun, Menu, X } from "lucide-react";
6+
import { useNavigate } from "react-router-dom";
57

68
const Navbar: React.FC = () => {
79
const [isOpen, setIsOpen] = useState(false);
810

911
const themeContext = useContext(ThemeContext);
12+
const authContext = useContext(AuthContext);
13+
const navigate = useNavigate();
1014

11-
if (!themeContext) return null;
15+
if (!themeContext || !authContext) return null;
1216

1317
const { toggleTheme, mode } = themeContext;
18+
const { isAuthenticated, logout } = authContext;
1419

1520
const navLinkStyles = ({ isActive }: { isActive: boolean }) =>
1621
`px-4 py-2 rounded-xl text-sm lg:text-base font-semibold transition-all duration-300 ${
@@ -21,6 +26,12 @@ const Navbar: React.FC = () => {
2126

2227
const closeMenu = () => setIsOpen(false);
2328

29+
const handleLogout = async () => {
30+
await logout();
31+
closeMenu();
32+
navigate("/login", { replace: true });
33+
};
34+
2435
return (
2536
<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">
2637
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
@@ -53,9 +64,26 @@ const Navbar: React.FC = () => {
5364
Contributors
5465
</NavLink>
5566

56-
<NavLink to="/login" className={navLinkStyles}>
57-
Login
58-
</NavLink>
67+
{!isAuthenticated && (
68+
<>
69+
<NavLink to="/login" className={navLinkStyles}>
70+
Login
71+
</NavLink>
72+
73+
<NavLink to="/signup" className={navLinkStyles}>
74+
Signup
75+
</NavLink>
76+
</>
77+
)}
78+
79+
{isAuthenticated && (
80+
<button
81+
onClick={handleLogout}
82+
className="px-4 py-2 rounded-xl text-sm lg:text-base font-semibold transition-all duration-300 text-white bg-rose-500 hover:bg-rose-600 shadow-sm"
83+
>
84+
Logout
85+
</button>
86+
)}
5987

6088
{/* Theme Toggle */}
6189
<button
@@ -131,13 +159,34 @@ const Navbar: React.FC = () => {
131159
Contributors
132160
</NavLink>
133161

134-
<NavLink
135-
to="/login"
136-
className={navLinkStyles}
137-
onClick={closeMenu}
138-
>
139-
Login
140-
</NavLink>
162+
{!isAuthenticated && (
163+
<>
164+
<NavLink
165+
to="/login"
166+
className={navLinkStyles}
167+
onClick={closeMenu}
168+
>
169+
Login
170+
</NavLink>
171+
172+
<NavLink
173+
to="/signup"
174+
className={navLinkStyles}
175+
onClick={closeMenu}
176+
>
177+
Signup
178+
</NavLink>
179+
</>
180+
)}
181+
182+
{isAuthenticated && (
183+
<button
184+
onClick={handleLogout}
185+
className="text-left px-4 py-2 rounded-xl text-sm lg:text-base font-semibold transition-all duration-300 text-white bg-rose-500 hover:bg-rose-600 shadow-sm"
186+
>
187+
Logout
188+
</button>
189+
)}
141190
</div>
142191
</div>
143192
)}

src/components/__test__/Navbar.test.tsx

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,37 @@
11
// src/components/__tests__/Navbar.test.tsx
2-
import { render, screen, fireEvent } from '@testing-library/react'
3-
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { render, screen, fireEvent, act } from '@testing-library/react'
3+
import { describe, it, expect, vi } from 'vitest'
44
import { MemoryRouter } from 'react-router-dom'
55
import { ThemeContext } from "../../context/ThemeContext";
6+
import { AuthContext } from "../../context/AuthContext";
67
import Navbar from '../Navbar.tsx'
78

89
// Helper to render Navbar with a mock ThemeContext
9-
const renderNavbar = (mode: 'light' | 'dark' = 'light') => {
10+
const renderNavbar = (
11+
mode: 'light' | 'dark' = 'light',
12+
isAuthenticated = false
13+
) => {
1014
const toggleTheme = vi.fn()
15+
const logout = vi.fn().mockResolvedValue(undefined)
1116
render(
1217
<MemoryRouter>
1318
<ThemeContext.Provider value={{ mode, toggleTheme }}>
14-
<Navbar />
19+
<AuthContext.Provider
20+
value={{
21+
user: isAuthenticated ? { id: '1', username: 'tester', email: 'tester@example.com' } : null,
22+
isAuthenticated,
23+
isLoading: false,
24+
refreshAuth: vi.fn(),
25+
handleLoginSuccess: vi.fn(),
26+
logout,
27+
}}
28+
>
29+
<Navbar />
30+
</AuthContext.Provider>
1531
</ThemeContext.Provider>
1632
</MemoryRouter>
1733
)
18-
return { toggleTheme }
34+
return { toggleTheme, logout }
1935
}
2036

2137
describe('Navbar', () => {
@@ -31,6 +47,14 @@ describe('Navbar', () => {
3147
expect(screen.getByRole('link', { name: /^tracker$/i })).toBeInTheDocument()
3248
expect(screen.getByRole('link', { name: /contributors/i })).toBeInTheDocument()
3349
expect(screen.getByRole('link', { name: /login/i })).toBeInTheDocument()
50+
expect(screen.getByRole('link', { name: /signup/i })).toBeInTheDocument()
51+
})
52+
53+
it('shows logout instead of login and signup when authenticated', () => {
54+
renderNavbar('light', true)
55+
expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument()
56+
expect(screen.queryByRole('link', { name: /login/i })).not.toBeInTheDocument()
57+
expect(screen.queryByRole('link', { name: /signup/i })).not.toBeInTheDocument()
3458
})
3559

3660
// --- Theme toggle ---
@@ -51,34 +75,42 @@ describe('Navbar', () => {
5175
// --- Mobile menu ---
5276
it('mobile menu is hidden by default', () => {
5377
renderNavbar()
54-
expect(screen.queryByText('About')).not.toBeInTheDocument()
78+
expect(screen.getAllByRole('link', { name: /signup/i })).toHaveLength(1)
5579
})
5680

5781
it('opens mobile menu when hamburger is clicked', () => {
5882
renderNavbar()
59-
const hamburger = screen.getAllByRole('button')[1] // second button = hamburger
83+
const hamburger = screen.getByLabelText(/toggle menu/i)
6084
fireEvent.click(hamburger)
61-
expect(screen.getByText('About')).toBeInTheDocument()
62-
expect(screen.getByText('Contact')).toBeInTheDocument()
85+
expect(screen.getAllByRole('link', { name: /login/i })).toHaveLength(2)
86+
expect(screen.getAllByRole('link', { name: /signup/i })).toHaveLength(2)
6387
})
6488

6589
it('closes mobile menu when a nav link is clicked', () => {
6690
renderNavbar()
67-
const hamburger = screen.getAllByRole('button')[1]
91+
const hamburger = screen.getByLabelText(/toggle menu/i)
6892
fireEvent.click(hamburger) // open
6993
const homeLinks = screen.getAllByRole('link', { name: /home/i })
7094
fireEvent.click(homeLinks[homeLinks.length - 1]) // click the mobile one
71-
expect(screen.queryByText('About')).not.toBeInTheDocument() // closed
95+
expect(screen.getAllByRole('link', { name: /signup/i })).toHaveLength(1) // closed
7296
})
7397

7498
it('calls toggleTheme from the mobile menu button', () => {
7599
const { toggleTheme } = renderNavbar('dark')
76-
const hamburger = screen.getAllByRole('button')[1]
100+
const hamburger = screen.getByLabelText(/toggle menu/i)
77101
fireEvent.click(hamburger)
78-
fireEvent.click(screen.getByText(/light/i))
102+
fireEvent.click(screen.getAllByLabelText(/toggle theme/i)[1])
79103
expect(toggleTheme).toHaveBeenCalledTimes(1)
80104
})
81105

106+
it('calls logout when the authenticated logout button is clicked', async () => {
107+
const { logout } = renderNavbar('light', true)
108+
await act(async () => {
109+
fireEvent.click(screen.getByRole('button', { name: /logout/i }))
110+
})
111+
expect(logout).toHaveBeenCalledTimes(1)
112+
})
113+
82114
// --- Returns null when ThemeContext is missing ---
83115
it('renders nothing if ThemeContext is not provided', () => {
84116
const { container } = render(

src/context/AuthContext.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/* eslint-disable react-refresh/only-export-components */
2+
import { createContext, useCallback, useEffect, useState, ReactNode } from "react";
3+
import axios from "axios";
4+
5+
interface AuthUser {
6+
id: string;
7+
username: string;
8+
email: string;
9+
}
10+
11+
interface AuthContextType {
12+
user: AuthUser | null;
13+
isAuthenticated: boolean;
14+
isLoading: boolean;
15+
refreshAuth: () => Promise<void>;
16+
handleLoginSuccess: (user: AuthUser) => void;
17+
logout: () => Promise<void>;
18+
}
19+
20+
const backendUrl = import.meta.env.VITE_BACKEND_URL ?? "";
21+
22+
export const AuthContext = createContext<AuthContextType | null>(null);
23+
24+
const AuthProvider = ({ children }: { children: ReactNode }) => {
25+
const [user, setUser] = useState<AuthUser | null>(null);
26+
const [isLoading, setIsLoading] = useState(true);
27+
28+
const refreshAuth = useCallback(async () => {
29+
try {
30+
const response = await axios.get(`${backendUrl}/api/auth/me`, {
31+
withCredentials: true,
32+
});
33+
34+
setUser(response.data.authenticated ? response.data.user : null);
35+
} catch {
36+
setUser(null);
37+
} finally {
38+
setIsLoading(false);
39+
}
40+
}, []);
41+
42+
useEffect(() => {
43+
void refreshAuth();
44+
}, [refreshAuth]);
45+
46+
const handleLoginSuccess = useCallback((nextUser: AuthUser) => {
47+
setUser(nextUser);
48+
setIsLoading(false);
49+
}, []);
50+
51+
const logout = useCallback(async () => {
52+
await axios.get(`${backendUrl}/api/auth/logout`, {
53+
withCredentials: true,
54+
});
55+
56+
setUser(null);
57+
}, []);
58+
59+
return (
60+
<AuthContext.Provider
61+
value={{
62+
user,
63+
isAuthenticated: Boolean(user),
64+
isLoading,
65+
refreshAuth,
66+
handleLoginSuccess,
67+
logout,
68+
}}
69+
>
70+
{children}
71+
</AuthContext.Provider>
72+
);
73+
};
74+
75+
export default AuthProvider;
76+
export type { AuthContextType, AuthUser };

src/main.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import App from "./App.tsx";
44
import "./index.css";
55
import { BrowserRouter } from "react-router-dom";
66
import ThemeWrapper from "./context/ThemeContext.tsx";
7+
import AuthProvider from "./context/AuthContext.tsx";
78

89
createRoot(document.getElementById("root")!).render(
910
<StrictMode>
1011
<ThemeWrapper>
11-
<BrowserRouter>
12-
<App />
13-
</BrowserRouter>
12+
<BrowserRouter>
13+
<AuthProvider>
14+
<App />
15+
</AuthProvider>
16+
</BrowserRouter>
1417
</ThemeWrapper>
1518
</StrictMode>
1619
);

0 commit comments

Comments
 (0)