change all thresholds to 15 minutes for verification + add email changing option for existing users

This commit is contained in:
2026-01-08 16:28:12 -06:00
parent 2aa4c083a3
commit 1c6ffcf218
3 changed files with 266 additions and 9 deletions

View File

@@ -244,16 +244,18 @@ const initializeAgenda = async (mongoUri, pool, io) => { // Now accepts pgPool
}
});
agenda.define('delete unverified users', async (job) => {
agenda.define('deleteUnverifiedUser', async (job) => {
const { userId } = job.attrs.data;
try {
const result = await sharedPgPool.query(
"DELETE FROM users WHERE is_verified = false AND created_at < NOW() - INTERVAL '24 hours'"
"DELETE FROM users WHERE id = $1 AND is_verified = false",
[userId]
);
if (result.rowCount > 0) {
console.log(`Cleanup: Deleted ${result.rowCount} unverified users.`);
console.log(`Deleted unverified user with ID ${userId}`);
}
} catch (error) {
console.error("Error cleaning up users:", error);
console.error(`Error deleting unverified user ${userId}:`, error);
}
});
@@ -270,6 +272,19 @@ const initializeAgenda = async (mongoUri, pool, io) => { // Now accepts pgPool
}
});
// Define the user pending email deletion job
agenda.define('deleteUserPendingEmail', async (job) => {
const { userId } = job.attrs.data;
try {
const result = await sharedPgPool.query('DELETE FROM user_pending_emails WHERE user_id = $1', [userId]);
if (result.rowCount > 0) {
console.log(`Deleted expired pending email change for user ${userId}`);
}
} catch (error) {
console.error(`Error deleting pending email for user ${userId}:`, error);
}
});
await agenda.start();
agenda.on('ready', () => console.log('Agenda connected to MongoDB and ready!'));
@@ -279,8 +294,6 @@ const initializeAgenda = async (mongoUri, pool, io) => { // Now accepts pgPool
agenda.on('fail', (err, job) => console.error(`Job "${job.attrs.name}" failed: ${err.message}`));
await agenda.start();
await agenda.cancel({ name: 'delete unverified users' });
await agenda.every('24 hours', 'delete unverified users');
console.log('Agenda job processing started.');
return agenda;
};

142
index.js
View File

