Просмотр исходного кода

Merge branch 'master' into pr-history-labels-part-2

Paulo Reis 8 лет назад
Родитель
Сommit
02701a996d
34 измененных файлов с 650 добавлено и 91 удалено
  1. 2 2
      services/web/app/coffee/Features/Compile/ClsiManager.coffee
  2. 1 1
      services/web/app/coffee/Features/Compile/CompileController.coffee
  3. 7 0
      services/web/app/coffee/Features/Editor/EditorController.coffee
  4. 5 6
      services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee
  5. 14 0
      services/web/app/coffee/Features/Institutions/InstitutionsGetter.coffee
  6. 53 22
      services/web/app/coffee/Features/Newsletter/NewsletterManager.coffee
  7. 5 0
      services/web/app/coffee/Features/Project/ProjectController.coffee
  8. 2 1
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  9. 10 0
      services/web/app/coffee/Features/Project/ProjectOptionsHandler.coffee
  10. 0 1
      services/web/app/coffee/Features/User/UserPagesController.coffee
  11. 2 2
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  12. 20 10
      services/web/app/coffee/Features/User/UserUpdater.coffee
  13. 1 1
      services/web/app/coffee/router.coffee
  14. 122 0
      services/web/app/views/_mixins_links.pug
  15. 9 0
      services/web/app/views/project/editor/left-menu.pug
  16. 4 6
      services/web/app/views/project/list/modals.pug
  17. 2 2
      services/web/app/views/user/settings.pug
  18. 15 3
      services/web/config/settings.defaults.coffee
  19. 3 1
      services/web/package.json
  20. 2 1
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
  21. 11 0
      services/web/public/coffee/ide/settings/controllers/SettingsController.coffee
  22. 161 0
      services/web/public/stylesheets/app/portals.less
  23. 26 0
      services/web/public/stylesheets/components/embed-responsive.less
  24. 9 0
      services/web/public/stylesheets/components/icons.less
  25. 6 3
      services/web/public/stylesheets/core/_common-variables.less
  26. 22 0
      services/web/public/stylesheets/core/ol-variables.less
  27. 4 1
      services/web/public/stylesheets/ol-style.less
  28. 14 0
      services/web/test/unit/coffee/Editor/EditorControllerTests.coffee
  29. 14 24
      services/web/test/unit/coffee/Institutions/InstitutionsFeaturesTests.coffee
  30. 44 0
      services/web/test/unit/coffee/Institutions/InstitutionsGetterTests.coffee
  31. 12 0
      services/web/test/unit/coffee/Project/ProjectControllerTests.coffee
  32. 18 0
      services/web/test/unit/coffee/Project/ProjectOptionsHandlerTests.coffee
  33. 7 1
      services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
  34. 23 3
      services/web/test/unit/coffee/User/UserUpdaterTests.coffee

+ 2 - 2
services/web/app/coffee/Features/Compile/ClsiManager.coffee

@@ -7,8 +7,8 @@ ProjectGetter = require("../Project/ProjectGetter")
 ProjectEntityHandler = require("../Project/ProjectEntityHandler")
 logger = require "logger-sharelatex"
 Url = require("url")
-ClsiCookieManager = require("./ClsiCookieManager")()
-NewBackendCloudClsiCookieManager = require("./ClsiCookieManager")("newBackendcloud")
+ClsiCookieManager = require("./ClsiCookieManager")(Settings.apis.clsi?.backendGroupName)
+NewBackendCloudClsiCookieManager = require("./ClsiCookieManager")(Settings.apis.clsi_new?.backendGroupName)
 ClsiStateManager = require("./ClsiStateManager")
 _ = require("underscore")
 async = require("async")

+ 1 - 1
services/web/app/coffee/Features/Compile/CompileController.coffee

@@ -9,7 +9,7 @@ Settings = require "settings-sharelatex"
 AuthenticationController = require "../Authentication/AuthenticationController"
 UserGetter = require "../User/UserGetter"
 RateLimiter = require("../../infrastructure/RateLimiter")
-ClsiCookieManager = require("./ClsiCookieManager")()
+ClsiCookieManager = require("./ClsiCookieManager")(Settings.apis.clsi?.backendGroupName)
 Path = require("path")
 
 module.exports = CompileController =

+ 7 - 0
services/web/app/coffee/Features/Editor/EditorController.coffee

@@ -163,6 +163,13 @@ module.exports = EditorController =
 			EditorRealTimeController.emitToRoom project_id, 'compilerUpdated', compiler
 			callback()
 
+	setImageName : (project_id, imageName, callback = (err) ->) ->
+		ProjectOptionsHandler.setImageName project_id, imageName, (err) ->
+			return callback(err) if err?
+			logger.log imageName:imageName, project_id:project_id, "setting imageName"
+			EditorRealTimeController.emitToRoom project_id, 'imageNameUpdated', imageName
+			callback()
+
 	setSpellCheckLanguage : (project_id, languageCode, callback = (err) ->) ->
 		ProjectOptionsHandler.setSpellCheckLanguage project_id, languageCode, (err) ->
 			return callback(err) if err?

+ 5 - 6
services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee

@@ -1,4 +1,4 @@
-UserGetter = require '../User/UserGetter'
+InstitutionsGetter = require './InstitutionsGetter'
 PlansLocator = require '../Subscription/PlansLocator'
 Settings = require 'settings-sharelatex'
 logger = require 'logger-sharelatex'
@@ -13,11 +13,10 @@ module.exports = InstitutionsFeatures =
 
 
 	hasLicence: (userId, callback = (error, hasLicence) ->) ->
-		UserGetter.getUserFullEmails userId, (error, emailsData) ->
+		InstitutionsGetter.getConfirmedInstitutions userId, (error, institutions) ->
 			return callback error if error?
 
-			affiliation = emailsData.find (emailData) ->
-				licence = emailData.affiliation?.institution?.licence
-				emailData.confirmedAt? and licence? and licence != 'free'
+			hasLicence = institutions.some (institution) ->
+				institution.licence and institution.licence != 'free'
 
-			callback(null, !!affiliation)
+			callback(null, hasLicence)

+ 14 - 0
services/web/app/coffee/Features/Institutions/InstitutionsGetter.coffee

