diff --git a/backend/package.json b/backend/package.json index 9891c08..00102e3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "dotenv": "^16.4.5", "express": "^4.21.1", "express-session": "^1.18.1", + "jsonwebtoken": "^9.0.2", "mongoose": "^8.8.2", "passport": "^0.7.0", "passport-local": "^1.0.0" diff --git a/backend/routes/auth.js b/backend/routes/auth.js index e26c7a9..bb4246a 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -2,6 +2,7 @@ const express = require("express"); const passport = require("passport"); const User = require("../models/User"); const router = express.Router(); +const jwt = require("jsonwebtoken"); // Signup route router.post("/signup", async (req, res) => { @@ -23,9 +24,16 @@ router.post("/signup", async (req, res) => { }); // Login route -router.post("/login", passport.authenticate('local'), (req, res) => { - res.status(200).json( { message: 'Login successful', user: req.user } ); -}); +router.post("/login", passport.authenticate("local", { session: false }), (req, res) => { + try { + const user = req.user; + const token = jwt.sign({ id: user.id }, process.env.SESSION_SECRET, { expiresIn: "1d" }); + res.status(200).json({ message: "Login successful", token, user }); + } catch (error) { + res.status(500).json({ message: "Login failed", error: error.message }); + } +} +); // Logout route router.get("/logout", (req, res) => { @@ -39,4 +47,62 @@ router.get("/logout", (req, res) => { }); }); +// ---------------- AUTH MIDDLEWARE ---------------- +function requireAuth(req, res, next) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ message: "Missing or invalid token" }); + } + + const token = authHeader.split(" ")[1]; + try { + const decoded = jwt.verify(token, process.env.SESSION_SECRET); + req.userId = decoded.id; + next(); + } catch (err) { + return res.status(401).json({ message: "Invalid or expired token" }); + } +} + +// ---------------- GET PROFILE ---------------- +router.get("/profile", requireAuth, async (req, res) => { + try { + const user = await User.findById(req.userId).select("-password"); + if (!user) return res.status(404).json({ message: "User not found" }); + res.status(200).json({ user }); + } catch (err) { + res.status(500).json({ message: "Error fetching profile", error: err.message }); + } +}); + +// ---------------- EDIT PROFILE ---------------- +router.put("/profile", requireAuth, async (req, res) => { + try { + const updates = {}; + const { username, email, bio, avatar, newPassword } = req.body; + + if (username !== undefined) updates.username = username; + if (email !== undefined) updates.email = email; + if (bio !== undefined) updates.bio = bio; + if (avatar !== undefined) updates.avatar = avatar; + + // Handle password update if newPassword is provided + if (newPassword !== undefined && newPassword.trim().length > 0) { + // Password will be hashed by the User model's pre-save hook + updates.password = newPassword; + } + + const user = await User.findByIdAndUpdate(req.userId, updates, { + new: true, + runValidators: true, + select: "-password", + }); + + if (!user) return res.status(404).json({ message: "User not found" }); + + res.status(200).json({ message: "Profile updated successfully", user }); + } catch (err) { + res.status(500).json({ message: "Error updating profile", error: err.message }); + } +}); module.exports = router; diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 0000000..5b6f7a5 --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,25 @@ +/// +// src/services/api.ts +import axios from "axios"; + +// Backend base URL from .env +const backendUrl = import.meta.env.VITE_BACKEND_URL; + +const api = axios.create({ + baseURL: backendUrl, + headers: { "Content-Type": "application/json" }, +}); + +// Interceptor to attach token from localStorage +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem("token"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) +); + +export default api; diff --git a/public/profile.svg b/public/profile.svg new file mode 100644 index 0000000..17efa4c --- /dev/null +++ b/public/profile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Routes/Router.tsx b/src/Routes/Router.tsx index 40a7861..62e447f 100644 --- a/src/Routes/Router.tsx +++ b/src/Routes/Router.tsx @@ -1,4 +1,5 @@ -import { Routes, Route } from "react-router-dom"; +import { Routes, Route, useNavigate } from "react-router-dom"; +import { useEffect, useState } from "react"; import Tracker from "../pages/Tracker/Tracker.tsx"; import About from "../pages/About/About"; import Contact from "../pages/Contact/Contact"; @@ -7,8 +8,23 @@ import Signup from "../pages/Signup/Signup.tsx"; import Login from "../pages/Login/Login.tsx"; import ContributorProfile from "../pages/ContributorProfile/ContributorProfile.tsx"; import Home from "../pages/Home/Home.tsx"; +import Profile from "../pages/Profile/Profile.tsx"; +import ProtectedRoute from "../components/ProtectedRoute.tsx"; +import EditProfile from "../pages/Editprofile/EditProfile.tsx"; const Router = () => { + const [isAuthenticated, setIsAuthenticated] = useState( + !!localStorage.getItem("token") + ); + useEffect(() => { + const syncAuth = () => setIsAuthenticated(!!localStorage.getItem("token")); + window.addEventListener("authChange", syncAuth); + window.addEventListener("storage", syncAuth); + return () => { + window.removeEventListener("authChange", syncAuth); + window.removeEventListener("storage", syncAuth); + }; + }, []); return ( } /> @@ -19,6 +35,23 @@ const Router = () => { } /> } /> } /> + {/* Protected route */} + + + + } + /> + + + + } + /> ); }; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index c6cc86d..d16a8f3 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,11 +1,57 @@ -import { Link } from "react-router-dom"; -import { useState, useContext } from "react"; +import { Link, useNavigate, useLocation } from "react-router-dom"; +import { useState, useContext, useEffect } from "react"; import { ThemeContext } from "../context/ThemeContext"; -import { Moon, Sun } from 'lucide-react'; - +import { Moon, Sun } from "lucide-react"; +import { useRef } from "react"; const Navbar: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const onClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) + setMenuOpen(false); + }; + + const onStorage = (e: StorageEvent) => { + if (e.key === "token") setIsAuthed(!!e.newValue); + }; + + const onAuthChange = () => { + setIsAuthed(!!localStorage.getItem("token")); + }; + + window.addEventListener("storage", onStorage); + window.addEventListener("authChange", onAuthChange); + window.addEventListener("click", onClick); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener("authChange", onAuthChange); + window.removeEventListener("click", onClick); + }; + }, []); + + useEffect(() => { + setIsOpen(false); // close mobile menu on navigation + }, [location.pathname]); + + const handleLogout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + setIsAuthed(false); + setUser(null); + // notify same-tab listeners + window.dispatchEvent(new Event("authChange")); + navigate("/login"); + }; + const [isAuthed, setIsAuthed] = useState( + !!localStorage.getItem("token") + ); const [isOpen, setIsOpen] = useState(false); const themeContext = useContext(ThemeContext); @@ -46,12 +92,97 @@ const Navbar: React.FC = () => { > Contributors - - Login - + {/* replace your auth block with this */} + {isAuthed ? ( +
+ + + {/* Dropdown Menu */} + {menuOpen && ( +
+ {/* User Info */} +
+

+ {JSON.parse(localStorage.getItem("user") || "{}") + ?.username || "User"} +

+

+ {JSON.parse(localStorage.getItem("user") || "{}") + ?.email || "email@example.com"} +

+
+ + {/* Menu Links */} + setMenuOpen(false)} + > + View Profile + + setMenuOpen(false)} + > + Edit Profile + + +
+ )} +
+ ) : ( + + Login + + )} + + + ) : ( + setIsOpen(false)} + > + Login + + )} + + + ); +} diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx index d6f21a7..259a4b7 100644 --- a/src/pages/Login/Login.tsx +++ b/src/pages/Login/Login.tsx @@ -30,14 +30,30 @@ const Login: React.FC = () => { setIsLoading(true); try { - const response = await axios.post(`${backendUrl}/api/auth/login`, formData); - setMessage(response.data.message); + const response = await axios.post( + `${backendUrl}/api/auth/login`, + formData + ); - if (response.data.message === 'Login successful') { - navigate("/home"); + if (response.status === 200) { + setMessage(response.data.message || "Login successful"); + + localStorage.setItem("token", response.data.token); + localStorage.setItem("user", JSON.stringify(response.data.user)); + + window.dispatchEvent(new Event("authChange")); + + // Redirect after successful login + navigate("/"); + } else { + setMessage(response.data.message || "Login failed"); } } catch (error: any) { - setMessage(error.response?.data?.message || "Something went wrong"); + if (axios.isAxiosError(error) && error.response) { + setMessage(error.response.data?.message || "Invalid email or password"); + } else { + setMessage("Something went wrong. Please try again."); + } } finally { setIsLoading(false); } @@ -53,34 +69,70 @@ const Login: React.FC = () => { > {/* Animated background elements */}
-
-
-
-
+
+
+
+
{/* Branding */}
- Logo + Logo
-

+

GitHubTracker

-

+

Track your GitHub journey

{/* Form Card */} -
-

+
+

Welcome Back

@@ -130,18 +182,24 @@ const Login: React.FC = () => { {/* Message */} {message && ( -
+
{message}
)} {/* Footer Text */}
-

+

Don't have an account? {

-
+
); }; -export default Login; \ No newline at end of file +export default Login; diff --git a/src/pages/Profile/Profile.tsx b/src/pages/Profile/Profile.tsx new file mode 100644 index 0000000..3c07933 --- /dev/null +++ b/src/pages/Profile/Profile.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import api from "../../../lib/api"; + +interface User { + username: string; + email: string; + avatarUrl?: string; +} + +const Profile = () => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + // ---- Apply theme from localStorage ---- + useEffect(() => { + const savedTheme = localStorage.getItem("theme"); + const root = document.documentElement; + + if (savedTheme === "dark") { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } + }, []); + + // ---- Fetch user profile ---- + useEffect(() => { + const fetchProfile = async () => { + try { + const res = await api.get("/api/auth/profile"); + setUser(res.data.user); + } catch (err: any) { + setError(err.response?.data?.message || "Failed to load profile"); + } finally { + setLoading(false); + } + }; + fetchProfile(); + }, []); + + // ---- Loading ---- + if (loading) + return ( +
+

Loading...

+
+ ); + + // ---- Error ---- + if (error) + return ( +
+

{error}

+
+ ); + + // ---- Empty ---- + if (!user) return null; + + // ---- Main UI ---- + return ( +
+
+ avatar +

+ {user.username} +

+

{user.email}

+ + +
+
+ ); +}; + +export default Profile;