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/infrastructure/Features.coffee b/services/web/app/coffee/infrastructure/Features.coffee index afb2eefb9f..311d8943c9 100644 --- a/services/web/app/coffee/infrastructure/Features.coffee +++ b/services/web/app/coffee/infrastructure/Features.coffee @@ -20,5 +20,7 @@ module.exports = Features = return Settings.overleaf? when 'templates' return !Settings.overleaf? + when 'affiliations' + return Settings?.apis?.v1?.url? else throw new Error("unknown feature: #{feature}") diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 2d9cce105d..72a0ddebc4 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -115,8 +115,11 @@ module.exports = class Router UserEmailsController.showConfirm webRouter.post '/user/emails/confirm', UserEmailsController.confirm + webRouter.post '/user/emails/resend_confirmation', + AuthenticationController.requireLogin(), + UserEmailsController.resendConfirmation - unless Features.externalAuthenticationSystemUsed() + if Features.hasFeature 'affiliations' webRouter.post '/user/emails', AuthenticationController.requireLogin(), UserEmailsController.add diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index a781cd708a..a43e81c12c 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -9,7 +9,7 @@ block content .page-header h1 #{translate("account_settings")} .account-settings(ng-controller="AccountSettingsController", ng-cloak) - if locals.showAffiliationsUI + if locals.showAffiliationsUI && hasFeature('affiliations') include settings/user-affiliations form-messages(for="settingsForm") @@ -22,25 +22,26 @@ block content h3 #{translate("update_account_info")} form(async-form="settings", name="settingsForm", method="POST", action="/user/settings", novalidate) input(type="hidden", name="_csrf", value=csrfToken) - if !externalAuthenticationSystemUsed() - .form-group - label(for='email') #{translate("email")} - input.form-control( - type='email', - name='email', - placeholder="email@example.com" - required, - ng-model="email", - ng-init="email = "+JSON.stringify(user.email), - ng-model-options="{ updateOn: 'blur' }" - ) - span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty") - | #{translate("must_be_email_address")} - else - // show the email, non-editable - .form-group - label.control-label #{translate("email")} - div.form-control(readonly="true") #{user.email} + if !(locals.showAffiliationsUI && hasFeature('affiliations')) + if !externalAuthenticationSystemUsed() + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email', + name='email', + placeholder="email@example.com" + required, + ng-model="email", + ng-init="email = "+JSON.stringify(user.email), + ng-model-options="{ updateOn: 'blur' }" + ) + span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty") + | #{translate("must_be_email_address")} + else + // show the email, non-editable + .form-group + label.control-label #{translate("email")} + div.form-control(readonly="true") #{user.email} if shouldAllowEditingDetails .form-group diff --git a/services/web/app/views/user/settings/user-affiliations.pug b/services/web/app/views/user/settings/user-affiliations.pug index 6b08b09346..7333a67a08 100644 --- a/services/web/app/views/user/settings/user-affiliations.pug +++ b/services/web/app/views/user/settings/user-affiliations.pug @@ -3,44 +3,96 @@ form.row( name="affiliationsForm" ) .col-md-12 - h3 Emails and Affiliations - p.small Add additional email addresses to your account to access any upgrades your university or institution has, to make it easier for collaborators to find you, and to make sure you can recover your account. + h3 #{translate("emails_and_affiliations_title")} + p.small #{translate("emails_and_affiliations_explanation")} table.table.affiliations-table thead tr - th.affiliations-table-email Email - th.affiliations-table-institution Institution and role + th.affiliations-table-email #{translate("email")} + th.affiliations-table-institution #{translate("institution_and_role")} th.affiliations-table-inline-actions - tbody + tbody( + ng-if="!ui.hasError" + ) 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") {{ userEmail.affiliation.institution.name }} - div(ng-if="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 }} - td - a( - href + div(ng-if="userEmail.affiliation.institution") + div {{ userEmail.affiliation.institution.name }} + 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)" + ) + affiliation-form( + affiliation-data="affiliationToChange" + show-university-and-country="false" + show-role-and-department="true" + ) + .affiliation-change-actions.small + a( + href + ng-click="saveAffiliationChange();" + ) #{translate("save_or_cancel-save")} + |  #{translate("save_or_cancel-or" )}  + a( + href + ng-click="cancelAffiliationChange();" + ) #{translate("save_or_cancel-cancel")} + 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")} + |   + button.btn.btn-sm.btn-danger.affiliations-table-inline-action( ng-if="!userEmail.default" - ng-click="setDefaultUserEmail(userEmail.email)" - ) Make default - br - a( - href - ng-if="!userEmail.default" - ng-click="removeUserEmail(userEmail.email)" - ) Remove + ng-click="removeUserEmail(userEmail)" + tooltip=translate("remove") + ) + i.fa.fa-fw.fa-trash tr.affiliations-table-highlighted-row( ng-if="ui.isLoadingEmails" ) td.text-center(colspan="3") i.fa.fa-fw.fa-spin.fa-refresh - |  Loading... - + |  #{translate("loading")}... tr.affiliations-table-highlighted-row( ng-if="!ui.showAddEmailUI && !ui.isLoadingEmails" ) @@ -48,7 +100,7 @@ form.row( a( href ng-click="showAddEmailForm()" - ) Add another email + ) #{translate("add_another_email")} tr.affiliations-table-highlighted-row( ng-if="ui.showAddEmailUI" @@ -67,106 +119,130 @@ 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();" - ) change - | ) + | {{ newAffiliation.university.name }} + span.small + | ( + a( + href + ng-click="selectUniversityManually();" + ) #{translate("change")} + | ) .affiliations-table-label( ng-if="!newAffiliation.university && !ui.isValidEmail && !ui.showManualUniversitySelectionUI" - ) Start by adding your email address. + ) #{translate("start_by_adding_your_email")} .affiliations-table-label( ng-if="!newAffiliation.university && ui.isValidEmail && !ui.isBlacklistedEmail && !ui.showManualUniversitySelectionUI" ) - | Is your email affiliated with an institution? + | #{translate("is_email_affiliated")} br a( href ng-click="selectUniversityManually();" - ) Let us know - .affiliations-form-group( - ng-if="ui.showManualUniversitySelectionUI" + ) #{translate("let_us_know")} + affiliation-form( + affiliation-data="newAffiliation" + show-university-and-country="ui.showManualUniversitySelectionUI" + show-role-and-department="ui.isValidEmail && newAffiliation.university" ) - ui-select( - ng-model="newAffiliation.country" - ) - ui-select-match( - placeholder="Country" - ) {{ $select.selected.name }} - ui-select-choices( - repeat="country in countries | filter: $select.search" - ) - span( - ng-bind="country.name" - s) - .affiliations-form-group( - ng-if="ui.showManualUniversitySelectionUI" - ) - ui-select( - ng-model="newAffiliation.university" - ng-disabled="!newAffiliation.country" - tagging="addUniversityToSelection" - tagging-label="false" - ) - ui-select-match( - placeholder="Institution" - ) {{ $select.selected.name }} - ui-select-choices( - repeat="university in universities | filter: $select.search" - ) - span( - ng-bind="university.name" - ) - .affiliations-form-group( - ng-if="ui.isValidEmail && newAffiliation.university" - ) - ui-select( - ng-model="newAffiliation.role" - tagging - tagging-label="false" - ) - ui-select-match( - placeholder="Role" - ) {{ $select.selected }} - ui-select-choices( - repeat="role in roles | filter: $select.search" - ) - span( - ng-bind="role" - ) - - .affiliations-form-group( - ng-if="ui.isValidEmail && newAffiliation.university" - ) - ui-select( - ng-model="newAffiliation.department" - tagging - tagging-label="false" - ) - ui-select-match( - placeholder="Department" - ) {{ $select.selected }} - ui-select-choices( - repeat="department in departments | filter: $select.search" - ) - span( - ng-bind="department" - ) td - button.btn.btn-primary( + button.btn.btn-sm.btn-primary( ng-disabled="affiliationsForm.$invalid || ui.isAddingNewEmail" ng-click="addNewEmail()" ) span( ng-if="!ui.isAddingNewEmail" - ) Add new email + ) #{translate("add_new_email")} span( ng-if="ui.isAddingNewEmail" ) i.fa.fa-fw.fa-spin.fa-refresh - |  Adding... - hr \ No newline at end of file + |  #{translate("adding")}... + tbody( + ng-if="ui.hasError" + ) + tr.affiliations-table-error-row( + ng-if="true" + ) + td.text-center(colspan="3") + div + i.fa.fa-fw.fa-exclamation-triangle + |  #{translate("error_performing_request")} + div + button.btn.btn-xs( + ng-click="acknowledgeError();" + ) #{translate("reload_emails_and_affiliations")} + + hr + +script(type="text/ng-template", id="affiliationFormTpl") + .affiliations-form-group( + ng-if="$ctrl.showUniversityAndCountry" + ) + ui-select( + ng-model="$ctrl.affiliationData.country" + ) + ui-select-match( + placeholder="Country" + ) {{ $select.selected.name }} + ui-select-choices( + repeat="country in $ctrl.countries | filter: $select.search" + ) + span( + ng-bind="country.name" + ) + .affiliations-form-group( + ng-if="$ctrl.showUniversityAndCountry" + ) + ui-select( + ng-model="$ctrl.affiliationData.university" + ng-disabled="!$ctrl.affiliationData.country" + tagging="$ctrladdUniversityToSelection" + tagging-label="false" + ) + ui-select-match( + placeholder="Institution" + ) {{ $select.selected.name }} + ui-select-choices( + repeat="university in $ctrl.universities | filter: $select.search" + ) + span( + ng-bind="university.name" + ) + .affiliations-form-group( + ng-if="$ctrl.showRoleAndDepartment" + ) + ui-select( + ng-model="$ctrl.affiliationData.role" + tagging + tagging-label="false" + ) + ui-select-match( + placeholder="Role" + ) {{ $select.selected }} + ui-select-choices( + repeat="role in $ctrl.roles | filter: $select.search" + ) + span( + ng-bind="role" + ) + + .affiliations-form-group( + ng-if="$ctrl.showRoleAndDepartment" + ) + ui-select( + ng-model="$ctrl.affiliationData.department" + tagging + tagging-label="false" + ) + ui-select-match( + placeholder="Department" + ) {{ $select.selected }} + ui-select-choices( + repeat="department in $ctrl.departments | filter: $select.search" + ) + span( + ng-bind="department" + ) diff --git a/services/web/public/coffee/main.coffee b/services/web/public/coffee/main.coffee index 80e637d705..abbc59f9e4 100644 --- a/services/web/public/coffee/main.coffee +++ b/services/web/public/coffee/main.coffee @@ -20,6 +20,7 @@ define [ "main/subscription/team-invite-controller" "main/contact-us" "main/learn" + "main/affiliations/components/affiliationForm" "main/affiliations/controllers/UserAffiliationsController" "main/affiliations/factories/UserAffiliationsDataService" "main/keys" diff --git a/services/web/public/coffee/main/affiliations/components/affiliationForm.coffee b/services/web/public/coffee/main/affiliations/components/affiliationForm.coffee new file mode 100644 index 0000000000..1c0c7d6229 --- /dev/null +++ b/services/web/public/coffee/main/affiliations/components/affiliationForm.coffee @@ -0,0 +1,52 @@ +define [ + "base" +], (App) -> + affiliationFormController = ($scope, $element, $attrs, UserAffiliationsDataService) -> + ctrl = @ + ctrl.roles = [] + ctrl.departments = [] + ctrl.countries = [] + ctrl.universities = [] + _defaultDepartments = [] + + ctrl.addUniversityToSelection = (universityName) -> + { name: universityName, isUserSuggested: true } + # Populates the countries dropdown + UserAffiliationsDataService + .getCountries() + .then (countries) -> ctrl.countries = countries + # Populates the roles dropdown + UserAffiliationsDataService + .getDefaultRoleHints() + .then (roles) -> ctrl.roles = roles + # Fetches the default department hints + UserAffiliationsDataService + .getDefaultDepartmentHints() + .then (departments) -> + _defaultDepartments = departments + # Populates the universities dropdown (after selecting a country) + $scope.$watch "$ctrl.affiliationData.country", (newSelectedCountry, prevSelectedCountry) -> + if newSelectedCountry? and newSelectedCountry != prevSelectedCountry + ctrl.affiliationData.university = null + ctrl.affiliationData.role = null + ctrl.affiliationData.department = null + UserAffiliationsDataService + .getUniversitiesFromCountry(newSelectedCountry) + .then (universities) -> ctrl.universities = universities + # Populates the departments dropdown (after selecting a university) + $scope.$watch "$ctrl.affiliationData.university", (newSelectedUniversity, prevSelectedUniversity) -> + if newSelectedUniversity? and newSelectedUniversity != prevSelectedUniversity and newSelectedUniversity.departments?.length > 0 + ctrl.departments = _.uniq newSelectedUniversity.departments + else + ctrl.departments = _defaultDepartments + + return + + App.component "affiliationForm", { + bindings: + affiliationData: "=" + showUniversityAndCountry: "<" + showRoleAndDepartment: "<" + controller: affiliationFormController + templateUrl: "affiliationFormTpl" + } \ No newline at end of file diff --git a/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee b/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee index a8cf710afc..66162eda86 100644 --- a/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee +++ b/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee @@ -3,12 +3,6 @@ define [ ], (App) -> App.controller "UserAffiliationsController", ["$scope", "UserAffiliationsDataService", "$q", "_", ($scope, UserAffiliationsDataService, $q, _) -> $scope.userEmails = [] - $scope.countries = [] - $scope.universities = [] - $scope.roles = [] - $scope.departments = [] - - _defaultDepartments = [] LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/ EMAIL_REGEX = /^([A-Za-z0-9_\-\.]+)@([^\.]+)\.([A-Za-z0-9_\-\.]+)([^\.])$/ @@ -20,9 +14,6 @@ define [ else { local: null, domain: null } - $scope.addUniversityToSelection = (universityName) -> - { name: universityName, isUserSuggested: true } - $scope.getEmailSuggestion = (userInput) -> userInputLocalAndDomain = _matchLocalAndDomain(userInput) $scope.ui.isValidEmail = EMAIL_REGEX.test userInput @@ -55,6 +46,38 @@ define [ $scope.newAffiliation.department = null $scope.ui.showManualUniversitySelectionUI = true + $scope.changeAffiliation = (userEmail) -> + if userEmail.affiliation?.institution?.id? + UserAffiliationsDataService.getUniversityDetails userEmail.affiliation.institution.id + .then (universityDetails) -> $scope.affiliationToChange.university = universityDetails + + $scope.affiliationToChange.email = userEmail.email + $scope.affiliationToChange.role = userEmail.affiliation.role + $scope.affiliationToChange.department = userEmail.affiliation.department + + $scope.saveAffiliationChange = () -> + $scope.ui.isLoadingEmails = true + UserAffiliationsDataService + .addRoleAndDepartment( + $scope.affiliationToChange.email, + $scope.affiliationToChange.role, + $scope.affiliationToChange.department + ) + .then () -> + _reset() + _getUserEmails() + .catch () -> + $scope.ui.hasError = true + + $scope.cancelAffiliationChange = (email) -> + $scope.affiliationToChange.email = "" + $scope.affiliationToChange.university = null + $scope.affiliationToChange.role = null + $scope.affiliationToChange.department = null + + $scope.isChangingAffiliation = (email) -> + $scope.affiliationToChange.email == email + $scope.showAddEmailForm = () -> $scope.ui.showAddEmailUI = true @@ -81,27 +104,40 @@ define [ $scope.newAffiliation.role, $scope.newAffiliation.department ) - addEmailPromise.then () -> - _reset() - _getUserEmails() + addEmailPromise + .then () -> + _reset() + _getUserEmails() + .catch () -> + $scope.ui.hasError = true - $scope.setDefaultUserEmail = (email) -> + $scope.setDefaultUserEmail = (userEmail) -> $scope.ui.isLoadingEmails = true UserAffiliationsDataService - .setDefaultUserEmail email + .setDefaultUserEmail userEmail.email .then () -> _getUserEmails() + .catch () -> $scope.ui.hasError = true - $scope.removeUserEmail = (email) -> + $scope.removeUserEmail = (userEmail) -> + $scope.ui.isLoadingEmails = true + userEmailIdx = _.indexOf $scope.userEmails, userEmail + if userEmailIdx > -1 + $scope.userEmails.splice userEmailIdx, 1 + UserAffiliationsDataService + .removeUserEmail userEmail.email + .then () -> _getUserEmails() + .catch () -> $scope.ui.hasError = true + + $scope.resendConfirmationEmail = (userEmail) -> $scope.ui.isLoadingEmails = true UserAffiliationsDataService - .removeUserEmail email + .resendConfirmationEmail userEmail.email .then () -> _getUserEmails() + .catch () -> $scope.ui.hasError = true - $scope.getDepartments = () -> - if $scope.newAffiliation.university?.departments.length > 0 - _.uniq $scope.newAffiliation.university.departments - else - UserAffiliationsDataService.getDefaultDepartmentHints() + $scope.acknowledgeError = () -> + _reset() + _getUserEmails() _reset = () -> $scope.newAffiliation = @@ -111,12 +147,19 @@ define [ role: null department: null $scope.ui = + hasError: false + showChangeAffiliationUI: false showManualUniversitySelectionUI: false isLoadingEmails: false isAddingNewEmail: false showAddEmailUI: false isValidEmail: false isBlacklistedEmail: false + $scope.affiliationToChange = + email: "" + university: null + role: null + department: null _reset() # Populates the emails table @@ -127,40 +170,9 @@ define [ .then (emails) -> $scope.userEmails = emails $scope.ui.isLoadingEmails = false + .catch () -> + $scope.ui.hasError = true + _getUserEmails() - # Populates the countries dropdown - UserAffiliationsDataService - .getCountries() - .then (countries) -> $scope.countries = countries - - # Populates the roles dropdown - UserAffiliationsDataService - .getDefaultRoleHints() - .then (roles) -> $scope.roles = roles - - # Fetches the default department hints - UserAffiliationsDataService - .getDefaultDepartmentHints() - .then (departments) -> - _defaultDepartments = departments - - # Populates the universities dropdown (after selecting a country) - $scope.$watch "newAffiliation.country", (newSelectedCountry, prevSelectedCountry) -> - if newSelectedCountry? and newSelectedCountry != prevSelectedCountry - $scope.newAffiliation.university = null - $scope.newAffiliation.role = null - $scope.newAffiliation.department = null - UserAffiliationsDataService - .getUniversitiesFromCountry(newSelectedCountry) - .then (universities) -> $scope.universities = universities - - # Populates the departments dropdown (after selecting a university) - $scope.$watch "newAffiliation.university", (newSelectedUniversity, prevSelectedUniversity) -> - if newSelectedUniversity? and newSelectedUniversity != prevSelectedUniversity - if newSelectedUniversity.departments?.length > 0 - $scope.departments = _.uniq newSelectedUniversity.departments - else - $scope.departments = _defaultDepartments - ] \ No newline at end of file diff --git a/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee b/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee index ba558eb595..cbcadf7e67 100644 --- a/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee +++ b/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee @@ -27,7 +27,7 @@ define [ getDefaultDepartmentHints = () -> $q.resolve defaultDepartmentHints - getUserEmails = () -> + getUserEmails = () -> $http.get "/user/emails" .then (response) -> response.data @@ -53,6 +53,10 @@ define [ else $q.reject null + getUniversityDetails = (universityId) -> + $http.get "/institutions/list/#{ universityId }" + .then (response) -> response.data + addUserEmail = (email) -> $http.post "/user/emails", { email, @@ -80,8 +84,17 @@ define [ _csrf: window.csrfToken } + addRoleAndDepartment = (email, role, department) -> + $http.post "/user/emails/endorse", { + email, + role, + department, + _csrf: window.csrfToken + } + setDefaultUserEmail = (email) -> $http.post "/user/emails/default", { + email, _csrf: window.csrfToken } @@ -91,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 @@ -101,11 +120,14 @@ define [ getUserEmails getUniversitiesFromCountry getUniversityDomainFromPartialDomainInput + getUniversityDetails addUserEmail addUserAffiliationWithUnknownUniversity addUserAffiliation + 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 f8663f7b5f..3b6d321925 100644 --- a/services/web/public/stylesheets/app/account-settings.less +++ b/services/web/public/stylesheets/app/account-settings.less @@ -24,18 +24,36 @@ 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%); } - + .affiliations-table-error-row { + background-color: @alert-danger-bg; + color: @alert-danger-text; + .btn { + margin-top: @table-cell-padding; + .button-variant(@btn-danger-color; darken(@btn-danger-bg, 8%); @btn-danger-border); + } + } .affiliations-form-group { margin-top: @table-cell-padding; &:first-child { margin-top: 0; } } + .affiliation-change-container, + .affiliation-change-actions { + margin-top: @table-cell-padding; + } + .affiliations-table-label { padding-top: 4px; } diff --git a/services/web/public/stylesheets/components/input-suggestions.less b/services/web/public/stylesheets/components/input-suggestions.less index bf2fd4a253..2424ad5cdc 100644 --- a/services/web/public/stylesheets/components/input-suggestions.less +++ b/services/web/public/stylesheets/components/input-suggestions.less @@ -10,7 +10,7 @@ .input-suggestions-shadow { background-color: @input-bg; - padding-top: 4px; + padding-top: @input-suggestion-v-offset; } .input-suggestions-shadow-existing { color: transparent; diff --git a/services/web/public/stylesheets/components/ui-select.less b/services/web/public/stylesheets/components/ui-select.less index 5513378629..f78cc2f6a3 100644 --- a/services/web/public/stylesheets/components/ui-select.less +++ b/services/web/public/stylesheets/components/ui-select.less @@ -35,12 +35,14 @@ > .btn { border-color: @input-border-focus; box-shadow: inset 0 1px 1px rgba(0,0,0,0.075), 0 0 8px fade(@input-border-focus, 60%); + padding-top: @input-suggestion-v-offset; } } > .btn { color: @input-color; background-color: @input-bg; border: 1px solid @input-border; + padding-top: @input-suggestion-v-offset; &[disabled] { cursor: not-allowed; background-color: @input-bg-disabled; @@ -53,6 +55,7 @@ .ui-select-container[tagging] { .ui-select-toggle { cursor: text; + padding-top: @input-suggestion-v-offset; > i.caret.pull-right { display: none; } diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 8dc3294a15..fefe12da73 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -995,3 +995,5 @@ @history-toolbar-bg-color : @toolbar-alt-bg-color; @history-toolbar-color : @text-color; +// Input suggestions +@input-suggestion-v-offset : 6px; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index 9f4eb29c95..0e05ccdd68 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -289,6 +289,8 @@ @sys-msg-color : #FFF; @sys-msg-border : solid 1px lighten(@ol-blue, 10%); +@input-suggestion-v-offset : 4px; + //== Colors // //## Gray and brand colors for use across Bootstrap. 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