From a2967fbb9498304569be10a6bd8c674e0021099a Mon Sep 17 00:00:00 2001 From: Agastya Khati Date: Wed, 20 Aug 2025 12:04:36 +0530 Subject: [PATCH] feat: Add complete user authentication system --- server/controllers/user.controllers.js | 254 ++++++++++++------------- server/middlewares/auth.middleware.js | 27 +-- src/components/LoginPage.jsx | 36 +++- src/components/ProtectedRoute.jsx | 19 ++ src/components/SignupPage.jsx | 88 ++++++--- src/contexts/AuthContext.jsx | 101 ++++++++++ 6 files changed, 342 insertions(+), 183 deletions(-) create mode 100644 src/components/ProtectedRoute.jsx create mode 100644 src/contexts/AuthContext.jsx diff --git a/server/controllers/user.controllers.js b/server/controllers/user.controllers.js index e9447f3..2ca5e13 100644 --- a/server/controllers/user.controllers.js +++ b/server/controllers/user.controllers.js @@ -1,147 +1,135 @@ -import ApiError from "../utils/ApiError.js"; -import ApiResponse from "../utils/ApiResponse.js"; -import asyncHandler from "../utils/asyncHandler.js"; import { User } from "../models/user.models.js"; +import jwt from "jsonwebtoken"; -const generateAccessAndRefreshToken = async (user) => { - const accessToken = await user.generateAccessToken(); - const refreshToken = await user.generateRefreshToken(); - user.refreshToken = refreshToken; - // Skip validation since only partial fields are updated (other required fields may be missing) - await user.save({ - validateBeforeSave: false, - }); +const generateAccessAndRefreshTokens = async (userId) => { + try { + const user = await User.findById(userId); + const accessToken = await user.generateAccessToken(); + const refreshToken = await user.generateRefreshToken(); - return { accessToken: accessToken, refreshToken: refreshToken }; -}; - -const registerUser = asyncHandler(async (req, res) => { - const { username, email, password, fullName } = req.body; - - // Checking if the each and every field that it's not empty - if ( - [username, email, password, fullName].some((field) => field?.trim() === "") - ) { - //if Any of the field is empty it will throw and exception - throw new ApiError(400, "All the fields are required"); - } - const isUserExists = await User.findOne({ $or: [{ username }, { email }] }); - if (isUserExists) { - // Just a simple if else check if both the name and email have already been created and throws error based on the condition - - // if (isUserExists.username === username && isUserExists.email === email) { - // throw new ApiError( - // 401, - // "User with the following username or email exists" - // ); - // } else if ( - // isUserExists.username === username && - // !isUserExists.email === email - // ) { - // throw new ApiError(401, "User with the following username exists"); - // } else throw new ApiError(401, "User with the following email exists"); - - // ***** More cleaner version ***** // - - let conflictField = []; - - if (isUserExists.username === username.toLowerCase()) - conflictField.push("username"); - if (isUserExists.email === email) conflictField.push("email"); - - const message = `User with this ${conflictField.join( - " and " - )} already exists`; - throw new ApiError(401, message); - } + user.refreshToken = refreshToken; + await user.save({ validateBeforeSave: false }); - //If not already exists create a new user with their credentials - const user = await User.create({ - username: username.toLowerCase(), - email, - password, - fullName, - }); - if (!user) { - throw new ApiError( - 500, - "Something went wrong while creating user in the database" - ); + return { accessToken, refreshToken }; + } catch (error) { + throw new Error("Something went wrong while generating tokens"); } +}; - const createdUser = await User.findById(user._id).select( - "-password -refreshToken" - ); - - //Fallback checking if for any reason the user has been deleted - - if (!createdUser) { - throw new ApiError( - 500, - "Something went wrong while fetching user from the database" - ); +const registerUser = async (req, res) => { + try { + const { fullName, email, username, password } = req.body; + + // Validation + if ([fullName, email, username, password].some((field) => field?.trim() === "")) { + return res.status(400).json({ message: "All fields are required" }); + } + + // Check if user already exists + const existedUser = await User.findOne({ + $or: [{ username }, { email }] + }); + + if (existedUser) { + return res.status(409).json({ message: "User with email or username already exists" }); + } + + // Create user + const user = await User.create({ + fullName, + email, + username: username.toLowerCase(), + password, + }); + + const createdUser = await User.findById(user._id).select("-password -refreshToken"); + + if (!createdUser) { + return res.status(500).json({ message: "Something went wrong while registering user" }); + } + + return res.status(201).json({ + message: "User registered successfully", + user: createdUser + }); + } catch (error) { + return res.status(500).json({ message: error.message }); } - return res - .status(201) - .json(new ApiResponse(200, createdUser, "User registered successfully")); -}); - -const loginUser = asyncHandler(async (req, res) => { - const { username, email, password } = req.body; - //Any one field is required - if (!username && !email) { - throw new ApiError(400, "Username or email is required"); - } - const user = await User.findOne({ - $or: [{ email }, { username }], - }); +}; - if (!user) { - throw new ApiError(404, "No user with the current username or email"); +const loginUser = async (req, res) => { + try { + const { email, username, password } = req.body; + + if (!username && !email) { + return res.status(400).json({ message: "Username or email is required" }); + } + + const user = await User.findOne({ + $or: [{ username }, { email }] + }); + + if (!user) { + return res.status(404).json({ message: "User does not exist" }); + } + + const isPasswordValid = await user.isPasswordCorrect(password); + + if (!isPasswordValid) { + return res.status(401).json({ message: "Invalid user credentials" }); + } + + const { accessToken, refreshToken } = await generateAccessAndRefreshTokens(user._id); + + const loggedInUser = await User.findById(user._id).select("-password -refreshToken"); + + const options = { + httpOnly: true, + secure: true, + }; + + return res + .status(200) + .cookie("accessToken", accessToken, options) + .cookie("refreshToken", refreshToken, options) + .json({ + message: "User logged in successfully", + user: loggedInUser, + accessToken, + refreshToken + }); + } catch (error) { + return res.status(500).json({ message: error.message }); } +}; - const isPasswordCorrect = await user.isPasswordCorrect(password); +const logoutUser = async (req, res) => { + try { + await User.findByIdAndUpdate( + req.user._id, + { + $unset: { + refreshToken: 1 + } + }, + { + new: true + } + ); - if (!isPasswordCorrect) { - throw new ApiError(401, "Invalid password"); + const options = { + httpOnly: true, + secure: true, + }; + + return res + .status(200) + .clearCookie("accessToken", options) + .clearCookie("refreshToken", options) + .json({ message: "User logged out successfully" }); + } catch (error) { + return res.status(500).json({ message: error.message }); } - //Generating user access and refresh tokens - const { accessToken, refreshToken } = await generateAccessAndRefreshToken( - user - ); - - const loggedInUser = await User.findById(user._id).select( - "-password -refreshToken" - ); - const options = { - httpOnly: true, - secure: true, - }; - return res - .status(200) - .cookie("accessToken", accessToken, options) - .cookie("refreshToken", refreshToken, options) - .json(new ApiResponse(200, loggedInUser, "User logged in successfully")); -}); - -const logoutUser = asyncHandler(async (req, res) => { - await User.findByIdAndUpdate( - req.user._id, - { $set: { refreshToken: undefined } } - - // Returns the updated document - // { new: true } - ); - const options = { - httpOnly: true, - secure: true, - }; - - return res - .status(200) - .clearCookie("accessToken", options) - .clearCookie("refreshToken", options) - .json(new ApiResponse(200, {}, "User logged out")); -}); +}; export { registerUser, loginUser, logoutUser }; diff --git a/server/middlewares/auth.middleware.js b/server/middlewares/auth.middleware.js index c53bcab..da79856 100644 --- a/server/middlewares/auth.middleware.js +++ b/server/middlewares/auth.middleware.js @@ -1,32 +1,25 @@ -import { User } from "../models/user.models.js"; -import asyncHandler from "../utils/asyncHandler.js"; -import ApiError from "../utils/ApiError.js"; import jwt from "jsonwebtoken"; +import { User } from "../models/user.models.js"; -export const verifyUser = asyncHandler(async (req, res, next) => { +export const verifyUser = async (req, res, next) => { try { - // Fetching tokens either from cookies or header - const token = - req.cookies?.accessToken || - req.header("Authorization")?.replace("Bearer ", ""); + const token = req.cookies?.accessToken || req.header("Authorization")?.replace("Bearer ", ""); if (!token) { - throw new ApiError(401, "Unauthorized request"); + return res.status(401).json({ message: "Unauthorized request" }); } - // Decoded token contains id, email and username - // This might throw error so wrap it in a try catch const decodedToken = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET); - const user = await User.findById(decodedToken?._id).select( - "-password -refreshToken" - ); + const user = await User.findById(decodedToken?._id).select("-password -refreshToken"); + if (!user) { - throw new ApiError(401, "Invalid Access Token"); + return res.status(401).json({ message: "Invalid access token" }); } + req.user = user; next(); } catch (error) { - throw new ApiError(401, error?.message || "Invalid access token"); + return res.status(401).json({ message: error?.message || "Invalid access token" }); } -}); +}; diff --git a/src/components/LoginPage.jsx b/src/components/LoginPage.jsx index 129e10d..cd9d5d0 100644 --- a/src/components/LoginPage.jsx +++ b/src/components/LoginPage.jsx @@ -1,6 +1,8 @@ import { Link, useNavigate } from "react-router-dom"; import { Eye, EyeOff, Mail, Lock } from "lucide-react"; import React, { useState } from "react"; +import { useAuth } from '../contexts/AuthContext'; +import toast from 'react-hot-toast'; export default function SignIn() { const [userInfo, setUserInfo] = useState({ @@ -9,17 +11,28 @@ export default function SignIn() { }); const navigate = useNavigate(); const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); const handleInputChange = (e) => { const { name, value } = e.target; setUserInfo((prev) => ({ ...prev, [name]: value })); }; - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); + setLoading(true); - // For now, skip API call and just navigate - navigate("/dashboard"); + const result = await login(userInfo); + + if (result.success) { + toast.success('Welcome back!'); + navigate("/dashboard"); + } else { + toast.error(result.message || 'Login failed'); + } + + setLoading(false); }; const togglePasswordVisibility = () => { @@ -41,7 +54,6 @@ export default function SignIn() {
-
@@ -55,12 +67,12 @@ export default function SignIn() { value={userInfo.email} onChange={handleInputChange} required + disabled={loading} />
-
@@ -74,11 +86,13 @@ export default function SignIn() { value={userInfo.password} onChange={handleInputChange} required + disabled={loading} /> diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..2bc3f42 --- /dev/null +++ b/src/components/ProtectedRoute.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; + +const ProtectedRoute = ({ children }) => { + const { isAuthenticated, loading } = useAuth(); + + if (loading) { + return ( +
+
+
+ ); + } + + return isAuthenticated ? children : ; +}; + +export default ProtectedRoute; diff --git a/src/components/SignupPage.jsx b/src/components/SignupPage.jsx index cde9397..f8730b5 100644 --- a/src/components/SignupPage.jsx +++ b/src/components/SignupPage.jsx @@ -1,12 +1,16 @@ import React, { useState } from "react"; import { Eye, EyeOff, User, Mail, Lock } from "lucide-react"; import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from '../contexts/AuthContext'; +import toast from 'react-hot-toast'; export default function SignUp() { const navigate = useNavigate(); + const { signup } = useAuth(); const [formData, setFormData] = useState({ - name: "", + fullName: "", + username: "", email: "", password: "", confirmPassword: "", @@ -14,7 +18,7 @@ export default function SignUp() { const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); const handleChange = (e) => { setFormData((prev) => ({ @@ -23,18 +27,34 @@ export default function SignUp() { })); }; - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); - setError(""); // Password match check if (formData.password !== formData.confirmPassword) { - setError("Passwords do not match"); + toast.error("Passwords do not match"); return; } - // Directly navigate without authentication - navigate("/dashboard"); + // Password strength check + if (formData.password.length < 6) { + toast.error("Password must be at least 6 characters long"); + return; + } + + setLoading(true); + + const { confirmPassword, ...signupData } = formData; + const result = await signup(signupData); + + if (result.success) { + toast.success('Account created successfully! Please login.'); + navigate("/login"); + } else { + toast.error(result.message || 'Signup failed'); + } + + setLoading(false); }; return ( @@ -45,21 +65,33 @@ export default function SignUp() {
- {error && ( -

{error}

- )} -
- {/* Name */} + {/* Full Name */}
+
+ + {/* Username */} +
+ +
@@ -74,6 +106,7 @@ export default function SignUp() { value={formData.email} onChange={handleChange} required + disabled={loading} className="w-full outline-none bg-transparent text-gray-700" />
@@ -84,16 +117,18 @@ export default function SignUp() {
-
- -
- {/* Submit */} diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..d135395 --- /dev/null +++ b/src/contexts/AuthContext.jsx @@ -0,0 +1,101 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; + +const AuthContext = createContext(); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Check if user is logged in on app start + const token = localStorage.getItem('accessToken'); + const userData = localStorage.getItem('user'); + + if (token && userData) { + setUser(JSON.parse(userData)); + } + setLoading(false); + }, []); + + const login = async (credentials) => { + try { + const response = await fetch(`${import.meta.env.VITE_API_URL}/api/v1/users/sign-in`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(credentials), + }); + + const data = await response.json(); + + if (response.ok) { + setUser(data.user); + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('user', JSON.stringify(data.user)); + return { success: true, data }; + } else { + return { success: false, message: data.message }; + } + } catch (error) { + return { success: false, message: 'Network error occurred' }; + } + }; + + const signup = async (userData) => { + try { + const response = await fetch(`${import.meta.env.VITE_API_URL}/api/v1/users/sign-up`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userData), + }); + + const data = await response.json(); + + if (response.ok) { + return { success: true, data }; + } else { + return { success: false, message: data.message }; + } + } catch (error) { + return { success: false, message: 'Network error occurred' }; + } + }; + + const logout = async () => { + try { + await fetch(`${import.meta.env.VITE_API_URL}/api/v1/users/logout`, { + method: 'POST', + credentials: 'include', + }); + } catch (error) { + console.error('Logout error:', error); + } finally { + setUser(null); + localStorage.removeItem('accessToken'); + localStorage.removeItem('user'); + } + }; + + const value = { + user, + login, + signup, + logout, + loading, + isAuthenticated: !!user, + }; + + return {children}; +};