Skip to content

Commit 447a690

Browse files
committed
Add JWT-based profile and edit features
Implemented JWT authentication in backend, added profile fetch and edit routes, and created corresponding frontend components for protected profile viewing and editing. Updated Navbar and routing to support authentication state and user profile management.
1 parent 22e52f9 commit 447a690

10 files changed

Lines changed: 698 additions & 47 deletions

File tree

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"dotenv": "^16.4.5",
1919
"express": "^4.21.1",
2020
"express-session": "^1.18.1",
21+
"jsonwebtoken": "^9.0.2",
2122
"mongoose": "^8.8.2",
2223
"passport": "^0.7.0",
2324
"passport-local": "^1.0.0"

backend/routes/auth.js

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const express = require("express");
22
const passport = require("passport");
33
const User = require("../models/User");
44
const router = express.Router();
5+
const jwt = require("jsonwebtoken");
56

67
// Signup route
78
router.post("/signup", async (req, res) => {
@@ -23,9 +24,16 @@ router.post("/signup", async (req, res) => {
2324
});
2425

2526
// Login route
26-
router.post("/login", passport.authenticate('local'), (req, res) => {
27-
res.status(200).json( { message: 'Login successful', user: req.user } );
28-
});
27+
router.post("/login", passport.authenticate("local", { session: false }), (req, res) => {
28+
try {
29+
const user = req.user;
30+
const token = jwt.sign({ id: user.id }, process.env.SESSION_SECRET, { expiresIn: "1d" });
31+
res.status(200).json({ message: "Login successful", token, user });
32+
} catch (error) {
33+
res.status(500).json({ message: "Login failed", error: error.message });
34+
}
35+
}
36+
);
2937

3038
// Logout route
3139
router.get("/logout", (req, res) => {
@@ -39,4 +47,56 @@ router.get("/logout", (req, res) => {
3947
});
4048
});
4149

50+
// ---------------- AUTH MIDDLEWARE ----------------
51+
function requireAuth(req, res, next) {
52+
const authHeader = req.headers.authorization;
53+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
54+
return res.status(401).json({ message: "Missing or invalid token" });
55+
}
56+
57+
const token = authHeader.split(" ")[1];
58+
try {
59+
const decoded = jwt.verify(token, process.env.SESSION_SECRET);
60+
req.userId = decoded.id;
61+
next();
62+
} catch (err) {
63+
return res.status(401).json({ message: "Invalid or expired token" });
64+
}
65+
}
66+
67+
// ---------------- GET PROFILE ----------------
68+
router.get("/profile", requireAuth, async (req, res) => {
69+
try {
70+
const user = await User.findById(req.userId).select("-password");
71+
if (!user) return res.status(404).json({ message: "User not found" });
72+
res.status(200).json({ user });
73+
} catch (err) {
74+
res.status(500).json({ message: "Error fetching profile", error: err.message });
75+
}
76+
});
77+
78+
// ---------------- EDIT PROFILE ----------------
79+
router.put("/profile", requireAuth, async (req, res) => {
80+
try {
81+
const updates = {};
82+
const { username, email, bio, avatar } = req.body;
83+
84+
if (username !== undefined) updates.username = username;
85+
if (email !== undefined) updates.email = email;
86+
if (bio !== undefined) updates.bio = bio;
87+
if (avatar !== undefined) updates.avatar = avatar;
88+
89+
const user = await User.findByIdAndUpdate(req.userId, updates, {
90+
new: true,
91+
runValidators: true,
92+
select: "-password",
93+
});
94+
95+
if (!user) return res.status(404).json({ message: "User not found" });
96+
97+
res.status(200).json({ message: "Profile updated successfully", user });
98+
} catch (err) {
99+
res.status(500).json({ message: "Error updating profile", error: err.message });
100+
}
101+
});
42102
module.exports = router;

lib/api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/// <reference types="vite/client" />
2+
// src/services/api.ts
3+
import axios from "axios";
4+
5+
// Backend base URL from .env
6+
const backendUrl = import.meta.env.VITE_BACKEND_URL;
7+
8+
const api = axios.create({
9+
baseURL: backendUrl,
10+
headers: { "Content-Type": "application/json" },
11+
});
12+
13+
// Interceptor to attach token from localStorage
14+
api.interceptors.request.use(
15+
(config) => {
16+
const token = localStorage.getItem("token");
17+
if (token) {
18+
config.headers.Authorization = `Bearer ${token}`;
19+
}
20+
return config;
21+
},
22+
(error) => Promise.reject(error)
23+
);
24+
25+
export default api;

public/profile.svg

Lines changed: 1 addition & 0 deletions
Loading

src/Routes/Router.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Routes, Route } from "react-router-dom";
1+
import { Routes, Route, useNavigate } from "react-router-dom";
2+
import { useEffect, useState } from "react";
23
import Tracker from "../pages/Tracker/Tracker.tsx";
34
import About from "../pages/About/About";
45
import Contact from "../pages/Contact/Contact";
@@ -7,8 +8,23 @@ import Signup from "../pages/Signup/Signup.tsx";
78
import Login from "../pages/Login/Login.tsx";
89
import ContributorProfile from "../pages/ContributorProfile/ContributorProfile.tsx";
910
import Home from "../pages/Home/Home.tsx";
11+
import Profile from "../pages/Profile/Profile.tsx";
12+
import ProtectedRoute from "../components/ProtectedRoute.tsx";
13+
import EditProfile from "../pages/Editprofile/EditProfile.tsx";
1014

