Skip to content
Open
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
73 changes: 72 additions & 1 deletion backend/controllers/authController.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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" <noreply@spamdetection.local>',
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 };
10 changes: 10 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
13 changes: 12 additions & 1 deletion backend/routes/authRoutes.js
Original file line number Diff line number Diff line change
@@ -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');

Expand All @@ -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;
4 changes: 4 additions & 0 deletions frontend/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,6 +27,8 @@ createRoot(document.getElementById('root')).render(
<Route path="/app" element={<App />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/history" element={<History/>}/>
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password/:id/:token" element={<ResetPassword />} />
</Routes>
</AuthProvider>
</GoogleOAuthProvider>
Expand Down
89 changes: 89 additions & 0 deletions frontend/src/pages/ForgotPassword.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`min-h-screen flex items-center justify-center px-4 transition-all duration-500 ${isDark ? activeTheme.dark : activeTheme.light}`}>
<div className={`w-full max-w-md backdrop-blur-xl border rounded-3xl shadow-2xl p-8 sm:p-10 transition-all duration-500 ${isDark ? activeTheme.cardDark : activeTheme.card}`}>
<h2 className="flex items-center justify-center gap-3 text-3xl font-extrabold mb-2">
<img src={SpamLogo} alt="Spam Logo" className="w-24 h-16 object-contain"></img> Spam Detector
</h2>
<p className="text-center opacity-70 mb-8 text-sm font-semibold">Reset your password</p>

{error && (
<div className="bg-red-500/15 border border-red-500/30 text-red-500 px-4 py-3 rounded-xl mb-6 text-sm font-medium">
{error}
</div>
)}

{message && (
<div className="bg-green-500/15 border border-green-500/30 text-green-500 px-4 py-3 rounded-xl mb-6 text-sm font-medium">
{message}
</div>
)}

<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-semibold mb-2 opacity-80">Email Address</label>
<input
type="email"
value={email}
onChange={(e) => 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}`}
/>
</div>

<button
type="submit"
disabled={loading}
className={`w-full py-3.5 rounded-xl font-bold transition-all active:scale-95 shadow-md ${activeTheme.accent} ${loading ? 'opacity-60 cursor-not-allowed' : ''}`}
>
{loading ? 'Sending Link...' : 'Send Reset Link'}
</button>
</form>

<p className="text-center mt-6 text-sm opacity-70 font-medium">
Remembered your password?{' '}
<Link to="/" className="text-blue-600 dark:text-blue-450 hover:underline font-semibold ml-1">
Sign In
</Link>
</p>
</div>
</div>
);
};

export default ForgotPassword;
6 changes: 6 additions & 0 deletions frontend/src/pages/Login.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ const Login = () => {
)}
</button>
</div>

<div className="flex justify-end mt-2">
<Link to="/forgot-password" className="text-sm text-blue-600 dark:text-blue-450 hover:underline font-semibold">
Forgot Password?
</Link>
</div>
</div>


Expand Down
136 changes: 136 additions & 0 deletions frontend/src/pages/ResetPassword.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`min-h-screen flex items-center justify-center px-4 transition-all duration-500 ${isDark ? activeTheme.dark : activeTheme.light}`}>
<div className={`w-full max-w-md backdrop-blur-xl border rounded-3xl shadow-2xl p-8 sm:p-10 transition-all duration-500 ${isDark ? activeTheme.cardDark : activeTheme.card}`}>
<h2 className="flex items-center justify-center gap-3 text-3xl font-extrabold mb-2">
<img src={SpamLogo} alt="Spam Logo" className="w-24 h-16 object-contain"></img> Spam Detector
</h2>
<p className="text-center opacity-70 mb-8 text-sm font-semibold">Choose a new password</p>

{error && (
<div className="bg-red-500/15 border border-red-500/30 text-red-500 px-4 py-3 rounded-xl mb-6 text-sm font-medium">
{error}
</div>
)}

{message && (
<div className="bg-green-500/15 border border-green-500/30 text-green-500 px-4 py-3 rounded-xl mb-6 text-sm font-medium">
{message} <br/> Redirecting to login...
</div>
)}

<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-semibold mb-2 opacity-80">New Password</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
name="password"
value={form.password}
onChange={handleChange}
placeholder="Min. 6 characters"
className={`w-full px-4 py-3 pr-12 border rounded-xl outline-none focus:ring-2 transition-all ${isDark ? activeTheme.inputDark : activeTheme.input}`}
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-500 focus:outline-none"
>
{showPassword ? <EyeOff size={20} strokeWidth={2} /> : <Eye size={20} strokeWidth={2} />}
</button>
</div>
</div>

<div>
<label className="block text-sm font-semibold mb-2 opacity-80">Confirm New Password</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
name="confirmPassword"
value={form.confirmPassword}
onChange={handleChange}
placeholder="Confirm password"
className={`w-full px-4 py-3 pr-12 border rounded-xl outline-none focus:ring-2 transition-all ${isDark ? activeTheme.inputDark : activeTheme.input}`}
/>
</div>
</div>

<button
type="submit"
disabled={loading || !!message}
className={`w-full py-3.5 rounded-xl font-bold transition-all active:scale-95 shadow-md ${activeTheme.accent} ${loading ? 'opacity-60 cursor-not-allowed' : ''}`}
>
{loading ? 'Resetting...' : 'Reset Password'}
</button>
</form>

<p className="text-center mt-6 text-sm opacity-70 font-medium">
<Link to="/" className="text-blue-600 dark:text-blue-450 hover:underline font-semibold ml-1">
Back to Sign In
</Link>
</p>
</div>
</div>
);
};

export default ResetPassword;
Loading