@@ -0,0 +1,14 @@
+UserGetter = require '../User/UserGetter'
+logger = require 'logger-sharelatex'
+
+module.exports = InstitutionsGetter =
+	getConfirmedInstitutions: (userId, callback = (error, institutions) ->) ->
+		UserGetter.getUserFullEmails userId, (error, emailsData) ->
+			return callback error if error?
+
+			confirmedInstitutions = emailsData.filter (emailData) -> 
+				emailData.confirmedAt? and emailData.affiliation?.institution?
+			.map (emailData) ->
+				emailData.affiliation?.institution
+
+			callback(null, confirmedInstitutions)

+ 53 - 22
services/web/app/coffee/Features/Newsletter/NewsletterManager.coffee

@@ -1,37 +1,68 @@
 async = require('async')
-Request = require('request')
 logger = require 'logger-sharelatex'
 Settings = require 'settings-sharelatex'
+crypto = require('crypto')
+Mailchimp = require('mailchimp-api-v3')
+
+if !Settings.mailchimp?.api_key?
+	logger.info "Using newsletter provider: none"
+	mailchimp =
+		request: (opts, cb)-> cb()
+else
+	logger.info "Using newsletter provider: mailchimp"
+	mailchimp = new Mailchimp(Settings.mailchimp?.api_key)
 
 module.exports =
+
 	subscribe: (user, callback = () ->)->
-		if !Settings.markdownmail?
-			logger.warn "No newsletter provider configured so not subscribing user"
-			return callback()
-		logger.log user:user, email:user.email, "trying to subscribe user to the mailing list"
 		options = buildOptions(user, true)
-		Request.post options, (err, response, body)->
-			logger.log body:body, user:user, "finished attempting to subscribe the user to the news letter"
+		logger.log options:options, user:user, email:user.email, "trying to subscribe user to the mailing list"
+		mailchimp.request options, (err)->
+			if err?
+				logger.err err:err, "error subscribing person to newsletter"
+			else
+				logger.log user:user, "finished subscribing user to the newsletter"
 			callback(err)
 
 	unsubscribe: (user, callback = () ->)->
-		if !Settings.markdownmail?
-			logger.warn "No newsletter provider configured so not unsubscribing user"
-			return callback()
 		logger.log user:user, email:user.email, "trying to unsubscribe user to the mailing list"
 		options = buildOptions(user, false)
-		Request.post options, (err, response, body)->
-			logger.log err:err, body:body, email:user.email, "compled newsletter unsubscribe attempt"
+		mailchimp.request options, (err)->
+			if err?
+				logger.err err:err, "error unsubscribing person to newsletter"
+			else
+				logger.log user:user, "finished unsubscribing user to the newsletter"
 			callback(err)
 
+	changeEmail: (oldEmail, newEmail, callback = ()->)->
+		options = buildOptions({email:oldEmail})
+		delete options.body.status
+		options.body.email_address = newEmail
+		mailchimp.request options, (err)->
+			# if the user has unsubscribed mailchimp will error on email address change
+			if err? and err?.message.indexOf("could not be validated") == -1
+				logger.err err:err, "error changing email in newsletter"
+				return callback(err)
+			else
+				logger.log "finished changing email in the newsletter"
+				return callback()
+
+hashEmail = (email)->
+	crypto.createHash('md5').update(email.toLowerCase()).digest("hex")
+
 buildOptions = (user, is_subscribed)->
-	options =
-		json:
-			secret_token: Settings.markdownmail.secret 
-			name: "#{user.first_name} #{user.last_name}"
-			email: user.email
-			subscriber_list_id: Settings.markdownmail.list_id
-			is_subscribed: is_subscribed
-		url: "https://www.markdownmail.io/lists/subscribe"
-		timeout: 30 * 1000
-	return options
+	status =  if is_subscribed then "subscribed" else "unsubscribed"
+	subscriber_hash = hashEmail(user.email)
+	opts =
+		method: "PUT"
+		path: "/lists/#{Settings.mailchimp?.list_id}/members/#{subscriber_hash}"
+		body:
+			status_if_new: status
+			status: status
+			email_address:user.email
+			merge_fields:
+				FNAME: user.first_name
+				LNAME: user.last_name
+				MONGO_ID:user._id
+	return opts
+

+ 5 - 0
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -49,6 +49,10 @@ module.exports = ProjectController =
 			jobs.push (callback) ->
 				editorController.setCompiler project_id, req.body.compiler, callback
 
+		if req.body.imageName?
+			jobs.push (callback) ->
+				editorController.setImageName project_id, req.body.imageName, callback
+
 		if req.body.name?
 			jobs.push (callback) ->
 				editorController.renameProject project_id, req.body.name, callback
@@ -347,6 +351,7 @@ module.exports = ProjectController =
 					useV2History: !!project.overleaf?.history?.display
 					richTextEnabled: Features.hasFeature('rich-text')
 					showTestControls: req.query?.tc == 'true' || user.isAdmin
+					allowedImageNames: Settings.allowedImageNames || []
 				timer.done()
 
 	_buildProjectList: (allProjects, v1Projects = [])->

+ 2 - 1
services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee

@@ -1,5 +1,5 @@
 _ = require("underscore")
-
+Path = require 'path'
 
 module.exports = ProjectEditorHandler =
 	trackChangesAvailable: false
@@ -20,6 +20,7 @@ module.exports = ProjectEditorHandler =
 			members:     []
 			invites:     invites
 			tokens:      project.tokens
+			imageName:   if project.imageName? then Path.basename(project.imageName) else undefined
 
 		if !result.invites?
 			result.invites = []

+ 10 - 0
services/web/app/coffee/Features/Project/ProjectOptionsHandler.coffee

@@ -17,6 +17,16 @@ module.exports =
 			if callback?
 				callback()
 
+	setImageName : (project_id, imageName, callback = ()->)->
+		logger.log project_id:project_id, imageName:imageName, "setting the imageName"
+		imageName = imageName.toLowerCase()
+		if ! _.some(settings.allowedImageNames, (allowed) -> imageName is allowed.imageName)
+			return callback()
+		conditions = {_id:project_id}
+		update = {imageName: settings.imageRoot + '/' + imageName}
+		Project.update conditions, update, {}, (err)->
+			if callback?
+				callback()
 
 	setSpellCheckLanguage: (project_id, languageCode, callback = ()->)->
 		logger.log project_id:project_id, languageCode:languageCode, "setting the spell check language"

