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

Merge branch 'master' into ns-use-regex-test

Nate Stemen 8 лет назад
Родитель
Сommit
4d991aa176
92 измененных файлов с 2713 добавлено и 704 удалено
  1. 9 0
      services/web/app/coffee/Features/Analytics/AnalyticsController.coffee
  2. 1 1
      services/web/app/coffee/Features/Analytics/AnalyticsRouter.coffee
  3. 28 16
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  4. 1 1
      services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee
  5. 4 2
      services/web/app/coffee/Features/Compile/ClsiManager.coffee
  6. 1 1
      services/web/app/coffee/Features/Compile/CompileController.coffee
  7. 7 0
      services/web/app/coffee/Features/Editor/EditorController.coffee
  8. 8 0
      services/web/app/coffee/Features/Exports/ExportsController.coffee
  9. 19 0
      services/web/app/coffee/Features/Exports/ExportsHandler.coffee
  10. 13 4
      services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee
  11. 5 6
      services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee
  12. 14 0
      services/web/app/coffee/Features/Institutions/InstitutionsGetter.coffee
  13. 53 22
      services/web/app/coffee/Features/Newsletter/NewsletterManager.coffee
  14. 5 1
      services/web/app/coffee/Features/Project/ProjectController.coffee
  15. 2 1
      services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee
  16. 2 1
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  17. 10 0
      services/web/app/coffee/Features/Project/ProjectOptionsHandler.coffee
  18. 10 6
      services/web/app/coffee/Features/User/UserCreator.coffee
  19. 0 1
      services/web/app/coffee/Features/User/UserPagesController.coffee
  20. 2 2
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  21. 21 11
      services/web/app/coffee/Features/User/UserUpdater.coffee
  22. 5 0
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  23. 2 0
      services/web/app/coffee/infrastructure/Features.coffee
  24. 1 1
      services/web/app/coffee/infrastructure/Server.coffee
  25. 2 1
      services/web/app/coffee/router.coffee
  26. 21 0
      services/web/app/views/_mixins/faq_search.pug
  27. 122 0
      services/web/app/views/_mixins/links.pug
  28. 1 0
      services/web/app/views/layout.pug
  29. 74 0
      services/web/app/views/mixins/_pagination.pug
  30. 1 1
      services/web/app/views/project/editor.pug
  31. 50 41
      services/web/app/views/project/editor/history.pug
  32. 111 2
      services/web/app/views/project/editor/history/entriesListV2.pug
  33. 14 2
      services/web/app/views/project/editor/history/previewPanelV2.pug
  34. 69 7
      services/web/app/views/project/editor/history/toolbarV2.pug
  35. 9 0
      services/web/app/views/project/editor/left-menu.pug
  36. 1 1
      services/web/app/views/project/editor/pdf.pug
  37. 8 7
      services/web/app/views/project/list/modals.pug
  38. 1 1
      services/web/app/views/subscriptions/plans.pug
  39. 8 3
      services/web/app/views/user/settings.pug
  40. 17 4
      services/web/config/settings.defaults.coffee
  41. 3 1
      services/web/package.json
  42. 2 1
      services/web/public/coffee/ide/directives/layout.coffee
  43. 1 1
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
  44. 187 17
      services/web/public/coffee/ide/history/HistoryV2Manager.coffee
  45. 20 0
      services/web/public/coffee/ide/history/components/historyEntriesList.coffee
  46. 20 3
      services/web/public/coffee/ide/history/components/historyEntry.coffee
  47. 20 0
      services/web/public/coffee/ide/history/components/historyLabel.coffee
  48. 36 0
      services/web/public/coffee/ide/history/components/historyLabelsList.coffee
  49. 27 2
      services/web/public/coffee/ide/history/controllers/HistoryListController.coffee
  50. 29 0
      services/web/public/coffee/ide/history/controllers/HistoryV2AddLabelModalController.coffee
  51. 23 0
      services/web/public/coffee/ide/history/controllers/HistoryV2DeleteLabelModalController.coffee
  52. 19 62
      services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee
  53. 12 0
      services/web/public/coffee/ide/history/controllers/HistoryV2ToolbarController.coffee
  54. 24 16
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  55. 3 1
      services/web/public/coffee/ide/services/ide.coffee
  56. 11 0
      services/web/public/coffee/ide/settings/controllers/SettingsController.coffee
  57. 12 6
      services/web/public/coffee/main/account-settings.coffee
  58. 5 0
      services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee
  59. 8 9
      services/web/public/coffee/main/new-subscription.coffee
  60. 11 5
      services/web/public/coffee/main/plans.coffee
  61. 10 2
      services/web/public/coffee/main/project-list/modal-controllers.coffee
  62. BIN
      services/web/public/img/review-icon-sprite-ol.png
  63. BIN
      services/web/public/img/review-icon-sprite-ol@2x.png
  64. 2 0
      services/web/public/stylesheets/_style_includes.less
  65. 111 338
      services/web/public/stylesheets/app/editor/history-v2.less
  66. 12 5
      services/web/public/stylesheets/app/editor/review-panel.less
  67. 29 28
      services/web/public/stylesheets/app/editor/toolbar.less
  68. 5 0
      services/web/public/stylesheets/app/metrics.less
  69. 161 0
      services/web/public/stylesheets/app/portals.less
  70. 16 2
      services/web/public/stylesheets/components/daterange-picker.less
  71. 26 0
      services/web/public/stylesheets/components/embed-responsive.less
  72. 9 0
      services/web/public/stylesheets/components/icons.less
  73. 674 0
      services/web/public/stylesheets/components/nvd3.less
  74. 16 0
      services/web/public/stylesheets/components/nvd3_override.less
  75. 18 11
      services/web/public/stylesheets/core/_common-variables.less
  76. 49 8
      services/web/public/stylesheets/core/ol-variables.less
  77. 5 1
      services/web/public/stylesheets/ol-style.less
  78. 85 0
      services/web/scripts/increase_compile_timeouts.js
  79. 20 0
      services/web/test/unit/coffee/Analytics/AnalyticsControllerTests.coffee
  80. 13 1
      services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee
  81. 14 0
      services/web/test/unit/coffee/Editor/EditorControllerTests.coffee
  82. 37 0
      services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee
  83. 41 3
      services/web/test/unit/coffee/Institutions/InstitutionsAPITests.coffee
  84. 14 24
      services/web/test/unit/coffee/Institutions/InstitutionsFeaturesTests.coffee
  85. 44 0
      services/web/test/unit/coffee/Institutions/InstitutionsGetterTests.coffee
  86. 12 0
      services/web/test/unit/coffee/Project/ProjectControllerTests.coffee
  87. 9 1
      services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee
  88. 18 0
      services/web/test/unit/coffee/Project/ProjectOptionsHandlerTests.coffee
  89. 9 1
      services/web/test/unit/coffee/User/UserCreatorTests.coffee
  90. 7 1
      services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
  91. 25 5
      services/web/test/unit/coffee/User/UserUpdaterTests.coffee
  92. 17 2
      services/web/test/unit_frontend/coffee/ide/history/HistoryV2ManagerTests.coffee

+ 9 - 0
services/web/app/coffee/Features/Analytics/AnalyticsController.coffee

@@ -1,6 +1,7 @@
 AnalyticsManager = require "./AnalyticsManager"
 Errors = require "../Errors/Errors"
 AuthenticationController = require("../Authentication/AuthenticationController")
+InstitutionsAPI = require("../Institutions/InstitutionsAPI")
 GeoIpLookup = require '../../infrastructure/GeoIpLookup'
 
 module.exports = AnalyticsController =
@@ -23,6 +24,14 @@ module.exports = AnalyticsController =
 		AnalyticsManager.recordEvent user_id, req.params.event, req.body, (error) ->
 			respondWith(error, res, next)
 
+	licences: (req, res, next) ->
+		{resource_id, start_date, end_date, lag} = req.query
+		InstitutionsAPI.getInstitutionLicences resource_id, start_date, end_date, lag, (error, licences) ->
+			if error?
+				next(error)
+			else
+				res.send licences
+
 respondWith = (error, res, next) ->
 	if error instanceof Errors.ServiceNotConfiguredError
 		# ignore, no-op

+ 1 - 1
services/web/app/coffee/Features/Analytics/AnalyticsRouter.coffee

@@ -23,4 +23,4 @@ module.exports =
 
 		publicApiRouter.use '/analytics/uniExternalCollaboration',
 			AuthenticationController.httpAuth,
-			AnalyticsProxy.call('/uniExternalCollaboration')
+			AnalyticsProxy.call('/uniExternalCollaboration')

+ 28 - 16
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -1,6 +1,5 @@
 AuthenticationManager = require ("./AuthenticationManager")
 LoginRateLimiter = require("../Security/LoginRateLimiter")
-UserGetter = require "../User/UserGetter"
 UserUpdater = require "../User/UserUpdater"
 Metrics = require('metrics-sharelatex')
 logger = require("logger-sharelatex")
@@ -64,7 +63,10 @@ module.exports = AuthenticationController =
 			if user # `user` is either a user object or false
 				AuthenticationController.finishLogin(user, req, res, next)
 			else
-				res.json message: info
+				if info.redir?
+					res.json {redir: info.redir}
+				else
+					res.json message: info
 		)(req, res, next)
 
 	finishLogin: (user, req, res, next) ->
@@ -81,20 +83,30 @@ module.exports = AuthenticationController =
 
 	doPassportLogin: (req, username, password, done) ->
 		email = username.toLowerCase()
-		LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
-			return done(err) if err?
-			if !isAllowed
-				logger.log email:email, "too many login requests"
-				return done(null, null, {text: req.i18n.translate("to_many_login_requests_2_mins"), type: 'error'})
-			AuthenticationManager.authenticate email: email, password, (error, user) ->
-				return done(error) if error?
-				if user?
-					# async actions
-					return done(null, user)
-				else
-					AuthenticationController._recordFailedLogin()
-					logger.log email: email, "failed log in"
-					return done(null, false, {text: req.i18n.translate("email_or_password_wrong_try_again"), type: 'error'})
+		Modules = require "../../infrastructure/Modules"
+		Modules.hooks.fire 'preDoPassportLogin', req, email, (err, infoList) ->
+			return next(err) if err?
+			info = infoList.find((i) => i?)
+			if info?
+				return done(null, false, info)
+			LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
+				return done(err) if err?
+				if !isAllowed
+					logger.log email:email, "too many login requests"
+					return done(null, null, {text: req.i18n.translate("to_many_login_requests_2_mins"), type: 'error'})
+				AuthenticationManager.authenticate email: email, password, (error, user) ->
+					return done(error) if error?
+					if user?
+						# async actions
+						return done(null, user)
+					else
+						AuthenticationController._recordFailedLogin()
+						logger.log email: email, "failed log in"
+						return done(
+							null,
+							false,
+							{text: req.i18n.translate("email_or_password_wrong_try_again"), type: 'error'}
+						)
 
 	_loginAsyncHandlers: (req, user) ->
 		UserHandler.setupLoginData(user, ()->)

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

@@ -49,7 +49,7 @@ module.exports = (backendGroup)->
 			return callback()
 		serverId = @_parseServerIdFromResponse(response)
 		if !serverId? # We don't get a cookie back if it hasn't changed
-			return callback()
+			return rclient.expire(@buildKey(project_id), Settings.clsiCookie.ttl, callback)
 		if rclient_secondary?
 			@_setServerIdInRedis rclient_secondary, project_id, serverId
 		@_setServerIdInRedis rclient, project_id, serverId, (err) ->

+ 4 - 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")
@@ -100,6 +100,7 @@ module.exports = ClsiManager =
 					timer = new Metrics.Timer("compile.currentBackend")
 					request opts, (err, response, body)->
 						timer.done()
+						Metrics.inc "compile.currentBackend.response.#{response?.statusCode}"
 						if err?
 							logger.err err:err, project_id:project_id, url:opts?.url, "error making request to clsi"
 							return callback(err)
@@ -111,6 +112,7 @@ module.exports = ClsiManager =
 			newBackend: (cb)-> 
 				startTime = new Date()
 				ClsiManager._makeNewBackendRequest project_id, opts, (err, response, body)->
+					Metrics.inc "compile.newBackend.response.#{response?.statusCode}"
 					cb(err, {response:response, body:body, finishTime:new Date() - startTime})
 		}, (err, results)->
 			timeDifference = results.newBackend?.finishTime - results.currentBackend?.finishTime 

+ 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?

+ 8 - 0
services/web/app/coffee/Features/Exports/ExportsController.coffee

@@ -37,3 +37,11 @@ module.exports =
 				status_detail: parsed_export.status_detail
 			}
 			res.send export_json: json
+
+	exportZip: (req, res) ->
+		{export_id} = req.params
+		AuthenticationController.getLoggedInUserId(req)
+		ExportsHandler.fetchZip export_id, (err, export_zip_url) ->
+			return err if err?
+
+			res.redirect export_zip_url

+ 19 - 0
services/web/app/coffee/Features/Exports/ExportsHandler.coffee

@@ -57,6 +57,9 @@ module.exports = ExportsHandler = self =
 					historyId: project.overleaf?.history?.id
 					historyVersion: historyVersion
 					v1ProjectId: project.overleaf?.id
+					metadata:
+						compiler: project.compiler
+						imageName: project.imageName
 				user:
 					id: user_id
 					firstName: user.first_name
@@ -115,3 +118,19 @@ module.exports = ExportsHandler = self =
 				err = new Error("v1 export returned a failure status code: #{res.statusCode}")
 				logger.err err:err, export:export_id, "v1 export returned failure status code: #{res.statusCode}"
 				callback err
+
+	fetchZip: (export_id, callback=(err, zip_url) ->) ->
+		console.log("#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/zip_url")
+		request.get {
+			url: "#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/zip_url"
+			auth: {user: settings.apis.v1.user, pass: settings.apis.v1.pass }
+		}, (err, res, body) ->
+			if err?
+				logger.err err:err, export:export_id, "error making request to v1 export"
+				callback err
+			else if 200 <= res.statusCode < 300
+				callback null, body
+			else
+				err = new Error("v1 export returned a failure status code: #{res.statusCode}")
+				logger.err err:err, export:export_id, "v1 export zip fetch returned failure status code: #{res.statusCode}"
+				callback err

+ 13 - 4
services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee

@@ -9,15 +9,22 @@ module.exports = InstitutionsAPI =
 			method: 'GET'
 			path: "/api/v2/institutions/#{institutionId.toString()}/affiliations"
 			defaultErrorMessage: "Couldn't get institution affiliations"
-		}, callback
+		}, (error, body) -> callback(error, body or [])
 
+	getInstitutionLicences: (institutionId, startDate, endDate, lag, callback = (error, body) ->) ->
+		makeAffiliationRequest {
+			method: 'GET'
+			path: "/api/v2/institutions/#{institutionId.toString()}/institution_licences"
+			body: {start_date: startDate, end_date: endDate, lag}
+			defaultErrorMessage: "Couldn't get institution licences"
+		}, callback
 
 	getUserAffiliations: (userId, callback = (error, body) ->) ->
 		makeAffiliationRequest {
 			method: 'GET'
 			path: "/api/v2/users/#{userId.toString()}/affiliations"
 			defaultErrorMessage: "Couldn't get user affiliations"
-		}, callback
+		}, (error, body) -> callback(error, body or [])
 
 
 	addAffiliation: (userId, email, affiliationOptions, callback) ->
@@ -25,11 +32,11 @@ module.exports = InstitutionsAPI =
 			callback = affiliationOptions
 			affiliationOptions = {}
 
-		{ university, department, role } = affiliationOptions
+		{ university, department, role, confirmedAt } = affiliationOptions
 		makeAffiliationRequest {
 			method: 'POST'
 			path: "/api/v2/users/#{userId.toString()}/affiliations"
-			body: { email, university, department, role }
+			body: { email, university, department, role, confirmedAt }
 			defaultErrorMessage: "Couldn't create affiliation"
 		}, callback
 
@@ -80,6 +87,8 @@ makeAffiliationRequest = (requestOptions, callback = (error) ->) ->
 				errorMessage = "#{response.statusCode}: #{body.errors}"
 			else
 				errorMessage = "#{requestOptions.defaultErrorMessage}: #{response.statusCode}"
+
+			logger.err path: requestOptions.path, body: requestOptions.body, errorMessage
 			return callback(new Error(errorMessage))
 
 		callback(null, body)

+ 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 - 1
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,7 +351,7 @@ module.exports = ProjectController =
 					useV2History: !!project.overleaf?.history?.display
 					richTextEnabled: Features.hasFeature('rich-text')
 					showTestControls: req.query?.tc == 'true' || user.isAdmin
-					showPublishModal: req.query?.pm == 'true'
+					allowedImageNames: Settings.allowedImageNames || []
 				timer.done()
 
 	_buildProjectList: (allProjects, v1Projects = [])->

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

@@ -55,7 +55,8 @@ module.exports = ProjectCreationHandler =
 		if Settings.apis?.project_history?.displayHistoryForNewProjects
 			project.overleaf.history.display = true
 		if Settings.currentImageName?
-			project.imageName = Settings.currentImageName
+			# avoid clobbering any imageName already set in attributes (e.g. importedImageName)
+			project.imageName ?= Settings.currentImageName
 		project.rootFolder[0] = rootFolder
 		User.findById owner_id, "ace.spellCheckLanguage", (err, user)->
 			project.spellCheckLanguage = user.ace.spellCheckLanguage

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

+ 10 - 6
services/web/app/coffee/Features/User/UserCreator.coffee

@@ -6,15 +6,18 @@ metrics = require('metrics-sharelatex')
 
 module.exports = UserCreator =
 
