فهرست منبع

Merge pull request #735 from sharelatex/pr-affiliations-ui-adjustments

Affiliations UI, second round
James Allen 8 سال پیش
والد
کامیت
b150a7b4ae

+ 13 - 0
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', {

+ 2 - 0
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}")

+ 4 - 1
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

+ 21 - 20
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

+ 182 - 106
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 
-						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
+						| {{ 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 
-						a(
-							href
-							ng-if="!userEmail.default"
-							ng-click="setDefaultUserEmail(userEmail.email)"
-						) Make default
-						br
-						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="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"
-						)
-							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"
+							) #{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.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
+								|  #{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"
+				)

+ 1 - 0
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"

+ 52 - 0
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"
+	}

+ 67 - 55
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 = (userEmail) ->
+			$scope.ui.isLoadingEmails = true
+			UserAffiliationsDataService
+				.setDefaultUserEmail userEmail.email
+				.then () -> _getUserEmails()
+				.catch () -> $scope.ui.hasError = true
 
-		$scope.setDefaultUserEmail = (email) ->
+		$scope.removeUserEmail = (userEmail) ->
 			$scope.ui.isLoadingEmails = true
+			userEmailIdx = _.indexOf $scope.userEmails, userEmail
+			if userEmailIdx > -1
+				$scope.userEmails.splice userEmailIdx, 1
 			UserAffiliationsDataService
-				.setDefaultUserEmail email
+				.removeUserEmail userEmail.email
 				.then () -> _getUserEmails()
+				.catch () -> $scope.ui.hasError = true
 
-		$scope.removeUserEmail = (email) ->
+		$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
-		_getUserEmails()
+				.catch () ->
+					$scope.ui.hasError = true
 
-		# 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
+		_getUserEmails()
 
 	]

+ 23 - 1
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
 		}
 	]

+ 20 - 2
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;
 	}

+ 1 - 1
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;

+ 3 - 0
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;
 		}

+ 2 - 0
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;

+ 2 - 0
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.

+ 113 - 9
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