diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js index 99abc14..f4bd3b6 100644 --- a/backend/controllers/authController.js +++ b/backend/controllers/authController.js @@ -1,4 +1,5 @@ const jwt = require('jsonwebtoken'); +const nodemailer = require('nodemailer'); const { validationResult } = require('express-validator'); const { OAuth2Client } = require('google-auth-library'); const User = require('../models/User'); @@ -151,4 +152,74 @@ const googleLogin = async (req, res) => { } }; -module.exports = { register, login, getMe, googleLogin }; +const forgotPassword = async (req, res) => { + try { + const { email } = req.body; + const user = await User.findOne({ email }); + if (!user) { + // Send a successful response to prevent email enumeration + return res.json({ message: 'If an account with that email exists, a reset link has been sent.' }); + } + + // Generate token using password hash to make it single-use + const secret = process.env.JWT_SECRET + user.password; + const token = jwt.sign({ id: user._id, email: user.email }, secret, { expiresIn: '15m' }); + + // Mock reset link + const resetLink = `http://localhost:3000/reset-password/${user._id}/${token}`; + + const transporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST || 'smtp.ethereal.email', + port: process.env.EMAIL_PORT || 587, + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + }); + + if (process.env.EMAIL_USER && process.env.EMAIL_PASS) { + await transporter.sendMail({ + from: '"Spam Detection System" ', + to: user.email, + subject: 'Password Reset Request', + text: `Please use the following link to reset your password: ${resetLink} \n\nThis link expires in 15 minutes.`, + }); + } else { + console.log(`[DEMO] Password Reset Link for ${user.email}: ${resetLink}`); + } + + res.json({ message: 'If an account with that email exists, a reset link has been sent.' }); + } catch (err) { + console.error('Forgot password error:', err); + res.status(500).json({ error: 'Server error. Please try again later.' }); + } +}; + +const resetPassword = async (req, res) => { + try { + const { id, token } = req.params; + const { password } = req.body; + + const user = await User.findById(id); + if (!user) { + return res.status(400).json({ error: 'Invalid or expired token.' }); + } + + const secret = process.env.JWT_SECRET + user.password; + try { + jwt.verify(token, secret); + } catch (err) { + return res.status(400).json({ error: 'Invalid or expired token.' }); + } + + user.password = password; + await user.save(); + + res.json({ message: 'Password has been successfully reset. You can now login.' }); + } catch (err) { + console.error('Reset password error:', err); + res.status(500).json({ error: 'Server error. Please try again later.' }); + } +}; + +module.exports = { register, login, getMe, googleLogin, forgotPassword, resetPassword }; diff --git a/backend/package-lock.json b/backend/package-lock.json index 3ff9a60..1bd6d8f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "jsonwebtoken": "^9.0.3", "mongoose": "^9.6.3", "multer": "^2.2.0", + "nodemailer": "^9.0.1", "nodemon": "^3.1.14", "uuid": "^14.0.1" } @@ -2175,6 +2176,15 @@ "uuid": "bin/uuid" } }, + "node_modules/nodemailer": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", diff --git a/backend/package.json b/backend/package.json index 097df76..0133584 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "jsonwebtoken": "^9.0.3", "mongoose": "^9.6.3", "multer": "^2.2.0", + "nodemailer": "^9.0.1", "nodemon": "^3.1.14", "uuid": "^14.0.1" } diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index b35adcc..6169a6d 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const { body } = require('express-validator'); -const { register, login, getMe, googleLogin } = require('../controllers/authController'); +const { register, login, getMe, googleLogin, forgotPassword, resetPassword } = require('../controllers/authController'); const { protect } = require('../middleware/authMiddleware'); const { registerLimiter, loginLimiter } = require('../middleware/rateLimiter'); @@ -21,4 +21,15 @@ router.post('/register', registerValidation,registerLimiter, register); router.post('/google', googleLogin); router.get('/me', protect, getMe); +const forgotPasswordValidation = [ + body('email').isEmail().withMessage('Please enter a valid email'), +]; + +const resetPasswordValidation = [ + body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'), +]; + +router.post('/forgot-password', forgotPasswordValidation, forgotPassword); +router.post('/reset-password/:id/:token', resetPasswordValidation, resetPassword); + module.exports = router; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index ae2c722..2dab5be 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -5,6 +5,8 @@ import App from './pages/App.jsx' import Login from './pages/Login.jsx' import Register from './pages/Register.jsx' import Dashboard from './pages/Dashboard.jsx' +import ForgotPassword from './pages/ForgotPassword.jsx' +import ResetPassword from './pages/ResetPassword.jsx' import { AuthProvider } from './context/AuthContext.jsx' import { ThemeProvider } from './context/ThemeContext.jsx' import { GoogleOAuthProvider } from '@react-oauth/google' @@ -25,6 +27,8 @@ createRoot(document.getElementById('root')).render( } /> } /> }/> + } /> + } /> diff --git a/frontend/src/pages/ForgotPassword.jsx b/frontend/src/pages/ForgotPassword.jsx new file mode 100644 index 0000000..a2f8a8f --- /dev/null +++ b/frontend/src/pages/ForgotPassword.jsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import api from '../utils/axiosInstance'; +import { Link } from 'react-router-dom'; +import { useTheme } from '../context/ThemeContext'; +import '../App.css'; +import SpamLogo from "/src/assets/SpamLogo.png"; + +const ForgotPassword = () => { + const [email, setEmail] = useState(''); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { activeTheme, isDark } = useTheme(); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!email) { + setError('Please enter your email address.'); + setMessage(''); + return; + } + + setLoading(true); + setError(''); + setMessage(''); + + try { + const res = await api.post(`${import.meta.env.VITE_API_URI}/api/auth/forgot-password`, { email }); + setMessage(res.data.message || 'If an account with that email exists, a reset link has been sent.'); + } catch (err) { + setError(err.response?.data?.error || 'Failed to send password reset request.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ Spam Logo Spam Detector +

+

Reset your password

+ + {error && ( +
+ {error} +
+ )} + + {message && ( +
+ {message} +
+ )} + +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className={`w-full px-4 py-3 border rounded-xl outline-none focus:ring-2 transition-all ${isDark ? activeTheme.inputDark : activeTheme.input}`} + /> +
+ + +
+ +

+ Remembered your password?{' '} + + Sign In + +

+
+
+ ); +}; + +export default ForgotPassword; diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index c227fc0..780c173 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -120,6 +120,12 @@ const Login = () => { )} + +
+ + Forgot Password? + +
diff --git a/frontend/src/pages/ResetPassword.jsx b/frontend/src/pages/ResetPassword.jsx new file mode 100644 index 0000000..9261aa3 --- /dev/null +++ b/frontend/src/pages/ResetPassword.jsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; +import api from '../utils/axiosInstance'; +import { Link, useParams, useNavigate } from 'react-router-dom'; +import { useTheme } from '../context/ThemeContext'; +import '../App.css'; +import SpamLogo from "/src/assets/SpamLogo.png"; +import { Eye, EyeOff } from "lucide-react"; + +const ResetPassword = () => { + const { id, token } = useParams(); + const navigate = useNavigate(); + const [form, setForm] = useState({ password: '', confirmPassword: '' }); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const { activeTheme, isDark } = useTheme(); + + const handleChange = (e) => { + setForm({ ...form, [e.target.name]: e.target.value }); + setError(''); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!form.password || !form.confirmPassword) { + setError('Please fill in all fields.'); + return; + } + + if (form.password !== form.confirmPassword) { + setError('Passwords do not match.'); + return; + } + + if (form.password.length < 6) { + setError('Password must be at least 6 characters long.'); + return; + } + + setLoading(true); + setError(''); + setMessage(''); + + try { + const res = await api.post(`${import.meta.env.VITE_API_URI}/api/auth/reset-password/${id}/${token}`, { password: form.password }); + setMessage(res.data.message || 'Password successfully reset.'); + + // Auto redirect to login after 3 seconds + setTimeout(() => { + navigate('/'); + }, 3000); + + } catch (err) { + setError(err.response?.data?.error || 'Failed to reset password. The link may have expired.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ Spam Logo Spam Detector +

+

Choose a new password

+ + {error && ( +
+ {error} +
+ )} + + {message && ( +
+ {message}
Redirecting to login... +
+ )} + +
+
+ +
+ + +
+
+ +
+ +
+ +
+
+ + +
+ +

+ + Back to Sign In + +

+
+
+ ); +}; + +export default ResetPassword;