-	createNewUser: (opts, callback)->
-		logger.log opts:opts, "creating new user"
+	createNewUser: (attributes, options, callback = (error, user) ->)->
+		if arguments.length == 2
+			callback = options
+			options = {}
+		logger.log user: attributes, "creating new user"
 		user = new User()
 
-		username = opts.email.match(/^[^@]*/)
-		if !opts.first_name? or opts.first_name == ""
-			opts.first_name = username[0]
+		username = attributes.email.match(/^[^@]*/)
+		if !attributes.first_name? or attributes.first_name == ""
+			attributes.first_name = username[0]
 
-		for key, value of opts
+		for key, value of attributes
 			user[key] = value
 			
 		user.ace.syntaxValidation = true
@@ -27,6 +30,7 @@ module.exports = UserCreator =
 		user.save (err)->
 			callback(err, user)
 
+			return if options?.skip_affiliation
 			# call addaffiliation after the main callback so it runs in the
 			# background. There is no guaranty this will run so we must no rely on it
 			addAffiliation user._id, user.email, (error) ->

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

+ 21 - 11
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,11 +159,14 @@ 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'
-		addAffiliation userId, email, (error) =>
+		addAffiliation userId, email, {confirmedAt: confirmedAt}, (error) =>
 			if error?
 				logger.err error: error, 'problem adding affiliation while confirming email'
 				return callback(error)
@@ -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"

+ 5 - 0
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -333,3 +333,8 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 			defaultLineHeight          : if isOl then 'normal' else 'compact'
 			renderAnnouncements        : !isOl
 		next()
+
+	webRouter.use (req, res, next) ->
+		res.locals.ExposedSettings =
+			isOverleaf: Settings.overleaf?
+		next()

+ 2 - 0
services/web/app/coffee/infrastructure/Features.coffee

@@ -25,5 +25,7 @@ module.exports = Features =
 			when 'rich-text'
 				isEnabled = true # Switch to false to disable
 				Settings.overleaf? and isEnabled
+			when 'redirect-sl'
+				return Settings.redirectToV2?
 			else
 				throw new Error("unknown feature: #{feature}")

+ 1 - 1
services/web/app/coffee/infrastructure/Server.coffee

@@ -68,7 +68,7 @@ Modules.loadViewIncludes app
 
 app.use bodyParser.urlencoded({ extended: true, limit: "2mb"})
 # Make sure we can process the max doc length plus some overhead for JSON encoding
-app.use bodyParser.json({limit: Settings.max_doc_length + 16 * 1024}) # 16kb overhead
+app.use bodyParser.json({limit: Settings.max_doc_length + 64 * 1024}) # 64kb overhead
 app.use multer(dest: Settings.path.uploadFolder)
 app.use methodOverride()
 

+ 2 - 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")
@@ -245,6 +245,7 @@ module.exports = class Router
 
 		webRouter.post '/project/:project_id/export/:brand_variation_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportProject
 		webRouter.get '/project/:project_id/export/:export_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportStatus
+		webRouter.get '/project/:project_id/export/:export_id/zip', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportZip
 
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects

+ 21 - 0
services/web/app/views/_mixins/faq_search.pug

@@ -0,0 +1,21 @@
+mixin faq_search(headerText, headerClass)
+	- if(typeof(settings.algolia) != "undefined" && typeof(settings.algolia.indexes) != "undefined" && typeof(settings.algolia.indexes.wiki) != "undefined")
+		if headerText
+			div(class=headerClass) #{headerText}
+		.wiki(ng-controller="SearchWikiController")
+			form.project-search.form-horizontal(role="form")
+				.form-group.has-feedback.has-feedback-left
+					.col-sm-12
+						input.form-control(type='text', ng-model='searchQueryText', ng-keyup='search()', placeholder="Search help library....")
+						i.fa.fa-search.form-control-feedback-left
+						i.fa.fa-times.form-control-feedback(
+							ng-click="clearSearchText()",
+							style="cursor: pointer;",
+							ng-show="searchQueryText.length > 0"
+						)
+
+			.row
+				.col-md-12(ng-cloak)
+					a(ng-href='{{hit.url}}',ng-repeat='hit in hits').search-result.card.card-thin
+						span(ng-bind-html='hit.name')
+						div.search-result-content(ng-show="hit.content != ''", ng-bind-html='hit.content')

+ 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")
+			| &nbsp;
+		| #{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'}

+ 1 - 0
services/web/app/views/layout.pug

@@ -70,6 +70,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 			window.systemMessages = !{JSON.stringify(systemMessages).replace(/\//g, '\\/')};
 			window.ab = {};
 			window.user_id = '#{getLoggedInUserId()}';
+			window.ExposedSettings = JSON.parse('!{JSON.stringify(ExposedSettings).replace(/\//g, "\\/")}');
 
 		- if (typeof(settings.algolia) != "undefined")
 			script.

+ 74 - 0
services/web/app/views/mixins/_pagination.pug

@@ -0,0 +1,74 @@
+mixin paginate(pages, page_path, max_btns)
+	//- @param pages.current_page the current page viewed
+	//- @param pages.total_pages previously calculated,
+	//- based on total entries and entries per page
+	//- @param page_path the relative path, minus a trailing slash and page param
+	//- @param max_btns max number of buttons on either side of the current page
+	//- button and excludes first, prev, next, last
+
+	if pages && pages.current_page && pages.total_pages
+		- var max_btns = max_btns || 4
+		- var prev_page = Math.max(parseInt(pages.current_page, 10) - max_btns, 1)
+		- var next_page = parseInt(pages.current_page, 10) + 1
+		- var next_index = 0;
+		- var full_page_path = page_path + "/page/"
+
+		nav(role="navigation" aria-label="Pagination Navigation")
+			ul.pagination
+				if pages.current_page > 1
+					li
+						a(
+							aria-label="Go to first page"
+							href=page_path
+						) « First
+					li
+						a(
+							aria-label="Go to previous page"
+							href=full_page_path + (parseInt(pages.current_page, 10) - 1)
+							rel="prev"
+						) ‹ Prev
+
+				if pages.current_page - max_btns > 1
+					li
+						span …
+
+				while prev_page < pages.current_page
+					li
+						a(
+							aria-label="Go to page " + prev_page
+							href=full_page_path + prev_page
+						) #{prev_page}
+						- prev_page++
+
+				li(class="active")
+					span(
+						aria-label="Current Page, Page " + pages.current_page
+						aria-current="true"
+					) #{pages.current_page}
+
+				if pages.current_page < pages.total_pages
+					while next_page <= pages.total_pages && next_index < max_btns
+						li
+							a(
+								aria-label="Go to page " + next_page
+								href=full_page_path + next_page
+							) #{next_page}
+						- next_page++
+						- next_index++
+
+					if next_page <= pages.total_pages 
+						li
+							span …
+
+					li
+						a(
+							aria-label="Go to next page"
+							href=full_page_path + (parseInt(pages.current_page, 10) + 1)
+							rel="next"
+						) Next ›
+
+					li
+						a(
+							aria-label="Go to last page"
+							href=full_page_path + pages.total_pages
+						) Last »

+ 1 - 1
services/web/app/views/project/editor.pug

@@ -65,7 +65,7 @@ block content
 					ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME) }",
 					layout="main",
 					ng-hide="state.loading",
-					resize-on="layout:chat:resize",
+					resize-on="layout:chat:resize,history:toggle",
 					minimum-restore-size-west="130"
 					custom-toggler-pane=hasFeature('custom-togglers') ? "'west'" : "false"
 					custom-toggler-msg-when-open=hasFeature('custom-togglers') ? "'" + translate("tooltip_hide_filetree") + "'" : "false"

+ 50 - 41
services/web/app/views/project/editor/history.pug

@@ -1,45 +1,4 @@
 div#history(ng-show="ui.view == 'history'")
-	span
-		.upgrade-prompt(ng-if="project.features.versioning === false && ui.view === 'history'")
-			.message(ng-if="project.owner._id == user.id")
-				p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
-				p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
-				ul.list-unstyled
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("unlimited_projects")}
-					
-					li
-						i.fa.fa-check &nbsp;
-						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
-					
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("full_doc_history")}
-					
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("sync_to_dropbox")}
-
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("sync_to_github")}
-
-					li  
-						i.fa.fa-check &nbsp;
-						|#{translate("compile_larger_projects")}
-				p.text-center(ng-controller="FreeTrialModalController")
-					a.btn.btn-success(
-						href
-						ng-class="buttonClass"
-						ng-click="startFreeTrial('history')"
-					) #{translate("start_free_trial")}
-
-			.message(ng-show="project.owner._id != user.id")
-				p #{translate("ask_proj_owner_to_upgrade_for_history")}
-				p
-					a.small(href, ng-click="toggleHistory()") #{translate("cancel")}
-
 	include ./history/entriesListV1
 	include ./history/entriesListV2
 
@@ -67,3 +26,53 @@ script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
 		)
 			span(ng-show="!state.inflight") #{translate("restore")}
 			span(ng-show="state.inflight") #{translate("restoring")} ...
+
+
+script(type="text/ng-template", id="historyLabelTpl")
+	.history-label(
+		ng-class="{ 'history-label-own' : $ctrl.isOwnedByCurrentUser }"
+	)
+		span.history-label-comment(
+			tooltip-append-to-body="true"
+			tooltip-template="'historyLabelTooltipTpl'"
+			tooltip-placement="left"
+			tooltip-enable="$ctrl.showTooltip"
+		)
+			i.fa.fa-tag
+			| &nbsp;{{ $ctrl.labelText }}
+		button.history-label-delete-btn(
+			ng-if="$ctrl.isOwnedByCurrentUser"
+			stop-propagation="click"
+			ng-click="$ctrl.onLabelDelete()"
+		) &times;
+
+script(type="text/ng-template", id="historyLabelTooltipTpl")
+	.history-label-tooltip
+		p.history-label-tooltip-title 
+			i.fa.fa-tag
+			| &nbsp;{{ $ctrl.labelText }}
+		p.history-label-tooltip-owner #{translate("history_label_created_by")} {{ $ctrl.labelOwnerName }}
+		time.history-label-tooltip-datetime {{ $ctrl.labelCreationDateTime | formatDate }}
+
+
+script(type="text/ng-template", id="historyV2DeleteLabelModalTemplate")
+	.modal-header
+		h3 #{translate("history_delete_label")}
+	.modal-body
+		.alert.alert-danger(ng-show="state.error.message") {{ state.error.message}} 
+		.alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")}
+		p.help-block(ng-if="labelDetails")
+			| #{translate("history_are_you_sure_delete_label")}
+			strong "{{ labelDetails.comment }}"
+			| ?
+	.modal-footer
+		button.btn.btn-default(
+			type="button"
+			ng-disabled="state.inflight"
+			ng-click="$dismiss()"
+		) #{translate("cancel")}
+		button.btn.btn-primary(
+			type="button"
+			ng-click="deleteLabel()"
+			ng-disabled="state.inflight"
+		) {{ state.inflight ? '#{translate("history_deleting_label")}' : '#{translate("history_delete_label")}' }}

+ 111 - 2
services/web/app/views/project/editor/history/entriesListV2.pug

@@ -3,13 +3,28 @@ aside.change-list(
 	ng-controller="HistoryV2ListController"
 )
 	history-entries-list(
+		ng-if="!history.showOnlyLabels && !history.error"
 		entries="history.updates"
 		current-user="user"
+		current-user-is-owner="project.owner._id === user.id"
+		users="projectUsers"
 		load-entries="loadMore()"
 		load-disabled="history.loading || history.atEnd"
 		load-initialize="ui.view == 'history'"
 		is-loading="history.loading"
+		free-history-limit-hit="history.freeHistoryLimitHit"
 		on-entry-select="handleEntrySelect(selectedEntry)"
+		on-label-delete="handleLabelDelete(label)"
+	)
+	history-labels-list(
+		ng-if="history.showOnlyLabels && !history.error"
+		labels="history.labels"
+		current-user="user"
+		users="projectUsers"
+		is-loading="history.loading"
+		selected-label="history.selection.label"
+		on-label-select="handleLabelSelect(label)"
+		on-label-delete="handleLabelDelete(label)"
 	)
 
 aside.change-list(
@@ -65,6 +80,14 @@ aside.change-list(
 						)
 
 				div.description(ng-click="select()")
+					history-label(
+						ng-repeat="label in update.labels"
+						label-text="label.comment"
+						label-owner-name="getDisplayNameById(label.user_id)"
+						label-creation-date-time="label.created_at"
+						is-owned-by-current-user="label.user_id === user.id"
+						on-label-delete="deleteLabel(label)"
+					)
 					div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
 					div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
 						| #{translate("file_action_edited")}
@@ -106,12 +129,55 @@ script(type="text/ng-template", id="historyEntriesListTpl")
 				ng-repeat="entry in $ctrl.entries"
 				entry="entry"
 				current-user="$ctrl.currentUser"
+				users="$ctrl.users"
 				on-select="$ctrl.onEntrySelect({ selectedEntry: selectedEntry })"
-				ng-show="!$ctrl.isLoading"
+				on-label-delete="$ctrl.onLabelDelete({ label: label })"
 			)
 			.loading(ng-show="$ctrl.isLoading")
 				i.fa.fa-spin.fa-refresh
 				| &nbsp;&nbsp; #{translate("loading")}...
+			.history-entries-list-upgrade-prompt(
+				ng-if="$ctrl.freeHistoryLimitHit && $ctrl.currentUserIsOwner"
+				ng-controller="FreeTrialModalController"
+			)
+				p #{translate("currently_seeing_only_24_hrs_history")}
+				p: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
+				ul.list-unstyled
+					li 
+						i.fa.fa-check &nbsp;
+						| #{translate("unlimited_projects")}
+					
+					li
+						i.fa.fa-check &nbsp;
+						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
+					
+					li 
+						i.fa.fa-check &nbsp;
+						| #{translate("full_doc_history")}
+					
+					li 
+						i.fa.fa-check &nbsp;
+						| #{translate("sync_to_dropbox")}
+
+					li 
+						i.fa.fa-check &nbsp;
+						| #{translate("sync_to_github")}
+
+					li  
+						i.fa.fa-check &nbsp;
+						|#{translate("compile_larger_projects")}
+				p.text-center
+					a.btn.btn-success(
+						href
+						ng-class="buttonClass"
+						ng-click="startFreeTrial('history')"
+					) #{translate("start_free_trial")}
+				p.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
+			.history-entries-list-upgrade-prompt(
+				ng-if="$ctrl.freeHistoryLimitHit && !$ctrl.currentUserIsOwner"
+			) 
+				p #{translate("currently_seeing_only_24_hrs_history")}
+				strong #{translate("ask_proj_owner_to_upgrade_for_full_history")}
 
 script(type="text/ng-template", id="historyEntryTpl")
 	.history-entry(
@@ -129,6 +195,15 @@ script(type="text/ng-template", id="historyEntryTpl")
 		time.history-entry-day(ng-if="::$ctrl.entry.meta.first_in_day") {{ ::$ctrl.entry.meta.end_ts | relativeDate }}
 
 		.history-entry-details(ng-click="$ctrl.onSelect({ selectedEntry: $ctrl.entry })")
+			history-label(
+				ng-repeat="label in $ctrl.entry.labels | orderBy : '-created_at'"
+				label-text="label.comment"
+				label-owner-name="$ctrl.displayNameById(label.user_id)"
+				label-creation-date-time="label.created_at"
+				is-owned-by-current-user="label.user_id === $ctrl.currentUser.id"
+				on-label-delete="$ctrl.onLabelDelete({ label: label })"
+			)
+
 			ol.history-entry-changes
 				li.history-entry-change(
 					ng-repeat="pathname in ::$ctrl.entry.pathnames"
@@ -148,6 +223,7 @@ script(type="text/ng-template", id="historyEntryTpl")
 						ng-if="::project_op.remove"
 					) #{translate("file_action_deleted")}
 					span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }}
+
 			.history-entry-metadata
 				time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }}
 				span
@@ -171,4 +247,37 @@ script(type="text/ng-template", id="historyEntryTpl")
 					li.history-entry-metadata-user(ng-if="::$ctrl.entry.meta.users.length == 0")
 						span.name(
 							ng-style="$ctrl.getUserCSSStyle();"
-						) #{translate("anonymous")}
+						) #{translate("anonymous")}
+
+script(type="text/ng-template", id="historyLabelsListTpl")
+	.history-labels-list
+		.history-entry-label(
+			ng-repeat="label in $ctrl.labels track by label.id"
+			ng-click="$ctrl.onLabelSelect({ label: label })"
+			ng-class="{ 'history-entry-label-selected': label.id === $ctrl.selectedLabel.id }" 
+		)
+			history-label(
+				show-tooltip="false"
+				label-text="label.comment"
+				is-owned-by-current-user="label.user_id === $ctrl.currentUser.id"
+				on-label-delete="$ctrl.onLabelDelete({ label: label })"
+			)
+			.history-entry-label-metadata
+				.history-entry-label-metadata-user(ng-init="user = $ctrl.getUserById(label.user_id)")
+					| Saved by 
+					span.name(
+						ng-if="user && user._id !== $ctrl.currentUser.id"
+						ng-style="$ctrl.getUserCSSStyle(user, label);"
+					) {{ ::$ctrl.displayName(user) }} 
+					span.name(
+						ng-if="user && user._id == $ctrl.currentUser.id"
+						ng-style="$ctrl.getUserCSSStyle(user, label);"
+					) You
+					span.name(
+						ng-if="user == null"
+						ng-style="$ctrl.getUserCSSStyle(user, label);"
+					) #{translate("anonymous")}
+				time.history-entry-label-metadata-time {{ ::label.created_at | formatDate }}
+		.loading(ng-show="$ctrl.isLoading")
+			i.fa.fa-spin.fa-refresh
+			| &nbsp;&nbsp; #{translate("loading")}...