+ 0 - 1
services/web/app/coffee/Features/User/UserPagesController.coffee

@@ -68,7 +68,6 @@ module.exports =
 				shouldAllowEditingDetails: shouldAllowEditingDetails
 				languages: Settings.languages,
 				accountSettingsTabActive: true
-				showAffiliationsUI: (req.query?.aff == "true") or false
 
 	sessionsPage: (req, res, next) ->
 		user = AuthenticationController.getSessionUser(req)

+ 2 - 2
services/web/app/coffee/Features/User/UserRegistrationHandler.coffee

@@ -1,4 +1,3 @@
-sanitize = require('sanitizer')
 User = require("../../models/User").User
 UserCreator = require("./UserCreator")
 UserGetter = require("./UserGetter")
@@ -54,7 +53,8 @@ module.exports = UserRegistrationHandler =
 					(cb)-> User.update {_id: user._id}, {"$set":{holdingAccount:false}}, cb
 					(cb)-> AuthenticationManager.setUserPassword user._id, userDetails.password, cb
 					(cb)->
-						NewsLetterManager.subscribe user, ->
+						if userDetails.subscribeToNewsletter == "true"
+							NewsLetterManager.subscribe user, ->
 						cb() #this can be slow, just fire it off
 				], (err)->
 					logger.log user: user, "registered"

+ 20 - 10
services/web/app/coffee/Features/User/UserUpdater.coffee

@@ -11,6 +11,7 @@ EmailHelper = require "../Helpers/EmailHelper"
 Errors = require "../Errors/Errors"
 Settings = require "settings-sharelatex"
 request = require 'request'
+NewsletterManager = require "../Newsletter/NewsletterManager"
 
 module.exports = UserUpdater =
 	updateUser: (query, update, callback = (error) ->) ->
@@ -99,15 +100,21 @@ module.exports = UserUpdater =
 	setDefaultEmailAddress: (userId, email, callback) ->
 		email = EmailHelper.parseEmail(email)
 		return callback(new Error('invalid email')) if !email?
-		query = _id: userId, 'emails.email': email
-		update = $set: email: email
-		@updateUser query, update, (error, res) ->
-			if error?
-				logger.err error:error, 'problem setting default emails'
+		UserGetter.getUserEmail userId, (error, oldEmail) =>
+			if err?
 				return callback(error)
-			if res.n == 0 # TODO: Check n or nMatched?
-				return callback(new Error('Default email does not belong to user'))
-			callback()
+			query = _id: userId, 'emails.email': email
+			update = $set: email: email
+			@updateUser query, update, (error, res) ->
+				if error?
+					logger.err error:error, 'problem setting default emails'
+					return callback(error)
+				else if res.n == 0 # TODO: Check n or nMatched?
+					return callback(new Error('Default email does not belong to user'))
+				else
+					NewsletterManager.changeEmail oldEmail, email, callback
+
+
 
 	updateV1AndSetDefaultEmailAddress: (userId, email, callback) ->
 		@updateEmailAddressInV1 userId, email, (error) =>
@@ -152,7 +159,10 @@ module.exports = UserUpdater =
 				else
 					return callback new Error("non-success code from v1: #{response.statusCode}")
 
-	confirmEmail: (userId, email, callback) ->
+	confirmEmail: (userId, email, confirmedAt, callback) ->
+		if arguments.length == 3
+			callback = confirmedAt
+			confirmedAt = new Date()
 		email = EmailHelper.parseEmail(email)
 		return callback(new Error('invalid email')) if !email?
 		logger.log {userId, email}, 'confirming user email'
@@ -166,7 +176,7 @@ module.exports = UserUpdater =
 				'emails.email': email
 			update =
 				$set:
-					'emails.$.confirmedAt': new Date()
+					'emails.$.confirmedAt': confirmedAt
 			@updateUser query, update, (error, res) ->
 				return callback(error) if error?
 				logger.log {res, userId, email}, "tried to confirm email"

+ 1 - 1
services/web/app/coffee/router.coffee

@@ -22,7 +22,7 @@ UserPagesController = require('./Features/User/UserPagesController')
 DocumentController = require('./Features/Documents/DocumentController')
 CompileManager = require("./Features/Compile/CompileManager")
 CompileController = require("./Features/Compile/CompileController")
-ClsiCookieManager = require("./Features/Compile/ClsiCookieManager")()
+ClsiCookieManager = require("./Features/Compile/ClsiCookieManager")(Settings.apis.clsi?.backendGroupName)
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")

+ 122 - 0
services/web/app/views/_mixins_links.pug

