Nicer verification link landing page

This commit is contained in:
2026-01-08 17:28:46 -06:00
parent ef6d170238
commit e57e63c559
2 changed files with 168 additions and 26 deletions

109
index.js
View File

@@ -13,7 +13,7 @@ const cronParser = require('cron-parser');
const { ObjectId } = require('mongodb'); const { ObjectId } = require('mongodb');
const { RateLimiterMemory } = require('rate-limiter-flexible'); const { RateLimiterMemory } = require('rate-limiter-flexible');
const crypto = require('crypto'); const crypto = require('crypto');
const { sendVerificationEmail, sendPasswordResetEmail } = require('./mailer'); const { sendVerificationEmail, sendPasswordResetEmail, generateVerificationPageHTML } = require('./mailer');
// HTTP rate limiter: 5 requests per second per IP // HTTP rate limiter: 5 requests per second per IP
const httpRateLimiter = new RateLimiterMemory({ const httpRateLimiter = new RateLimiterMemory({
@@ -651,24 +651,36 @@ app.get('/verify-email', async (req, res) => {
const { token } = req.query; const { token } = req.query;
if (!token) { if (!token) {
return res.status(400).send('Missing token'); return res.status(400).send(
generateVerificationPageHTML(
'Missing Token',
'The verification link is incomplete. Please check your email and try again.',
false
)
);
} }
try { try {
// 1. Find the user with this token // Check if token exists at all
const result = await pool.query( const result = await pool.query(
`SELECT * FROM users WHERE verification_token = $1`, `SELECT * FROM users WHERE verification_token = $1`,
[token] [token]
); );
if (result.rows.length === 0) { if (result.rows.length === 0) {
return res.status(400).send('Invalid or expired token.'); // Token not found - check if already verified
return res.send(
generateVerificationPageHTML(
'Already Verified',
'This verification link has already been used. Your email is verified and you can log in to the app.',
true
)
);
} }
const user = result.rows[0]; const user = result.rows[0];
// 2. Verify them and clear the token // Verify them and clear the token
// We clear the token so the link cannot be used twice
await pool.query( await pool.query(
`UPDATE users `UPDATE users
SET is_verified = true, verification_token = NULL SET is_verified = true, verification_token = NULL
@@ -679,15 +691,23 @@ app.get('/verify-email', async (req, res) => {
// Cancel any scheduled deletion job for this user // Cancel any scheduled deletion job for this user
await agenda.cancel({ name: 'deleteUnverifiedUser', 'data.userId': user.id }); await agenda.cancel({ name: 'deleteUnverifiedUser', 'data.userId': user.id });
// 3. Send them to a success page or back to the app res.send(
res.send(` generateVerificationPageHTML(
<h1>Email Verified!</h1> 'Email Verified!',
<p>You can now close this window and log in to the app.</p> 'Your email has been successfully verified. You can now log in to the app and start using BlindMaster.',
`); true
)
);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).send('Server Error'); res.status(500).send(
generateVerificationPageHTML(
'Server Error',
'An unexpected error occurred. Please try again or contact support if the problem persists.',
false
)
);
} }
}); });
@@ -902,28 +922,58 @@ app.get('/verify-email-change', async (req, res) => {
const { token } = req.query; const { token } = req.query;
if (!token) { if (!token) {
return res.status(400).send('Missing token'); return res.status(400).send(
generateVerificationPageHTML(
'Missing Token',
'The verification link is incomplete. Please request a new email change from the app.',
false
)
);
} }
try { try {
// Find the pending email change with this token // First, check if token exists at all (expired or not)
const result = await pool.query( const tokenCheck = await pool.query(
`SELECT user_id, pending_email FROM user_pending_emails WHERE token = $1 AND expires_at > NOW()`, `SELECT user_id, pending_email, expires_at FROM user_pending_emails WHERE token = $1`,
[token] [token]
); );
if (result.rows.length === 0) { if (tokenCheck.rows.length === 0) {
return res.status(400).send('Invalid or expired token.'); // Token doesn't exist - likely already processed
return res.send(
generateVerificationPageHTML(
'Already Verified',
'This verification link has already been used. If your email change was successful, you can log in with your new email address.',
true
)
);
} }
const { user_id, pending_email } = result.rows[0]; const { user_id, pending_email, expires_at } = tokenCheck.rows[0];
// Check if expired
if (new Date(expires_at) <= new Date()) {
return res.status(400).send(
generateVerificationPageHTML(
'Link Expired',
'This verification link has expired. Please request a new email change from the app.',
false
)
);
}
// Check if new email is now taken (race condition check) // 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]); const {rows: emailCheck} = await pool.query('SELECT id FROM users WHERE email = $1', [pending_email]);
if (emailCheck.length > 0 && emailCheck[0].id !== user_id) { if (emailCheck.length > 0 && emailCheck[0].id !== user_id) {
await pool.query('DELETE FROM user_pending_emails WHERE user_id = $1', [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 }); await agenda.cancel({ name: 'deleteUserPendingEmail', 'data.userId': user_id });
return res.status(400).send('Email address is no longer available.'); return res.status(400).send(
generateVerificationPageHTML(
'Email Unavailable',
'This email address is no longer available. Please try a different email address.',
false
)
);
} }
// Update the user's email // Update the user's email
@@ -938,14 +988,23 @@ app.get('/verify-email-change', async (req, res) => {
// Cancel the scheduled deletion job // Cancel the scheduled deletion job
await agenda.cancel({ name: 'deleteUserPendingEmail', 'data.userId': user_id }); await agenda.cancel({ name: 'deleteUserPendingEmail', 'data.userId': user_id });
res.send(` res.send(
<h1>Email Changed Successfully!</h1> generateVerificationPageHTML(
<p>Your email has been updated. You can now close this window.</p> 'Email Changed Successfully!',
`); 'Your email has been updated successfully. You can now log in to the app with your new email address.',
true
)
);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).send('Internal server error'); res.status(500).send(
generateVerificationPageHTML(
'Server Error',
'An unexpected error occurred. Please try again or contact support if the problem persists.',
false
)
);
} }
}); });