+ 14 - 2
services/web/app/views/project/editor/history/previewPanelV2.pug

@@ -45,7 +45,7 @@
 			text="history.diff.text",
 			highlights="history.diff.highlights",
 			read-only="true",
-			resize-on="layout:main:resize",
+			resize-on="layout:main:resize,history:toggle",
 			navigate-highlights="true"
 		)
 		.alert.alert-info(ng-if="history.diff.binary")
@@ -70,12 +70,24 @@
 			font-size="settings.fontSize",
 			text="history.selectedFile.text",
 			read-only="true",
-			resize-on="layout:main:resize",
+			resize-on="layout:main:resize,history:toggle",
 		)
 		.alert.alert-info(ng-if="history.selectedFile.binary")
 			| We're still working on showing image and binary changes, sorry. Stay tuned!
 	.loading-panel(ng-show="history.selectedFile.loading")
 		i.fa.fa-spin.fa-refresh
 		| &nbsp;&nbsp;#{translate("loading")}...
+	.error-panel(ng-show="history.error")
+		.alert.alert-danger 
+			p
+				| #{translate("generic_history_error")} 
+				a(
+					ng-href="mailto:#{settings.adminEmail}?Subject=Error%20loading%20history%20for%project%20{{ project_id }}"
+				) #{settings.adminEmail}
+			p.clearfix
+				a.alert-link-as-btn.pull-right(
+					href
+					ng-click="toggleHistory()"
+			) #{translate("back_to_editor")}
 	.error-panel(ng-show="history.selectedFile.error")
 		.alert.alert-danger #{translate("generic_something_went_wrong")}

+ 69 - 7
services/web/app/views/project/editor/history/toolbarV2.pug

@@ -1,13 +1,75 @@
 .history-toolbar(
+	ng-controller="HistoryV2ToolbarController"
 	ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
 )
-	span(ng-show="history.loadingFileTree")
+	span.history-toolbar-selected-version(ng-show="history.loadingFileTree")
 		i.fa.fa-spin.fa-refresh
 		| &nbsp;&nbsp; #{translate("loading")}...
-	span(ng-show="!history.loadingFileTree") #{translate("browsing_project_as_of")}&nbsp;
+	span.history-toolbar-selected-version(
+		ng-show="!history.loadingFileTree && !history.showOnlyLabels && history.selection.updates.length && !history.error"
+	) #{translate("browsing_project_as_of")}&nbsp;
 		time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }}
-	.history-toolbar-btn(
-		ng-click="toggleHistoryViewMode();"
-	) 
-		i.fa
-		| #{translate("compare_to_another_version")}
+	span.history-toolbar-selected-version(
+		ng-show="!history.loadingFileTree && history.showOnlyLabels && history.selection.label && !history.error"
+	) #{translate("browsing_project_labelled")}&nbsp;
+		span.history-toolbar-selected-label "{{ history.selection.label.comment }}"
+	div.history-toolbar-actions(
+		ng-if="!history.error"
+	)
+		button.history-toolbar-btn(
+			ng-click="showAddLabelDialog();"
+			ng-if="!history.showOnlyLabels"
+			ng-disabled="history.loadingFileTree || history.selection.updates.length == 0"
+		) 
+			i.fa.fa-tag
+			| &nbsp;#{translate("history_label_this_version")}
+		button.history-toolbar-btn(
+			ng-click="toggleHistoryViewMode();"
+			ng-disabled="history.loadingFileTree || history.selection.updates.length == 0"
+		) 
+			i.fa.fa-exchange
+			| &nbsp;#{translate("compare_to_another_version")}
+
+	.history-toolbar-entries-list(
+		ng-if="!history.error"
+	)
+		toggle-switch(
+			ng-model="history.showOnlyLabels"
+			label-true=translate("history_view_labels")
+			label-false=translate("history_view_all")
+			description=translate("history_view_a11y_description")
+		)
+
+script(type="text/ng-template", id="historyV2AddLabelModalTemplate")
+	form(
+		name="addLabelModalForm"
+		ng-submit="addLabelModalFormSubmit();"
+		novalidate
+	)
+		.modal-header
+			h3 #{translate("history_add_label")}
+		.modal-body
+			.alert.alert-danger(ng-show="state.error.message") {{ state.error.message}} 
+			.alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")}
+			.form-group
+				input.form-control(
+					type="text"
+					placeholder=translate("history_new_label_name")
+					ng-model="inputs.labelName"
+					focus-on="open"
+					required
+				)
+			p.help-block(ng-if="update")
+				| #{translate("history_new_label_added_at")} 
+				strong {{ update.meta.end_ts | formatDate:'ddd Do MMM YYYY, h:mm a' }}
+		.modal-footer
+			button.btn.btn-default(
+				type="button"
+				ng-disabled="state.inflight"
+				ng-click="$dismiss()"
+			) #{translate("cancel")}
+			input.btn.btn-primary(
+				ng-disabled="addLabelModalForm.$invalid || state.inflight"
+				ng-value="state.inflight ? '" + translate("history_adding_label") + "' : '" + translate("history_add_label") + "'"
+				type="submit"
+			)

+ 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

+ 1 - 1
services/web/app/views/project/editor/pdf.pug

@@ -305,7 +305,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				dbl-click-callback="syncToCode"
 			)
 			iframe(
-				ng-src="{{ pdf.url }}"
+				ng-src="{{ pdf.url | trusted }}"
 				ng-if="settings.pdfViewer == 'native'"
 			)
 

+ 8 - 7
services/web/app/views/project/list/modals.pug

@@ -192,11 +192,14 @@ script(type='text/ng-template', id='deleteProjectsModalTemplate')
 			ng-click="cancel()"
 		) &times;
 		h3(ng-if="action == 'delete'") #{translate("delete_projects")}
+		h3(ng-if="action == 'archive'") #{translate("archive_projects")}
 		h3(ng-if="action == 'leave'") #{translate("leave_projects")}
 		h3(ng-if="action == 'delete-and-leave'") #{translate("delete_and_leave_projects")}
+		h3(ng-if="action == 'archive-and-leave'") #{translate("archive_and_leave_projects")}
 	.modal-body
 		div(ng-show="projectsToDelete.length > 0")
-			p #{translate("about_to_delete_projects")}
+			p(ng-if="action == 'delete' || action == 'delete-and-leave'") #{translate("about_to_delete_projects")}
+			p(ng-if="action == 'archive' || action == 'archive-and-leave'") #{translate("about_to_archive_projects")}
 			ul
 				li(ng-repeat="project in projectsToDelete | orderBy:'name'")
 					strong {{project.name}}
@@ -345,13 +348,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.
 						|

+ 1 - 1
services/web/app/views/subscriptions/plans.pug

@@ -14,5 +14,5 @@ block content
 		.content.plans(ng-controller="PlansController")
 			.container(class="more-details" ng-cloak ng-if="plansVariant === 'more-details'")
 				include _plans_page_details_more
-			.container(ng-cloak ng-if="plansVariant === 'default' || !shouldABTestPlans")
+			.container(ng-cloak ng-if="plansVariant === 'default' || !shouldABTestPlans || timeout")
 				include _plans_page_details_less

+ 8 - 3
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")}
@@ -167,7 +167,12 @@ block content
 									)
 										i.fa.fa-check
 										| #{translate("unsubscribed")}
-				
+
+									if !settings.overleaf && user.overleaf
+										p
+											| Please note: If you have linked your account with Overleaf
+											| v2, then deleting your ShareLaTeX account will also delete
+											| account and all of it's associated projects and data.
 									p #{translate("need_to_leave")} 
 										a(href, ng-click="deleteAccount()")  #{translate("delete_your_account")}
 

+ 17 - 4
services/web/config/settings.defaults.coffee

@@ -135,6 +135,8 @@ module.exports = settings =
 			url: "http://#{process.env['FILESTORE_HOST'] or 'localhost'}:3009"
 		clsi:
 			url: "http://#{process.env['CLSI_HOST'] or 'localhost'}:3013"
+			# url: "http://#{process.env['CLSI_LB_HOST']}:3014"
+			backendGroupName: undefined
 		templates:
 			url: "http://#{process.env['TEMPLATES_HOST'] or 'localhost'}:3007"
 		githubSync:
@@ -277,10 +279,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
@@ -339,7 +341,7 @@ module.exports = settings =
 	# disablePerUserCompiles: true
 
 	# Domain the client (pdfjs) should download the compiled pdf from
-	# pdfDownloadDomain: "http://compiles.sharelatex.test:3014"
+	# pdfDownloadDomain: "http://clsi-lb:3014"
 
 	# Maximum size of text documents in the real-time editing system.
 	max_doc_length: 2 * 1024 * 1024 # 2mb
@@ -472,3 +474,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/directives/layout.coffee

