From 1c6ffcf218856b9d1bb6955cab90cb76e4c4543d Mon Sep 17 00:00:00 2001 From: pulipakaa24 Date: Thu, 8 Jan 2026 16:28:12 -0600 Subject: [PATCH] change all thresholds to 15 minutes for verification + add email changing option for existing users --- agenda.js | 25 +++++++--- index.js | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++++- mailer.js | 108 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 266 insertions(+), 9 deletions(-) diff --git a/agenda.js b/agenda.js index bdc7428..c59117b 100644 --- a/agenda.js +++ b/agenda.js @@ -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); } }); @@ -269,6 +271,19 @@ const initializeAgenda = async (mongoUri, pool, io) => { // Now accepts pgPool console.error(`Error deleting password reset token for ${email}:`, error); } }); + + // 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(); @@ -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; }; diff --git a/index.js b/index.js index c9f77e0..0535c3d 100644 --- a/index.js +++ b/index.js @@ -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(`

Email Verified!

@@ -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(` +

Email Changed Successfully!

+

Your email has been updated. You can now close this window.

+ `); + + } 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 diff --git a/mailer.js b/mailer.js index 5684ed6..9bbb757 100644 --- a/mailer.js +++ b/mailer.js @@ -94,7 +94,7 @@ async function sendVerificationEmail(toEmail, token, name, localHour = new Date(

- This verification link will expire in 24 hours. + This verification link will expire in 15 minutes.

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 }; \ No newline at end of file +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: ` + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

BlindMaster

+

Smart Home Automation

+
+

+ Verify Your New Email +

+

+ ${name && name.trim() ? `Hi ${name.trim()}, you` : 'You'} requested to change your email address from: +

+

+ ${oldEmail} +

+

+ Click the button below to confirm this change: +

+
+ + Verify New Email + +
+
+
+

+ This verification link will expire in 15 minutes. +

+

+ If you didn't request this email change, please ignore this email. +

+
+

+ © 2026 BlindMaster. All rights reserved. +

+
+
+ + + `, + }); + console.log("Email change verification sent successfully:", info.messageId); + return true; + } catch (error) { + console.error("Error sending email change verification:", error); + return false; + } +} \ No newline at end of file