1115
const Router = () => {
16+
const [isAuthenticated, setIsAuthenticated] = useState(
17+
!!localStorage.getItem("token")
18+
);
19+
useEffect(() => {
20+
const syncAuth = () => setIsAuthenticated(!!localStorage.getItem("token"));
21+
window.addEventListener("authChange", syncAuth);
22+
window.addEventListener("storage", syncAuth);
23+
return () => {
24+
window.removeEventListener("authChange", syncAuth);
25+
window.removeEventListener("storage", syncAuth);
26+
};
27+
}, []);
1228
return (
1329
<Routes>
1430
<Route path="/" element={<Home />} />
@@ -19,6 +35,23 @@ const Router = () => {
1935
<Route path="/contact" element={<Contact />} />
2036
<Route path="/contributors" element={<Contributors />} />
2137
<Route path="/contributor/:username" element={<ContributorProfile />} />
38+
{/* Protected route */}
39+
<Route
40+
path="/profile"
41+
element={
42+
<ProtectedRoute isAuthenticated={isAuthenticated}>
43+
<Profile />
44+
</ProtectedRoute>
45+
}
46+
/>
47+
<Route
48+
path="/profile/edit"
49+
element={
50+
<ProtectedRoute isAuthenticated={isAuthenticated}>
51+
<EditProfile />
52+
</ProtectedRoute>
53+
}
54+
/>
2255
</Routes>
2356
);
2457
};

src/components/Navbar.tsx

Lines changed: 164 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,53 @@
1-
import { Link } from "react-router-dom";
2-
import { useState, useContext } from "react";
1+
import { Link, useNavigate, useLocation } from "react-router-dom";
2+
import { useState, useContext, useEffect } from "react";
33
import { ThemeContext } from "../context/ThemeContext";
4-
import { Moon, Sun } from 'lucide-react';
5-
4+
import { Moon, Sun } from "lucide-react";
5+
import { useRef } from "react";
66

77
const Navbar: React.FC = () => {
8+
const navigate = useNavigate();
9+
const location = useLocation();
10+
11+
const [menuOpen, setMenuOpen] = useState(false);
12+
const menuRef = useRef<HTMLDivElement>(null);
13+
14+
useEffect(() => {
15+
const onClick = (e: MouseEvent) => {
16+
if (menuRef.current && !menuRef.current.contains(e.target as Node))
17+
setMenuOpen(false);
18+
};
19+
20+
const onStorage = (e: StorageEvent) => {
21+
if (e.key === "token") setIsAuthed(!!e.newValue);
22+
};
23+
24+
const onAuthChange = () => {
25+
setIsAuthed(!!localStorage.getItem("token"));
26+
};
27+
28+
window.addEventListener("storage", onStorage);
29+
window.addEventListener("authChange", onAuthChange);
30+
window.addEventListener("click", onClick);
31+
return () => {
32+
window.removeEventListener("storage", onStorage);
33+
window.removeEventListener("authChange", onAuthChange);
34+
window.removeEventListener("click", onClick);
35+
};
36+
}, []);
37+
38+
useEffect(() => {
39+
setIsOpen(false); // close mobile menu on navigation
40+
}, [location.pathname]);
41+
42+
const handleLogout = () => {
43+
localStorage.removeItem("token");
44+
setIsAuthed(false);
45+
navigate("/login");
46+
};
847

48+
const [isAuthed, setIsAuthed] = useState<boolean>(
49+
!!localStorage.getItem("token")
50+
);
951
const [isOpen, setIsOpen] = useState<boolean>(false);
1052
const themeContext = useContext(ThemeContext);
1153

@@ -46,12 +88,97 @@ const Navbar: React.FC = () => {
4688
>
4789
Contributors
4890
</Link>
49-
<Link
50-
to="/login"
51-
className="text-lg font-medium hover:text-gray-300 transition-all px-2 py-1 border border-transparent hover:border-gray-400 rounded"
52-
>
53-
Login
54-
</Link>
91+
{/* replace your auth block with this */}
92+
{isAuthed ? (
93+
<div className="relative" ref={menuRef}>
94+
<button
95+
onClick={() => setMenuOpen((v) => !v)}
96+
className="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
97+
aria-haspopup="menu"
98+
aria-expanded={menuOpen}
99+
>
100+
{/* Avatar Icon */}
101+
<img
102+
src={
103+
JSON.parse(localStorage.getItem("user") || "{}")
104+
?.avatarUrl || "/profile.svg"
105+
}
106+
alt="avatar"
107+
className="h-8 w-8 rounded-full object-cover border border-gray-300 dark:border-gray-600"
108+
/>
109+
110+
{/* Username beside icon */}
111+
{/* <span className="text-sm font-medium">
112+
{JSON.parse(localStorage.getItem("user") || "{}")?.username ||
113+
"Me"}
114+
</span> */}
115+
116+
{/* Chevron icon */}
117+
<svg
118+
className={`h-4 w-4 transition-transform ${
119+
menuOpen ? "rotate-180" : ""
120+
}`}
121+
viewBox="0 0 20 20"
122+
fill="currentColor"
123+
>
124+
<path d="M5.25 7.5L10 12.25L14.75 7.5H5.25Z" />
125+
</svg>
126+
</button>
127+
128+
{/* Dropdown Menu */}
129+
{menuOpen && (
130+
<div
131+
role="menu"
132+
className="absolute right-0 mt-2 w-56 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg p-2"
133+
>
134+
{/* User Info */}
135+
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-700">
136+
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
137+
{JSON.parse(localStorage.getItem("user") || "{}")
138+
?.username || "User"}
139+
</p>
140+
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
141+
{JSON.parse(localStorage.getItem("user") || "{}")
142+
?.email || "email@example.com"}
143+
</p>
144+
</div>
145+
146+
{/* Menu Links */}
147+
<Link
148+
to="/profile"
149+
className="block px-3 py-2 text-sm rounded hover:bg-gray-100 dark:hover:bg-gray-700"
150+
onClick={() => setMenuOpen(false)}
151+
>
152+
View Profile
153+
</Link>
154+
<Link
155+
to="/profile/edit"
156+
className="block px-3 py-2 text-sm rounded hover:bg-gray-100 dark:hover:bg-gray-700"
157+
onClick={() => setMenuOpen(false)}
158+
>
159+
Edit Profile
160+
</Link>
161+
<button
162+
onClick={() => {
163+
setMenuOpen(false);
164+
handleLogout();
165+
}}
166+
className="block w-full text-left px-3 py-2 text-sm rounded hover:bg-gray-100 dark:hover:bg-gray-700"
167+
>
168+
Logout
169+
</button>
170+
</div>
171+
)}
172+
</div>
173+
) : (
174+
<Link
175+
to="/login"
176+
className="text-lg font-medium hover:text-gray-300 transition-all px-2 py-1 border border-transparent hover:border-gray-400 rounded"
177+
>
178+
Login
179+
</Link>
180+
)}
181+
55182
<button
56183
onClick={toggleTheme}
57184
className="text-sm font-semibold px-3 py-1 rounded border border-gray-500 hover:text-gray-300 hover:border-gray-300 transition duration-200"
@@ -117,13 +244,33 @@ const Navbar: React.FC = () => {
117244
>
118245
Contributors
119246
</Link>
120-
<Link
121-
to="/login"
122-
className="block text-lg font-medium hover:text-gray-300 transition-all px-2 py-1 border border-transparent hover:border-gray-400 rounded"
123-
onClick={() => setIsOpen(false)}
124-
>
125-
Login
126-
</Link>
247+
{isAuthed ? (
248+
<>
249+
<Link
250+
to="/profile"
251+
className="block text-lg font-medium hover:text-gray-300 transition-all px-2 py-1 border border-transparent hover:border-gray-400 rounded"
252+
onClick={() => setIsOpen(false)}
253+
>
254+
Profile
255+
</Link>
256+
<button
257+
onClick={() => {
258+
handleLogout();
259+
}}
260+
className="block text-left text-lg font-medium px-2 py-1 border border-transparent hover:border-gray-400 rounded w-full"
261+
>
262+
Logout
263+
</button>
264+
</>
265+
) : (
266+
<Link
267+
to="/login"
268+
className="block text-lg font-medium hover:text-gray-300 transition-all px-2 py-1 border border-transparent hover:border-gray-400 rounded"
269+
onClick={() => setIsOpen(false)}
270+
>
271+
Login
272+
</Link>
273+
)}
127274
<button
128275
onClick={() => {
129276
toggleTheme();

src/components/ProtectedRoute.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Navigate } from "react-router-dom";
2+
import { ReactNode } from "react";
3+
4+
interface ProtectedRouteProps {
5+
children: ReactNode;
6+
isAuthenticated: boolean;
7+
}
8+
9+
const ProtectedRoute = ({ children, isAuthenticated }: ProtectedRouteProps) => {
10+
if (!isAuthenticated) {
11+
// Redirect to login page if user is not authenticated
12+
return <Navigate to="/login" replace />;
13+
}
14+
15+
return <>{children}</>;
16+
};
17+
18+
export default ProtectedRoute;

0 commit comments

Comments
 (0)