@@ -0,0 +1,122 @@
+mixin linkAdvisors(linkText, linkClass, track)
+	//- To Do: verify path
+	- var gaCategory = track && track.category ? track.category : 'All'
+	- var gaAction = track && track.action ? track.action : null
+	- var gaLabel = track && track.label ? track.label : null
+	- var mb = track && track.mb ? 'true' : null
+	- var mbSegmentation = track && track.segmentation ? track.segmentation : null
+	- var trigger = track && track.trigger ? track.trigger : null
+	a(href="/advisors" 
+		class=linkClass ? linkClass : ''
+		event-tracking-ga=gaCategory
+		event-tracking=gaAction
+		event-tracking-label=gaLabel
+		event-tracking-trigger=trigger
+		event-tracking-mb=mb
+		event-segmentation=mbSegmentation
+	)
+		| #{linkText ? linkText : 'advisor programme'}
+
+mixin linkBenefits(linkText, linkClass)
+	//- To Do: verify path
+	a(href="/benefits" class=linkClass ? linkClass : '')
+		| #{linkText ? linkText : 'benefits'}
+		
+mixin linkBlog(linkText, linkClass, slug)
+	if slug
+		a(href="/blog/#{slug}" class=linkClass ? linkClass : '')
+			| #{linkText ? linkText : 'blog'}
+
+mixin linkContact(linkText, linkClass)
+	a(href="/contact" class=linkClass ? linkClass : '')
+		| #{linkText ? linkText : 'contact'}
+
+mixin linkEducation(linkText, linkClass)
+	//- To Do: verify path
+	a(href="/plans" class=linkClass ? linkClass : '')
+		| #{linkText ? linkText : 'teaching toolkit'}
+
+mixin linkEmail(linkText, linkClass, email)
+	//- To Do: env var?
+	- var emailDomain = 'overleaf.com'
+	a(href="mailto:#{email ? email : 'contact'}@#{emailDomain}" class=linkClass ? linkClass : '')
+		| #{linkText ? linkText : 'email'}
+
+mixin linkInvite(linkText, linkClass, track)
+	- var gaCategory = track && track.category ? track.category : 'All'
+	- var gaAction = track && track.action ? track.action : null
+	- var gaLabel = track && track.label ? track.label : null
+	- var mb = track && track.mb ? 'true' : null
+	- var mbSegmentation = track && track.segmentation ? track.segmentation : null
+	- var trigger = track && track.trigger ? track.trigger : null
+
+	a(href="/user/bonus"
+		class=linkClass ? linkClass : ''
+		event-tracking-ga=gaCategory
+		event-tracking=gaAction
+		event-tracking-label=gaLabel
+		event-tracking-trigger=trigger
+		event-tracking-mb=mb
+		event-segmentation=mbSegmentation
+	)
+		| #{linkText ? linkText : 'invite your friends'}
+
+mixin linkPlansAndPricing(linkText, linkClass)
+	//- To Do: verify path
+	a(href="/plans" class=linkClass ? linkClass : '')
+		| #{linkText ? linkText : 'plans and pricing'}
+
+mixin linkPrintNewTab(linkText, linkClass, icon, track)
+	- var gaCategory = track && track.category ? track.category : null
+	- var gaAction = track && track.action ? track.action : null
+	- var gaLabel = track && track.label ? track.label : null
+	- var mb = track && track.mb ? 'true' : null
+	- var mbSegmentation = track && track.segmentation ? track.segmentation : null
+	- var trigger = track && track.trigger ? track.trigger : null
+
+	a(href='?media=print'
+		class=linkClass ? linkClass : ''
+		event-tracking-ga=gaCategory
+		event-tracking=gaAction
+		event-tracking-label=gaLabel
+		event-tracking-trigger=trigger
+		event-tracking-mb=mb
+		event-segmentation=mbSegmentation
+		target="_BLANK"
+	)
+		if icon
+			i(class="fa fa-print")
+			|  
+		| #{linkText ? linkText : 'print'}
+
+mixin linkSignIn(linkText, linkClass)
+	a(href="/login" class=linkClass ? linkClass : '')
+		| #{linkText ? linkText : 'sign in'}
+
+mixin linkSignUp(linkText, linkClass)
+	a(href="/register" class=linkClass ? linkClass : '')
+		| #{linkText ? linkText : 'sign up'}
+
+mixin linkTweet(linkText, linkClass, tweetText, track)
+	//- twitter-share-button is required by twitter
+	- var gaCategory = track && track.category ? track.category : 'All'
+	- var gaAction = track && track.action ? track.action : null
+	- var gaLabel = track && track.label ? track.label : null
+	- var mb = track && track.mb ? 'true' : null
+	- var mbSegmentation = track && track.segmentation ? track.segmentation : null
+	- var trigger = track && track.trigger ? track.trigger : null
+	a(class="twitter-share-button " + linkClass
+		event-tracking-ga=gaCategory
+		event-tracking=gaAction
+		event-tracking-label=gaLabel
+		event-tracking-trigger=trigger
+		event-tracking-mb=mb
+		event-segmentation=mbSegmentation
+		href="https://twitter.com/intent/tweet?text=" + tweetText
+		target="_BLANK"
+	) #{linkText ? linkText : 'tweet'}
+
+mixin linkUniversities(linkText, linkClass)
+	//- To Do: verify path
+	a(href="/universities" class=linkClass ? linkClass : '')
+		| #{linkText ? linkText : 'universities'}

+ 9 - 0
services/web/app/views/project/editor/left-menu.pug

@@ -188,6 +188,15 @@ aside#left-menu.full-size(
 					option(value="pdfjs") #{translate("built_in")}
 					option(value="native") #{translate("native")}
 
+			if (getSessionUser() && getSessionUser().isAdmin && typeof(allowedImageNames) !== 'undefined' && allowedImageNames.length > 0)
+				.form-controls(ng-show="permissions.write")
+					label(for="imageName") #{translate("TeXLive")}
+					select(
+						name="imageName"
+						ng-model="project.imageName"
+					)
+						each image in allowedImageNames
+							option(value=image.imageName) #{image.imageDesc}
 
 	h4 #{translate("hotkeys")}
 	ul.list-unstyled.nav

+ 4 - 6
services/web/app/views/project/list/modals.pug

@@ -345,13 +345,11 @@ script(type="text/ng-template", id="v1ImportModalTemplate")
 					i.fa.fa-flask
 				.v1-import-col
 					h2.v1-import-title #[strong Warning:] Overleaf v2 is Experimental
-					p We are still working hard to bring some Overleaf v1 features to the v2 editor. In v2 there is:
+					p We are still working hard to bring some Overleaf v1 features to the v2 editor. In v2:
 					ul
-						li <strong>No Journals and Services</strong> menu to submit directly to our partners yet
-						li <strong>No Rich Text (WYSIWYG)</strong> mode yet
-						li <strong>No linked files</strong> (to URLs or to files in other Overleaf projects) yet
-						li <strong>No Zotero and CiteULike</strong> integrations yet
-						li <strong>No labelled versions</strong> yet
+						li You may not be able to access all of your <strong>Labelled versions</strong> yet
+						li There are <strong>no Zotero and CiteULike</strong> integrations yet
+						li Some <strong>Journals and Services in the Submit menu</strong> don't support direct submissions yet
 					p.row-spaced-small
 						| If you currently use the <strong>Overleaf Git bridge</strong> with your v1 project, you can migrate your project to the Overleaf v2 GitHub integration.
 						|

+ 2 - 2
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 && hasFeature('affiliations')
+							if hasFeature('affiliations')
 								include settings/user-affiliations
 								
 							form-messages(for="settingsForm")
@@ -22,7 +22,7 @@ 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 !(locals.showAffiliationsUI && hasFeature('affiliations'))
+										if !hasFeature('affiliations')
 											if !externalAuthenticationSystemUsed() 
 												.form-group
 													label(for='email')  #{translate("email")}

+ 15 - 3
services/web/config/settings.defaults.coffee

@@ -135,6 +135,7 @@ module.exports = settings =
 			url: "http://#{process.env['FILESTORE_HOST'] or 'localhost'}:3009"
 		clsi:
 			url: "http://#{process.env['CLSI_HOST'] or 'localhost'}:3013"
+			backendGroupName: undefined
 		templates:
 			url: "http://#{process.env['TEMPLATES_HOST'] or 'localhost'}:3007"
 		githubSync:
@@ -277,10 +278,10 @@ module.exports = settings =
 	# Third party services
 	# --------------------
 	#
-	# ShareLaTeX's regular newsletter is managed by Markdown mail. Add your
+	# ShareLaTeX's regular newsletter is managed by mailchimp. Add your
 	# credentials here to integrate with this.
-	# markdownmail:
-	# 	secret: ""
+	# mailchimp:
+	# 	api_key: ""
 	# 	list_id: ""
 	#
 	# Fill in your unique token from various analytics services to enable
@@ -472,3 +473,14 @@ module.exports = settings =
 		autoCompile:
 			everyone: 100
 			standard: 25
+
+	# currentImage: "texlive-full:2017.1"
+	# imageRoot: "<DOCKER REPOSITORY ROOT>" # without any trailing slash
+	
+	# allowedImageNames: [
+	# 	{imageName: 'texlive-full:2017.1', imageDesc: 'TeXLive 2017'}
+	# 	{imageName:   'wl_texlive:2018.1', imageDesc: 'Legacy OL TeXLive 2015'}
+	# 	{imageName: 'texlive-full:2016.1', imageDesc: 'Legacy SL TeXLive 2016'}
+	# 	{imageName: 'texlive-full:2015.1', imageDesc: 'Legacy SL TeXLive 2015'}
+	# 	{imageName: 'texlive-full:2014.2', imageDesc: 'Legacy SL TeXLive 2014.2'}
+	# ]

+ 3 - 1
services/web/package.json

@@ -59,6 +59,7 @@
     "lodash": "^4.13.1",
     "logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master",
     "lynx": "0.1.1",
+    "mailchimp-api-v3": "^1.12.0",
     "marked": "^0.3.5",
     "method-override": "^2.3.3",
     "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.7.1",
@@ -98,7 +99,8 @@
     "v8-profiler": "^5.2.3",
     "valid-url": "^1.0.9",
     "xml2js": "0.2.0",
