diff --git a/services/web/app/coffee/Features/User/UserEmailsController.coffee b/services/web/app/coffee/Features/User/UserEmailsController.coffee index af1a355d6e..1afb60b99b 100644 --- a/services/web/app/coffee/Features/User/UserEmailsController.coffee +++ b/services/web/app/coffee/Features/User/UserEmailsController.coffee @@ -61,6 +61,19 @@ module.exports = UserEmailsController = return next(error) if error? res.sendStatus 204 + resendConfirmation: (req, res, next) -> + userId = AuthenticationController.getLoggedInUserId(req) + email = EmailHelper.parseEmail(req.body.email) + return res.sendStatus 422 unless email? + UserGetter.getUserByAnyEmail email, {_id:1}, (error, user) -> + return next(error) if error? + if !user? or user?._id?.toString() != userId + logger.log {userId, email, foundUserId: user?._id}, "email doesn't match logged in user" + return res.sendStatus 422 + logger.log {userId, email}, 'resending email confirmation token' + UserEmailsConfirmationHandler.sendConfirmationEmail userId, email, (error) -> + return next(error) if error? + res.sendStatus 200 showConfirm: (req, res, next) -> res.render 'user/confirm_email', { diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 4419dddac7..72a0ddebc4 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -115,6 +115,9 @@ module.exports = class Router UserEmailsController.showConfirm webRouter.post '/user/emails/confirm', UserEmailsController.confirm + webRouter.post '/user/emails/resend_confirmation', + AuthenticationController.requireLogin(), + UserEmailsController.resendConfirmation if Features.hasFeature 'affiliations' webRouter.post '/user/emails', diff --git a/services/web/app/views/user/settings/user-affiliations.pug b/services/web/app/views/user/settings/user-affiliations.pug index ef4c055dc7..7333a67a08 100644 --- a/services/web/app/views/user/settings/user-affiliations.pug +++ b/services/web/app/views/user/settings/user-affiliations.pug @@ -17,27 +17,37 @@ form.row( tr( ng-repeat="userEmail in userEmails" ) - td {{ userEmail.email + (userEmail.default ? ' (default)' : '') }} + td + | {{ userEmail.email + (userEmail.default ? ' (default)' : '') }} + div(ng-if="!userEmail.confirmedAt").small + strong #{translate('unconfirmed')}. + | + | #{translate('please_check_your_inbox')}. + br + a( + href, + ng-click="resendConfirmationEmail(userEmail)" + ) #{translate('resend_confirmation_email')} td div(ng-if="userEmail.affiliation.institution") div {{ userEmail.affiliation.institution.name }} - a( - href - ng-if="!isChangingAffiliation(userEmail.email) && !userEmail.affiliation.role && !userEmail.affiliation.department" - ng-click="changeAffiliation(userEmail);" - ) #{translate("add_role_and_department")} - div( + span.small + a( + href + ng-if="!isChangingAffiliation(userEmail.email) && !userEmail.affiliation.role && !userEmail.affiliation.department" + ng-click="changeAffiliation(userEmail);" + ) #{translate("add_role_and_department")} + div.small( ng-if="!isChangingAffiliation(userEmail.email) && (userEmail.affiliation.role || userEmail.affiliation.department)" ) span(ng-if="userEmail.affiliation.role") {{ userEmail.affiliation.role }} span(ng-if="userEmail.affiliation.role && userEmail.affiliation.department") , span(ng-if="userEmail.affiliation.department") {{ userEmail.affiliation.department }} - | ( + br a( href ng-click="changeAffiliation(userEmail);" ) #{translate("change")} - | ) .affiliation-change-container( ng-if="isChangingAffiliation(userEmail.email)" ) @@ -46,7 +56,7 @@ form.row( show-university-and-country="false" show-role-and-department="true" ) - .affiliation-change-actions + .affiliation-change-actions.small a( href ng-click="saveAffiliationChange();" @@ -56,18 +66,27 @@ form.row( href ng-click="cancelAffiliationChange();" ) #{translate("save_or_cancel-cancel")} - td - a.affiliations-table-inline-action( - href - ng-if="!userEmail.default" + td.affiliations-table-inline-actions + // Disabled buttons don't work with tooltips, due to pointer-events: none, + // so create a wrapper for the tooltip + div.affiliations-table-inline-action-disabled-wrapper( + tooltip=translate("please_confirm_your_email_before_making_it_default") + ng-if="!userEmail.default && !userEmail.confirmedAt" + ) + button.btn.btn-sm.btn-success.affiliations-table-inline-action( + disabled + ) #{translate("make_default")} + button.btn.btn-sm.btn-success.affiliations-table-inline-action( + ng-if="!userEmail.default && userEmail.confirmedAt" ng-click="setDefaultUserEmail(userEmail)" - ) #{translate("make_default")} - br - a.affiliations-table-inline-action( - href + ) #{translate("make_default")} + |   + button.btn.btn-sm.btn-danger.affiliations-table-inline-action( ng-if="!userEmail.default" ng-click="removeUserEmail(userEmail)" - ) #{translate("remove")} + tooltip=translate("remove") + ) + i.fa.fa-fw.fa-trash tr.affiliations-table-highlighted-row( ng-if="ui.isLoadingEmails" ) @@ -100,15 +119,17 @@ form.row( input-required="true" ) td - .affiliations-table-label( + p.affiliations-table-label( ng-if="newAffiliation.university && !ui.showManualUniversitySelectionUI" ) - | {{ newAffiliation.university.name }} ( - a( - href - ng-click="selectUniversityManually();" - ) #{translate("change")} - | ) + | {{ newAffiliation.university.name }} + span.small + | ( + a( + href + ng-click="selectUniversityManually();" + ) #{translate("change")} + | ) .affiliations-table-label( ng-if="!newAffiliation.university && !ui.isValidEmail && !ui.showManualUniversitySelectionUI" ) #{translate("start_by_adding_your_email")} @@ -127,7 +148,7 @@ form.row( show-role-and-department="ui.isValidEmail && newAffiliation.university" ) td - button.btn.btn-primary( + button.btn.btn-sm.btn-primary( ng-disabled="affiliationsForm.$invalid || ui.isAddingNewEmail" ng-click="addNewEmail()" ) diff --git a/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee b/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee index 03032c8734..66162eda86 100644 --- a/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee +++ b/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee @@ -128,6 +128,13 @@ define [ .then () -> _getUserEmails() .catch () -> $scope.ui.hasError = true + $scope.resendConfirmationEmail = (userEmail) -> + $scope.ui.isLoadingEmails = true + UserAffiliationsDataService + .resendConfirmationEmail userEmail.email + .then () -> _getUserEmails() + .catch () -> $scope.ui.hasError = true + $scope.acknowledgeError = () -> _reset() _getUserEmails() diff --git a/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee b/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee index 38e5c4e96c..cbcadf7e67 100644 --- a/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee +++ b/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee @@ -104,6 +104,12 @@ define [ _csrf: window.csrfToken } + resendConfirmationEmail = (email) -> + $http.post "/user/emails/resend_confirmation", { + email, + _csrf: window.csrfToken + } + isDomainBlacklisted = (domain) -> domain.toLowerCase() of domainsBlackList @@ -121,6 +127,7 @@ define [ addRoleAndDepartment setDefaultUserEmail removeUserEmail + resendConfirmationEmail isDomainBlacklisted } ] \ No newline at end of file diff --git a/services/web/public/stylesheets/app/account-settings.less b/services/web/public/stylesheets/app/account-settings.less index a27f9d6b8f..3b6d321925 100644 --- a/services/web/public/stylesheets/app/account-settings.less +++ b/services/web/public/stylesheets/app/account-settings.less @@ -24,11 +24,14 @@ width: 40%; } .affiliations-table-inline-actions { - width: 20%; + text-align: right; } .affiliations-table-inline-action { text-transform: capitalize; } + .affiliations-table-inline-action-disabled-wrapper { + display: inline-block; + } .affiliations-table-highlighted-row { background-color: tint(@content-alt-bg-color, 6%); } diff --git a/services/web/test/acceptance/coffee/UserEmailsTests.coffee b/services/web/test/acceptance/coffee/UserEmailsTests.coffee index 256c8a690f..71837f4633 100644 --- a/services/web/test/acceptance/coffee/UserEmailsTests.coffee +++ b/services/web/test/acceptance/coffee/UserEmailsTests.coffee @@ -17,7 +17,7 @@ describe "UserEmails", -> token = null async.series [ (cb) => - @user.request { + @user.request { method: 'POST', url: '/user/emails', json: @@ -45,7 +45,7 @@ describe "UserEmails", -> token = tokens[0].token cb() (cb) => - @user.request { + @user.request { method: 'POST', url: '/user/emails/confirm', json: @@ -80,7 +80,7 @@ describe "UserEmails", -> (cb) => @user2.login cb (cb) => # Create email for first user - @user.request { + @user.request { method: 'POST', url: '/user/emails', json: {@email} @@ -99,21 +99,21 @@ describe "UserEmails", -> cb() (cb) => # Delete the email from the first user - @user.request { + @user.request { method: 'POST', url: '/user/emails/delete', json: {@email} }, cb (cb) => # Create email for second user - @user2.request { + @user2.request { method: 'POST', url: '/user/emails', json: {@email} }, cb (cb) => # Original confirmation token should no longer work - @user.request { + @user.request { method: 'POST', url: '/user/emails/confirm', json: @@ -158,7 +158,7 @@ describe "UserEmails", -> token = null async.series [ (cb) => - @user.request { + @user.request { method: 'POST', url: '/user/emails', json: @@ -183,12 +183,12 @@ describe "UserEmails", -> db.tokens.update { token: token }, { - $set: { + $set: { expiresAt: new Date(Date.now() - 1000000) } }, cb (cb) => - @user.request { + @user.request { method: 'POST', url: '/user/emails/confirm', json: @@ -198,3 +198,107 @@ describe "UserEmails", -> expect(response.statusCode).to.equal 404 cb() ], done + + describe 'resending the confirmation', -> + it 'should generate a new token', (done) -> + async.series [ + (cb) => + @user.request { + method: 'POST', + url: '/user/emails', + json: + email: 'reconfirmation-email@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 204 + cb() + (cb) => + db.tokens.find { + use: 'email_confirmation', + 'data.user_id': @user._id, + usedAt: { $exists: false } + }, (error, tokens) => + # There should only be one confirmation token at the moment + expect(tokens.length).to.equal 1 + expect(tokens[0].data.email).to.equal 'reconfirmation-email@example.com' + expect(tokens[0].data.user_id).to.equal @user._id + cb() + (cb) => + @user.request { + method: 'POST', + url: '/user/emails/resend_confirmation', + json: + email: 'reconfirmation-email@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 200 + cb() + (cb) => + db.tokens.find { + use: 'email_confirmation', + 'data.user_id': @user._id, + usedAt: { $exists: false } + }, (error, tokens) => + # There should be two tokens now + expect(tokens.length).to.equal 2 + expect(tokens[0].data.email).to.equal 'reconfirmation-email@example.com' + expect(tokens[0].data.user_id).to.equal @user._id + expect(tokens[1].data.email).to.equal 'reconfirmation-email@example.com' + expect(tokens[1].data.user_id).to.equal @user._id + cb() + ], done + + it 'should create a new token if none exists', (done) -> + # This should only be for users that have sign up with their main + # emails before the confirmation system existed + async.series [ + (cb) => + db.tokens.remove { + use: 'email_confirmation', + 'data.user_id': @user._id, + usedAt: { $exists: false } + }, cb + (cb) => + @user.request { + method: 'POST', + url: '/user/emails/resend_confirmation', + json: + email: @user.email + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 200 + cb() + (cb) => + db.tokens.find { + use: 'email_confirmation', + 'data.user_id': @user._id, + usedAt: { $exists: false } + }, (error, tokens) => + # There should still only be one confirmation token + expect(tokens.length).to.equal 1 + expect(tokens[0].data.email).to.equal @user.email + expect(tokens[0].data.user_id).to.equal @user._id + cb() + ], done + + it "should not allow reconfirmation if the email doesn't match the user", (done) -> + async.series [ + (cb) => + @user.request { + method: 'POST', + url: '/user/emails/resend_confirmation', + json: + email: 'non-matching-email@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 422 + cb() + (cb) => + db.tokens.find { + use: 'email_confirmation', + 'data.user_id': @user._id, + usedAt: { $exists: false } + }, (error, tokens) => + expect(tokens.length).to.equal 0 + cb() + ], done