@@ -88,6 +88,10 @@ let agenda;
// Clear all expired password reset tokens on startup
await pool.query("DELETE FROM password_reset_tokens WHERE expires_at < NOW()");
console.log("Cleared expired password reset tokens");
// Clear all expired pending email changes on startup
await pool.query("DELETE FROM user_pending_emails WHERE expires_at < NOW()");
console.log("Cleared expired pending email changes");
})();
const JWT_SECRET = process.env.JWT_SECRET;
const TOKEN_EXPIRY = '5d';
@@ -599,6 +603,10 @@ app.post('/create_user', async (req, res) => {
[name, email, hashedPass, token]
);
// Schedule deletion of unverified user after 15 minutes
const expiryTime = new Date(Date.now() + 15 * 60 * 1000);
await agenda.schedule(expiryTime, 'deleteUnverifiedUser', { userId: newUser.rows[0].id });
await sendVerificationEmail(email, token, name);
// Create temporary token for verification checking
@@ -668,6 +676,9 @@ app.get('/verify-email', async (req, res) => {
[user.id]
);
// Cancel any scheduled deletion job for this user
await agenda.cancel({ name: 'deleteUnverifiedUser', 'data.userId': user.id });
// 3. Send them to a success page or back to the app
res.send(`
<h1>Email Verified!</h1>
@@ -703,6 +714,7 @@ app.post('/resend_verification', authenticateToken, async (req, res) => {
}
try {
const { localHour } = req.body;
const {rows} = await pool.query('SELECT email, name, is_verified FROM users WHERE id = $1', [req.user]);
if (rows.length === 0) return res.status(404).json({error: 'User not found'});
@@ -715,7 +727,7 @@ app.post('/resend_verification', authenticateToken, async (req, res) => {
const token = crypto.randomBytes(32).toString('hex');
await pool.query('UPDATE users SET verification_token = $1 WHERE id = $2', [token, req.user]);
await sendVerificationEmail(user.email, token, user.name);
await sendVerificationEmail(user.email, token, user.name, localHour);
res.status(200).json({ message: 'Verification email sent' });
} catch (err) {
@@ -811,6 +823,134 @@ app.post('/change_password', authenticateToken, async (req, res) => {
}
});
app.post('/request-email-change', authenticateToken, async (req, res) => {
const { newEmail, localHour } = req.body;
if (!newEmail) {
return res.status(400).json({ error: 'New email is required' });
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(newEmail)) {
return res.status(400).json({ error: 'Invalid email format' });
}
try {
// Check if new email is already in use
const {rows: emailCheck} = await pool.query('SELECT id FROM users WHERE email = $1', [newEmail]);
if (emailCheck.length > 0) {
return res.status(409).json({ error: 'Email already in use' });
}
// Get user info
const {rows: userRows} = await pool.query('SELECT name, email FROM users WHERE id = $1', [req.user]);
if (userRows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
const user = userRows[0];
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
// Insert or update pending email with ON CONFLICT
await pool.query(
`INSERT INTO user_pending_emails (user_id, pending_email, token, expires_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id)
DO UPDATE SET pending_email = $2, token = $3, expires_at = $4`,
[req.user, newEmail, token, expiresAt]
);
// Cancel any existing job for this user
await agenda.cancel({ name: 'deleteUserPendingEmail', 'data.userId': req.user });
// Schedule job to delete pending email after 15 minutes
await agenda.schedule(expiresAt, 'deleteUserPendingEmail', { userId: req.user });
// Send verification email
const { sendEmailChangeVerification } = require('./mailer');
await sendEmailChangeVerification(newEmail, token, user.name, user.email, localHour);
res.status(200).json({ message: 'Verification email sent to new address' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.get('/verify-email-change', async (req, res) => {
const { token } = req.query;
if (!token) {
return res.status(400).send('Missing token');
}
try {
// Find the pending email change with this token
const result = await pool.query(
`SELECT user_id, pending_email FROM user_pending_emails WHERE token = $1 AND expires_at > NOW()`,
[token]
);
if (result.rows.length === 0) {
return res.status(400).send('Invalid or expired token.');
}
const { user_id, pending_email } = result.rows[0];
// Check if new email is now taken (race condition check)
const {rows: emailCheck} = await pool.query('SELECT id FROM users WHERE email = $1', [pending_email]);
if (emailCheck.length > 0 && emailCheck[0].id !== user_id) {
await pool.query('DELETE FROM user_pending_emails WHERE user_id = $1', [user_id]);
await agenda.cancel({ name: 'deleteUserPendingEmail', 'data.userId': user_id });
return res.status(400).send('Email address is no longer available.');
}
// Update the user's email
await pool.query(
`UPDATE users SET email = $1 WHERE id = $2`,
[pending_email, user_id]
);
// Delete the pending email record
await pool.query('DELETE FROM user_pending_emails WHERE user_id = $1', [user_id]);
// Cancel the scheduled deletion job
await agenda.cancel({ name: 'deleteUserPendingEmail', 'data.userId': user_id });
res.send(`
<h1>Email Changed Successfully!</h1>
<p>Your email has been updated. You can now close this window.</p>
`);
} catch (err) {
console.error(err);
res.status(500).send('Internal server error');
}
});
app.get('/pending-email-status', authenticateToken, async (req, res) => {
try {
const {rows} = await pool.query(
'SELECT pending_email FROM user_pending_emails WHERE user_id = $1',
[req.user]
);
if (rows.length === 0) {
return res.status(200).json({ hasPending: false });
}
res.status(200).json({
hasPending: true,
pendingEmail: rows[0].pending_email
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Helper function to generate 6-character alphanumeric code
function generateResetCode() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Exclude confusing chars like 0, O, 1, I

108
mailer.js
View File

@@ -94,7 +94,7 @@ async function sendVerificationEmail(toEmail, token, name, localHour = new Date(
<tr>
<td style="padding: 30px 40px; text-align: center;">
<p style="margin: 0 0 10px 0; color: #999999; font-size: 13px; line-height: 1.5;">
This verification link will expire in <strong style="color: #666666;">24 hours</strong>.
This verification link will expire in <strong style="color: #666666;">15 minutes</strong>.
</p>
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.5;">
If you didn't create a BlindMaster account, please ignore this email!!!
@@ -225,4 +225,108 @@ async function sendPasswordResetEmail(toEmail, code, name, localHour = new Date(
}
}
module.exports = { sendVerificationEmail, sendPasswordResetEmail };
module.exports = { sendVerificationEmail, sendPasswordResetEmail, sendEmailChangeVerification };
// Helper function to send email change verification email
async function sendEmailChangeVerification(newEmail, token, name, oldEmail, localHour = new Date().getHours()) {
const primaryColor = getColorForTime(localHour);
const verificationLink = `https://wahwa.com/verify-email-change?token=${token}`;
try {
const info = await transporter.sendMail({
from: `"BlindMaster" <${process.env.EMAIL_FROM}>`,
to: newEmail,
subject: "Verify your new BlindMaster email address",
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=ABeeZee:ital@0;1&display=swap" rel="stylesheet">
</head>
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: 'ABeeZee', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden; max-width: 600px;">
<!-- Header with brand color -->
<tr>
<td align="center" style="background-color: ${primaryColor}; padding: 40px 20px;">
<h1 style="margin: 0; color: #ffffff; font-size: 32px; font-weight: bold; letter-spacing: 0.5px;">BlindMaster</h1>
<p style="margin: 10px 0 0 0; color: #ffffff; font-size: 14px; opacity: 0.95;">Smart Home Automation</p>
</td>
</tr>
<!-- Message -->
<tr>
<td style="padding: 50px 40px 30px 40px; text-align: center;">
<h2 style="margin: 0 0 20px 0; color: #333333; font-size: 28px; font-weight: normal;">
Verify Your New Email
</h2>
<p style="margin: 0 0 15px 0; color: #666666; font-size: 16px; line-height: 1.6;">
${name && name.trim() ? `Hi ${name.trim()}, you` : 'You'} requested to change your email address from:
</p>
<p style="margin: 0 0 15px 0; color: #999999; font-size: 14px;">
<strong>${oldEmail}</strong>
</p>
<p style="margin: 0 0 30px 0; color: #666666; font-size: 16px; line-height: 1.6;">
Click the button below to confirm this change:
</p>
</td>
</tr>
<!-- CTA Button -->
<tr>
<td align="center" style="padding: 0 40px 40px 40px;">
<a href="${verificationLink}"
style="display: inline-block; padding: 16px 48px; background-color: ${primaryColor}; color: #ffffff; text-decoration: none; border-radius: 8px; font-size: 16px; font-weight: bold; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); transition: all 0.3s ease;">
Verify New Email
</a>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding: 0 40px;">
<div style="border-top: 1px solid #e0e0e0;"></div>
</td>
</tr>
<!-- Footer info -->
<tr>
<td style="padding: 30px 40px; text-align: center;">
<p style="margin: 0 0 10px 0; color: #999999; font-size: 13px; line-height: 1.5;">
This verification link will expire in <strong style="color: #666666;">15 minutes</strong>.
</p>
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.5;">
If you didn't request this email change, please ignore this email.
</p>
</td>
</tr>
<!-- Footer bar -->
<tr>
<td align="center" style="background-color: #f9f9f9; padding: 25px 40px;">
<p style="margin: 0; color: #999999; font-size: 12px;">
© 2026 BlindMaster. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
});
console.log("Email change verification sent successfully:", info.messageId);
return true;
} catch (error) {
console.error("Error sending email change verification:", error);
return false;
}
}