@@ -112,7 +112,8 @@ define [
 					element.layout().resizeAll()
 
 					if attrs.resizeOn?
-						scope.$on attrs.resizeOn, () -> onExternalResize()
+						for event in attrs.resizeOn.split ","
+							scope.$on event, () -> onExternalResize()
 
 					if hasCustomToggler
 						state = element.layout().readState()

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

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

+ 187 - 17
services/web/public/coffee/ide/history/HistoryV2Manager.coffee

@@ -6,9 +6,14 @@ define [
 	"ide/history/controllers/HistoryV2ListController"
 	"ide/history/controllers/HistoryV2DiffController"
 	"ide/history/controllers/HistoryV2FileTreeController"
+	"ide/history/controllers/HistoryV2ToolbarController"
+	"ide/history/controllers/HistoryV2AddLabelModalController"
+	"ide/history/controllers/HistoryV2DeleteLabelModalController"
 	"ide/history/directives/infiniteScroll"
 	"ide/history/components/historyEntriesList"
 	"ide/history/components/historyEntry"
+	"ide/history/components/historyLabelsList"
+	"ide/history/components/historyLabel"
 	"ide/history/components/historyFileTree"
 	"ide/history/components/historyFileEntity"
 ], (moment, ColorManager, displayNameForUser, HistoryViewModes) ->
@@ -22,6 +27,9 @@ define [
 					@hide()
 				else
 					@show()
+				@ide.$timeout () =>
+					@$scope.$broadcast "history:toggle"
+				, 0
 
 			@$scope.toggleHistoryViewMode = () =>
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
@@ -30,6 +38,9 @@ define [
 				else
 					@reset()
 					@$scope.history.viewMode = HistoryViewModes.COMPARE
+				@ide.$timeout () =>
+					@$scope.$broadcast "history:toggle"
+				, 0
 
 			@$scope.$watch "history.selection.updates", (updates) =>
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
@@ -44,6 +55,18 @@ define [
 				else 
 					@reloadDiff()
 
+			@$scope.$watch "history.showOnlyLabels", (showOnlyLabels, prevVal) =>
+				if showOnlyLabels? and showOnlyLabels != prevVal 
+					if showOnlyLabels
+						@selectedLabelFromUpdatesSelection()
+					else
+						@$scope.history.selection.label = null
+						if @$scope.history.selection.updates.length == 0
+							@autoSelectLastUpdate()
+
+			@$scope.$watch "history.updates.length", () =>
+				@recalculateSelectedUpdates()
+
 		show: () ->
 			@$scope.ui.view = "history"
 			@reset()
@@ -59,7 +82,10 @@ define [
 				viewMode: null
 				nextBeforeTimestamp: null
 				atEnd: false
+				userHasFullFeature: @$scope.project?.features?.versioning or false
+				freeHistoryLimitHit: false
 				selection: {
+					label: null
 					updates: []
 					docs: {}
 					pathname: null
@@ -68,6 +94,9 @@ define [
 						toV: null
 					}
 				}
+				error: null
+				showOnlyLabels: false
+				labels: null
 				files: []
 				diff: null # When history.viewMode == HistoryViewModes.COMPARE
 				selectedFile: null # When history.viewMode == HistoryViewModes.POINT_IN_TIME
@@ -81,10 +110,9 @@ define [
 				_csrf: window.csrfToken
 			})
 
-		loadFileTreeForUpdate: (update) ->
-			{fromV, toV} = update
+		loadFileTreeForVersion: (version) ->
 			url = "/project/#{@$scope.project_id}/filetree/diff"
-			query = [ "from=#{toV}", "to=#{toV}" ]
+			query = [ "from=#{version}", "to=#{version}" ]
 			url += "?" + query.join("&")
 			@$scope.history.loadingFileTree = true
 			@$scope.history.selectedFile = null
@@ -113,6 +141,10 @@ define [
 			return if @$scope.history.updates.length == 0
 			@selectUpdate @$scope.history.updates[0]
 
+		autoSelectLastLabel: () ->
+			return if @$scope.history.labels.length == 0
+			@selectLabel @$scope.history.labels[0]
+			
 		selectUpdate: (update) ->
 			selectedUpdateIndex = @$scope.history.updates.indexOf update
 			if selectedUpdateIndex == -1
@@ -122,28 +154,109 @@ define [
 				update.selectedFrom = false
 			@$scope.history.updates[selectedUpdateIndex].selectedTo = true
 			@$scope.history.updates[selectedUpdateIndex].selectedFrom = true
-			@loadFileTreeForUpdate @$scope.history.updates[selectedUpdateIndex]
+			@recalculateSelectedUpdates()
+			@loadFileTreeForVersion @$scope.history.updates[selectedUpdateIndex].toV
+
+		selectedLabelFromUpdatesSelection: () ->
+			# Get the number of labels associated with the currently selected update
+			nSelectedLabels = @$scope.history.selection.updates?[0]?.labels?.length
+			# If the currently selected update has no labels, select the last one (version-wise)
+			if nSelectedLabels == 0
+				@autoSelectLastLabel()
+			# If the update has one label, select it 
+			else if nSelectedLabels == 1
+				@selectLabel @$scope.history.selection.updates[0].labels[0]
+			# If there are multiple labels for the update, select the latest
+			else if nSelectedLabels > 1
+				sortedLabels = @ide.$filter("orderBy")(@$scope.history.selection.updates[0].labels, '-created_at')
+				lastLabelFromUpdate = sortedLabels[0]
+				@selectLabel lastLabelFromUpdate
+				
+		selectLabel: (labelToSelect) ->
+			updateToSelect = null
+
+			if @_isLabelSelected labelToSelect
+				# Label already selected
+				return
+
+			for update in @$scope.history.updates
+				if update.toV == labelToSelect.version
+					updateToSelect = update
+					break
+
+			@$scope.history.selection.label = labelToSelect
+			if updateToSelect?
+				@selectUpdate updateToSelect
+			else
+				@$scope.history.selection.updates = []
+				@loadFileTreeForVersion labelToSelect.version
+
+		recalculateSelectedUpdates: () ->
+			beforeSelection = true
+			afterSelection = false
+			@$scope.history.selection.updates = []
+			for update in @$scope.history.updates
+				if update.selectedTo
+					inSelection = true
+					beforeSelection = false
+
+				update.beforeSelection = beforeSelection
+				update.inSelection = inSelection
+				update.afterSelection = afterSelection
+
+				if inSelection
+					@$scope.history.selection.updates.push update
+
+				if update.selectedFrom
+					inSelection = false
+					afterSelection = true
 
 		BATCH_SIZE: 10
 		fetchNextBatchOfUpdates: () ->
-			url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
+			updatesURL = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
 			if @$scope.history.nextBeforeTimestamp?
-				url += "&before=#{@$scope.history.nextBeforeTimestamp}"
+				updatesURL += "&before=#{@$scope.history.nextBeforeTimestamp}"
+			labelsURL = "/project/#{@ide.project_id}/labels"
+
 			@$scope.history.loading = true
 			@$scope.history.loadingFileTree = true
-			@ide.$http
-				.get(url)
+			
+			requests = 
+				updates: @ide.$http.get updatesURL
+			
+			if !@$scope.history.labels?
+				requests.labels = @ide.$http.get labelsURL
+
+			@ide.$q.all requests
 				.then (response) =>
-					{ data } = response
-					@_loadUpdates(data.updates)
-					@$scope.history.nextBeforeTimestamp = data.nextBeforeTimestamp
-					if !data.nextBeforeTimestamp?
+					updatesData = response.updates.data
+					if response.labels?
+						@$scope.history.labels = @_sortLabelsByVersionAndDate response.labels.data
+					@_loadUpdates(updatesData.updates)
+					@$scope.history.nextBeforeTimestamp = updatesData.nextBeforeTimestamp
+					if !updatesData.nextBeforeTimestamp? or @$scope.history.freeHistoryLimitHit
 						@$scope.history.atEnd = true
 					@$scope.history.loading = false
+					if @$scope.history.updates.length == 0
+						@$scope.history.loadingFileTree = false
+				.catch (error) =>
+					{ status, statusText } = error
+					@$scope.history.error = { status, statusText }
+					@$scope.history.loading = false
+					@$scope.history.loadingFileTree = false
+
+		_sortLabelsByVersionAndDate: (labels) ->
+			@ide.$filter("orderBy")(labels, [ '-version', '-created_at' ])
 
 		loadFileAtPointInTime: () ->
 			pathname = @$scope.history.selection.pathname
-			toV =  @$scope.history.selection.updates[0].toV
+			if @$scope.history.selection.updates?[0]?
+				toV = @$scope.history.selection.updates[0].toV
+			else if @$scope.history.selection.label?
+				toV = @$scope.history.selection.label.version
+
+			if !toV?
+				return
 			url = "/project/#{@$scope.project_id}/diff"
 			query = ["pathname=#{encodeURIComponent(pathname)}", "from=#{toV}", "to=#{toV}"]
 			url += "?" + query.join("&")
@@ -199,6 +312,32 @@ define [
 					diff.loading = false
 					diff.error = true
 
+		labelCurrentVersion: (labelComment) => 
+			@_labelVersion labelComment, @$scope.history.selection.updates[0].toV
+
+		deleteLabel: (label) =>
+			url = "/project/#{@$scope.project_id}/labels/#{label.id}"
+
+			@ide.$http({
+				url,
+				method: "DELETE"
+				headers:
+					"X-CSRF-Token": window.csrfToken
+			}).then (response) =>
+				@_deleteLabelLocally label
+
+		_isLabelSelected: (label) ->
+			label.id == @$scope.history.selection.label?.id
+
+		_deleteLabelLocally: (labelToDelete) ->
+			for update, i in @$scope.history.updates
+				if update.toV == labelToDelete.version
+					update.labels = _.filter update.labels, (label) -> 
+						label.id != labelToDelete.id
+					break 
+			@$scope.history.labels = _.filter @$scope.history.labels, (label) -> 
+				label.id != labelToDelete.id
+
 		_parseDiff: (diff) ->
 			if diff.binary
 				return { binary: true }
@@ -252,23 +391,34 @@ define [
 
 		_loadUpdates: (updates = []) ->
 			previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1]
-
-			for update in updates or []
+			dateTimeNow = new Date()
+			timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1)
+			cutOffIndex = null
+			
+			for update, i in updates or []
 				for user in update.meta.users or []
 					if user?
 						user.hue = ColorManager.getHueForUserId(user.id)
 
 				if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day")
 					update.meta.first_in_day = true
-
+				
 				update.selectedFrom = false
 				update.selectedTo = false
 				update.inSelection = false
 
 				previousUpdate = update
 
+				if !@$scope.history.userHasFullFeature and update.meta.end_ts < timestamp24hoursAgo
+					cutOffIndex = i or 1 # Make sure that we show at least one entry (to allow labelling).
+					@$scope.history.freeHistoryLimitHit = true
+					break
+
 			firstLoad = @$scope.history.updates.length == 0
 
+			if !@$scope.history.userHasFullFeature and cutOffIndex?
+				updates = updates.slice 0, cutOffIndex
+
 			@$scope.history.updates =
 				@$scope.history.updates.concat(updates)
 
@@ -276,7 +426,27 @@ define [
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
 					@autoSelectRecentUpdates()
 				else 
-					@autoSelectLastUpdate()
+					if @$scope.history.showOnlyLabels
+						@autoSelectLastLabel()
+					else
+						@autoSelectLastUpdate()
+
+		_labelVersion: (comment, version) ->
+			url = "/project/#{@$scope.project_id}/labels"
+			@ide.$http
+				.post url, {
+					comment,
+					version,
+					_csrf: window.csrfToken
+				}
+				.then (response) =>
+					@_addLabelToLocalUpdate response.data
+
+		_addLabelToLocalUpdate: (label) =>
+			localUpdate = _.find @$scope.history.updates, (update) -> update.toV == label.version
+			if localUpdate?
+				localUpdate.labels = @_sortLabelsByVersionAndDate localUpdate.labels.concat label
+			@$scope.history.labels = @_sortLabelsByVersionAndDate @$scope.history.labels.concat label
 
 		_perDocSummaryOfUpdates: (updates) ->
 			# Track current_pathname -> original_pathname

+ 20 - 0
services/web/public/coffee/ide/history/components/historyEntriesList.coffee

@@ -3,17 +3,37 @@ define [
 ], (App) ->
 	historyEntriesListController = ($scope, $element, $attrs) ->
 		ctrl = @ 
+		ctrl.$entryListViewportEl = null
+		_isEntryElVisible = ($entryEl) ->
+			entryElTop = $entryEl.offset().top
+			entryElBottom = entryElTop + $entryEl.outerHeight()
+			entryListViewportElTop = ctrl.$entryListViewportEl.offset().top
+			entryListViewportElBottom = entryListViewportElTop + ctrl.$entryListViewportEl.height()
+			return entryElTop >= entryListViewportElTop and entryElBottom <= entryListViewportElBottom;
+		_getScrollTopPosForEntry = ($entryEl) ->
+			halfViewportElHeight = ctrl.$entryListViewportEl.height() / 2
+			return $entryEl.offset().top - halfViewportElHeight
+		ctrl.onEntryLinked = (entry, $entryEl) ->
+			if entry.selectedTo and entry.selectedFrom and !_isEntryElVisible $entryEl
+				$scope.$applyAsync () ->
+					ctrl.$entryListViewportEl.scrollTop _getScrollTopPosForEntry $entryEl
+		ctrl.$onInit = () ->
+			ctrl.$entryListViewportEl = $element.find "> .history-entries"
 		return
 
 	App.component "historyEntriesList", {
 		bindings:
 			entries: "<"
+			users: "<"
 			loadEntries: "&"
 			loadDisabled: "<"
 			loadInitialize: "<"
 			isLoading: "<"
 			currentUser: "<"
+			freeHistoryLimitHit: "<"
+			currentUserIsOwner: "<"
 			onEntrySelect: "&"
+			onLabelDelete: "&"
 		controller: historyEntriesListController
 		templateUrl: "historyEntriesListTpl"
 	}

+ 20 - 3
services/web/public/coffee/ide/history/components/historyEntry.coffee

@@ -1,27 +1,44 @@
 define [
 	"base"
+	"ide/colors/ColorManager"
 	"ide/history/util/displayNameForUser"
-], (App, displayNameForUser) ->
-	historyEntryController = ($scope, $element, $attrs) ->
+], (App, ColorManager, displayNameForUser) ->
+	historyEntryController = ($scope, $element, $attrs, _) ->
 		ctrl = @
+		# This method (and maybe the one below) will be removed soon. User details data will be 
+		# injected into the history API responses, so we won't need to fetch user data from other
+		# local data structures.
+		_getUserById = (id) ->
+			_.find ctrl.users, (user) ->
+				curUserId = user?._id or user?.id
+				curUserId == id
 		ctrl.displayName = displayNameForUser
+		ctrl.displayNameById = (id) ->
+			displayNameForUser(_getUserById(id))
 		ctrl.getProjectOpDoc = (projectOp) ->
 			if projectOp.rename? then "#{ projectOp.rename.pathname} → #{ projectOp.rename.newPathname }"
 			else if projectOp.add? then "#{ projectOp.add.pathname}"
 			else if projectOp.remove? then "#{ projectOp.remove.pathname}"
 		ctrl.getUserCSSStyle = (user) ->
-			hue = user?.hue or 100
+			curUserId = user?._id or user?.id
+			hue = ColorManager.getHueForUserId(curUserId) or 100
 			if ctrl.entry.inSelection 
 				color : "#FFF" 
 			else 
 				color: "hsl(#{ hue }, 70%, 50%)"
+		ctrl.$onInit = () ->
+			ctrl.historyEntriesList.onEntryLinked ctrl.entry, $element.find "> .history-entry"
 		return
 
 	App.component "historyEntry", {
 		bindings:
 			entry: "<"
 			currentUser: "<"
+			users: "<"
 			onSelect: "&"
+			onLabelDelete: "&"
+		require:
+			historyEntriesList: '^historyEntriesList'
 		controller: historyEntryController
 		templateUrl: "historyEntryTpl"
 	}

+ 20 - 0
services/web/public/coffee/ide/history/components/historyLabel.coffee

@@ -0,0 +1,20 @@
+define [
+	"base"
+], (App) ->
+	historyLabelController = ($scope, $element, $attrs, $filter, _) ->
+		ctrl = @
+		ctrl.$onInit = () ->
+			ctrl.showTooltip ?= true
+		return
+
+	App.component "historyLabel", {
+		bindings:
+			labelText: "<"
+			labelOwnerName: "<?"
+			labelCreationDateTime: "<?"
+			isOwnedByCurrentUser: "<"
+			onLabelDelete: "&"
+			showTooltip: "<?"
+		controller: historyLabelController
+		templateUrl: "historyLabelTpl"
+	}

+ 36 - 0
services/web/public/coffee/ide/history/components/historyLabelsList.coffee

@@ -0,0 +1,36 @@
+define [
+	"base"
+	"ide/colors/ColorManager"
+	"ide/history/util/displayNameForUser"
+], (App, ColorManager, displayNameForUser) ->
+	historyLabelsListController = ($scope, $element, $attrs) ->
+		ctrl = @ 
+		# This method (and maybe the one below) will be removed soon. User details data will be 
+		# injected into the history API responses, so we won't need to fetch user data from other
+		# local data structures.
+		ctrl.getUserById = (id) ->
+			_.find ctrl.users, (user) ->
+				curUserId = user?._id or user?.id
+				curUserId == id
+		ctrl.displayName = displayNameForUser
+		ctrl.getUserCSSStyle = (user, label) ->
+			curUserId = user?._id or user?.id
+			hue = ColorManager.getHueForUserId(curUserId) or 100
+			if label.id == ctrl.selectedLabel?.id
+				color : "#FFF" 
+			else 
+				color: "hsl(#{ hue }, 70%, 50%)"
+		return
+
+	App.component "historyLabelsList", {
+		bindings:
+			labels: "<"
+			users: "<"
+			currentUser: "<"
+			isLoading: "<"
+			selectedLabel: "<"
+			onLabelSelect: "&"
+			onLabelDelete: "&"
+		controller: historyLabelsListController
+		templateUrl: "historyLabelsListTpl"
+	}

+ 27 - 2
services/web/public/coffee/ide/history/controllers/HistoryListController.coffee

@@ -3,9 +3,34 @@ define [
 	"ide/history/util/displayNameForUser"
 ], (App, displayNameForUser) ->
 
-	App.controller "HistoryListController", ["$scope", "ide", ($scope, ide) ->
+	App.controller "HistoryListController", ["$scope", "$modal", "ide", ($scope, $modal, ide) ->
 		$scope.hoveringOverListSelectors = false
-		
+
+		$scope.projectUsers = []
+
+		$scope.$watch "project.members", (newVal) ->
+			if newVal?
+				$scope.projectUsers = newVal.concat $scope.project.owner
+
+		# This method (and maybe the one below) will be removed soon. User details data will be 
+		# injected into the history API responses, so we won't need to fetch user data from other
+		# local data structures.
+		_getUserById = (id) ->
+			_.find $scope.projectUsers, (user) ->
+				curUserId = user?._id or user?.id
+				curUserId == id
+
+		$scope.getDisplayNameById = (id) ->
+			displayNameForUser(_getUserById(id))
+
+		$scope.deleteLabel = (labelDetails) ->
+			$modal.open(
+				templateUrl: "historyV2DeleteLabelModalTemplate"
+				controller: "HistoryV2DeleteLabelModalController"
+				resolve:
+					labelDetails: () -> labelDetails
+			)
+
 		$scope.loadMore = () =>
 			ide.historyManager.fetchNextBatchOfUpdates()
 

+ 29 - 0
services/web/public/coffee/ide/history/controllers/HistoryV2AddLabelModalController.coffee

@@ -0,0 +1,29 @@
+define [
+	"base",
+], (App) ->
+	App.controller "HistoryV2AddLabelModalController", ["$scope", "$modalInstance", "ide", "update", ($scope, $modalInstance, ide, update) ->
+		$scope.update = update
+		$scope.inputs = 
+			labelName: null
+		$scope.state =
+			inflight: false
+			error: false
+			
+		$modalInstance.opened.then () ->
+			$scope.$applyAsync () ->
+				$scope.$broadcast "open"
+
+		$scope.addLabelModalFormSubmit = () ->
+			$scope.state.inflight = true
+			ide.historyManager.labelCurrentVersion $scope.inputs.labelName
+				.then (response) ->
+					$scope.state.inflight = false
+					$modalInstance.close()
+				.catch (response) ->
+					{ data, status } = response
+					$scope.state.inflight = false
+					if status == 400
+						$scope.state.error = { message: data }
+					else
+						$scope.state.error = true
+	]

+ 23 - 0
services/web/public/coffee/ide/history/controllers/HistoryV2DeleteLabelModalController.coffee

@@ -0,0 +1,23 @@
+define [
+	"base",
+], (App) ->
+	App.controller "HistoryV2DeleteLabelModalController", ["$scope", "$modalInstance", "ide", "labelDetails", ($scope, $modalInstance, ide, labelDetails) ->
+		$scope.labelDetails = labelDetails
+		$scope.state =
+			inflight: false
+			error: false
+
+		$scope.deleteLabel = () ->
+			$scope.state.inflight = true
+			ide.historyManager.deleteLabel labelDetails
+				.then (response) ->
+					$scope.state.inflight = false
+					$modalInstance.close()
+				.catch (response) ->
+					{ data, status } = response
+					$scope.state.inflight = false
+					if status == 400
+						$scope.state.error = { message: data }
+					else
+						$scope.state.error = true
+	]

+ 19 - 62
services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee

@@ -3,74 +3,31 @@ define [
 	"ide/history/util/displayNameForUser"
 ], (App, displayNameForUser) ->
 
-	App.controller "HistoryV2ListController", ["$scope", "ide", ($scope, ide) ->
+	App.controller "HistoryV2ListController", ["$scope", "$modal", "ide", ($scope, $modal, ide) ->
 		$scope.hoveringOverListSelectors = false
+		$scope.listConfig =
+			showOnlyLabelled: false
 		
+		$scope.projectUsers = []
+
+		$scope.$watch "project.members", (newVal) ->
+			if newVal?
+				$scope.projectUsers = newVal.concat $scope.project.owner
+
 		$scope.loadMore = () =>
 			ide.historyManager.fetchNextBatchOfUpdates()
 
 		$scope.handleEntrySelect = (entry) ->
-			# $scope.$applyAsync () ->
 			ide.historyManager.selectUpdate(entry)
-			$scope.recalculateSelectedUpdates()
-
-		$scope.recalculateSelectedUpdates = () ->
-			beforeSelection = true
-			afterSelection = false
-			$scope.history.selection.updates = []
-			for update in $scope.history.updates
-				if update.selectedTo
-					inSelection = true
-					beforeSelection = false
-
-				update.beforeSelection = beforeSelection
-				update.inSelection = inSelection
-				update.afterSelection = afterSelection
-
-				if inSelection
-					$scope.history.selection.updates.push update
-
-				if update.selectedFrom
-					inSelection = false
-					afterSelection = true
-
-		$scope.recalculateHoveredUpdates = () ->
-			hoverSelectedFrom = false
-			hoverSelectedTo = false
-			for update in $scope.history.updates
-				# Figure out whether the to or from selector is hovered over
-				if update.hoverSelectedFrom
-					hoverSelectedFrom = true
-				if update.hoverSelectedTo
-					hoverSelectedTo = true
-
-			if hoverSelectedFrom
-				# We want to 'hover select' everything between hoverSelectedFrom and selectedTo
-				inHoverSelection = false
-				for update in $scope.history.updates
-					if update.selectedTo
-						update.hoverSelectedTo = true
-						inHoverSelection = true
-					update.inHoverSelection = inHoverSelection
-					if update.hoverSelectedFrom
-						inHoverSelection = false
-			if hoverSelectedTo
-				# We want to 'hover select' everything between hoverSelectedTo and selectedFrom
-				inHoverSelection = false
-				for update in $scope.history.updates
-					if update.hoverSelectedTo
-						inHoverSelection = true
-					update.inHoverSelection = inHoverSelection
-					if update.selectedFrom
-						update.hoverSelectedFrom = true
-						inHoverSelection = false
-
-		$scope.resetHoverState = () ->
-			for update in $scope.history.updates
-				delete update.hoverSelectedFrom
-				delete update.hoverSelectedTo
-				delete update.inHoverSelection
 
-		$scope.$watch "history.updates.length", () ->
-			$scope.recalculateSelectedUpdates()
+		$scope.handleLabelSelect = (label) ->
+			ide.historyManager.selectLabel(label)
+			
+		$scope.handleLabelDelete = (labelDetails) ->
+			$modal.open(
+				templateUrl: "historyV2DeleteLabelModalTemplate"
+				controller: "HistoryV2DeleteLabelModalController"
+				resolve:
+					labelDetails: () -> labelDetails
+			)
 	]

+ 12 - 0
services/web/public/coffee/ide/history/controllers/HistoryV2ToolbarController.coffee

@@ -0,0 +1,12 @@
+define [
+	"base",
+], (App) ->
+	App.controller "HistoryV2ToolbarController", ["$scope", "$modal", "ide", ($scope, $modal, ide) ->
+		$scope.showAddLabelDialog = () ->
+			$modal.open(
+				templateUrl: "historyV2AddLabelModalTemplate"
+				controller: "HistoryV2AddLabelModalController"
+				resolve:
+					update: () -> $scope.history.selection.updates[0]
+			)
+	]

+ 24 - 16
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -12,6 +12,10 @@ define [
 	# and then again on ack.
 	AUTO_COMPILE_DEBOUNCE = 2000
 
+	App.filter('trusted', ['$sce', ($sce)->
+		return (url)-> return $sce.trustAsResourceUrl(url);
+	])
+
 	App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, logHintsFeedback, localStorage) ->
 		# enable per-user containers by default
 		perUserCompile = true
@@ -224,6 +228,13 @@ define [
 				_csrf: window.csrfToken
 			}, {params: params}
 
+		buildPdfDownloadUrl = (pdfDownloadDomain, path)->
+			 #we only download builds from compiles server for security reasons
+			if pdfDownloadDomain? and path? and path.indexOf("build") != -1
+				return "#{pdfDownloadDomain}#{path}"
+			else
+				return path
+
 		parseCompileResponse = (response) ->
 
 			# keep last url
@@ -244,11 +255,7 @@ define [
 			$scope.pdf.compileInProgress = false
 			$scope.pdf.autoCompileDisabled = false
 
-			buildPdfDownloadUrl = (path)->
-				if pdfDownloadDomain?
-					return "#{pdfDownloadDomain}#{path}"
-				else
-					return path
+
 			# make a cache to look up files by name
 			fileByPath = {}
 			if response?.outputFiles?
@@ -267,24 +274,24 @@ define [
 			if response.status == "timedout"
 				$scope.pdf.view = 'errors'
 				$scope.pdf.timedout = true
-				fetchLogs(fileByPath)
+				fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain})
 			else if response.status == "terminated"
 				$scope.pdf.view = 'errors'
 				$scope.pdf.compileTerminated = true
-				fetchLogs(fileByPath)
+				fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain})
 			else if response.status in ["validation-fail", "validation-pass"]
 				$scope.pdf.view = 'pdf'
-				$scope.pdf.url = buildPdfDownloadUrl last_pdf_url
+				$scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, last_pdf_url
 				$scope.shouldShowLogs = true
 				$scope.pdf.failedCheck = true if response.status is "validation-fail"
 				event_tracking.sendMB "syntax-check-#{response.status}"
-				fetchLogs(fileByPath, { validation: true })
+				fetchLogs(fileByPath, { validation: true, pdfDownloadDomain:pdfDownloadDomain})
 			else if response.status == "exited"
 				$scope.pdf.view = 'pdf'
 				$scope.pdf.compileExited = true
-				$scope.pdf.url = buildPdfDownloadUrl last_pdf_url
+				$scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, last_pdf_url
 				$scope.shouldShowLogs = true
-				fetchLogs(fileByPath)
+				fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain})
 			else if response.status == "autocompile-backoff"
 				if $scope.pdf.isAutoCompileOnLoad # initial autocompile
 					$scope.pdf.view = 'uncompiled'
@@ -300,7 +307,7 @@ define [
 				$scope.pdf.view = 'errors'
 				$scope.pdf.failure = true
 				$scope.shouldShowLogs = true
-				fetchLogs(fileByPath)
+				fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain})
 			else if response.status == 'clsi-maintenance'
 				$scope.pdf.view = 'errors'
 				$scope.pdf.clsiMaintenance = true
@@ -320,12 +327,12 @@ define [
 
 				# define the base url. if the pdf file has a build number, pass it to the clsi in the url
 				if fileByPath['output.pdf']?.url?
-					$scope.pdf.url = buildPdfDownloadUrl fileByPath['output.pdf'].url
+					$scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, fileByPath['output.pdf'].url
 				else if fileByPath['output.pdf']?.build?
 					build = fileByPath['output.pdf'].build
-					$scope.pdf.url = buildPdfDownloadUrl "/project/#{$scope.project_id}/build/#{build}/output/output.pdf"
+					$scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, "/project/#{$scope.project_id}/build/#{build}/output/output.pdf"
 				else
-					$scope.pdf.url = buildPdfDownloadUrl "/project/#{$scope.project_id}/output/output.pdf"
+					$scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, "/project/#{$scope.project_id}/output/output.pdf"
 				# check if we need to bust cache (build id is unique so don't need it in that case)
 				if not fileByPath['output.pdf']?.build?
 					qs.cache_bust = "#{Date.now()}"
@@ -335,7 +342,7 @@ define [
 				qs.popupDownload = true
 				$scope.pdf.downloadUrl = "/project/#{$scope.project_id}/output/output.pdf" + createQueryString(qs)
 
-				fetchLogs(fileByPath)
+				fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain})
 
 			IGNORE_FILES = ["output.fls", "output.fdb_latexmk"]
 			$scope.pdf.outputFiles = []
@@ -384,6 +391,7 @@ define [
 				# check if we need to bust cache (build id is unique so don't need it in that case)
 				if not file?.build?
 					opts.params.cache_bust = "#{Date.now()}"
+				opts.url = buildPdfDownloadUrl options.pdfDownloadDomain, opts.url
 				return $http(opts)
 
 			# accumulate the log entries

+ 3 - 1
services/web/public/coffee/ide/services/ide.coffee

@@ -3,11 +3,13 @@ define [
 ], (App) ->
 	# We create and provide this as service so that we can access the global ide
 	# from within other parts of the angular app.
-	App.factory "ide", ["$http", "queuedHttp", "$modal", "$q", ($http, queuedHttp, $modal, $q) ->
+	App.factory "ide", ["$http", "queuedHttp", "$modal", "$q", "$filter", "$timeout", ($http, queuedHttp, $modal, $q, $filter, $timeout) ->
 		ide = {}
 		ide.$http = $http
 		ide.queuedHttp = queuedHttp
 		ide.$q = $q
+		ide.$filter = $filter
+		ide.$timeout = $timeout
 
 		@recentEvents = []
 		ide.pushEvent = (type, meta = {}) =>

+ 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 () =>

+ 12 - 6
services/web/public/coffee/main/account-settings.coffee

@@ -1,7 +1,7 @@
 define [
 	"base"
 ], (App) ->
-	App.controller "AccountSettingsController", ["$scope", "$http", "$modal", "event_tracking", ($scope, $http, $modal, event_tracking) ->
+	App.controller "AccountSettingsController", ["$scope", "$http", "$modal", "event_tracking", "UserAffiliationsDataService", ($scope, $http, $modal, event_tracking, UserAffiliationsDataService) ->
 		$scope.subscribed = true
 
 		$scope.unsubscribe = () ->
@@ -21,8 +21,14 @@ define [
 		$scope.deleteAccount = () ->
 			modalInstance = $modal.open(
 				templateUrl: "deleteAccountModalTemplate"
-				controller: "DeleteAccountModalController",
-				scope: $scope
+				controller: "DeleteAccountModalController"
+				resolve:
+					userDefaultEmail: () ->
+						UserAffiliationsDataService
+							.getUserDefaultEmail()
+							.then (defaultEmailDetails) ->
+								return defaultEmailDetails?.email or null
+							.catch () -> null
 			)
 
 		$scope.upgradeIntegration = (service) ->
@@ -30,8 +36,8 @@ define [
 	]
 
 	App.controller "DeleteAccountModalController", [
-		"$scope", "$modalInstance", "$timeout", "$http",
-		($scope,   $modalInstance,   $timeout,   $http) ->
+		"$scope", "$modalInstance", "$timeout", "$http", "userDefaultEmail",
+		($scope,   $modalInstance,   $timeout,   $http,   userDefaultEmail) ->
 			$scope.state =
 				isValid : false
 				deleteText: ""
@@ -46,7 +52,7 @@ define [
 				, 700
 
 			$scope.checkValidation = ->
-				$scope.state.isValid = $scope.state.deleteText == $scope.email and $scope.state.password.length > 0
+				$scope.state.isValid = userDefaultEmail? and $scope.state.deleteText == userDefaultEmail and $scope.state.password.length > 0
 
 			$scope.delete = () ->
 				$scope.state.inflight = true

+ 5 - 0
services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee

@@ -31,6 +31,10 @@ define [
 			$http.get "/user/emails"
 				.then (response) -> response.data
 
+		getUserDefaultEmail = () ->
+			getUserEmails().then (userEmails) ->
+				_.find userEmails, (userEmail) -> userEmail.default
+
 		getUniversitiesFromCountry = (country) ->
 			if universities[country.code]?
 				universitiesFromCountry = universities[country.code]
@@ -118,6 +122,7 @@ define [
 			getDefaultRoleHints
 			getDefaultDepartmentHints
 			getUserEmails
+			getUserDefaultEmail
 			getUniversitiesFromCountry
 			getUniversityDomainFromPartialDomainInput
 			getUniversityDetails

+ 8 - 9
services/web/public/coffee/main/new-subscription.coffee

@@ -4,20 +4,21 @@ define [
 	"libs/recurly-4.8.5"
 ], (App)->
 
-	App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack, event_tracking, ccUtils)->
+	App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack, event_tracking, ccUtils, ipCookie)->
 		throw new Error("Recurly API Library Missing.")  if typeof recurly is "undefined"
 
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
 		$scope.plans = MultiCurrencyPricing.plans
 		$scope.planCode = window.plan_code
+		$scope.plansVariant = ipCookie('plansVariant')
 
 		$scope.switchToStudent = ()->
 			currentPlanCode = window.plan_code
 			planCode = currentPlanCode.replace('collaborator', 'student')
-			event_tracking.sendMB 'subscription-form-switch-to-student', { plan: window.plan_code }
+			event_tracking.sendMB 'subscription-form-switch-to-student', { plan: window.plan_code, variant: $scope.plansVariant }
 			window.location = "/user/subscription/new?planCode=#{planCode}&currency=#{$scope.currencyCode}&cc=#{$scope.data.coupon}"
 
-		event_tracking.sendMB "subscription-form", { plan : window.plan_code }
+		event_tracking.sendMB "subscription-form", { plan : window.plan_code, variant: $scope.plansVariant }
 
 		$scope.paymentMethod =
 			value: "credit_card"
@@ -143,13 +144,14 @@ define [
 					currencyCode	: postData.subscriptionDetails.currencyCode,
 					plan_code		: postData.subscriptionDetails.plan_code,
 					coupon_code		: postData.subscriptionDetails.coupon_code,
-					isPaypal		: postData.subscriptionDetails.isPaypal
+					isPaypal		: postData.subscriptionDetails.isPaypal,
+					variant			: $scope.plansVariant
 				}
 
 
 				$http.post("/user/subscription/create", postData)
 					.then ()->
-						event_tracking.sendMB "subscription-submission-success"
+						event_tracking.sendMB "subscription-submission-success", { variant: $scope.plansVariant }
 						window.location.href = "/user/subscription/thank-you"
 					.catch ()->
 						$scope.processing = false
@@ -234,7 +236,4 @@ define [
 			{code:'VU',name:'Vanuatu'},{code:'VA',name:'Vatican City'},{code:'VE',name:'Venezuela'},{code:'VN',name:'Vietnam'},
 			{code:'WK',name:'Wake Island'},{code:'WF',name:'Wallis and Futuna'},{code:'EH',name:'Western Sahara'},{code:'YE',name:'Yemen'},
 			{code:'ZM',name:'Zambia'},{code:'AX',name:'&angst;land Islandscode:'}
-		]
-
-		sixpack.participate 'plans', ['default', 'more-details'], (chosenVariation, rawResponse)->
-			$scope.plansVariant = chosenVariation
+		]

+ 11 - 5
services/web/public/coffee/main/plans.coffee

@@ -145,15 +145,21 @@ define [
 		}
 
 
-	App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack, $filter) ->
+	App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack, $filter, ipCookie) ->
 
 		$scope.showPlans = false
 		$scope.shouldABTestPlans = window.shouldABTestPlans
 
 		if $scope.shouldABTestPlans
 			sixpack.participate 'plans-details', ['default', 'more-details'], (chosenVariation, rawResponse)->
-				$scope.plansVariant = chosenVariation
-				event_tracking.send 'subscription-funnel', 'plans-page-loaded', chosenVariation
+				if rawResponse?.status != 'failed' 
+					$scope.plansVariant = chosenVariation
+					expiration = new Date();
+					expiration.setDate(expiration.getDate() + 5);
+					ipCookie('plansVariant', chosenVariation, {expires: expiration})
+					event_tracking.send 'subscription-funnel', 'plans-page-loaded', chosenVariation
+				else
+					$scope.timeout = true
 
 		$scope.showPlans = true
 
@@ -184,9 +190,9 @@ define [
 			if $scope.ui.view == "annual"
 				plan = "#{plan}_annual"
 			plan = eventLabel(plan, location)
-			event_tracking.sendMB 'plans-page-start-trial', {plan}
+			event_tracking.sendMB 'plans-page-start-trial', {plan, variant: $scope.plansVariant}
 			event_tracking.send 'subscription-funnel', 'sign_up_now_button', plan
-			if $scope.shouldABTestPlans
+			if $scope.plansVariant
 				sixpack.convert 'plans-details'
 
 		$scope.switchToMonthly = (e, location) ->

+ 10 - 2
services/web/public/coffee/main/project-list/modal-controllers.coffee

@@ -101,11 +101,19 @@ define [
 	App.controller 'DeleteProjectsModalController', ($scope, $modalInstance, $timeout, projects) ->
 		$scope.projectsToDelete = projects.filter (project) -> project.accessLevel == "owner"
 		$scope.projectsToLeave = projects.filter (project) -> project.accessLevel != "owner"
+		$scope.projectsToArchive = projects.filter (project) ->
+			project.accessLevel == "owner" and !project.archived
 
 		if $scope.projectsToLeave.length > 0 and $scope.projectsToDelete.length > 0
-			$scope.action = "delete-and-leave"
+			if $scope.projectsToArchive.length > 0 and window.ExposedSettings.isOverleaf
+				$scope.action = "archive-and-leave"
+			else
+				$scope.action = "delete-and-leave"
 		else if $scope.projectsToLeave.length == 0 and $scope.projectsToDelete.length > 0
-			$scope.action = "delete"
+			if $scope.projectsToArchive.length > 0 and window.ExposedSettings.isOverleaf
+				$scope.action = "archive"
+			else
+				$scope.action = "delete"
 		else
 			$scope.action = "leave"
 

BIN
services/web/public/img/review-icon-sprite-ol.png


BIN
services/web/public/img/review-icon-sprite-ol@2x.png


+ 2 - 0
services/web/public/stylesheets/_style_includes.less

@@ -43,6 +43,8 @@
 @import "components/hover.less";
 @import "components/ui-select.less";
 @import "components/input-suggestions.less";
+@import "components/nvd3.less";
+@import "components/nvd3_override.less";
 
 // Components w/ JavaScript
 @import "components/modals.less";

+ 111 - 338
services/web/public/stylesheets/app/editor/history-v2.less

@@ -15,16 +15,31 @@
 .history-toolbar when (@is-overleaf = false) {
 	border-bottom: @toolbar-border-bottom;
 }
-	.history-toolbar-time {
-		font-weight: bold;
+	.history-toolbar-selected-version {
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		margin-right: (@line-height-computed / 2);
 	}
-	.history-toolbar-btn {
-		.btn;
-		.btn-info;
-		.btn-xs;
-		padding-left: @padding-small-horizontal;
-		padding-right: @padding-small-horizontal;
-		margin-left: (@line-height-computed / 2);
+		.history-toolbar-time,
+		.history-toolbar-selected-label {
+			font-weight: bold;
+		}
+	.history-toolbar-actions {
+		flex-grow: 1;
+	}
+		.history-toolbar-btn {
+			.btn;
+			.btn-info;
+			.btn-xs;
+			padding-left: @padding-small-horizontal;
+			padding-right: @padding-small-horizontal;
+			margin-right: (@line-height-computed / 2);
+		}
+	.history-toolbar-entries-list {
+		flex: 0 0 @changesListWidth;
+		padding: 0 10px;
+		border-left: 1px solid @editor-border-color;
 	}
 
 .history-entries {
@@ -48,11 +63,76 @@
 	padding: 5px 10px;
 	cursor: pointer;
 
-	.history-entry-selected & {
+	.history-entry-selected &,
+	.history-entry-label-selected & {
 		background-color: @history-entry-selected-bg;
 		color: #FFF;
 	}
 }
+	.history-label {
+		display: inline-block;
+		color: @history-entry-label-color;
+		font-size: @font-size-small;
+		margin-bottom: 3px;
+		margin-right: 10px;
+		white-space: nowrap;
+		.history-entry-selected &,
+		.history-entry-label-selected & {
+			color: @history-entry-selected-label-color;
+		}
+	}
+		.history-label-comment,
+		.history-label-delete-btn {
+			padding: 0 @padding-xs-horizontal 1px @padding-xs-horizontal;
+			border: 0;
+			background-color: @history-entry-label-bg-color;
+			.history-entry-selected &,
+			.history-entry-label-selected & {
+				background-color: @history-entry-selected-label-bg-color;
+			}
+		}
+		.history-label-comment {
+			display: block;
+			float: left;
+			border-radius: 9999px;
+			max-width: 190px;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			.history-label-own & {
+				padding-right: (@padding-xs-horizontal / 2);
+				border-radius: 9999px 0 0 9999px;
+			}
+		}
+		.history-label-delete-btn {
+			padding-left: (@padding-xs-horizontal / 2);
+			padding-right: @padding-xs-horizontal;
+			border-radius: 0 9999px 9999px 0;
+			&:hover {
+				background-color: darken(@history-entry-label-bg-color, 8%);
+				.history-entry-selected &,
+				.history-entry-label-selected & {
+					background-color: darken(@history-entry-selected-label-bg-color, 8%);
+				}
+			}
+		}
+
+	.history-label-tooltip {
+		white-space: normal;
+		padding: (@line-height-computed / 4);
+		text-align: left;
+	}
+		.history-label-tooltip-title,
+		.history-label-tooltip-owner,
+		.history-label-tooltip-datetime {
+			margin: 0 0 (@line-height-computed / 4) 0;
+		}  
+		.history-label-tooltip-title {
+			font-weight: bold;
+		}
+		.history-label-tooltip-datetime {
+			margin-bottom: 0;
+		}
+
 	.history-entry-changes {
 		.list-unstyled;
 		margin-bottom: 3px;
@@ -68,7 +148,8 @@
 				color: @history-highlight-color;
 				font-weight: bold;
 				word-break: break-all;
-				.history-entry-selected & {
+				.history-entry-selected &,
+				.history-entry-label-selected & {
 					color: #FFF;
 				}
 			}
@@ -93,6 +174,25 @@
 				}
 			}
 
+.history-entries-list-upgrade-prompt {
+	background-color: #FFF;
+	margin-bottom: 2px;
+	padding: 5px 10px;
+}
+
+.history-labels-list {
+	.history-entries;
+	overflow-y: auto;
+}
+	.history-entry-label {
+		.history-entry-details;
+		padding: 7px 10px;
+		&.history-entry-label-selected {
+			background-color: @history-entry-selected-bg;
+			color: #FFF;
+		}
+	}
+
 .history-file-tree-inner {
 	.full-size;
 	overflow-y: auto;
@@ -169,330 +269,3 @@
 				color: @brand-primary;
 			}
 		}
-// @changesListWidth:   250px;
-// @changesListPadding: @line-height-computed / 2;
-
-// @selector-padding-vertical: 10px;
-// @selector-padding-horizontal: @line-height-computed / 2;
-// @day-header-height: 24px;
-
-// @range-bar-color: @link-color;
-// @range-bar-selected-offset: 14px;
-
-// #history {
-// 	.upgrade-prompt {
-// 		position: absolute;
-// 		top: 0;
-// 		bottom: 0;
-// 		left: 0;
-// 		right: 0;
-// 		z-index: 100;
-// 		background-color: rgba(128,128,128,0.4);
-// 		.message {
-// 			margin: auto;
-// 			margin-top: 100px;
-// 			padding: (@line-height-computed / 2) @line-height-computed;
-// 			width: 400px;
-// 			background-color: white;
-// 			border-radius: 8px;
-// 		}
-// 		.message-wider {
-// 			width: 650px;
-// 			margin-top: 60px;
-// 			padding: 0;
-// 		}
-
-// 			.message-header {
-// 				.modal-header;
-// 			}
-
-// 			.message-body {
-// 				.modal-body;
-// 			}
-// 	}
-
-// 	.diff-panel {
-// 		.full-size;
-// 		margin-right: @changesListWidth;
-// 	}
-
-// 	.diff {
-// 		.full-size;
-// 		.toolbar {
-// 			padding: 3px;
-// 			.name {
-// 				float: left;
-// 				padding: 3px @line-height-computed / 4;
-// 				display: inline-block;
-// 			}
-// 		}
-// 		.diff-editor {
-// 			.full-size;
-// 			top: 40px;
-// 		}
-// 		.hide-ace-cursor {
-// 			.ace_active-line, .ace_cursor-layer, .ace_gutter-active-line {
-// 				display: none;
-// 			}
-// 		}
-// 		.diff-deleted {
-// 			padding: @line-height-computed;
-// 		}
-// 		.deleted-warning {
-// 			background-color: @brand-danger;
-// 			color: white;
-// 			padding: @line-height-computed / 2;
-// 			margin-right: @line-height-computed / 4;
-// 		}
-// 		&-binary {
-// 			.alert {
-// 				margin: @line-height-computed / 2;
-// 			}
-// 		}
-// 	}
-
-// 	aside.change-list {
-// 		border-left: 1px solid @editor-border-color;
-// 		height: 100%;
-// 		width: @changesListWidth;
-// 		position: absolute;
-// 		right: 0;
-
-// 		.loading {
-// 			text-align: center;
-// 			font-family: @font-family-serif;
-// 		}
-
-// 		ul {
-// 			li.change {
-// 				position: relative;
-// 				user-select: none;
-// 				-ms-user-select: none;
-// 				-moz-user-select: none;
-// 				-webkit-user-select: none;
-
-// 				.day {
-// 					background-color: #fafafa;
-// 					border-bottom: 1px solid @editor-border-color;
-// 					padding: 4px;
-// 					font-weight: bold;
-// 					text-align: center;
-// 					height: @day-header-height;
-// 					font-size: 14px;
-// 					line-height: 1;
-// 				}
-// 				.selectors {
-// 					input {
-// 						margin: 0;
-// 					}
-// 					position: absolute;
-// 					left: @selector-padding-horizontal;
-// 					top: 0;
-// 					bottom: 0;
-// 					width: 24px;
-// 					.selector-from {
-// 						position: absolute;
-// 						bottom: @selector-padding-vertical;
-// 						left: 0;
-// 						opacity: 0.8;
-// 					}
-// 					.selector-to {
-// 						position: absolute;
-// 						top: @selector-padding-vertical;
-// 						left: 0;
-// 						opacity: 0.8;
-// 					}
-// 					.range {
-// 						position: absolute;
-// 						left: 5px;
-// 						width: 4px;
-// 						top: 0;
-// 						bottom: 0;
-// 					}
-// 				}
-// 				.description {
-// 					padding: (@line-height-computed / 4);
-// 					padding-left: 38px;
-// 					min-height: 38px;
-// 					border-bottom: 1px solid @editor-border-color;
-// 					cursor: pointer;
-// 					&:hover {
-// 						background-color: @gray-lightest;
-// 					}
-// 				}
-// 				.users {
-// 					.user {
-// 						font-size: 0.8rem;
-// 						color: @gray;
-// 						text-transform: capitalize;
-// 						position: relative;
-// 						padding-left: 16px;
-// 						.color-square {
-// 							height: 12px;
-// 							width: 12px;
-// 							border-radius: 3px;
-// 							position: absolute;
-// 							left: 0;
-// 							bottom: 3px;
-// 						}
-// 						.name {
-// 							width: 94%;
-// 							white-space: nowrap;
-// 							overflow: hidden;
-// 							text-overflow: ellipsis;
-// 						}
-// 					}
-// 				}
-// 				.time {
-// 					float: right;
-// 					color: @gray;
-// 					display: inline-block;
-// 					padding-right: (@line-height-computed / 2);
-// 					font-size: 0.8rem;
-// 					line-height: @line-height-computed;
-// 				}
-// 				.doc {
-// 					font-size: 0.9rem;
-// 					font-weight: bold;
-// 				}
-// 				.action {
-// 					color: @gray;
-// 					text-transform: uppercase;
-// 					font-size: 0.7em;
-// 					margin-bottom: -2px;
-// 					margin-top: 2px;
-// 					&-edited {
-// 						margin-top: 0;
-// 					}
-// 				}
-// 			}
-// 			li.loading-changes, li.empty-message {
-// 				padding: 6px;
-// 				cursor: default;
-// 				&:hover {
-// 					background-color: inherit;
-// 				}
-// 			}
-// 			li.selected {
-// 				border-left: 4px solid @range-bar-color;
-// 				.day {
-// 					padding-left: 0;
-// 				}
-// 				.description {
-// 					padding-left: 34px;
-// 				}
-// 				.selectors {
-// 					left: @selector-padding-horizontal - 4px;
-// 					.range {
-// 						background-color: @range-bar-color;
-// 					}
-// 				}
-// 			}
-// 			li.selected-to {
-// 				.selectors {
-// 					.range {
-// 						top: @range-bar-selected-offset;
-// 					}
-// 					.selector-to {
-// 						opacity: 1;
-// 					}
-// 				}
-// 			}
-// 			li.selected-from {
-// 				.selectors {
-// 					.range {
-// 						bottom: @range-bar-selected-offset;
-// 					}
-// 					.selector-from {
-// 						opacity: 1;
-// 					}
-// 				}
-// 			}
-// 			li.first-in-day {
-// 				.selectors {
-// 					.selector-to {
-// 						top: @day-header-height + @selector-padding-vertical;
-// 					}
-// 				}
-// 			}
-// 			li.first-in-day.selected-to {
-// 				.selectors {
-// 					.range {
-// 						top: @day-header-height + @range-bar-selected-offset;
-// 					}
-// 				}
-// 			}
-// 		}
-// 		ul.hover-state {
-// 			li {
-// 				.selectors {
-// 					.range {
-// 						background-color: transparent;
-// 						top: 0;
-// 						bottom: 0;
-// 					}
-// 				}
-// 			}
-// 			li.hover-selected {
-// 				.selectors {
-// 					.range {
-// 						top: 0;
-// 						background-color: @gray-light;
-// 					}
-// 				}
-// 			}
-// 			li.hover-selected-to {
-// 				.selectors {
-// 					.range {
-// 						top: @range-bar-selected-offset;
-// 					}
-// 					.selector-to {
-// 						opacity: 1;
-// 					}
-// 				}
-// 			}
-// 			li.hover-selected-from {
-// 				.selectors {
-// 					.range {
-// 						bottom: @range-bar-selected-offset;
-// 					}
-// 					.selector-from {
-// 						opacity: 1;
-// 					}
-// 				}
-// 			}
-// 			li.first-in-day.hover-selected-to {
-// 				.selectors {
-// 					.range {
-// 						top: @day-header-height + @range-bar-selected-offset;
-// 					}
-// 				}
-// 			}
-// 		}
-// 	}
-// }
-
-// .diff-deleted {
-// 	padding-top: 15px;
-// }
-
-// .editor-dark {
-// 	#history {
-// 		aside.change-list {
-// 			border-color: @editor-dark-toolbar-border-color;
-			
-// 			ul li.change {
-// 				.day {
-// 					background-color: darken(@editor-dark-background-color, 10%);
-// 					border-bottom: 1px solid @editor-dark-toolbar-border-color;
-// 				}
-// 				.description {
-// 					border-bottom: 1px solid @editor-dark-toolbar-border-color;
-// 					&:hover {
-// 						background-color: black;
-// 					}
-// 				}
-// 			}
-// 		}
-// 	}
-// }

+ 12 - 5
services/web/public/stylesheets/app/editor/review-panel.less

@@ -922,7 +922,7 @@
 	}
 }
 
-.review-icon {
+.review-icon when (@is-overleaf = false) {
 	display: inline-block;
 	background: url('/img/review-icon-sprite.png') top/30px no-repeat;
 	width: 30px;
@@ -945,10 +945,17 @@
 	}
 }
 
-.review-icon when (@is-overleaf) {
-	background-position-y: -60px;
-	.toolbar .btn-full-height:hover & {
-		background-position-y: -60px;
+.review-icon when (@is-overleaf = true) {
+	display: inline-block;
+	background: url('/img/review-icon-sprite-ol.png') top/30px no-repeat;
+	width: 30px;
+
+	&::before {
+		content: '\00a0'; // Non-breakable space. A non-breakable character here makes this icon work like font-awesome.
+	}
+	
+	@media (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) {
+		background-image: url('/img/review-icon-sprite-ol@2x.png');
 	}
 }
 

+ 29 - 28
services/web/public/stylesheets/app/editor/toolbar.less

@@ -194,56 +194,57 @@
 }
 
 .toggle-switch {
-  position: relative;
+	position: relative;
 	height: 100%;
 	width: 100%;
-  background-color: @toggle-switch-bg;
-  border-radius: @btn-border-radius-base;
+	background-color: @toggle-switch-bg;
+	border-radius: @btn-border-radius-base;
 }
 
 .toggle-switch-label {
 	position: relative;
 	display: block;
 	font-weight: normal;
-  z-index: 2;
-  float: left;
-  width: 50%;
-  height: 100%;
-  line-height: 24px;
+	z-index: 2;
+	float: left;
+	width: 50%;
+	height: 100%;
+	line-height: 24px;
 	text-align: center;
 	margin-bottom: 0;
-  cursor: pointer;
-  user-select: none;
-  transition: color 0.12s ease-out;
+	cursor: pointer;
+	user-select: none;
+	color: @text-color;
+	transition: color 0.12s ease-out;
 }
 
 .toggle-switch-input {
-  position: absolute;
-  opacity: 0;
+	position: absolute;
+	opacity: 0;
 }
 
 .toggle-switch-input:checked + .toggle-switch-label {
-  color: #fff;
-  font-weight: bold;
+	color: #fff;
+	font-weight: bold;
 }
 
 .toggle-switch-selection {
-  display: block;
-  position: absolute;
-  z-index: 1;
-  top: 2px;
-  left: 2px;
-  right: 2px;
-  width: calc(~"50% - 2px");
-  height: calc(~"100% - 4px");
-  background: @toggle-switch-highlight-color;
-  border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base;
-  transition: transform 0.12s ease-out, border-radius 0.12s ease-out;
+	display: block;
+	position: absolute;
+	z-index: 1;
+	top: 2px;
+	left: 2px;
+	right: 2px;
+	width: calc(~"50% - 2px");
+	height: calc(~"100% - 4px");
+	background: @toggle-switch-highlight-color;
+	border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base;
+	transition: transform 0.12s ease-out, border-radius 0.12s ease-out;
 }
 
 .toggle-switch-input:checked:nth-child(4) ~ .toggle-switch-selection {
-  transform: translate(100%);
-  border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0;
+	transform: translate(100%);
+	border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0;
 }
 
 /**************************************

+ 5 - 0
services/web/public/stylesheets/app/metrics.less

@@ -87,6 +87,11 @@
       margin-top: 0;
       margin-bottom: 0;
     }
+
+    .metric-tooltip {
+      top: -1em;
+      font-size: .5em;
+    }
   }
   // END: Metrics header
 

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

+ 16 - 2
services/web/public/stylesheets/components/daterange-picker.less

@@ -34,7 +34,7 @@
 @daterangepicker-in-range-bg-color:          #ebf4f8;
 
 @daterangepicker-active-color:               #fff;
-@daterangepicker-active-bg-color:            #a93529;
+@daterangepicker-active-bg-color:            #4F9C45;
 @daterangepicker-active-border-color:        transparent;
 
 @daterangepicker-unselected-color:           #999;
@@ -393,13 +393,14 @@
 
   .daterangepicker_input {
     position: relative;
+    padding-left: 0;
 
     i {
       position: absolute;
 
       // NOTE: These appear to be eyeballed to me...
       left: 8px;
-      top: 8px;
+      top: 10px;
     }
   }
   &.rtl {
@@ -476,6 +477,19 @@
 /*  Larger Screen Styling */
 @media (min-width: 564px) {
   .daterangepicker {
+    .glyphicon {
+      font-family: FontAwesome;
+    }
+    .glyphicon-chevron-left:before{
+      content: "\f053";
+    }
+    .glyphicon-chevron-right:before{
+      content: "\f054";
+    }
+    .glyphicon-calendar:before{
+      content: "\f073";
+    }
+
     width: auto;
 
     .ranges {

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

+ 674 - 0
services/web/public/stylesheets/components/nvd3.less

@@ -0,0 +1,674 @@
+/* nvd3 version 1.8.4 (https://github.com/novus/nvd3) 2016-07-03 */
+.nvd3 .nv-axis {
+    pointer-events:none;
+    opacity: 1;
+}
+
+.nvd3 .nv-axis path {
+    fill: none;
+    stroke: #000;
+    stroke-opacity: .75;
+    shape-rendering: crispEdges;
+}
+
+.nvd3 .nv-axis path.domain {
+    stroke-opacity: .75;
+}
+
+.nvd3 .nv-axis.nv-x path.domain {
+    stroke-opacity: 0;
+}
+
+.nvd3 .nv-axis line {
+    fill: none;
+    stroke: #e5e5e5;
+    shape-rendering: crispEdges;
+}
+
+.nvd3 .nv-axis .zero line,
+    /*this selector may not be necessary*/ .nvd3 .nv-axis line.zero {
+    stroke-opacity: .75;
+}
+
+.nvd3 .nv-axis .nv-axisMaxMin text {
+    font-weight: bold;
+}
+
+.nvd3 .x  .nv-axis .nv-axisMaxMin text,
+.nvd3 .x2 .nv-axis .nv-axisMaxMin text,
+.nvd3 .x3 .nv-axis .nv-axisMaxMin text {
+    text-anchor: middle
+}
+
+.nvd3 .nv-axis.nv-disabled {
+    opacity: 0;
+}
+
+.nvd3 .nv-bars rect {
+    fill-opacity: .75;
+
+    transition: fill-opacity 250ms linear;
+    -moz-transition: fill-opacity 250ms linear;
+    -webkit-transition: fill-opacity 250ms linear;
+}
+
+.nvd3 .nv-bars rect.hover {
+    fill-opacity: 1;
+}
+
+.nvd3 .nv-bars .hover rect {
+    fill: lightblue;
+}
+
+.nvd3 .nv-bars text {
+    fill: rgba(0,0,0,0);
+}
+
+.nvd3 .nv-bars .hover text {
+    fill: rgba(0,0,0,1);
+}
+
+.nvd3 .nv-multibar .nv-groups rect,
+.nvd3 .nv-multibarHorizontal .nv-groups rect,
+.nvd3 .nv-discretebar .nv-groups rect {
+    stroke-opacity: 0;
+
+    transition: fill-opacity 250ms linear;
+    -moz-transition: fill-opacity 250ms linear;
+    -webkit-transition: fill-opacity 250ms linear;
+}
+
+.nvd3 .nv-multibar .nv-groups rect:hover,
+.nvd3 .nv-multibarHorizontal .nv-groups rect:hover,
+.nvd3 .nv-candlestickBar .nv-ticks rect:hover,
+.nvd3 .nv-discretebar .nv-groups rect:hover {
+    fill-opacity: 1;
+}
+
+.nvd3 .nv-discretebar .nv-groups text,
+.nvd3 .nv-multibarHorizontal .nv-groups text {
+    font-weight: bold;
+    fill: rgba(0,0,0,1);
+    stroke: rgba(0,0,0,0);
+}
+
+/* boxplot CSS */
+.nvd3 .nv-boxplot circle {
+  fill-opacity: 0.5;
+}
+
+.nvd3 .nv-boxplot circle:hover {
+  fill-opacity: 1;
+}
+
+.nvd3 .nv-boxplot rect:hover {
+  fill-opacity: 1;
+}
+
+.nvd3 line.nv-boxplot-median {
+  stroke: black;
+}
+
+.nv-boxplot-tick:hover {
+  stroke-width: 2.5px;
+}
+/* bullet */
+.nvd3.nv-bullet { font: 10px sans-serif; }
+.nvd3.nv-bullet .nv-measure { fill-opacity: .8; }
+.nvd3.nv-bullet .nv-measure:hover { fill-opacity: 1; }
+.nvd3.nv-bullet .nv-marker { stroke: #000; stroke-width: 2px; }
+.nvd3.nv-bullet .nv-markerTriangle { stroke: #000; fill: #fff; stroke-width: 1.5px; }
+.nvd3.nv-bullet .nv-markerLine { stroke: #000; stroke-width: 1.5px; }
+.nvd3.nv-bullet .nv-tick line { stroke: #666; stroke-width: .5px; }
+.nvd3.nv-bullet .nv-range.nv-s0 { fill: #eee; }
+.nvd3.nv-bullet .nv-range.nv-s1 { fill: #ddd; }
+.nvd3.nv-bullet .nv-range.nv-s2 { fill: #ccc; }
+.nvd3.nv-bullet .nv-title { font-size: 14px; font-weight: bold; }
+.nvd3.nv-bullet .nv-subtitle { fill: #999; }
+
+
+.nvd3.nv-bullet .nv-range {
+    fill: #bababa;
+    fill-opacity: .4;
+}
+.nvd3.nv-bullet .nv-range:hover {
+    fill-opacity: .7;
+}
+
+.nvd3.nv-candlestickBar .nv-ticks .nv-tick {
+    stroke-width: 1px;
+}
+
+.nvd3.nv-candlestickBar .nv-ticks .nv-tick.hover {
+    stroke-width: 2px;
+}
+
+.nvd3.nv-candlestickBar .nv-ticks .nv-tick.positive rect {
+    stroke: #2ca02c;
+    fill: #2ca02c;
+}
+
+.nvd3.nv-candlestickBar .nv-ticks .nv-tick.negative rect {
+    stroke: #d62728;
+    fill: #d62728;
+}
+
+.with-transitions .nv-candlestickBar .nv-ticks .nv-tick {
+    transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
+    -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
+    -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
+
+}
+
+.nvd3.nv-candlestickBar .nv-ticks line {
+    stroke: #333;
+}
+
+
+.nv-force-node {
+  stroke: #fff;
+  stroke-width: 1.5px;
+}
+.nv-force-link {
+  stroke: #999;
+  stroke-opacity: .6;
+}
+.nv-force-node text {
+  stroke-width: 0px
+}
+
+.nvd3 .nv-legend .nv-disabled rect {
+    /*fill-opacity: 0;*/
+}
+
+.nvd3 .nv-check-box .nv-box {
+    fill-opacity:0;
+    stroke-width:2;
+}
+
+.nvd3 .nv-check-box .nv-check {
+    fill-opacity:0;
+    stroke-width:4;
+}
+
+.nvd3 .nv-series.nv-disabled .nv-check-box .nv-check {
+    fill-opacity:0;
+    stroke-opacity:0;
+}
+
+.nvd3 .nv-controlsWrap .nv-legend .nv-check-box .nv-check {
+    opacity: 0;
+}
+
+/* line plus bar */
+.nvd3.nv-linePlusBar .nv-bar rect {
+    fill-opacity: .75;
+}
+
+.nvd3.nv-linePlusBar .nv-bar rect:hover {
+    fill-opacity: 1;
+}
+.nvd3 .nv-groups path.nv-line {
+    fill: none;
+}
+
+.nvd3 .nv-groups path.nv-area {
+    stroke: none;
+}
+
+.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point {
+    fill-opacity: 0;
+    stroke-opacity: 0;
+}
+
+.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point {
+    fill-opacity: .5 !important;
+    stroke-opacity: .5 !important;
+}
+
+
+.with-transitions .nvd3 .nv-groups .nv-point {
+    transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
+    -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
+    -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
+
+}
+
+.nvd3.nv-scatter .nv-groups .nv-point.hover,
+.nvd3 .nv-groups .nv-point.hover {
+    stroke-width: 7px;
+    fill-opacity: .95 !important;
+    stroke-opacity: .95 !important;
+}
+
+
+.nvd3 .nv-point-paths path {
+    stroke: #aaa;
+    stroke-opacity: 0;
+    fill: #eee;
+    fill-opacity: 0;
+}
+
+
+
+.nvd3 .nv-indexLine {
+    cursor: ew-resize;
+}
+
+/********************
+ * SVG CSS
+ */
+
+/********************
+  Default CSS for an svg element nvd3 used
+*/
+svg.nvd3-svg {
+    -webkit-touch-callout: none;
+    -webkit-user-select: none;
+    -khtml-user-select: none;
+    -ms-user-select: none;
+    -moz-user-select: none;
+    user-select: none;
+    display: block;
+    width:100%;
+    height:100%;
+}
+
+/********************
+  Box shadow and border radius styling
+*/
+.nvtooltip.with-3d-shadow, .with-3d-shadow .nvtooltip {
+    -moz-box-shadow: 0 5px 10px rgba(0,0,0,.2);
+    -webkit-box-shadow: 0 5px 10px rgba(0,0,0,.2);
+    box-shadow: 0 5px 10px rgba(0,0,0,.2);
+
+    -webkit-border-radius: 5px;
+    -moz-border-radius: 5px;
+    border-radius: 5px;
+}
+
+
+.nvd3 text {
+    font: normal 12px Arial;
+}
+
+.nvd3 .title {
+    font: bold 14px Arial;
+}
+
+.nvd3 .nv-background {
+    fill: white;
+    fill-opacity: 0;
+}
+
+.nvd3.nv-noData {
+    font-size: 18px;
+    font-weight: bold;
+}
+
+
+/**********
+*  Brush
+*/
+
+.nv-brush .extent {
+    fill-opacity: .125;
+    shape-rendering: crispEdges;
+}
+
+.nv-brush .resize path {
+    fill: #eee;
+    stroke: #666;
+}
+
+
+/**********
+*  Legend
+*/
+
+.nvd3 .nv-legend .nv-series {
+    cursor: pointer;
+}
+
+.nvd3 .nv-legend .nv-disabled circle {
+    fill-opacity: 0;
+}
+
+/* focus */
+.nvd3 .nv-brush .extent {
+    fill-opacity: 0 !important;
+}
+
+.nvd3 .nv-brushBackground rect {
+    stroke: #000;
+    stroke-width: .4;
+    fill: #fff;
+    fill-opacity: .7;
+}
+
+/**********
+*  Print
+*/
+
+@media print {
+  .nvd3 text {
+    stroke-width: 0;
+    fill-opacity: 1;
+  }
+}
+
+.nvd3.nv-ohlcBar .nv-ticks .nv-tick {
+    stroke-width: 1px;
+}
+
+.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover {
+    stroke-width: 2px;
+}
+
+.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive {
+    stroke: #2ca02c;
+}
+
+.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative {
+    stroke: #d62728;
+}
+
+
+.nvd3 .background path {
+    fill: none;
+    stroke: #EEE;
+    stroke-opacity: .4;
+    shape-rendering: crispEdges;
+}
+
+.nvd3 .foreground path {
+    fill: none;
+    stroke-opacity: .7;
+}
+
+.nvd3 .nv-parallelCoordinates-brush .extent 
+{
+    fill: #fff;
+    fill-opacity: .6;
+    stroke: gray;
+    shape-rendering: crispEdges;
+}
+
+.nvd3 .nv-parallelCoordinates .hover  {
+    fill-opacity: 1;
+	stroke-width: 3px;
+}
+
+
+.nvd3 .missingValuesline line {
+  fill: none;
+  stroke: black;
+  stroke-width: 1;
+  stroke-opacity: 1;
+  stroke-dasharray: 5, 5; 
+}
+.nvd3.nv-pie path {
+    stroke-opacity: 0;
+    transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear;
+    -moz-transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear;
+    -webkit-transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear;
+
+}
+
+.nvd3.nv-pie .nv-pie-title {
+    font-size: 24px;
+    fill: rgba(19, 196, 249, 0.59);
+}
+
+.nvd3.nv-pie .nv-slice text {
+    stroke: #000;
+    stroke-width: 0;
+}
+
+.nvd3.nv-pie path {
+    stroke: #fff;
+    stroke-width: 1px;
+    stroke-opacity: 1;
+}
+
+.nvd3.nv-pie path {
+    fill-opacity: .7;
+}
+.nvd3.nv-pie .hover path {
+    fill-opacity: 1;
+}
+.nvd3.nv-pie .nv-label {
+    pointer-events: none;
+}
+.nvd3.nv-pie .nv-label rect {
+    fill-opacity: 0;
+    stroke-opacity: 0;
+}
+
+/* scatter */
+.nvd3 .nv-groups .nv-point.hover {
+    stroke-width: 20px;
+    stroke-opacity: .5;
+}
+
+.nvd3 .nv-scatter .nv-point.hover {
+    fill-opacity: 1;
+}
+.nv-noninteractive {
+    pointer-events: none;
+}
+
+.nv-distx, .nv-disty {
+    pointer-events: none;
+}
+
+/* sparkline */
+.nvd3.nv-sparkline path {
+    fill: none;
+}
+
+.nvd3.nv-sparklineplus g.nv-hoverValue {
+    pointer-events: none;
+}
+
+.nvd3.nv-sparklineplus .nv-hoverValue line {
+    stroke: #333;
+    stroke-width: 1.5px;
+}
+
+.nvd3.nv-sparklineplus,
+.nvd3.nv-sparklineplus g {
+    pointer-events: all;
+}
+
+.nvd3 .nv-hoverArea {
+    fill-opacity: 0;
+    stroke-opacity: 0;
+}
+
+.nvd3.nv-sparklineplus .nv-xValue,
+.nvd3.nv-sparklineplus .nv-yValue {
+    stroke-width: 0;
+    font-size: .9em;
+    font-weight: normal;
+}
+
+.nvd3.nv-sparklineplus .nv-yValue {
+    stroke: #f66;
+}
+
+.nvd3.nv-sparklineplus .nv-maxValue {
+    stroke: #2ca02c;
+    fill: #2ca02c;
+}
+
+.nvd3.nv-sparklineplus .nv-minValue {
+    stroke: #d62728;
+    fill: #d62728;
+}
+
+.nvd3.nv-sparklineplus .nv-currentValue {
+    font-weight: bold;
+    font-size: 1.1em;
+}
+/* stacked area */
+.nvd3.nv-stackedarea path.nv-area {
+    fill-opacity: .7;
+    stroke-opacity: 0;
+    transition: fill-opacity 250ms linear, stroke-opacity 250ms linear;
+    -moz-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear;
+    -webkit-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear;
+}
+
+.nvd3.nv-stackedarea path.nv-area.hover {
+    fill-opacity: .9;
+}
+
+
+.nvd3.nv-stackedarea .nv-groups .nv-point {
+    stroke-opacity: 0;
+    fill-opacity: 0;
+}
+
+
+.nvtooltip {
+    position: absolute;
+    background-color: rgba(255,255,255,1.0);
+    color: rgba(0,0,0,1.0);
+    padding: 1px;
+    border: 1px solid rgba(0,0,0,.2);
+    z-index: 10000;
+    display: block;
+
+    font-family: Arial;
+    font-size: 13px;
+    text-align: left;
+    pointer-events: none;
+
+    white-space: nowrap;
+
+    -webkit-touch-callout: none;
+    -webkit-user-select: none;
+    -khtml-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+}
+
+.nvtooltip {
+    background: rgba(255,255,255, 0.8);
+    border: 1px solid rgba(0,0,0,0.5);
+    border-radius: 4px;
+}
+
+/*Give tooltips that old fade in transition by
+    putting a "with-transitions" class on the container div.
+*/
+.nvtooltip.with-transitions, .with-transitions .nvtooltip {
+    transition: opacity 50ms linear;
+    -moz-transition: opacity 50ms linear;
+    -webkit-transition: opacity 50ms linear;
+
+    transition-delay: 200ms;
+    -moz-transition-delay: 200ms;
+    -webkit-transition-delay: 200ms;
+}
+
+.nvtooltip.x-nvtooltip,
+.nvtooltip.y-nvtooltip {
+    padding: 8px;
+}
+
+.nvtooltip h3 {
+    margin: 0;
+    padding: 4px 14px;
+    line-height: 18px;
+    font-weight: normal;
+    background-color: rgba(247,247,247,0.75);
+    color: rgba(0,0,0,1.0);
+    text-align: center;
+
+    border-bottom: 1px solid #ebebeb;
+
+    -webkit-border-radius: 5px 5px 0 0;
+    -moz-border-radius: 5px 5px 0 0;
+    border-radius: 5px 5px 0 0;
+}
+
+.nvtooltip p {
+    margin: 0;
+    padding: 5px 14px;
+    text-align: center;
+}
+
+.nvtooltip span {
+    display: inline-block;
+    margin: 2px 0;
+}
+
+.nvtooltip table {
+    margin: 6px;
+    border-spacing:0;
+}
+
+
+.nvtooltip table td {
+    padding: 2px 9px 2px 0;
+    vertical-align: middle;
+}
+
+.nvtooltip table td.key {
+    font-weight: normal;
+}
+.nvtooltip table td.key.total {
+    font-weight: bold;
+}
+.nvtooltip table td.value {
+    text-align: right;
+    font-weight: bold;
+}
+
+.nvtooltip table td.percent {
+    color: darkgray;
+}
+
+.nvtooltip table tr.highlight td {
+    padding: 1px 9px 1px 0;
+    border-bottom-style: solid;
+    border-bottom-width: 1px;
+    border-top-style: solid;
+    border-top-width: 1px;
+}
+
+.nvtooltip table td.legend-color-guide div {
+    width: 8px;
+    height: 8px;
+    vertical-align: middle;
+}
+
+.nvtooltip table td.legend-color-guide div {
+    width: 12px;
+    height: 12px;
+    border: 1px solid #999;
+}
+
+.nvtooltip .footer {
+    padding: 3px;
+    text-align: center;
+}
+
+.nvtooltip-pending-removal {
+    pointer-events: none;
+    display: none;
+}
+
+
+/****
+Interactive Layer
+*/
+.nvd3 .nv-interactiveGuideLine {
+    pointer-events:none;
+}
+.nvd3 line.nv-guideline {
+    stroke: #ccc;
+}

+ 16 - 0
services/web/public/stylesheets/components/nvd3_override.less

@@ -0,0 +1,16 @@
+.nvd3 {
+  .nv-axis {
+    .tick {
+      line {
+        opacity: 0;
+      }
+    }
+    path.domain {
+      opacity: 0;
+    }
+  }
+}
+
+svg.nvd3-iddle {
+  @extend svg.nvd3-svg;
+}

+ 18 - 11
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;
@@ -986,14 +989,18 @@
 @sys-msg-border     : 1px solid @common-border-color;
 
 // v2 History
-@history-base-font-size           : @font-size-small;
-@history-base-bg                  : @gray-lightest;
-@history-entry-day-bg             : @gray;
-@history-entry-selected-bg        : @red;
-@history-base-color               : @gray-light;
-@history-highlight-color          : @gray;
-@history-toolbar-bg-color         : @toolbar-alt-bg-color;
-@history-toolbar-color            : @text-color;
+@history-base-font-size                : @font-size-small;
+@history-base-bg                       : @gray-lightest;
+@history-entry-label-bg-color          : @red;
+@history-entry-label-color             : #FFF;
+@history-entry-selected-label-bg-color : #FFF;
+@history-entry-selected-label-color    : @red;
+@history-entry-day-bg                  : @gray;
+@history-entry-selected-bg             : @red;
+@history-base-color                    : @gray-light;
+@history-highlight-color               : @gray;
+@history-toolbar-bg-color              : @toolbar-alt-bg-color;
+@history-toolbar-color                 : @text-color;
 
 // Input suggestions
 @input-suggestion-v-offset        : 6px;

+ 49 - 8
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;
@@ -265,6 +277,21 @@
 @chat-new-message-textarea-bg     : @ol-blue-gray-1;
 @chat-new-message-textarea-color  : @ol-blue-gray-6;
 
+// Pagination
+@pagination-active-bg             : @ol-dark-green;
+@pagination-active-border         : @gray-lighter;
+@pagination-active-color          : #FFF;
+@pagination-bg                    : #FFF;
+@pagination-border                : @gray-lighter;
+@pagination-color                 : @ol-dark-green;
+@pagination-disabled-color        : @gray-dark;
+@pagination-disabled-bg           : @gray-lightest;
+@pagination-disabled-border       : @gray-lighter;
+@pagination-hover-color           : @ol-dark-green;
+@pagination-hover-bg              : @gray-lightest;
+@pagination-hover-border          : @gray-lighter;
+
+
 // PDF
 @pdf-top-offset                 : @toolbar-small-height;
 @pdf-bg                         : @ol-blue-gray-1;
@@ -273,16 +300,29 @@
 @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;
-@history-base-bg                 : @ol-blue-gray-1;
-@history-entry-day-bg            : @ol-blue-gray-2;
-@history-entry-selected-bg       : @ol-green;
-@history-base-color              : @ol-blue-gray-2;
-@history-highlight-color         : @ol-type-color;
-@history-toolbar-bg-color        : @editor-toolbar-bg;
-@history-toolbar-color           : #FFF;
+@history-base-font-size               : @font-size-small;
+@history-base-bg                      : @ol-blue-gray-1;
+@history-entry-label-bg-color         : @ol-blue;
+@history-entry-label-color            : #FFF;
+@history-entry-selected-label-bg-color: #FFF;
+@history-entry-selected-label-color   : @ol-blue;
+@history-entry-day-bg                 : @ol-blue-gray-2;
+@history-entry-selected-bg            : @ol-green;
+@history-base-color                   : @ol-blue-gray-2;
+@history-highlight-color              : @ol-type-color;
+@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;
@@ -300,6 +340,7 @@
 @gray-light:            #a4a4a4;
 @gray-lighter:          #cfcfcf;
 @gray-lightest:         #f0f0f0;
+@white:                 #ffffff;
 
 @blue:                  #405ebf;
 @blueDark:              #040D2D;

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

@@ -6,8 +6,12 @@
 @import "app/ol-style-guide.less";
 @import "_style_includes.less";
 @import "_ol_style_includes.less";
+@import "components/embed-responsive.less";
+@import "components/icons.less";
+@import "components/pagination.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";

+ 85 - 0
services/web/scripts/increase_compile_timeouts.js

@@ -0,0 +1,85 @@
+const mongojs = require('../app/js/infrastructure/mongojs')
+const { db } = mongojs
+const async = require('async')
+const minilist = require('minimist')
+
+const newTimeout = 240
+const oldTimeoutLimits = {$gt: 60, $lt: 240}
+
+const updateUser = function (user, callback) {
+  console.log(`Updating user ${user._id}`)
+  const update = {
+    $set: {
+      'features.compileTimeout': newTimeout
+    }
+  }
+  db.users.update({
+    _id: user._id,
+    'features.compileTimeout': oldTimeoutLimits
+  }, update, callback)
+}
+
+const updateUsers = (users, callback) =>
+  async.eachLimit(users, ASYNC_LIMIT, updateUser, function (error) {
+    if (error) {
+      callback(error)
+      return
+    }
+    counter += users.length
+    console.log(`${counter} users updated`)
+    if (DO_ALL) {
+      return loopForUsers(callback)
+    } else {
+      console.log('*** run again to continue updating ***')
+      return callback()
+    }
+  })
+
+var loopForUsers = callback =>
+  db.users.find(
+    { 'features.compileTimeout': oldTimeoutLimits },
+    { 'features.compileTimeout': 1 }
+  ).limit(FETCH_LIMIT, function (error, users) {
+    if (error) {
+      callback(error)
+      return
+    }
+    if (users.length === 0) {
+      console.log(`DONE (${counter} users updated)`)
+      return callback()
+    }
+    updateUsers(users, callback)
+  })
+
+var counter = 0
+var run = () =>
+  loopForUsers(function (error) {
+    if (error) { throw error }
+    process.exit()
+  })
+
+let FETCH_LIMIT, ASYNC_LIMIT, DO_ALL
+var setup = function () {
+  let args = minilist(process.argv.slice(2))
+  // --fetch N  get N users each time
+  FETCH_LIMIT = (args.fetch) ? args.fetch : 100
+  // --async M  run M updates in parallel
+  ASYNC_LIMIT = (args.async) ? args.async : 10
+  // --all means run to completion
+  if (args.all) {
+    if (args.fetch) {
+      console.error('error: do not use --fetch with --all')
+      process.exit(1)
+    } else {
+      DO_ALL = true
+      // if we are updating for all users then ignore the fetch limit.
+      FETCH_LIMIT = 0
+      // A limit() value of 0 (i.e. .limit(0)) is equivalent to setting
+      // no limit.
+      // https://docs.mongodb.com/manual/reference/method/cursor.limit
+    }
+  }
+}
+
+setup()
+run()

+ 20 - 0
services/web/test/unit/coffee/Analytics/AnalyticsControllerTests.coffee

@@ -17,9 +17,13 @@ describe 'AnalyticsController', ->
 			updateEditingSession: sinon.stub().callsArgWith(3)
 			recordEvent: sinon.stub().callsArgWith(3)
 
+		@InstitutionsAPI =
+			getInstitutionLicences: sinon.stub().callsArgWith(4)
+
 		@controller = SandboxedModule.require modulePath, requires:
 			"./AnalyticsManager":@AnalyticsManager
 			"../Authentication/AuthenticationController":@AuthenticationController
+			"../Institutions/InstitutionsAPI":@InstitutionsAPI
 			"logger-sharelatex":
 				log:->
 			'../../infrastructure/GeoIpLookup': @GeoIpLookup =
@@ -66,3 +70,19 @@ describe 'AnalyticsController', ->
 			@controller.recordEvent @req, @res
 			@AnalyticsManager.recordEvent.calledWith(@req.sessionID, @req.params["event"], @req.body).should.equal true
 			done()
+
+	describe "licences", ->
+		beforeEach ->
+			@req =
+				query:
+					resource_id:1
+					start_date:'1514764800'
+					end_date:'1530662400'
+					resource_type:'institution'
+				sessionID: "sessionIDHere"
+				session: {}
+
+		it "should trigger institutions api to fetch licences graph data", (done)->
+			@controller.licences @req, @res
+			@InstitutionsAPI.getInstitutionLicences.calledWith(@req.query["resource_id"], @req.query["start_date"], @req.query["end_date"], @req.query["lag"]).should.equal true
+			done()

+ 13 - 1
services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee

@@ -15,7 +15,6 @@ describe "AuthenticationController", ->
 		tk.freeze(Date.now())
 		@AuthenticationController = SandboxedModule.require modulePath, requires:
 			"./AuthenticationManager": @AuthenticationManager = {}
-			"../User/UserGetter" : @UserGetter = {}
 			"../User/UserUpdater" : @UserUpdater = {}
 			"metrics-sharelatex": @Metrics = { inc: sinon.stub() }
 			"../Security/LoginRateLimiter": @LoginRateLimiter = { processLoginRequest:sinon.stub(), recordSuccessfulLogin:sinon.stub() }
@@ -29,6 +28,7 @@ describe "AuthenticationController", ->
 				trackSession: sinon.stub()
 				untrackSession: sinon.stub()
 				revokeAllUserSessions: sinon.stub().callsArgWith(1, null)
+			"../../infrastructure/Modules": @Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, [])}}
 		@user =
 			_id: ObjectId()
 			email: @email = "USER@example.com"
@@ -214,6 +214,7 @@ describe "AuthenticationController", ->
 		beforeEach ->
 			@AuthenticationController._recordFailedLogin = sinon.stub()
 			@AuthenticationController._recordSuccessfulLogin = sinon.stub()
+			@Modules.hooks.fire = sinon.stub().callsArgWith(3, null, [])
 			# @AuthenticationController.establishUserSession = sinon.stub().callsArg(2)
 			@req.body =
 				email: @email
@@ -222,6 +223,17 @@ describe "AuthenticationController", ->
 					postLoginRedirect: "/path/to/redir/to"
 			@cb = sinon.stub()
 
+		describe "when the preDoPassportLogin hooks produce an info object", ->
+			beforeEach ->
+				@Modules.hooks.fire = sinon.stub().callsArgWith(3, null, [null, {redir: '/somewhere'}, null])
+
+			it "should stop early and call done with this info object", (done) ->
+				@AuthenticationController.doPassportLogin(@req, @req.body.email, @req.body.password, @cb)
+				@cb.callCount.should.equal 1
+				@cb.calledWith(null, false, {redir: '/somewhere'}).should.equal true
+				@LoginRateLimiter.processLoginRequest.callCount.should.equal 0
+				done()
+
 		describe "when the users rate limit", ->
 
 			beforeEach ->

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

+ 37 - 0
services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee

@@ -63,6 +63,8 @@ describe 'ExportsHandler', ->
 		beforeEach (done) ->
 			@project =
 				id: @project_id
+				compiler: 'pdflatex'
+				imageName: 'mock-image-name'
 				overleaf:
 					id: @project_history_id # for projects imported from v1
 					history:
@@ -100,6 +102,9 @@ describe 'ExportsHandler', ->
 						historyId: @project_history_id
 						historyVersion: @historyVersion
 						v1ProjectId: @project_history_id
+						metadata:
+							compiler: 'pdflatex'
+							imageName: 'mock-image-name'
 					user:
 						id: @user_id
 						firstName: @user.first_name
@@ -132,6 +137,9 @@ describe 'ExportsHandler', ->
 						historyId: @project_history_id
 						historyVersion: @historyVersion
 						v1ProjectId: @project_history_id
+						metadata:
+							compiler: 'pdflatex'
+							imageName: 'mock-image-name'
 					user:
 						id: @user_id
 						firstName: @custom_first_name
@@ -273,3 +281,32 @@ describe 'ExportsHandler', ->
 				@callback.calledWith(null, { body: @body })
 				.should.equal true
 
+	describe 'fetchZip', ->
+		beforeEach (done) ->
+			@settings.apis =
+				v1:
+					url: 'http://localhost:5000'
+					user: 'overleaf'
+					pass: 'pass'
+			@export_id = 897
+			@body = "https://writelatex-conversions-dev.s3.amazonaws.com/exports/ieee_latexqc/tnb/2912/xggmprcrpfwbsnqzqqmvktddnrbqkqkr.zip?X-Amz-Expires=14400&X-Amz-Date=20180730T181003Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAJDGDIJFGLNVGZH6A/20180730/us-east-1/s3/aws4_request&X-Amz-SignedHeaders=host&X-Amz-Signature=dec990336913cef9933f0e269afe99722d7ab2830ebf2c618a75673ee7159fee"
+			@stubGet = sinon.stub().yields(null, {statusCode: 200}, { body: @body })
+			done()
+
+		describe "when all goes well", ->
+			beforeEach (done) ->
+				@stubRequest.get = @stubGet
+				@ExportsHandler.fetchZip @export_id, (error, body) =>
+					@callback(error, body)
+					done()
+
+			it 'should issue the request', ->
+				expect(@stubGet.getCall(0).args[0]).to.deep.equal
+					url: @settings.apis.v1.url + '/api/v1/sharelatex/exports/' + @export_id + '/zip_url'
+					auth:
+						user: @settings.apis.v1.user
+						pass: @settings.apis.v1.pass
+
+			it 'should return the v1 export id', ->
+				@callback.calledWith(null, { body: @body })
+				.should.equal true

+ 41 - 3
services/web/test/unit/coffee/Institutions/InstitutionsAPITests.coffee

@@ -11,12 +11,12 @@ describe "InstitutionsAPI", ->
 
 	beforeEach ->
 		@logger = err: sinon.stub(), log: ->
-		settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } }
+		@settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } }
 		@request = sinon.stub()
 		@InstitutionsAPI = SandboxedModule.require modulePath, requires:
 			"logger-sharelatex": @logger
 			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
-			'settings-sharelatex': settings
+			'settings-sharelatex': @settings
 			'request': @request
 
 		@stubbedUser = 
@@ -40,6 +40,34 @@ describe "InstitutionsAPI", ->
 				should.not.exist(requestOptions.body)
 				body.should.equal responseBody
 				done()
+        
+		it 'handle empty response', (done)->
+			@settings.apis = null
+			@InstitutionsAPI.getInstitutionAffiliations @institutionId, (err, body) =>
+				should.not.exist(err)
+				expect(body).to.be.a 'Array'
+				body.length.should.equal 0
+				done()
+
+	describe 'getInstitutionLicences', ->
+		it 'get licences', (done)->
+			@institutionId = 123
+			responseBody = {"lag":"monthly","data":[{"key":"users","values":[{"x":"2018-01-01","y":1}]}]}
+			@request.yields(null, { statusCode: 200 }, responseBody)
+			startDate = '1417392000'
+			endDate = '1420848000'
+			@InstitutionsAPI.getInstitutionLicences @institutionId, startDate, endDate, 'monthly', (err, body) =>
+				should.not.exist(err)
+				@request.calledOnce.should.equal true
+				requestOptions = @request.lastCall.args[0]
+				expectedUrl = "v1.url/api/v2/institutions/#{@institutionId}/institution_licences"
+				requestOptions.url.should.equal expectedUrl
+				requestOptions.method.should.equal 'GET'
+				requestOptions.body['start_date'].should.equal startDate
+				requestOptions.body['end_date'].should.equal endDate
+				requestOptions.body.lag.should.equal 'monthly'
+				body.should.equal responseBody
+				done()
 
 	describe 'getUserAffiliations', ->
 		it 'get affiliations', (done)->
@@ -65,6 +93,14 @@ describe "InstitutionsAPI", ->
 				err.message.should.have.string body.errors
 				done()
 
+		it 'handle empty response', (done)->
+			@settings.apis = null
+			@InstitutionsAPI.getUserAffiliations @stubbedUser._id, (err, body) =>
+				should.not.exist(err)
+				expect(body).to.be.a 'Array'
+				body.length.should.equal 0
+				done()
+
 	describe 'addAffiliation', ->
 		beforeEach ->
 			@request.callsArgWith(1, null, { statusCode: 201 })
@@ -74,6 +110,7 @@ describe "InstitutionsAPI", ->
 				university: { id: 1 }
 				role: 'Prof'
 				department: 'Math'
+				confirmedAt: new Date()
 			@InstitutionsAPI.addAffiliation @stubbedUser._id, @newEmail, affiliationOptions, (err)=>
 				should.not.exist(err)
 				@request.calledOnce.should.equal true
@@ -83,11 +120,12 @@ describe "InstitutionsAPI", ->
 				requestOptions.method.should.equal 'POST'
 
 				body = requestOptions.body
-				Object.keys(body).length.should.equal 4
+				Object.keys(body).length.should.equal 5
 				body.email.should.equal @newEmail
 				body.university.should.equal affiliationOptions.university
 				body.department.should.equal affiliationOptions.department
 				body.role.should.equal affiliationOptions.role
+				body.confirmedAt.should.equal affiliationOptions.confirmedAt
 				done()
 
 		it 'handle error', (done)->

+ 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 =

+ 9 - 1
services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee

@@ -111,7 +111,7 @@ describe 'ProjectCreationHandler', ->
 					project.spellCheckLanguage.should.equal "de"
 					done()
 
-			it "should set the imageName to currentImageName if set", (done) ->
+			it "should set the imageName to currentImageName if set and no imageName attribute", (done) ->
 				@Settings.currentImageName = "mock-image-name"
 				@handler.createBlankProject ownerId, projectName, (err, project)=>
 					project.imageName.should.equal @Settings.currentImageName
@@ -123,6 +123,14 @@ describe 'ProjectCreationHandler', ->
 					expect(project.imageName).to.not.exist
 					done()
 
+			it "should set the imageName to the attribute value if set and not overwrite it with the currentImageName", (done) ->
+				@Settings.currentImageName = "mock-image-name"
+				attributes =
+					imageName: "attribute-image-name"
+				@handler.createBlankProject ownerId, projectName, attributes, (err, project)=>
+					project.imageName.should.equal attributes.imageName
+					done()
+
 			it "should not set the overleaf.history.display if not configured in settings", (done) ->
 				@Settings.apis.project_history.displayHistoryForNewProjects = false
 				@handler.createBlankProject ownerId, projectName, (err, project)=>

+ 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", ->
 

+ 9 - 1
services/web/test/unit/coffee/User/UserCreatorTests.coffee

@@ -20,7 +20,7 @@ describe "UserCreator", ->
 		@addAffiliation = sinon.stub().yields()
 		@UserCreator = SandboxedModule.require modulePath, requires:
 			"../../models/User": User:@UserModel
-			"logger-sharelatex":{log:->}
+			"logger-sharelatex":{ log: sinon.stub(), err: sinon.stub() }
 			'metrics-sharelatex': {timeAsyncMethod: ()->}
 			"../Institutions/InstitutionsAPI": addAffiliation: @addAffiliation
 
@@ -88,3 +88,11 @@ describe "UserCreator", ->
 				process.nextTick () =>
 					sinon.assert.calledWith(@addAffiliation, user._id, user.email)
 					done()
+
+		it "should not add affiliation if skipping", (done)->
+			attributes =  email: @email
+			options = skip_affiliation: true
+			@UserCreator.createNewUser attributes, options, (err, user) =>
+				process.nextTick () =>
+					sinon.assert.notCalled(@addAffiliation)
+					done()

+ 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

+ 25 - 5
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'))
 
@@ -221,7 +241,7 @@ describe "UserUpdater", ->
 			@UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
 				should.not.exist(err)
 				@addAffiliation.calledOnce.should.equal true
-				sinon.assert.calledWith(@addAffiliation, @stubbedUser._id, @newEmail)
+				sinon.assert.calledWith(@addAffiliation, @stubbedUser._id, @newEmail, { confirmedAt: new Date() } )
 				done()
 
 		it 'handle error', (done)->
@@ -244,7 +264,7 @@ describe "UserUpdater", ->
 				done()
 
 		it 'handle affiliation error', (done)->
-			@addAffiliation.callsArgWith(2, new Error('nope'))
+			@addAffiliation.callsArgWith(3, new Error('nope'))
 			@UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
 				should.exist(err)
 				@UserUpdater.updateUser.called.should.equal false

+ 17 - 2
services/web/test/unit_frontend/coffee/ide/history/HistoryV2ManagerTests.coffee

@@ -4,30 +4,45 @@ define ['ide/history/HistoryV2Manager'], (HistoryV2Manager) ->
 			@scope =
 				$watch: sinon.stub()
 				$on: sinon.stub()
+				project:
+					features:
+						versioning: true
 			@ide = {}
 			@historyManager = new HistoryV2Manager(@ide, @scope)
 
-		it "should setup the history scope on intialization", ->
+		it "should setup the history scope on initialization", ->
 			expect(@scope.history).to.deep.equal({
 				isV2: true
 				updates: []
 				viewMode: null
 				nextBeforeTimestamp: null
 				atEnd: false
+				userHasFullFeature: true
+				freeHistoryLimitHit: false
 				selection: {
+					label: null
 					updates: []
-					pathname: null
 					docs: {}
+					pathname: null
 					range: {
 						fromV: null
 						toV: null
 					}
 				}
+				error: null
+				showOnlyLabels: false
+				labels: null
 				diff: null
 				files: []
 				selectedFile: null
 			})
 
+
+		it "should setup history without full access to the feature if the project does not have versioning", ->
+			@scope.project.features.versioning = false
+			@historyManager = new HistoryV2Manager(@ide, @scope)
+			expect(@scope.history.userHasFullFeature).to.equal false
+
 		describe "_perDocSummaryOfUpdates", ->
 			it "should return the range of updates for the docs", ->
 				result = @historyManager._perDocSummaryOfUpdates([{