feat: add password reset functionality and email notifications

- Implemented forgot password and reset password routes in the backend.
- Added email sending capabilities using Nodemailer for password reset requests.
- Created ResetPassword page in the frontend for users to reset their passwords.
- Updated user model to include reset token and expiry fields.
- Integrated hiscores API with caching mechanism for improved performance.
- Enhanced authentication modal to include forgot password option.
- Updated environment configuration for SMTP settings.
This commit is contained in:
2026-02-03 23:37:47 +00:00
parent 3cec7abee9
commit 95063d4066
17 changed files with 945 additions and 21 deletions

View File

@@ -1,7 +1,9 @@
import { Hono } from 'hono';
import { randomBytes } from 'crypto';
import { prisma } from '../db';
import { hashPassword, verifyPassword } from '../utils/password';
import { createSession, destroySession, requireAuth } from '../middleware/session';
import { sendPasswordResetEmail, isEmailConfigured } from '../utils/email';
const auth = new Hono();
@@ -132,4 +134,100 @@ auth.get('/me', requireAuth, (c) => {
});
});
/**
* POST /api/forgot-password
* Request a password reset
*/
auth.post('/forgot-password', async (c) => {
const body = await c.req.json();
const { email } = body;
if (!email) {
return c.json({ error: 'Email is required' }, 400);
}
// Find user by email
const user = await prisma.user.findUnique({
where: { email },
});
// Always return success to prevent email enumeration
if (!user) {
return c.json({ message: 'If an account with that email exists, a reset link has been sent.' });
}
// Generate secure token
const resetToken = randomBytes(32).toString('hex');
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Store token in database
await prisma.user.update({
where: { id: user.id },
data: {
resetToken,
resetTokenExpiry,
},
});
// Send email with reset link
const baseUrl = process.env.APP_URL || 'https://leagues.tools';
if (isEmailConfigured()) {
const emailResult = await sendPasswordResetEmail(email, resetToken, baseUrl);
if (!emailResult.success) {
console.error(`Failed to send password reset email to ${email}:`, emailResult.error);
}
} else {
// Log token in development when email is not configured
console.log(`Password reset requested for ${email}. Token: ${resetToken}`);
console.log(`Reset URL: ${baseUrl}/reset-password?token=${resetToken}`);
}
return c.json({ message: 'If an account with that email exists, a reset link has been sent.' });
});
/**
* POST /api/reset-password
* Reset password using token
*/
auth.post('/reset-password', async (c) => {
const body = await c.req.json();
const { token, password } = body;
if (!token || !password) {
return c.json({ error: 'Token and password are required' }, 400);
}
if (password.length < 8) {
return c.json({ error: 'Password must be at least 8 characters' }, 400);
}
// Find user with this token
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: {
gt: new Date(), // Token must not be expired
},
},
});
if (!user) {
return c.json({ error: 'Invalid or expired reset token' }, 400);
}
// Update password and clear token
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
resetToken: null,
resetTokenExpiry: null,
},
});
return c.json({ message: 'Password has been reset successfully. You can now log in.' });
});
export default auth;