mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #3909 from overleaf/jel-reconfirm-email-template
Add reconfirm email template GitOrigin-RevId: 2488c79c25a7148f601e3e3e2021cdbee4be7b4c
This commit is contained in:
parent
78326fb352
commit
4f8a905e9b
7 changed files with 255 additions and 98 deletions
|
@ -283,6 +283,32 @@ templates.projectInvite = ctaTemplate({
|
|||
}
|
||||
})
|
||||
|
||||
templates.reconfirmEmail = ctaTemplate({
|
||||
subject() {
|
||||
return `Reconfirm Email - ${settings.appName}`
|
||||
},
|
||||
title() {
|
||||
return 'Reconfirm Email'
|
||||
},
|
||||
message(opts) {
|
||||
return [
|
||||
`Please reconfirm your email address, ${opts.to}, on your ${settings.appName} account.`
|
||||
]
|
||||
},
|
||||
secondaryMessage() {
|
||||
return [
|
||||
'If you did not request this, you can simply ignore this message.',
|
||||
`If you have any questions or trouble confirming your email address, please get in touch with our support team at ${settings.adminEmail}.`
|
||||
]
|
||||
},
|
||||
ctaText() {
|
||||
return 'Reconfirm Email'
|
||||
},
|
||||
ctaURL(opts) {
|
||||
return opts.confirmEmailUrl
|
||||
}
|
||||
})
|
||||
|
||||
templates.verifyEmailToJoinTeam = ctaTemplate({
|
||||
subject(opts) {
|
||||
return `${_.escape(
|
||||
|
|
|
@ -1,31 +1,17 @@
|
|||
/* eslint-disable
|
||||
node/handle-callback-err,
|
||||
max-len,
|
||||
no-unused-vars,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const Settings = require('settings-sharelatex')
|
||||
const crypto = require('crypto')
|
||||
const logger = require('logger-sharelatex')
|
||||
const { db } = require('../../infrastructure/mongodb')
|
||||
const Errors = require('../Errors/Errors')
|
||||
const { promisifyAll } = require('../../util/promises')
|
||||
|
||||
const ONE_HOUR_IN_S = 60 * 60
|
||||
|
||||
module.exports = {
|
||||
const OneTimeTokenHandler = {
|
||||
getNewToken(use, data, options, callback) {
|
||||
// options is optional
|
||||
if (options == null) {
|
||||
if (!options) {
|
||||
options = {}
|
||||
}
|
||||
if (callback == null) {
|
||||
if (!callback) {
|
||||
callback = function (error, data) {}
|
||||
}
|
||||
if (typeof options === 'function') {
|
||||
|
@ -36,7 +22,7 @@ module.exports = {
|
|||
const createdAt = new Date()
|
||||
const expiresAt = new Date(createdAt.getTime() + expiresIn * 1000)
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
return db.tokens.insertOne(
|
||||
db.tokens.insertOne(
|
||||
{
|
||||
use,
|
||||
token,
|
||||
|
@ -45,20 +31,20 @@ module.exports = {
|
|||
expiresAt
|
||||
},
|
||||
function (error) {
|
||||
if (error != null) {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
return callback(null, token)
|
||||
callback(null, token)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
getValueFromTokenAndExpire(use, token, callback) {
|
||||
if (callback == null) {
|
||||
if (!callback) {
|
||||
callback = function (error, data) {}
|
||||
}
|
||||
const now = new Date()
|
||||
return db.tokens.findOneAndUpdate(
|
||||
db.tokens.findOneAndUpdate(
|
||||
{
|
||||
use,
|
||||
token,
|
||||
|
@ -71,15 +57,19 @@ module.exports = {
|
|||
}
|
||||
},
|
||||
function (error, result) {
|
||||
if (error != null) {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
const token = result.value
|
||||
if (token == null) {
|
||||
if (!token) {
|
||||
return callback(new Errors.NotFoundError('no token found'))
|
||||
}
|
||||
return callback(null, token.data)
|
||||
callback(null, token.data)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OneTimeTokenHandler.promises = promisifyAll(OneTimeTokenHandler)
|
||||
|
||||
module.exports = OneTimeTokenHandler
|
||||
|
|
|
@ -5,10 +5,11 @@ const settings = require('settings-sharelatex')
|
|||
const Errors = require('../Errors/Errors')
|
||||
const UserUpdater = require('./UserUpdater')
|
||||
const UserGetter = require('./UserGetter')
|
||||
const { promisify } = require('util')
|
||||
const { callbackify, promisify } = require('util')
|
||||
|
||||
// Reject email confirmation tokens after 90 days
|
||||
const TOKEN_EXPIRY_IN_S = 90 * 24 * 60 * 60
|
||||
const TOKEN_USE = 'email_confirmation'
|
||||
|
||||
function sendConfirmationEmail(userId, email, emailTemplate, callback) {
|
||||
if (arguments.length === 3) {
|
||||
|
@ -28,7 +29,7 @@ function sendConfirmationEmail(userId, email, emailTemplate, callback) {
|
|||
}
|
||||
const data = { user_id: userId, email }
|
||||
OneTimeTokenHandler.getNewToken(
|
||||
'email_confirmation',
|
||||
TOKEN_USE,
|
||||
data,
|
||||
{ expiresIn: TOKEN_EXPIRY_IN_S },
|
||||
function (err, token) {
|
||||
|
@ -45,12 +46,36 @@ function sendConfirmationEmail(userId, email, emailTemplate, callback) {
|
|||
)
|
||||
}
|
||||
|
||||
async function sendReconfirmationEmail(userId, email) {
|
||||
email = EmailHelper.parseEmail(email)
|
||||
if (!email) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
|
||||
const data = { user_id: userId, email }
|
||||
const token = await OneTimeTokenHandler.promises.getNewToken(
|
||||
TOKEN_USE,
|
||||
data,
|
||||
{ expiresIn: TOKEN_EXPIRY_IN_S }
|
||||
)
|
||||
|
||||
const emailOptions = {
|
||||
to: email,
|
||||
confirmEmailUrl: `${settings.siteUrl}/user/emails/confirm?token=${token}`,
|
||||
sendingUser_id: userId
|
||||
}
|
||||
|
||||
await EmailHandler.promises.sendEmail('reconfirmEmail', emailOptions)
|
||||
}
|
||||
|
||||
const UserEmailsConfirmationHandler = {
|
||||
sendConfirmationEmail,
|
||||
|
||||
sendReconfirmationEmail: callbackify(sendReconfirmationEmail),
|
||||
|
||||
confirmEmailFromToken(token, callback) {
|
||||
OneTimeTokenHandler.getValueFromTokenAndExpire(
|
||||
'email_confirmation',
|
||||
TOKEN_USE,
|
||||
token,
|
||||
function (error, data) {
|
||||
if (error) {
|
||||
|
|
|
@ -87,6 +87,32 @@ function resendConfirmation(req, res, next) {
|
|||
})
|
||||
}
|
||||
|
||||
function sendReconfirmation(req, res, next) {
|
||||
const userId = AuthenticationController.getLoggedInUserId(req)
|
||||
const email = EmailHelper.parseEmail(req.body.email)
|
||||
if (!email) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
UserGetter.getUserByAnyEmail(email, { _id: 1 }, function (error, user) {
|
||||
if (error) {
|
||||
return next(error)
|
||||
}
|
||||
if (!user || user._id.toString() !== userId) {
|
||||
return res.sendStatus(422)
|
||||
}
|
||||
UserEmailsConfirmationHandler.sendReconfirmationEmail(
|
||||
userId,
|
||||
email,
|
||||
function (error) {
|
||||
if (error) {
|
||||
return next(error)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const UserEmailsController = {
|
||||
list(req, res, next) {
|
||||
const userId = AuthenticationController.getLoggedInUserId(req)
|
||||
|
@ -176,6 +202,8 @@ const UserEmailsController = {
|
|||
|
||||
resendConfirmation,
|
||||
|
||||
sendReconfirmation,
|
||||
|
||||
showConfirm(req, res, next) {
|
||||
res.render('user/confirm_email', {
|
||||
token: req.query.token,
|
||||
|
|
|
@ -4,7 +4,7 @@ import getMeta from '../../../utils/meta'
|
|||
|
||||
export default App.controller(
|
||||
'UserAffiliationsReconfirmController',
|
||||
function ($scope, UserAffiliationsDataService, $window) {
|
||||
function ($scope, $http, $window) {
|
||||
const samlInitPath = ExposedSettings.samlInitPath
|
||||
$scope.reconfirm = {}
|
||||
$scope.ui = $scope.ui || {} // $scope.ui inherited on settings page
|
||||
|
@ -30,7 +30,11 @@ export default App.controller(
|
|||
function sendReconfirmEmail(email) {
|
||||
$scope.ui.hasError = false
|
||||
$scope.ui.isMakingRequest = true
|
||||
UserAffiliationsDataService.resendConfirmationEmail(email)
|
||||
$http
|
||||
.post('/user/emails/send-reconfirmation', {
|
||||
email,
|
||||
_csrf: window.csrfToken
|
||||
})
|
||||
.then(() => {
|
||||
$scope.reconfirm[email].reconfirmationSent = true
|
||||
})
|
||||
|
|
|
@ -307,6 +307,43 @@ describe('EmailBuilder', function () {
|
|||
})
|
||||
})
|
||||
|
||||
describe('reconfirmEmail', function () {
|
||||
before(function () {
|
||||
this.emailAddress = 'example@overleaf.com'
|
||||
this.userId = 'abc123'
|
||||
this.opts = {
|
||||
to: this.emailAddress,
|
||||
confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=aToken123`,
|
||||
sendingUser_id: this.userId
|
||||
}
|
||||
this.email = this.EmailBuilder.buildEmail('reconfirmEmail', this.opts)
|
||||
})
|
||||
|
||||
it('should build the email', function () {
|
||||
expect(this.email.html).to.exist
|
||||
expect(this.email.text).to.exist
|
||||
})
|
||||
|
||||
describe('HTML email', function () {
|
||||
it('should include a CTA button and a fallback CTA link', function () {
|
||||
const dom = cheerio.load(this.email.html)
|
||||
const buttonLink = dom('a:contains("Reconfirm Email")')
|
||||
expect(buttonLink.length).to.equal(1)
|
||||
expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl)
|
||||
const fallback = dom('.force-overleaf-style').last()
|
||||
expect(fallback.length).to.equal(1)
|
||||
const fallbackLink = fallback.html()
|
||||
expect(fallbackLink).to.contain(this.opts.confirmEmailUrl)
|
||||
})
|
||||
})
|
||||
|
||||
describe('plain text email', function () {
|
||||
it('should contain the CTA link', function () {
|
||||
expect(this.email.text).to.contain(this.opts.confirmEmailUrl)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyEmailToJoinTeam', function () {
|
||||
before(function () {
|
||||
this.emailAddress = 'example@overleaf.com'
|
||||
|
|
|
@ -18,6 +18,7 @@ describe('UserEmailsController', function () {
|
|||
|
||||
this.UserGetter = {
|
||||
getUserFullEmails: sinon.stub(),
|
||||
getUserByAnyEmail: sinon.stub(),
|
||||
promises: {
|
||||
getUser: sinon.stub().resolves(this.user)
|
||||
}
|
||||
|
@ -45,13 +46,7 @@ describe('UserEmailsController', function () {
|
|||
this.EmailHelper = { parseEmail: sinon.stub() }
|
||||
this.endorseAffiliation = sinon.stub().yields()
|
||||
this.InstitutionsAPI = {
|
||||
endorseAffiliation: this.endorseAffiliation,
|
||||
getInstitutionViaDomain: sinon
|
||||
.stub()
|
||||
.withArgs('overleaf.com')
|
||||
.resolves({ sso_enabled: true })
|
||||
.withArgs('example.com')
|
||||
.resolves({ sso_enabled: false })
|
||||
endorseAffiliation: this.endorseAffiliation
|
||||
}
|
||||
this.HttpErrorHandler = { conflict: sinon.stub() }
|
||||
this.UserEmailsController = SandboxedModule.require(modulePath, {
|
||||
|
@ -69,6 +64,7 @@ describe('UserEmailsController', function () {
|
|||
}),
|
||||
'../Helpers/EmailHelper': this.EmailHelper,
|
||||
'./UserEmailsConfirmationHandler': (this.UserEmailsConfirmationHandler = {
|
||||
sendReconfirmationEmail: sinon.stub(),
|
||||
promises: {
|
||||
sendConfirmationEmail: sinon.stub().resolves()
|
||||
}
|
||||
|
@ -439,8 +435,13 @@ describe('UserEmailsController', function () {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resendConfirmation', function () {
|
||||
beforeEach(function () {
|
||||
this.EmailHelper.parseEmail.returnsArg(0)
|
||||
this.UserGetter.getUserByAnyEmail.yields(undefined, {
|
||||
_id: this.user._id
|
||||
})
|
||||
this.req = {
|
||||
body: {}
|
||||
}
|
||||
|
@ -452,74 +453,120 @@ describe('UserEmailsController', function () {
|
|||
.stub()
|
||||
.yields()
|
||||
})
|
||||
describe('when institution SSO is released', function () {
|
||||
|
||||
it('should send the email', function (done) {
|
||||
this.req = {
|
||||
body: {
|
||||
email: 'test@example.com'
|
||||
}
|
||||
}
|
||||
this.UserEmailsController.sendReconfirmation(
|
||||
this.req,
|
||||
this.res,
|
||||
this.next
|
||||
)
|
||||
expect(this.UserEmailsConfirmationHandler.sendReconfirmationEmail).to.have
|
||||
.been.calledOnce
|
||||
done()
|
||||
})
|
||||
|
||||
it('should return 422 if email not valid', function (done) {
|
||||
this.req = {
|
||||
body: {}
|
||||
}
|
||||
this.UserEmailsController.resendConfirmation(
|
||||
this.req,
|
||||
this.res,
|
||||
this.next
|
||||
)
|
||||
expect(this.UserEmailsConfirmationHandler.sendConfirmationEmail).to.not
|
||||
.have.been.called
|
||||
expect(this.res.sendStatus.lastCall.args[0]).to.equal(422)
|
||||
done()
|
||||
})
|
||||
describe('email on another user account', function () {
|
||||
beforeEach(function () {
|
||||
this.Features.hasFeature.withArgs('saml').returns(true)
|
||||
})
|
||||
describe('for an institution SSO email', function () {
|
||||
beforeEach(function () {
|
||||
this.req.body.email = 'with-sso@overleaf.com'
|
||||
})
|
||||
it('should not send the email', function () {
|
||||
this.UserEmailsController.resendConfirmation(
|
||||
this.req,
|
||||
this.res,
|
||||
() => {
|
||||
this.UserEmailsConfirmationHandler.sendConfirmationEmail.should
|
||||
.not.have.been.called.once
|
||||
}
|
||||
)
|
||||
this.UserGetter.getUserByAnyEmail.yields(undefined, {
|
||||
_id: 'another-user-id'
|
||||
})
|
||||
})
|
||||
describe('for a non-institution SSO email', function () {
|
||||
beforeEach(function () {
|
||||
this.req.body.email = 'without-sso@example.com'
|
||||
})
|
||||
it('should send the email', function () {
|
||||
this.UserEmailsController.resendConfirmation(
|
||||
this.req,
|
||||
this.res,
|
||||
() => {
|
||||
this.UserEmailsConfirmationHandler.sendConfirmationEmail.should
|
||||
.have.been.called.once
|
||||
}
|
||||
)
|
||||
})
|
||||
it('should return 422', function (done) {
|
||||
this.req = {
|
||||
body: {
|
||||
email: 'test@example.com'
|
||||
}
|
||||
}
|
||||
this.UserEmailsController.resendConfirmation(
|
||||
this.req,
|
||||
this.res,
|
||||
this.next
|
||||
)
|
||||
expect(this.UserEmailsConfirmationHandler.sendConfirmationEmail).to.not
|
||||
.have.been.called
|
||||
expect(this.res.sendStatus.lastCall.args[0]).to.equal(422)
|
||||
done()
|
||||
})
|
||||
})
|
||||
describe('when institution SSO is not released', function () {
|
||||
})
|
||||
|
||||
describe('sendReconfirmation', function () {
|
||||
beforeEach(function () {
|
||||
this.res.sendStatus = sinon.stub()
|
||||
this.UserGetter.getUserByAnyEmail.yields(undefined, {
|
||||
_id: this.user._id
|
||||
})
|
||||
this.EmailHelper.parseEmail.returnsArg(0)
|
||||
})
|
||||
it('should send the email', function (done) {
|
||||
this.req = {
|
||||
body: {
|
||||
email: 'test@example.com'
|
||||
}
|
||||
}
|
||||
this.UserEmailsController.sendReconfirmation(
|
||||
this.req,
|
||||
this.res,
|
||||
this.next
|
||||
)
|
||||
expect(this.UserEmailsConfirmationHandler.sendReconfirmationEmail).to.have
|
||||
.been.calledOnce
|
||||
done()
|
||||
})
|
||||
it('should return 400 if email not valid', function (done) {
|
||||
this.req = {
|
||||
body: {}
|
||||
}
|
||||
this.UserEmailsController.sendReconfirmation(
|
||||
this.req,
|
||||
this.res,
|
||||
this.next
|
||||
)
|
||||
expect(this.UserEmailsConfirmationHandler.sendReconfirmationEmail).to.not
|
||||
.have.been.called
|
||||
expect(this.res.sendStatus.lastCall.args[0]).to.equal(400)
|
||||
done()
|
||||
})
|
||||
describe('email on another user account', function () {
|
||||
beforeEach(function () {
|
||||
this.Features.hasFeature.withArgs('saml').returns(false)
|
||||
})
|
||||
describe('for an institution SSO email', function () {
|
||||
beforeEach(function () {
|
||||
this.req.body.email = 'with-sso@overleaf.com'
|
||||
})
|
||||
it('should send the email', function () {
|
||||
this.UserEmailsController.resendConfirmation(
|
||||
this.req,
|
||||
this.res,
|
||||
() => {
|
||||
this.UserEmailsConfirmationHandler.sendConfirmationEmail.should
|
||||
.have.been.called.once
|
||||
}
|
||||
)
|
||||
this.UserGetter.getUserByAnyEmail.yields(undefined, {
|
||||
_id: 'another-user-id'
|
||||
})
|
||||
})
|
||||
describe('for a non-institution SSO email', function () {
|
||||
beforeEach(function () {
|
||||
this.req.body.email = 'without-sso@example.com'
|
||||
})
|
||||
it('should send the email', function () {
|
||||
this.UserEmailsController.resendConfirmation(
|
||||
this.req,
|
||||
this.res,
|
||||
() => {
|
||||
this.UserEmailsConfirmationHandler.sendConfirmationEmail.should
|
||||
.have.been.called.once
|
||||
}
|
||||
)
|
||||
})
|
||||
it('should return 422', function (done) {
|
||||
this.req = {
|
||||
body: {
|
||||
email: 'test@example.com'
|
||||
}
|
||||
}
|
||||
this.UserEmailsController.sendReconfirmation(
|
||||
this.req,
|
||||
this.res,
|
||||
this.next
|
||||
)
|
||||
expect(this.UserEmailsConfirmationHandler.sendReconfirmationEmail).to
|
||||
.not.have.been.called
|
||||
expect(this.res.sendStatus.lastCall.args[0]).to.equal(422)
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue