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
6 changes: 5 additions & 1 deletion backend/config/passportConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ passport.serializeUser((user, done) => {
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
done(null, user);
done(null, user ? {
id: user._id.toString(),
username: user.username,
email: user.email
} : null);
} catch (err) {
done(err, null);
}
Expand Down
25 changes: 22 additions & 3 deletions backend/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ router.post("/signup", validateRequest(signupSchema), async (req, res) => {
}
});

// Session status route
router.get("/me", (req, res) => {
const isAuthenticated = typeof req.isAuthenticated === "function" && req.isAuthenticated();

if (!isAuthenticated) {
return res.status(200).json({ authenticated: false, user: null });
}

return res.status(200).json({ authenticated: true, user: req.user });
});

// Login route
router.post("/login", validateRequest(loginSchema), passport.authenticate('local'), (req, res) => {
res.status(200).json( { message: 'Login successful', user: req.user } );
Expand All @@ -40,10 +51,18 @@ router.get("/logout", (req, res) => {

req.logout((err) => {

if (err)
if (err) {
return res.status(500).json({ message: 'Logout failed', error: err.message });
else
res.status(200).json({ message: 'Logged out successfully' });
}

req.session.destroy((sessionErr) => {
if (sessionErr) {
return res.status(500).json({ message: 'Logout failed', error: sessionErr.message });
}

res.clearCookie('connect.sid');
return res.status(200).json({ message: 'Logged out successfully' });
});
});
});

Expand Down
86 changes: 66 additions & 20 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
import { NavLink, Link } from "react-router-dom";
import { useState, useContext } from "react";
import { ThemeContext } from "../context/ThemeContext";
import { AuthContext } from "../context/AuthContext";
import { Moon, Sun, Menu, X } from "lucide-react";
import { useNavigate } from "react-router-dom";

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

const themeContext = useContext(ThemeContext);
if (!themeContext) return null;
const authContext = useContext(AuthContext);
const navigate = useNavigate();

const { toggleTheme, mode } = themeContext;
const storedUser = localStorage.getItem("user");
let user = null;
if (!themeContext || !authContext) return null;

try {
user = storedUser ? JSON.parse(storedUser) : null;
} catch (error) {
console.error("Invalid user data in local Storage");
localStorage.removeItem("user");
user = null;
}
const { toggleTheme, mode } = themeContext;
const { isAuthenticated, isLoading, logout } = authContext;

const navLinkStyles = ({ isActive }: { isActive: boolean }) =>
`px-4 py-2 rounded-xl text-sm lg:text-base font-semibold transition-all duration-300 ${isActive
Expand All @@ -29,6 +25,17 @@ const Navbar: React.FC = () => {

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

const handleLogout = async () => {
try {
await logout();
navigate("/login", { replace: true });
} catch {
// optionally surface a toast/message
} finally {
closeMenu();
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<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">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
Expand Down Expand Up @@ -64,12 +71,27 @@ const Navbar: React.FC = () => {
<NavLink to="/contributors" className={navLinkStyles}>
Contributors
</NavLink>
{!user && (<NavLink
to="/login"
className="text-lg font-medium hover:text-gray-300 transition-all px-2 py-1 border border-transparent hover:border-gray-400 rounded"
>
Login
</NavLink>)}

{!isLoading && !isAuthenticated && (
<>
<NavLink to="/login" className={navLinkStyles}>
Login
</NavLink>

<NavLink to="/signup" className={navLinkStyles}>
Signup
</NavLink>
</>
)}

{!isLoading && isAuthenticated && (
<button
onClick={handleLogout}
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"
>
Logout
</button>
)}

{user && <ProfileDropDown user={user} />}
{/* Theme Toggle */}
Expand Down Expand Up @@ -175,10 +197,34 @@ const Navbar: React.FC = () => {
</>
)}

<NavLink to="/login" className={navLinkStyles} onClick={closeMenu}>
Login
</NavLink>
{!isLoading && !isAuthenticated && (
<>
<NavLink
to="/login"
className={navLinkStyles}
onClick={closeMenu}
>
Login
</NavLink>

<NavLink
to="/signup"
className={navLinkStyles}
onClick={closeMenu}
>
Signup
</NavLink>
</>
)}

{!isLoading && isAuthenticated && (
<button
onClick={handleLogout}
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"
>
Logout
</button>
)}
</div>
</div>
)}
Expand Down
63 changes: 48 additions & 15 deletions src/components/__test__/Navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
// src/components/__tests__/Navbar.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { MemoryRouter } from 'react-router-dom'
import { ThemeContext } from "../../context/ThemeContext";
import { AuthContext } from "../../context/AuthContext";
import Navbar from '../Navbar.tsx'

// Helper to render Navbar with a mock ThemeContext
const renderNavbar = (mode: 'light' | 'dark' = 'light') => {
const renderNavbar = (
mode: 'light' | 'dark' = 'light',
isAuthenticated = false
) => {
const toggleTheme = vi.fn()
const logout = vi.fn().mockResolvedValue(undefined)
render(
<MemoryRouter>
<ThemeContext.Provider value={{ mode, toggleTheme }}>
<Navbar />
<AuthContext.Provider
value={{
user: isAuthenticated ? { id: '1', username: 'tester', email: 'tester@example.com' } : null,
isAuthenticated,
isLoading: false,
refreshAuth: vi.fn(),
handleLoginSuccess: vi.fn(),
logout,
}}
>
<Navbar />
</AuthContext.Provider>
</ThemeContext.Provider>
</MemoryRouter>
)
return { toggleTheme }
return { toggleTheme, logout }
}

describe('Navbar', () => {
Expand All @@ -31,6 +47,14 @@ describe('Navbar', () => {
expect(screen.getByRole('link', { name: /^tracker$/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /contributors/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /login/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /signup/i })).toBeInTheDocument()
})

it('shows logout instead of login and signup when authenticated', () => {
renderNavbar('light', true)
expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /login/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /signup/i })).not.toBeInTheDocument()
})

// --- Theme toggle ---
Expand All @@ -51,33 +75,42 @@ describe('Navbar', () => {
// --- Mobile menu ---
it('mobile menu is hidden by default', () => {
renderNavbar()
expect(screen.getAllByRole('link', { name: /^tracker$/i })).toHaveLength(1)
expect(screen.getAllByRole('link', { name: /signup/i })).toHaveLength(1)
})

it('opens mobile menu when hamburger is clicked', () => {
renderNavbar()
const hamburger = screen.getByRole('button', { name: /toggle menu/i })
const hamburger = screen.getByLabelText(/toggle menu/i)
fireEvent.click(hamburger)
expect(screen.getAllByRole('link', { name: /^tracker$/i })).toHaveLength(2)
expect(screen.getAllByRole('link', { name: /login/i })).toHaveLength(2)
expect(screen.getAllByRole('link', { name: /signup/i })).toHaveLength(2)
})

it('closes mobile menu when a nav link is clicked', () => {
renderNavbar()
const hamburger = screen.getByRole('button', { name: /toggle menu/i })
const hamburger = screen.getByLabelText(/toggle menu/i)
fireEvent.click(hamburger) // open
const trackerLinks = screen.getAllByRole('link', { name: /^tracker$/i })
fireEvent.click(trackerLinks[trackerLinks.length - 1]) // click the mobile one
expect(screen.getAllByRole('link', { name: /^tracker$/i })).toHaveLength(1) // closed
const homeLinks = screen.getAllByRole('link', { name: /home/i })
fireEvent.click(homeLinks[homeLinks.length - 1]) // click the mobile one
expect(screen.getAllByRole('link', { name: /signup/i })).toHaveLength(1) // closed
})

it('calls toggleTheme from the mobile menu button', () => {
const { toggleTheme } = renderNavbar('dark')
// The mobile theme button is the first button inside md:hidden (which is button at index 1 of all buttons)
const mobileThemeBtn = screen.getAllByRole('button', { name: /toggle theme/i })[1]
fireEvent.click(mobileThemeBtn)
const hamburger = screen.getByLabelText(/toggle menu/i)
fireEvent.click(hamburger)
fireEvent.click(screen.getAllByLabelText(/toggle theme/i)[1])
expect(toggleTheme).toHaveBeenCalledTimes(1)
})

it('calls logout when the authenticated logout button is clicked', async () => {
const { logout } = renderNavbar('light', true)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /logout/i }))
})
expect(logout).toHaveBeenCalledTimes(1)
})

// --- Returns null when ThemeContext is missing ---
it('renders nothing if ThemeContext is not provided', () => {
const { container } = render(
Expand Down
76 changes: 76 additions & 0 deletions src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useCallback, useEffect, useState, ReactNode } from "react";
import axios from "axios";

interface AuthUser {
id: string;
username: string;
email: string;
}

interface AuthContextType {
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
refreshAuth: () => Promise<void>;
handleLoginSuccess: (user: AuthUser) => void;
logout: () => Promise<void>;
}

const backendUrl = import.meta.env.VITE_BACKEND_URL ?? "";

export const AuthContext = createContext<AuthContextType | null>(null);

const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);

const refreshAuth = useCallback(async () => {
try {
const response = await axios.get(`${backendUrl}/api/auth/me`, {
withCredentials: true,
});

setUser(response.data.authenticated ? response.data.user : null);
} catch {
setUser(null);
} finally {
setIsLoading(false);
}
}, []);

useEffect(() => {
void refreshAuth();
}, [refreshAuth]);

const handleLoginSuccess = useCallback((nextUser: AuthUser) => {
setUser(nextUser);
setIsLoading(false);
}, []);

const logout = useCallback(async () => {
await axios.get(`${backendUrl}/api/auth/logout`, {
withCredentials: true,
});

setUser(null);
}, []);

return (
<AuthContext.Provider
value={{
user,
isAuthenticated: Boolean(user),
isLoading,
refreshAuth,
handleLoginSuccess,
logout,
}}
>
{children}
</AuthContext.Provider>
);
};

export default AuthProvider;
export type { AuthContextType, AuthUser };
13 changes: 6 additions & 7 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,16 @@ import App from "./App.tsx";
import "./index.css";
import { BrowserRouter } from "react-router-dom";
import ThemeWrapper from "./context/ThemeContext.tsx";
import axios from "axios";

axios.defaults.withCredentials = true;

import AuthProvider from "./context/AuthContext.tsx";

createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeWrapper>
<BrowserRouter>
<App />
</BrowserRouter>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</ThemeWrapper>
</StrictMode>
);
Loading
Loading