diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index e865c342bb..c1c77d9def 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -33,6 +33,7 @@ "username-taken": "Username taken", "email-taken": "Email taken", + "email-nochange": "The email entered is the same as the email already on file.", "email-invited": "Email was already invited", "email-not-confirmed": "You are unable to post until your email is confirmed, please click here to confirm your email.", "email-not-confirmed-chat": "You are unable to chat until your email is confirmed, please click here to confirm your email.", diff --git a/public/language/en-GB/register.json b/public/language/en-GB/register.json index 5ab3edd128..58cba536f0 100644 --- a/public/language/en-GB/register.json +++ b/public/language/en-GB/register.json @@ -22,7 +22,7 @@ "registration-queue-auto-approve-time": "Your membership to this forum will be fully activated in up to %1 hours.", "interstitial.intro": "We'd like some additional information in order to update your account…", "interstitial.intro-new": "We'd like some additional information before we can create your account…", - "interstitial.errors-found": "We could not complete your registration:", + "interstitial.errors-found": "Please review the entered information:", "gdpr_agree_data": "I consent to the collection and processing of my personal information on this website.", "gdpr_agree_email": "I consent to receive digest and notification emails from this website.", "gdpr_consent_denied": "You must give consent to this site to collect/process your information, and to send you emails.", diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json index 9c834a75ba..68195dbf57 100644 --- a/public/language/en-GB/user.json +++ b/public/language/en-GB/user.json @@ -201,5 +201,9 @@ "consent.export_uploads": "Export Uploaded Content (.zip)", "consent.export-uploads-success": "Exporting uploads, you will get a notification when it is complete.", "consent.export_posts": "Export Posts (.csv)", - "consent.export-posts-success": "Exporting posts, you will get a notification when it is complete." + "consent.export-posts-success": "Exporting posts, you will get a notification when it is complete.", + + "emailUpdate.intro": "Please enter your email address below. This forum uses your email address for scheduled digest and notifications, as well as for account recovery in the event of a lost password.", + "emailUpdate.optional": "This field is optional. You are not obligated to provide your email address, but without a validated email, you will not be able to recover your account.", + "emailUpdate.change-instructions": "A confirmation email will be sent to the entered email address with a unique link. Accessing that link will confirm your ownership of the email address and it will become active on your account. At any time, you are able to update your email on file from within your account page." } diff --git a/src/controllers/accounts/edit.js b/src/controllers/accounts/edit.js index 3bcaff1d85..9c298f18cc 100644 --- a/src/controllers/accounts/edit.js +++ b/src/controllers/accounts/edit.js @@ -77,8 +77,11 @@ editController.username = async function (req, res, next) { await renderRoute('username', req, res, next); }; -editController.email = async function (req, res, next) { - await renderRoute('email', req, res, next); +editController.email = async function (req, res) { + req.session.registration = req.session.registration || {}; + req.session.registration.updateEmail = true; + req.session.registration.uid = req.uid; + helpers.redirect(res, '/register/complete'); }; async function renderRoute(name, req, res, next) { diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js index 092efc6e8c..3f325f168d 100644 --- a/src/controllers/authentication.js +++ b/src/controllers/authentication.js @@ -33,6 +33,7 @@ async function registerAndLoginUser(req, res, userData) { if (deferRegistration) { userData.register = true; + userData.updateEmail = true; req.session.registration = userData; if (req.body.noscript === 'true') { @@ -209,11 +210,17 @@ authenticationController.registerComplete = function (req, res, next) { }; authenticationController.registerAbort = function (req, res) { - // End the session and redirect to home - req.session.destroy(() => { - res.clearCookie(nconf.get('sessionKey'), meta.configs.cookie.get()); + if (req.uid) { + // Clear interstitial data and go home + delete req.session.registration; res.redirect(`${nconf.get('relative_path')}/`); - }); + } else { + // End the session and redirect to home + req.session.destroy(() => { + res.clearCookie(nconf.get('sessionKey'), meta.configs.cookie.get()); + res.redirect(`${nconf.get('relative_path')}/`); + }); + } }; authenticationController.login = async (req, res, next) => { diff --git a/src/user/create.js b/src/user/create.js index c4ba0ae51b..bd7f54b3ba 100644 --- a/src/user/create.js +++ b/src/user/create.js @@ -117,7 +117,7 @@ module.exports = function (User) { User.updateDigestSetting(userData.uid, meta.config.dailyDigestFreq), ]); - if (userData.email && userData.uid > 1 && meta.config.requireEmailConfirmation) { + if (userData.email && userData.uid > 1) { User.email.sendValidationEmail(userData.uid, { email: userData.email, }).catch(err => winston.error(`[user.create] Validation email failed to send\n[emailer.send] ${err.stack}`)); diff --git a/src/user/email.js b/src/user/email.js index 01f3e34b02..7fd9346ce8 100644 --- a/src/user/email.js +++ b/src/user/email.js @@ -93,15 +93,27 @@ UserEmail.confirmByCode = async function (code) { if (!confirmObj || !confirmObj.uid || !confirmObj.email) { throw new Error('[[error:invalid-data]]'); } - const currentEmail = await user.getUserField(confirmObj.uid, 'email'); - if (!currentEmail || currentEmail.toLowerCase() !== confirmObj.email) { - throw new Error('[[error:invalid-email]]'); + + let oldEmail = await user.getUserField(confirmObj.uid, 'email'); + if (oldEmail) { + oldEmail = oldEmail || ''; + if (oldEmail === confirmObj.email) { + return; + } + + await db.sortedSetRemove('email:uid', oldEmail.toLowerCase()); + await db.sortedSetRemove('email:sorted', `${oldEmail.toLowerCase()}:${confirmObj.uid}`); + await user.auth.revokeAllSessions(confirmObj.uid); } - await UserEmail.confirmByUid(confirmObj.uid); - await db.delete(`confirm:${code}`); + + await Promise.all([ + user.setUserField('email', confirmObj.email), + UserEmail.confirmByUid(confirmObj.uid), + db.delete(`confirm:${code}`), + ]); }; -// confirm uid's email +// confirm uid's email via ACP UserEmail.confirmByUid = async function (uid) { if (!(parseInt(uid, 10) > 0)) { throw new Error('[[error:invalid-uid]]'); @@ -110,11 +122,18 @@ UserEmail.confirmByUid = async function (uid) { if (!currentEmail) { throw new Error('[[error:invalid-email]]'); } + await Promise.all([ + db.sortedSetAddBulk([ + ['email:uid', uid, currentEmail.toLowerCase()], + ['email:sorted', 0, `${currentEmail.toLowerCase()}:${uid}`], + [`user:${uid}:emails`, Date.now(), `${currentEmail}:${Date.now()}`], + ]), user.setUserField(uid, 'email:confirmed', 1), groups.join('verified-users', uid), groups.leave('unverified-users', uid), db.delete(`uid:${uid}:confirm:email:sent`), + user.reset.cleanByUid(uid), ]); await plugins.hooks.fire('action:user.email.confirmed', { uid: uid, email: currentEmail }); }; diff --git a/src/user/index.js b/src/user/index.js index 9f8330b791..f1172e7553 100644 --- a/src/user/index.js +++ b/src/user/index.js @@ -229,6 +229,53 @@ User.addInterstitials = function (callback) { plugins.hooks.register('core', { hook: 'filter:register.interstitial', method: [ + // Email address (for password reset + digest) + async (data) => { + if (!data.userData) { + throw new Error('[[error:invalid-data]]'); + } + if (!data.userData.updateEmail) { + return data; + } + + let email; + if (data.userData.uid) { + email = await User.getUserField(data.userData.uid, 'email'); + } + + data.interstitials.push({ + template: 'partials/email_update', + data: { email }, + callback: async (userData, formData) => { + // Validate and send email confirmation + if (formData.email && formData.email.length) { + if (!utils.isEmailValid(formData.email)) { + throw new Error('[[error:invalid-email]]'); + } + + if (userData.uid) { + const current = await User.getUserField(userData.uid, 'email'); + if (formData.email === current) { + throw new Error('[[error:email-nochange]]'); + } + + await User.email.sendValidationEmail(userData.uid, { + email: formData.email, + force: true, + }); + } else { + // New registrants have the confirm email sent from user.create() + userData.email = formData.email; + } + } + + delete userData.updateEmail; + }, + }); + + return data; + }, + // GDPR information collection/processing consent + email consent async function (data) { if (!meta.config.gdpr_enabled || (data.userData && data.userData.gdpr_consent)) { diff --git a/src/user/profile.js b/src/user/profile.js index bc9ec7a6b3..72148fcf3a 100644 --- a/src/user/profile.js +++ b/src/user/profile.js @@ -243,23 +243,7 @@ module.exports = function (User) { return; } - await db.sortedSetRemove('email:uid', oldEmail.toLowerCase()); - await db.sortedSetRemove('email:sorted', `${oldEmail.toLowerCase()}:${uid}`); - await User.auth.revokeAllSessions(uid); - - await Promise.all([ - db.sortedSetAddBulk([ - ['email:uid', uid, newEmail.toLowerCase()], - ['email:sorted', 0, `${newEmail.toLowerCase()}:${uid}`], - [`user:${uid}:emails`, Date.now(), `${newEmail}:${Date.now()}`], - ]), - User.setUserFields(uid, { email: newEmail, 'email:confirmed': 0 }), - groups.leave('verified-users', uid), - groups.join('unverified-users', uid), - User.reset.cleanByUid(uid), - ]); - - if (meta.config.requireEmailConfirmation && newEmail) { + if (newEmail) { await User.email.sendValidationEmail(uid, { email: newEmail, subject: '[[email:email.verify-your-email.subject]]', diff --git a/src/views/partials/email_update.tpl b/src/views/partials/email_update.tpl new file mode 100644 index 0000000000..0f7f9f8090 --- /dev/null +++ b/src/views/partials/email_update.tpl @@ -0,0 +1,9 @@ +
[[user:emailUpdate.intro]]
+[[user:emailUpdate.optional]]
+[[user:emailUpdate.change-instructions]]
+