View File

@@ -225,7 +225,90 @@ async function sendPasswordResetEmail(toEmail, code, name, localHour = new Date(
} }
} }
module.exports = { sendVerificationEmail, sendPasswordResetEmail, sendEmailChangeVerification }; // Helper function to generate styled HTML response for verification pages
function generateVerificationPageHTML(title, message, isSuccess = true) {
const primaryColor = '#2196F3'; // Blue theme
const icon = isSuccess ? '✓' : '✕';
const iconBg = isSuccess ? primaryColor : '#f44336';
return `
<!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">
<title>${title} - BlindMaster</title>
</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 20px; min-height: 100vh;">
<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>
<!-- Status Icon -->
<tr>
<td align="center" style="padding: 50px 40px 30px 40px;">
<div style="width: 80px; height: 80px; border-radius: 50%; background-color: ${iconBg}; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 30px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);">
<span style="color: #ffffff; font-size: 48px; font-weight: bold; line-height: 80px;">${icon}</span>
</div>
<h2 style="margin: 0 0 20px 0; color: #333333; font-size: 28px; font-weight: normal;">
${title}
</h2>
<p style="margin: 0; color: #666666; font-size: 16px; line-height: 1.6;">
${message}
</p>
</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; color: #999999; font-size: 13px; line-height: 1.5;">
You can safely close this window and return to the app.
</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>
`;
}
module.exports = {
sendVerificationEmail,
sendPasswordResetEmail,
sendEmailChangeVerification,
generateVerificationPageHTML
};
// Helper function to send email change verification email // Helper function to send email change verification email
async function sendEmailChangeVerification(newEmail, token, name, oldEmail, localHour = new Date().getHours()) { async function sendEmailChangeVerification(newEmail, token, name, oldEmail, localHour = new Date().getHours()) {