Nicer verification link landing page
This commit is contained in:
109
index.js
109
index.js
@@ -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
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
85
mailer.js
85
mailer.js
@@ -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()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user