-    "yauzl": "^2.8.0"
+    "yauzl": "^2.8.0",
+    "minimist": "1.2.0"
   },
   "devDependencies": {
     "autoprefixer": "^6.6.1",

+ 2 - 1
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee

@@ -162,7 +162,8 @@ define [
 			cursorPosition = @editor.getCursorPosition()
 			end = change.end
 			{lineUpToCursor, commandFragment} = Helpers.getContext(@editor, end)
-			if (i = lineUpToCursor.indexOf('%') > -1 and lineUpToCursor[i-1] != '\\')
+			if ((i = lineUpToCursor.indexOf('%')) > -1 and lineUpToCursor[i-1] != '\\')
+				console.log lineUpToCursor, i
 				return
 			lastCharIsBackslash = lineUpToCursor.slice(-1) == "\\"
 			lastTwoChars = lineUpToCursor.slice(-2)

+ 11 - 0
services/web/public/coffee/ide/settings/controllers/SettingsController.coffee

@@ -67,6 +67,11 @@ define [
 			if oldCompiler? and compiler != oldCompiler
 				settings.saveProjectSettings({compiler: compiler})
 
+		$scope.$watch "project.imageName", (imageName, oldImageName) =>
+			return if @ignoreUpdates
+			if oldImageName? and imageName != oldImageName
+				settings.saveProjectSettings({imageName: imageName})
+
 		$scope.$watch "project.rootDoc_id", (rootDoc_id, oldRootDoc_id) =>
 			return if @ignoreUpdates
 			# don't save on initialisation, Angular passes oldRootDoc_id as
@@ -83,6 +88,12 @@ define [
 				$scope.project.compiler = compiler
 			delete @ignoreUpdates
 
+		ide.socket.on "imageNameUpdated", (imageName) =>
+			@ignoreUpdates = true
+			$scope.$apply () =>
+				$scope.project.imageName = imageName
+			delete @ignoreUpdates
+
 		ide.socket.on "spellCheckLanguageUpdated", (languageCode) =>
 			@ignoreUpdates = true
 			$scope.$apply () =>

+ 161 - 0
services/web/public/stylesheets/app/portals.less

@@ -0,0 +1,161 @@
+.content-portal {
+  padding-top: @navbar-height!important;
+
+  /*
+    Begin Header
+  */
+  .banner-image {
+    background-size: cover;
+    background-position: 50% 50%;
+    background-repeat: no-repeat;
+    height: 375px;
+  }
+
+  .image-fill {
+    display: inline-block;
+    height: 100%;
+    vertical-align: middle;
+  }
+
+  .institution-logo {
+    left: 50%;
+    margin-left: -100px;
+    padding: 0;
+    position: absolute;
+    div {
+      background-color: @white;
+      box-shadow: 1px 11px 22px -9px @black-alpha-strong;
+      display: inline-block;
+      height: 125px;
+      overflow: hidden;
+      position: absolute;
+      text-align: center;
+      top: -110px;
+      white-space: nowrap;
+      width: @btn-portal-width;
+    }
+    img {
+      max-height: 75px;
+      max-width: 150px;
+      vertical-align: middle;
+    }
+  }
+
+  .portal-name {
+    background-color: @ol-blue-gray-0;
+    padding-bottom: @line-height-computed; //- center header when no tabs
+    padding-top: @padding-md;
+    text-align: center;
+    width: 100%;
+  }
+  // End Header
+
+  /*
+    Begin Layout
+  */
+  .button-pull,
+  .content-pull {
+    float: left;
+  }
+
+  .button-pull {
+    text-align: right;
+    > a.btn {
+      white-space: normal;
+      width: @btn-portal-width;
+      text-align: center;
+    }
+  }
+  .content-pull {
+    padding-right: @padding-sm;
+    width: calc(~"100% - "@btn-portal-width);
+  }
+  // End Layout
+
+  /*
+    Begin Card
+  */
+  .card {
+    margin-bottom: @margin-md;
+  }
+  // End Card
+
+  /*
+    Begin Actions
+  */
+  .portal-actions {
+    i {
+      margin-bottom: @margin-sm;
+    }
+  }
+  // End Actions
+
+  /*
+    Begin Print
+  */
+  .print {
+    .hidden-print {
+      display: none;
+    }
+  }
+  // End Print
+
+  /*
+    Begin Tabs
+  */
+  .nav-tabs {
+    // Overrides for nav.less
+    background-color: @ol-blue-gray-0;
+    border: 0!important;
+    margin-bottom: @margin-md;
+    margin-top: -@line-height-computed; //- adjusted for portal-name
+    padding: @padding-lg 0 @padding-md;
+    text-align: center;
+
+    a {
+      color: @link-color;
+      &:hover {
+        background-color: transparent!important;
+        border: 0!important;
+        color: @link-hover-color!important;
+      }
+    }
+
+    li {
+      display: inline-block;
+      float: none;
+      a {
+        border: 0;
+      }
+    }
+
+    li.active > a {
+      background-color: transparent!important;
+      border: 0;
+      border-bottom: 1px solid @accent-color-secondary!important;
+      color: @accent-color-secondary;
+      &:hover {
+        color: @accent-color-secondary!important;
+      }
+    }
+  }
+
+  .tab-content:extend(.container) {
+    background-color: transparent!important;
+    border: none!important;
+  }
+  // End Tabs
+
+  @media (max-width: @screen-size-sm-max) {
+    .content-pull {
+      padding: 0;
+      width: auto;
+    }
+
+    .button-pull {
+      > a.btn {
+        width: auto;
+      }
+    }
+  }
+}

+ 26 - 0
services/web/public/stylesheets/components/embed-responsive.less

@@ -0,0 +1,26 @@
+.embed-responsive {
+  display: block;
+  height: 0;
+  overflow: hidden;
+  padding: 0;
+  position: relative;
+}
+.embed-responsive .embed-responsive-item,
+.embed-responsive iframe,
+.embed-responsive embed,
+.embed-responsive object,
+.embed-responsive video {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  border: 0;
+}
+.embed-responsive-16by9 {
+  padding-bottom: 56.25% !important;
+}
+.embed-responsive-4by3 {
+  padding-bottom: 75% !important;
+}

+ 9 - 0
services/web/public/stylesheets/components/icons.less

@@ -0,0 +1,9 @@
+// Colors
+.icon-accent {
+  color: @accent-color-secondary;
+}
+
+// Sizes
+.icon-lg {
+  font-size: @font-size-h1;
+}

+ 6 - 3
services/web/public/stylesheets/core/_common-variables.less

@@ -62,9 +62,12 @@
 //
 //## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
 
-@margin-sm: 10px;
-@margin-md: 20px;
-@margin-lg: 30px;
+@margin-xs:                  5px;
+@margin-sm:                 10px;
+@margin-md:                 20px;
+@margin-lg:                 30px;
+@margin-xl:                 40px;
+@margin-xxl:                50px;
 
 @padding-base-vertical:     5px;
 @padding-base-horizontal:   16px;

+ 22 - 0
services/web/public/stylesheets/core/ol-variables.less

@@ -6,6 +6,7 @@
 @footer-height: 50px;
 
 // Styleguide colors
+@ol-blue-gray-0 : #f4f5f8;
 @ol-blue-gray-1 : #E4E8EE;
 @ol-blue-gray-2 : #9DA7B7;
 @ol-blue-gray-3 : #5D6879;
@@ -21,6 +22,8 @@
 @ol-dark-red    : #A6312B;
 
 @ol-type-color  : @ol-blue-gray-3;
+@accent-color-primary: @ol-green; 
+@accent-color-secondary: @ol-dark-green;
 
 // Navbar customization
 @navbar-title-color       : @ol-blue-gray-1;
@@ -65,8 +68,14 @@
 @btn-info-bg             : @ol-blue;
 @btn-info-border         : transparent;
 
+// Padding
 @padding-xs-horizontal   : 8px;
 
+@padding-sm: 10px;
+@padding-md: 20px;
+@padding-lg: 30px;
+@padding-xl: 40px;
+
 // Alerts
 @alert-padding : 15px;
 @alert-border-radius : @border-radius-base;
@@ -167,6 +176,9 @@
 @folders-tag-menu-hover         : rgba(0, 0, 0, .1);
 @folders-tag-menu-active-hover  : rgba(0, 0, 0, .1);
 
+// Portal
+@btn-portal-width               : 200px;
+
 // Project table
 @structured-list-line-height    : 2.5;
 @structured-list-link-color     : @ol-blue;
@@ -273,6 +285,9 @@
 @log-line-no-color              : #FFF;
 @log-hints-color                : @ol-blue-gray-4;
 
+// Portals
+@black-alpha-strong             : rgba(0,0,0,0.8);
+
 
 // v2 History
 @history-base-font-size               : @font-size-small;
@@ -288,6 +303,12 @@
 @history-toolbar-bg-color             : @editor-toolbar-bg;
 @history-toolbar-color                : #FFF;
 
+// Screens
+// added -size to not conflict with common_variables
+@screen-size-sm-max                   : 767px;
+@screen-size-md-min                   : 768px;
+@screen-size-md-max                   : 991px;
+
 // System messages
 @sys-msg-background             : @ol-blue;
 @sys-msg-color                  : #FFF;
@@ -304,6 +325,7 @@
 @gray-light:            #a4a4a4;
 @gray-lighter:          #cfcfcf;
 @gray-lightest:         #f0f0f0;
+@white:                 #ffffff;
 
 @blue:                  #405ebf;
 @blueDark:              #040D2D;

+ 4 - 1
services/web/public/stylesheets/ol-style.less

@@ -6,8 +6,11 @@
 @import "app/ol-style-guide.less";
 @import "_style_includes.less";
 @import "_ol_style_includes.less";
+@import "components/embed-responsive.less";
+@import "components/icons.less";
 
 // Pages
 @import "app/about.less";
 @import "app/blog-posts.less";
-@import "app/cms-page.less";
+@import "app/cms-page.less";
+@import "app/portals.less";

+ 14 - 0
services/web/test/unit/coffee/Editor/EditorControllerTests.coffee

@@ -32,6 +32,7 @@ describe "EditorController", ->
 			'../Project/ProjectEntityUpdateHandler' : @ProjectEntityUpdateHandler = {}
 			'../Project/ProjectOptionsHandler' : @ProjectOptionsHandler =
 				setCompiler: sinon.stub().yields()
+				setImageName: sinon.stub().yields()
 				setSpellCheckLanguage: sinon.stub().yields()
 			'../Project/ProjectDetailsHandler': @ProjectDetailsHandler =
 				setProjectDescription: sinon.stub().yields()
@@ -377,6 +378,19 @@ describe "EditorController", ->
 				.calledWith(@project_id, "compilerUpdated", @compiler)
 				.should.equal true
 
+	describe "setImageName", ->
+		beforeEach ->
+			@imageName = "texlive-1234.5"
+			@EditorController.setImageName @project_id, @imageName, @callback
+
+		it "should send the new imageName and project id to the project options handler", ->
+			@ProjectOptionsHandler.setImageName
+				.calledWith(@project_id, @imageName)
+				.should.equal true
+			@EditorRealTimeController.emitToRoom
+				.calledWith(@project_id, "imageNameUpdated", @imageName)
+				.should.equal true
+
 	describe "setSpellCheckLanguage", ->
 		beforeEach ->
 			@languageCode = "fr"

+ 14 - 24
services/web/test/unit/coffee/Institutions/InstitutionsFeaturesTests.coffee

@@ -8,11 +8,11 @@ modulePath = require('path').join __dirname, '../../../../app/js/Features/Instit
 describe 'InstitutionsFeatures', ->
 
 	beforeEach ->
-		@UserGetter = getUserFullEmails: sinon.stub()
+		@InstitutionsGetter = getConfirmedInstitutions: sinon.stub()
 		@PlansLocator = findLocalPlanInSettings: sinon.stub()
 		@institutionPlanCode = 'institution_plan_code'
 		@InstitutionsFeatures = SandboxedModule.require modulePath, requires:
-			'../User/UserGetter': @UserGetter
+			'./InstitutionsGetter': @InstitutionsGetter
 			'../Subscription/PlansLocator': @PlansLocator
 			'settings-sharelatex': institutionPlanCode: @institutionPlanCode
 			'logger-sharelatex':
@@ -23,47 +23,37 @@ describe 'InstitutionsFeatures', ->
 
 	describe "hasLicence", ->
 			it 'should handle error', (done)->
-				@UserGetter.getUserFullEmails.yields(new Error('Nope'))
+				@InstitutionsGetter.getConfirmedInstitutions.yields(new Error('Nope'))
 				@InstitutionsFeatures.hasLicence @userId, (error, hasLicence) ->
 					expect(error).to.exist
 					done()
 
-			it 'should return false if user has no affiliations', (done) ->
-				@UserGetter.getUserFullEmails.yields(null, [])
-				@InstitutionsFeatures.hasLicence @userId, (error, hasLicence) ->
-					expect(error).to.not.exist
-					expect(hasLicence).to.be.false
-					done()
-
 			it 'should return false if user has no confirmed affiliations', (done) ->
-				affiliations = [
-					{ confirmedAt: null, affiliation: institution: { licence: 'pro_plus' } }
-				]
-				@UserGetter.getUserFullEmails.yields(null, affiliations)
+				institutions = []
+				@InstitutionsGetter.getConfirmedInstitutions.yields(null, institutions)
 				@InstitutionsFeatures.hasLicence @userId, (error, hasLicence) ->
 					expect(error).to.not.exist
 					expect(hasLicence).to.be.false
 					done()
 
 			it 'should return false if user has no paid affiliations', (done) ->
-				affiliations = [
-					{ confirmedAt: new Date(), affiliation: institution: { licence: 'free' } }
+				institutions = [
+					{ licence: 'free' }
 				]
-				@UserGetter.getUserFullEmails.yields(null, affiliations)
+				@InstitutionsGetter.getConfirmedInstitutions.yields(null, institutions)
 				@InstitutionsFeatures.hasLicence @userId, (error, hasLicence) ->
 					expect(error).to.not.exist
 					expect(hasLicence).to.be.false
 					done()
 
 			it 'should return true if user has confirmed paid affiliation', (done)->
-				affiliations = [
-					{ confirmedAt: new Date(), affiliation: institution: { licence: 'pro_plus' } }
-					{ confirmedAt: new Date(), affiliation: institution: { licence: 'free' } }
-					{ confirmedAt: null, affiliation: institution: { licence: 'pro' } }
-					{ confirmedAt: null, affiliation: institution: { licence: null } }
-					{ confirmedAt: new Date(), affiliation: institution: {} }
+				institutions = [
+					{ licence: 'pro_plus' }
+					{ licence: 'free' }
+					{ licence: 'pro' }
+					{ licence: null }
 				]
-				@UserGetter.getUserFullEmails.yields(null, affiliations)
+				@InstitutionsGetter.getConfirmedInstitutions.yields(null, institutions)
 				@InstitutionsFeatures.hasLicence @userId, (error, hasLicence) ->
 					expect(error).to.not.exist
 					expect(hasLicence).to.be.true

+ 44 - 0
services/web/test/unit/coffee/Institutions/InstitutionsGetterTests.coffee

@@ -0,0 +1,44 @@
+SandboxedModule = require('sandboxed-module')
+require('chai').should()
+expect = require('chai').expect
+sinon = require('sinon')
+modulePath = require('path').join __dirname, '../../../../app/js/Features/Institutions/InstitutionsGetter.js'
+
+describe 'InstitutionsGetter', ->
+	beforeEach ->
+		@UserGetter = getUserFullEmails: sinon.stub()
+		@InstitutionsGetter = SandboxedModule.require modulePath, requires:
+			'../User/UserGetter': @UserGetter
+			'logger-sharelatex':
+				log:-> console.log(arguments)
+				err:->
+
+		@userId = '12345abcde'
+
+	describe "getConfirmedInstitutions", ->
+			it 'filters unconfirmed emails', (done) ->
+				@userEmails = [
+					{ confirmedAt: null, affiliation: institution: { id: 123 } }
+					{ confirmedAt: new Date(), affiliation: institution: { id: 456 } }
+					{ confirmedAt: new Date(), affiliation: null }
+					{ confirmedAt: new Date(), affiliation: institution: null }
+				]
+				@UserGetter.getUserFullEmails.yields(null, @userEmails)
+				@InstitutionsGetter.getConfirmedInstitutions @userId, (error, institutions) ->
+					expect(error).to.not.exist
+					institutions.length.should.equal 1
+					institutions[0].id.should.equal 456
+					done()
+
+			it 'should handle empty response', (done) ->
+				@UserGetter.getUserFullEmails.yields(null, [])
+				@InstitutionsGetter.getConfirmedInstitutions @userId, (error, institutions) ->
+					expect(error).to.not.exist
+					institutions.length.should.equal 0
+					done()
+
+			it 'should handle error', (done) ->
+				@UserGetter.getUserFullEmails.yields(new Error('Nope'))
+				@InstitutionsGetter.getConfirmedInstitutions @userId, (error, institutions) ->
+					expect(error).to.exist
+					done()

+ 12 - 0
services/web/test/unit/coffee/Project/ProjectControllerTests.coffee

@@ -145,6 +145,18 @@ describe "ProjectController", ->
 				done()
 			@ProjectController.updateProjectSettings @req, @res
 
+		it "should update the imageName", (done) ->
+			@EditorController.setImageName = sinon.stub().callsArg(2)
+			@req.body =
+				imageName: @imageName = "texlive-1234.5"
+			@res.sendStatus = (code) =>
+				@EditorController.setImageName
+					.calledWith(@project_id, @imageName)
+					.should.equal true
+				code.should.equal 204
+				done()
+			@ProjectController.updateProjectSettings @req, @res
+
 		it "should update the spell check language", (done) ->
 			@EditorController.setSpellCheckLanguage = sinon.stub().callsArg(2)
 			@req.body =

+ 18 - 0
services/web/test/unit/coffee/Project/ProjectOptionsHandlerTests.coffee

@@ -19,6 +19,11 @@ describe 'creating a project', ->
 					{name: "English", code: "en"}
 					{name: "French", code: "fr"}
 				]
+				imageRoot: "docker-repo/subdir"
+				allowedImageNames: [
+					{imageName: "texlive-0000.0", imageDesc: "test image 0"}
+					{imageName: "texlive-1234.5", imageDesc: "test image 1"}
+				]
 			'logger-sharelatex':
 				log:->
 				err:->
@@ -37,6 +42,19 @@ describe 'creating a project', ->
 				@projectModel.update.called.should.equal false
 				done()
 
+	describe 'Setting the imageName', ->
+		it 'should perform and update on mongo', (done)->
+			@handler.setImageName project_id, "texlive-1234.5", (err)=>
+				args = @projectModel.update.args[0]
+				args[0]._id.should.equal project_id
+				args[1].imageName.should.equal "docker-repo/subdir/texlive-1234.5"
+				done()
+			@projectModel.update.args[0][3]()
+
+		it 'should not perform and update on mongo if it is not a reconised compiler', (done)->
+			@handler.setImageName project_id, "something", (err)=>
+				@projectModel.update.called.should.equal false
+				done()
 
 	describe "setting the spellCheckLanguage", ->
 

+ 7 - 1
services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee

@@ -132,11 +132,17 @@ describe "UserRegistrationHandler", ->
 					@AuthenticationManager.setUserPassword.calledWith(@user._id, @passingRequest.password).should.equal true
 					done()			
 
-			it "should add the user to the news letter manager", (done)->
+			it "should add the user to the newsletter if accepted terms", (done)->
+				@passingRequest.subscribeToNewsletter = "true"
 				@handler.registerNewUser @passingRequest, (err)=>
 					@NewsLetterManager.subscribe.calledWith(@user).should.equal true
 					done()
 
+			it "should not add the user to the newsletter if not accepted terms", (done)->
+				@handler.registerNewUser @passingRequest, (err)=>
+					@NewsLetterManager.subscribe.calledWith(@user).should.equal false
+					done()
+
 			it "should track the registration event", (done)->
 				@handler.registerNewUser @passingRequest, (err)=>
 					@AnalyticsManager.recordEvent

+ 23 - 3
services/web/test/unit/coffee/User/UserUpdaterTests.coffee

@@ -18,21 +18,27 @@ describe "UserUpdater", ->
 			getUserEmail: sinon.stub()
 			getUserByAnyEmail: sinon.stub()
 			ensureUniqueEmailAddress: sinon.stub()
-		@logger = err: sinon.stub(), log: ->
+		@logger = 
+			err: sinon.stub()
+			log: ->
+			warn: ->
 		@addAffiliation = sinon.stub().yields()
 		@removeAffiliation = sinon.stub().callsArgWith(2, null)
 		@refreshFeatures = sinon.stub().yields()
+		@NewsletterManager = 
+			changeEmail:sinon.stub()
 		@UserUpdater = SandboxedModule.require modulePath, requires:
 			"logger-sharelatex": @logger
+			"../../infrastructure/mongojs":@mongojs
+			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
 			"./UserGetter": @UserGetter
 			'../Institutions/InstitutionsAPI':
 				addAffiliation: @addAffiliation
 				removeAffiliation: @removeAffiliation
 			'../Subscription/FeaturesUpdater': refreshFeatures: @refreshFeatures
-			"../../infrastructure/mongojs":@mongojs
-			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
 			"settings-sharelatex": @settings = {}
 			"request": @request = {}
+			"../Newsletter/NewsletterManager": @NewsletterManager
 
 		@stubbedUser = 
 			_id: "3131231"
@@ -174,6 +180,10 @@ describe "UserUpdater", ->
 				done()
 
 	describe 'setDefaultEmailAddress', ->
+		beforeEach ->
+			@UserGetter.getUserEmail.callsArgWith(1, null, @stubbedUser.email)
+			@NewsletterManager.changeEmail.callsArgWith(2, null)
+
 		it 'set default', (done)->
 			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 1)
 
@@ -185,6 +195,16 @@ describe "UserUpdater", ->
 				).should.equal true
 				done()
 
+		it 'set changed the email in newsletter', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 1)
+
+			@UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				should.not.exist(err)
+				@NewsletterManager.changeEmail.calledWith(
+					@stubbedUser.email, @newEmail
+				).should.equal true
+				done()
+
 		it 'handle error', (done)->
 			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope'))