Преглед на файлове

Merge branch 'master' into pr-v2-light-theme

Paulo Reis преди 8 години
родител
ревизия
a15706ce24
променени са 100 файла, в които са добавени 2272 реда и са изтрити 1041 реда
  1. 85 42
      services/web/Jenkinsfile
  2. 52 31
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  3. 1 1
      services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee
  4. 5 3
      services/web/app/coffee/Features/Compile/ClsiManager.coffee
  5. 1 1
      services/web/app/coffee/Features/Compile/CompileController.coffee
  6. 7 0
      services/web/app/coffee/Features/Editor/EditorController.coffee
  7. 16 0
      services/web/app/coffee/Features/Errors/Errors.coffee
  8. 8 0
      services/web/app/coffee/Features/Exports/ExportsController.coffee
  9. 19 0
      services/web/app/coffee/Features/Exports/ExportsHandler.coffee
  10. 55 10
      services/web/app/coffee/Features/History/HistoryController.coffee
  11. 16 5
      services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee
  12. 22 0
      services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee
  13. 14 0
      services/web/app/coffee/Features/Institutions/InstitutionsGetter.coffee
  14. 17 0
      services/web/app/coffee/Features/Institutions/InstitutionsManager.coffee
  15. 53 22
      services/web/app/coffee/Features/Newsletter/NewsletterManager.coffee
  16. 7 2
      services/web/app/coffee/Features/Project/ProjectController.coffee
  17. 2 1
      services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee
  18. 2 1
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  19. 10 0
      services/web/app/coffee/Features/Project/ProjectOptionsHandler.coffee
  20. 6 3
      services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee
  21. 5 5
      services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
  22. 0 3
      services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee
  23. 51 30
      services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
  24. 32 19
      services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
  25. 5 1
      services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee
  26. 17 17
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  27. 1 1
      services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee
  28. 2 1
      services/web/app/coffee/Features/User/UserController.coffee
  29. 11 7
      services/web/app/coffee/Features/User/UserCreator.coffee
  30. 1 1
      services/web/app/coffee/Features/User/UserDeleter.coffee
  31. 33 5
      services/web/app/coffee/Features/User/UserEmailsController.coffee
  32. 4 3
      services/web/app/coffee/Features/User/UserGetter.coffee
  33. 0 1
      services/web/app/coffee/Features/User/UserPagesController.coffee
  34. 2 2
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  35. 68 12
      services/web/app/coffee/Features/User/UserUpdater.coffee
  36. 11 1
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  37. 7 0
      services/web/app/coffee/infrastructure/Features.coffee
  38. 1 1
      services/web/app/coffee/infrastructure/Server.coffee
  39. 2 0
      services/web/app/coffee/models/Subscription.coffee
  40. 10 2
      services/web/app/coffee/router.coffee
  41. 122 0
      services/web/app/views/_mixins_links.pug
  42. 1 0
      services/web/app/views/layout.pug
  43. 4 2
      services/web/app/views/project/editor.pug
  44. 6 2
      services/web/app/views/project/editor/editor.pug
  45. 50 0
      services/web/app/views/project/editor/history.pug
  46. 67 2
      services/web/app/views/project/editor/history/entriesListV2.pug
  47. 14 2
      services/web/app/views/project/editor/history/previewPanelV2.pug
  48. 69 7
      services/web/app/views/project/editor/history/toolbarV2.pug
  49. 9 0
      services/web/app/views/project/editor/left-menu.pug
  50. 8 1
      services/web/app/views/project/editor/review-panel.pug
  51. 8 7
      services/web/app/views/project/list/modals.pug
  52. 1 1
      services/web/app/views/subscriptions/plans.pug
  53. 27 21
      services/web/app/views/user/settings.pug
  54. 188 119
      services/web/app/views/user/settings/user-affiliations.pug
  55. 18 5
      services/web/config/settings.defaults.coffee
  56. 1 0
      services/web/docker-compose.yml
  57. 3 1
      services/web/package.json
  58. 23 1
      services/web/public/coffee/base.coffee
  59. 21 0
      services/web/public/coffee/directives/mathjax.coffee
  60. 1 1
      services/web/public/coffee/ide.coffee
  61. 0 26
      services/web/public/coffee/ide/chat/directives/mathjax.coffee
  62. 1 1
      services/web/public/coffee/ide/chat/index.coffee
  63. 2 1
      services/web/public/coffee/ide/directives/layout.coffee
  64. 7 2
      services/web/public/coffee/ide/editor/EditorManager.coffee
  65. 35 4
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  66. 2 1
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
  67. 32 0
      services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionAdapter.coffee
  68. 34 46
      services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee
  69. 169 14
      services/web/public/coffee/ide/history/HistoryV2Manager.coffee
  70. 18 0
      services/web/public/coffee/ide/history/components/historyEntriesList.coffee
  71. 20 3
      services/web/public/coffee/ide/history/components/historyEntry.coffee
  72. 20 0
      services/web/public/coffee/ide/history/components/historyLabel.coffee
  73. 36 0
      services/web/public/coffee/ide/history/components/historyLabelsList.coffee
  74. 27 2
      services/web/public/coffee/ide/history/controllers/HistoryListController.coffee
  75. 29 0
      services/web/public/coffee/ide/history/controllers/HistoryV2AddLabelModalController.coffee
  76. 23 0
      services/web/public/coffee/ide/history/controllers/HistoryV2DeleteLabelModalController.coffee
  77. 19 62
      services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee
  78. 12 0
      services/web/public/coffee/ide/history/controllers/HistoryV2ToolbarController.coffee
  79. 23 17
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  80. 1 1
      services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee
  81. 3 1
      services/web/public/coffee/ide/services/ide.coffee
  82. 11 0
      services/web/public/coffee/ide/settings/controllers/SettingsController.coffee
  83. 2 0
      services/web/public/coffee/main.coffee
  84. 12 6
      services/web/public/coffee/main/account-settings.coffee
  85. 52 0
      services/web/public/coffee/main/affiliations/components/affiliationForm.coffee
  86. 107 69
      services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee
  87. 28 1
      services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee
  88. 2 1
      services/web/public/coffee/main/learn.coffee
  89. 8 9
      services/web/public/coffee/main/new-subscription.coffee
  90. 11 5
      services/web/public/coffee/main/plans.coffee
  91. 10 2
      services/web/public/coffee/main/project-list/modal-controllers.coffee
  92. BIN
      services/web/public/img/review-icon-sprite-ol.png
  93. BIN
      services/web/public/img/review-icon-sprite-ol@2x.png
  94. 2 0
      services/web/public/stylesheets/_style_includes.less
  95. 20 2
      services/web/public/stylesheets/app/account-settings.less
  96. 2 0
      services/web/public/stylesheets/app/editor.less
  97. 104 338
      services/web/public/stylesheets/app/editor/history-v2.less
  98. 3 0
      services/web/public/stylesheets/app/editor/publish-modal.less
  99. 55 6
      services/web/public/stylesheets/app/editor/review-panel.less
  100. 28 15
      services/web/public/stylesheets/app/editor/rich-text.less

+ 85 - 42
services/web/Jenkinsfile

@@ -1,19 +1,37 @@
 String cron_string = BRANCH_NAME == "master" ? "@daily" : ""
 
 pipeline {
-  
+
   agent any
-  
+
   environment  {
       HOME = "/tmp"
+      GIT_PROJECT = "web-sharelatex-internal"
+      JENKINS_WORKFLOW = "web-sharelatex-internal"
+      TARGET_URL = "${env.JENKINS_URL}blue/organizations/jenkins/${JENKINS_WORKFLOW}/detail/$BRANCH_NAME/$BUILD_NUMBER/pipeline"
+      GIT_API_URL = "https://api.github.com/repos/sharelatex/${GIT_PROJECT}/statuses/$GIT_COMMIT"
   }
-  
+
   triggers {
     pollSCM('* * * * *')
     cron(cron_string)
   }
-  
+
   stages {
+    stage('Pre') {
+      steps {
+        withCredentials([usernamePassword(credentialsId: 'GITHUB_INTEGRATION', usernameVariable: 'GH_AUTH_USERNAME', passwordVariable: 'GH_AUTH_PASSWORD')]) {
+          sh "curl $GIT_API_URL \
+            --data '{ \
+            \"state\" : \"pending\", \
+            \"target_url\": \"$TARGET_URL\", \
+            \"description\": \"Your build is underway\", \
+            \"context\": \"ci/jenkins\" }' \
+            -u $GH_AUTH_USERNAME:$GH_AUTH_PASSWORD"
+        }
+      }
+    }
+
     stage('Install modules') {
       steps {
         sshagent (credentials: ['GIT_DEPLOY_KEY']) {
@@ -21,7 +39,7 @@ pipeline {
         }
       }
     }
-    
+
     stage('Install') {
       agent {
         docker {
@@ -66,16 +84,39 @@ pipeline {
         sh 'make --no-print-directory lint'
       }
     }
-    
-    stage('Unit Test') {
-      agent {
-        docker {
-          image 'node:6.9.5'
-          reuseNode true
+
+    stage('Test and Minify') {
+      parallel {
+        stage('Unit Test') {
+          agent {
+            docker {
+              image 'node:6.9.5'
+              reuseNode true
+            }
+          }
+          steps {
+            sh 'make --no-print-directory test_unit MOCHA_ARGS="--reporter tap"'
+          }
+        }
+
+        stage('Acceptance Test') {
+          steps {
+            // Spawns its own docker containers
+            sh 'make --no-print-directory test_acceptance MOCHA_ARGS="--reporter tap"'
+          }
+        }
+
+        stage('Minify') {
+          agent {
+            docker {
+              image 'node:6.9.5'
+              reuseNode true
+            }
+          }
+          steps {
+            sh 'WEBPACK_ENV=production make minify'
+          }
         }
-      }
-      steps {
-        sh 'make --no-print-directory test_unit MOCHA_ARGS="--reporter tap"'
       }
     }
 
@@ -85,26 +126,7 @@ pipeline {
         sh 'make --no-print-directory test_frontend'
       }
     }
-    
-    stage('Acceptance Test') {
-      steps {
-        // Spawns its own docker containers
-        sh 'make --no-print-directory test_acceptance MOCHA_ARGS="--reporter tap"'
-      }
-    }
 
-    stage('Minify') {
-      agent {
-        docker {
-          image 'node:6.9.5'
-          reuseNode true
-        }
-      }
-      steps {
-        sh 'WEBPACK_ENV=production make minify'
-      }
-    }
-    
     stage('Package') {
       steps {
         sh 'rm -rf ./node_modules/grunt*'
@@ -113,7 +135,7 @@ pipeline {
         sh 'tar -czf build.tar.gz --exclude=build.tar.gz --exclude-vcs .'
       }
     }
-    
+
     stage('Publish') {
       steps {
         withAWS(credentials:'S3_CI_BUILDS_AWS_KEYS', region:"${S3_REGION_BUILD_ARTEFACTS}") {
@@ -123,8 +145,8 @@ pipeline {
         }
       }
     }
-    
-    
+
+
     stage('Sync OSS') {
       when {
         branch 'master'
@@ -136,29 +158,50 @@ pipeline {
       }
     }
   }
-  
+
   post {
     always {
       sh 'make clean_ci'
     }
 
+    success {
+      withCredentials([usernamePassword(credentialsId: 'GITHUB_INTEGRATION', usernameVariable: 'GH_AUTH_USERNAME', passwordVariable: 'GH_AUTH_PASSWORD')]) {
+        sh "curl $GIT_API_URL \
+          --data '{ \
+          \"state\" : \"success\", \
+          \"target_url\": \"$TARGET_URL\", \
+          \"description\": \"Your build succeeded!\", \
+          \"context\": \"ci/jenkins\" }' \
+          -u $GH_AUTH_USERNAME:$GH_AUTH_PASSWORD"
+      }
+    }
+
     failure {
-      mail(from: "${EMAIL_ALERT_FROM}", 
-           to: "${EMAIL_ALERT_TO}", 
+      mail(from: "${EMAIL_ALERT_FROM}",
+           to: "${EMAIL_ALERT_TO}",
            subject: "Jenkins build failed: ${JOB_NAME}:${BUILD_NUMBER}",
            body: "Build: ${BUILD_URL}")
+      withCredentials([usernamePassword(credentialsId: 'GITHUB_INTEGRATION', usernameVariable: 'GH_AUTH_USERNAME', passwordVariable: 'GH_AUTH_PASSWORD')]) {
+        sh "curl $GIT_API_URL \
+          --data '{ \
+          \"state\" : \"failure\", \
+          \"target_url\": \"$TARGET_URL\", \
+          \"description\": \"Your build failed\", \
+          \"context\": \"ci/jenkins\" }' \
+          -u $GH_AUTH_USERNAME:$GH_AUTH_PASSWORD"
+      }
     }
   }
-  
+
 
   // The options directive is for configuration that applies to the whole job.
   options {
     // Only build one at a time
     disableConcurrentBuilds()
-    
+
     // we'd like to make sure remove old builds, so we don't fill up our storage!
     buildDiscarder(logRotator(numToKeepStr:'50'))
-    
+
     // And we'd really like to be sure that this build doesn't hang forever, so let's time it out after:
     timeout(time: 30, unit: 'MINUTES')
   }

+ 52 - 31
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")
@@ -62,41 +61,63 @@ module.exports = AuthenticationController =
 			if err?
 				return next(err)
 			if user # `user` is either a user object or false
-				redir = AuthenticationController._getRedirectFromSession(req) || "/project"
-				AuthenticationController.afterLoginSessionSetup req, user, (err) ->
-					if err?
-						return next(err)
-					AuthenticationController._clearRedirectFromSession(req)
-					res.json {redir: redir}
+				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) ->
+		redir = AuthenticationController._getRedirectFromSession(req) || "/project"
+		AuthenticationController._loginAsyncHandlers(req, user)
+		AuthenticationController.afterLoginSessionSetup req, user, (err) ->
+			if err?
+				return next(err)
+			AuthenticationController._clearRedirectFromSession(req)
+			if req.headers?['accept']?.match(/^application\/json.*$/)
+				res.json {redir: redir}
+			else
+				res.redirect(redir)
+
 	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
-					UserHandler.setupLoginData(user, ()->)
-					LoginRateLimiter.recordSuccessfulLogin(email)
-					AuthenticationController._recordSuccessfulLogin(user._id)
-					Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip})
-					Analytics.identifyUser(user._id, req.sessionID)
-					logger.log email: email, user_id: user._id.toString(), "successful log in"
-					req.session.justLoggedIn = true
-					# capture the request ip for use when creating the session
-					user._login_req_ip = req.ip
-					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, ()->)
+		LoginRateLimiter.recordSuccessfulLogin(user.email)
+		AuthenticationController._recordSuccessfulLogin(user._id)
+		Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip})
+		Analytics.identifyUser(user._id, req.sessionID)
+		logger.log email: user.email, user_id: user._id.toString(), "successful log in"
+		req.session.justLoggedIn = true
+		# capture the request ip for use when creating the session
+		user._login_req_ip = req.ip
 
 	setInSessionUser: (req, props) ->
 		for key, value of props

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

+ 5 - 3
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,13 +112,14 @@ 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 
 			statusCodeSame = results.newBackend?.response?.statusCode == results.currentBackend?.response?.statusCode
 			currentCompileTime = results.currentBackend?.finishTime
 			newBackendCompileTime = results.newBackend?.finishTime
-			logger.log {statusCodeSame, timeDifference, currentCompileTime, newBackendCompileTime}, "both clsi requests returned"
+			logger.log {statusCodeSame, timeDifference, currentCompileTime, newBackendCompileTime, project_id}, "both clsi requests returned"
 
 
 

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

+ 16 - 0
services/web/app/coffee/Features/Errors/Errors.coffee

@@ -68,6 +68,20 @@ V1ConnectionError = (message) ->
 	return error
 V1ConnectionError.prototype.__proto___ = Error.prototype
 
+UnconfirmedEmailError = (message) ->
+	error = new Error(message)
+	error.name = "UnconfirmedEmailError"
+	error.__proto__ = UnconfirmedEmailError.prototype
+	return error
+UnconfirmedEmailError.prototype.__proto___ = Error.prototype
+
+EmailExistsError = (message) ->
+	error = new Error(message)
+	error.name = "EmailExistsError"
+	error.__proto__ = EmailExistsError.prototype
+	return error
+EmailExistsError.prototype.__proto___ = Error.prototype
+
 module.exports = Errors =
 	NotFoundError: NotFoundError
 	ServiceNotConfiguredError: ServiceNotConfiguredError
@@ -79,3 +93,5 @@ module.exports = Errors =
 	V1HistoryNotSyncedError: V1HistoryNotSyncedError
 	ProjectHistoryDisabledError: ProjectHistoryDisabledError
 	V1ConnectionError: V1ConnectionError
+	UnconfirmedEmailError: UnconfirmedEmailError
+	EmailExistsError: EmailExistsError

+ 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

+ 55 - 10
services/web/app/coffee/Features/History/HistoryController.coffee

@@ -21,6 +21,13 @@ module.exports = HistoryController =
 				req.useProjectHistory = false
 			next()
 
+	ensureProjectHistoryEnabled: (req, res, next = (error) ->) ->
+		if req.useProjectHistory?
+			next()
+		else
+			logger.log {project_id}, "project history not enabled"
+			res.sendStatus(404)
+
 	proxyToHistoryApi: (req, res, next = (error) ->) ->
 		user_id = AuthenticationController.getLoggedInUserId req
 		url = HistoryController.buildHistoryServiceUrl(req.useProjectHistory) + req.url
@@ -41,22 +48,17 @@ module.exports = HistoryController =
 		user_id = AuthenticationController.getLoggedInUserId req
 		url = HistoryController.buildHistoryServiceUrl(req.useProjectHistory) + req.url
 		logger.log url: url, "proxying to history api"
-		request {
+		HistoryController._makeRequest {
 			url: url
 			method: req.method
 			json: true
 			headers:
 				"X-User-Id": user_id
-		}, (error, response, body) ->
+		}, (error, body) ->
 			return next(error) if error?
-			if 200 <= response.statusCode < 300
-				HistoryManager.injectUserDetails body, (error, data) ->
-					return next(error) if error?
-					res.json data
-			else
-				error = new Error("history api responded with non-success code: #{response.statusCode}")
-				logger.error err: error, user_id: user_id, "error proxying request to history api"
-				next(error)
+			HistoryManager.injectUserDetails body, (error, data) ->
+				return next(error) if error?
+				res.json data
 
 	buildHistoryServiceUrl: (useProjectHistory) ->
 		# choose a history service, either document-level (trackchanges)
@@ -98,3 +100,46 @@ module.exports = HistoryController =
 				doc_id: doc._id
 			}
 
+	getLabels: (req, res, next) ->
+		project_id = req.params.Project_id
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		HistoryController._makeRequest {
+			method: "GET"
+			url: "#{settings.apis.project_history.url}/project/#{project_id}/labels"
+			json: true
+		}, (error, labels) ->
+			return next(error) if error?
+			res.json labels
+
+	createLabel: (req, res, next) ->
+		project_id = req.params.Project_id
+		{comment, version} = req.body
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		HistoryController._makeRequest {
+			method: "POST"
+			url: "#{settings.apis.project_history.url}/project/#{project_id}/user/#{user_id}/labels"
+			json: {comment, version}
+		}, (error, label) ->
+			return next(error) if error?
+			res.json label
+
+	deleteLabel: (req, res, next) ->
+		project_id = req.params.Project_id
+		label_id = req.params.label_id
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		HistoryController._makeRequest {
+			method: "DELETE"
+			url: "#{settings.apis.project_history.url}/project/#{project_id}/user/#{user_id}/labels/#{label_id}"
+		}, (error) ->
+			return next(error) if error?
+			res.sendStatus 204
+
+	_makeRequest: (options, callback) ->
+		request options, (error, response, body) ->
+			return callback(error) if error?
+			if 200 <= response.statusCode < 300
+				callback(null, body)
+			else
+				error = new Error("history api responded with non-success code: #{response.statusCode}")
+				logger.error err: error, "project-history api responded with non-success code: #{response.statusCode}"
+				callback(error)

+ 16 - 5
services/web/app/coffee/Features/User/UserAffiliationsManager.coffee → services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee

@@ -3,12 +3,20 @@ metrics = require("metrics-sharelatex")
 settings = require "settings-sharelatex"
 request = require "request"
 
-module.exports = UserAffiliationsManager =
-	getAffiliations: (userId, callback = (error, body) ->) ->
+module.exports = InstitutionsAPI =
+	getInstitutionAffiliations: (institutionId, callback = (error, body) ->) ->
+		makeAffiliationRequest {
+			method: 'GET'
+			path: "/api/v2/institutions/#{institutionId.toString()}/affiliations"
+			defaultErrorMessage: "Couldn't get institution affiliations"
+		}, callback
+
+
+	getUserAffiliations: (userId, callback = (error, body) ->) ->
 		makeAffiliationRequest {
 			method: 'GET'
 			path: "/api/v2/users/#{userId.toString()}/affiliations"
-			defaultErrorMessage: "Couldn't get affiliations"
+			defaultErrorMessage: "Couldn't get user affiliations"
 		}, callback
 
 
@@ -72,15 +80,18 @@ 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)
 
 [
-	'getAffiliations',
+	'getInstitutionAffiliations'
+	'getUserAffiliations',
 	'addAffiliation',
 	'removeAffiliation',
 ].map (method) ->
 	metrics.timeAsyncMethod(
-		UserAffiliationsManager, method, 'mongo.UserAffiliationsManager', logger
+		InstitutionsAPI, method, 'mongo.InstitutionsAPI', logger
 	)

+ 22 - 0
services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee

@@ -0,0 +1,22 @@
+InstitutionsGetter = require './InstitutionsGetter'
+PlansLocator = require '../Subscription/PlansLocator'
+Settings = require 'settings-sharelatex'
+logger = require 'logger-sharelatex'
+
+module.exports = InstitutionsFeatures =
+	getInstitutionsFeatures: (userId, callback = (error, features) ->) ->
+		InstitutionsFeatures.hasLicence userId, (error, hasLicence) ->
+			return callback error if error?
+			return callback(null, {}) unless hasLicence
+			plan = PlansLocator.findLocalPlanInSettings Settings.institutionPlanCode
+			callback(null, plan?.features or {})
+
+
+	hasLicence: (userId, callback = (error, hasLicence) ->) ->
+		InstitutionsGetter.getConfirmedInstitutions userId, (error, institutions) ->
+			return callback error if error?
+
+			hasLicence = institutions.some (institution) ->
+				institution.licence and institution.licence != 'free'
+
+			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)

+ 17 - 0
services/web/app/coffee/Features/Institutions/InstitutionsManager.coffee

@@ -0,0 +1,17 @@
+logger = require 'logger-sharelatex'
+async = require 'async'
+db = require("../../infrastructure/mongojs").db
+ObjectId = require("../../infrastructure/mongojs").ObjectId
+{ getInstitutionAffiliations } = require('./InstitutionsAPI')
+FeaturesUpdater = require('../Subscription/FeaturesUpdater')
+
+ASYNC_LIMIT = 10
+module.exports = InstitutionsManager =
+	upgradeInstitutionUsers: (institutionId, callback = (error) ->) ->
+		getInstitutionAffiliations institutionId, (error, affiliations) ->
+			return callback(error) if error
+			async.eachLimit affiliations, ASYNC_LIMIT, refreshFeatures, callback
+
+refreshFeatures = (affiliation, callback) ->
+	userId = ObjectId(affiliation.user_id)
+	FeaturesUpdater.refreshFeatures(userId, true, callback)

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

+ 7 - 2
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -28,6 +28,7 @@ Modules = require '../../infrastructure/Modules'
 ProjectEntityHandler = require './ProjectEntityHandler'
 crypto = require 'crypto'
 { V1ConnectionError } = require '../Errors/Errors'
+Features = require('../../infrastructure/Features')
 
 module.exports = ProjectController =
 
@@ -48,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
@@ -344,9 +349,9 @@ module.exports = ProjectController =
 					themes: THEME_LIST
 					maxDocLength: Settings.max_doc_length
 					useV2History: !!project.overleaf?.history?.display
-					showRichText: req.query?.rt == 'true'
+					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"

+ 6 - 3
services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee

@@ -7,6 +7,7 @@ Settings = require("settings-sharelatex")
 logger = require("logger-sharelatex")
 ReferalFeatures = require("../Referal/ReferalFeatures")
 V1SubscriptionManager = require("./V1SubscriptionManager")
+InstitutionsFeatures = require '../Institutions/InstitutionsFeatures'
 
 oneMonthInSeconds = 60 * 60 * 24 * 30
 
@@ -21,9 +22,11 @@ module.exports = FeaturesUpdater =
 				if error?
 					logger.err {err: error, user_id}, "error notifying v1 about updated features"
 
+
 		jobs =
 			individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb
 			groupFeatureSets:   (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb
+			institutionFeatures:(cb) -> InstitutionsFeatures.getInstitutionsFeatures user_id, cb
 			v1Features:         (cb) -> FeaturesUpdater._getV1Features user_id, cb
 			bonusFeatures:      (cb) -> ReferalFeatures.getBonusFeatures user_id, cb
 		async.series jobs, (err, results)->
@@ -32,9 +35,9 @@ module.exports = FeaturesUpdater =
 					"error getting subscription or group for refreshFeatures"
 				return callback(err)
 
-			{individualFeatures, groupFeatureSets, v1Features, bonusFeatures} = results
-			logger.log {user_id, individualFeatures, groupFeatureSets, v1Features, bonusFeatures}, 'merging user features'
-			featureSets = groupFeatureSets.concat [individualFeatures, v1Features, bonusFeatures]
+			{individualFeatures, groupFeatureSets, institutionFeatures, v1Features, bonusFeatures} = results
+			logger.log {user_id, individualFeatures, groupFeatureSets, institutionFeatures, v1Features, bonusFeatures}, 'merging user features'
+			featureSets = groupFeatureSets.concat [individualFeatures, institutionFeatures, v1Features, bonusFeatures]
 			features = _.reduce(featureSets, FeaturesUpdater._mergeFeatures, Settings.defaultFeatures)
 
 			logger.log {user_id, features}, 'updating user features'

+ 5 - 5
services/web/app/coffee/Features/Subscription/LimitationsManager.coffee

@@ -19,7 +19,7 @@ module.exports = LimitationsManager =
 			if user.features? and user.features.collaborators?
 				callback null, user.features.collaborators
 			else
-				callback null, Settings.defaultPlanCode.collaborators
+				callback null, Settings.defaultFeatures.collaborators
 
 	canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) ->
 		@allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) =>
@@ -89,13 +89,13 @@ module.exports = LimitationsManager =
 
 		return currentTotal >= subscription.membersLimit
 
-	hasGroupMembersLimitReached: (user_id, callback = (err, limitReached, subscription)->)->
-		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
+	hasGroupMembersLimitReached: (subscriptionId, callback = (err, limitReached, subscription)->)->
+		SubscriptionLocator.getSubscription subscriptionId, (err, subscription)->
 			if err?
-				logger.err err:err, user_id:user_id, "error getting users subscription"
+				logger.err err:err, subscriptionId: subscriptionId, "error getting subscription"
 				return callback(err)
 			if !subscription?
-				logger.err user_id:user_id, "no subscription found for user"
+				logger.err subscriptionId: subscriptionId, "no subscription found"
 				return callback("no subscription found")
 
 			limitReached = LimitationsManager.teamHasReachedMemberLimit(subscription)

+ 0 - 3
services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee

@@ -9,9 +9,6 @@ module.exports = SubscriptionDomainHandler =
 		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
 		return licence
 
-	rejectInvitationToGroup: (user, subscription, callback)->
-		removeUserFromGroup(subscription.admin_id, user._id, callback)
-
 	getDomainLicencePage: (user)->
 		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
 		if licence?.verifyEmail

+ 51 - 30
services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee

@@ -9,46 +9,56 @@ async = require("async")
 
 module.exports =
 
-	addUserToGroup: (req, res)->
+	addUserToGroup: (req, res, next)->
 		adminUserId = AuthenticationController.getLoggedInUserId(req)
 		newEmail = req.body?.email?.toLowerCase()?.trim()
-		logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group subscription"
-		SubscriptionGroupHandler.addUserToGroup adminUserId, newEmail, (err, user)->
-			if err?
-				logger.err err:err, newEmail:newEmail, adminUserId:adminUserId, "error adding user from group"
-				return res.sendStatus 500
-			result =
-				user:user
-			if err and err.limitReached
-				result.limitReached = true
-			res.json(result)
 
-	removeUserFromGroup: (req, res)->
+		getManagedSubscription adminUserId, (error, subscription) ->
+			return next(error) if error?
+
+			logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group subscription"
+
+			SubscriptionGroupHandler.addUserToGroup subscription._id, newEmail, (err, user)->
+				if err?
+					logger.err err:err, newEmail:newEmail, adminUserId:adminUserId, "error adding user from group"
+					return res.sendStatus 500
+				result =
+					user:user
+				if err and err.limitReached
+					result.limitReached = true
+				res.json(result)
+
+	removeUserFromGroup: (req, res, next)->
 		adminUserId = AuthenticationController.getLoggedInUserId(req)
 		userToRemove_id = req.params.user_id
-		logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription"
-		SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)->
-			if err?
-				logger.err err:err, adminUserId:adminUserId, userToRemove_id:userToRemove_id, "error removing user from group"
-				return res.sendStatus 500
-			res.send()
+		getManagedSubscription adminUserId, (error, subscription) ->
+			return next(error) if error?
+			logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription"
+			SubscriptionGroupHandler.removeUserFromGroup subscription._id, userToRemove_id, (err)->
+				if err?
+					logger.err err:err, adminUserId:adminUserId, userToRemove_id:userToRemove_id, "error removing user from group"
+					return res.sendStatus 500
+				res.send()
 
-	removeSelfFromGroup: (req, res)->
+	removeSelfFromGroup: (req, res, next)->
 		adminUserId = req.query.admin_user_id
 		userToRemove_id = AuthenticationController.getLoggedInUserId(req)
-		logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription after self request"
-		SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)->
-			if err?
-				logger.err err:err, userToRemove_id:userToRemove_id, adminUserId:adminUserId, "error removing self from group"
-				return res.sendStatus 500
-			res.send()
+		getManagedSubscription adminUserId, (error, subscription) ->
+			return next(error) if error?
+			logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription after self request"
+			SubscriptionGroupHandler.removeUserFromGroup subscription._id, userToRemove_id, (err)->
+				if err?
+					logger.err err:err, userToRemove_id:userToRemove_id, adminUserId:adminUserId, "error removing self from group"
+					return res.sendStatus 500
+				res.send()
 
-	renderSubscriptionGroupAdminPage: (req, res)->
+	renderSubscriptionGroupAdminPage: (req, res, next)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
-		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
+		getManagedSubscription user_id, (error, subscription)->
+			return next(error) if error?
 			if !subscription?.groupPlan
 				return res.redirect("/user/subscription")
-			SubscriptionGroupHandler.getPopulatedListOfMembers user_id, (err, users)->
+			SubscriptionGroupHandler.getPopulatedListOfMembers subscription._id, (err, users)->
 				res.render "subscriptions/group_admin",
 					title: 'group_admin'
 					users: users
@@ -57,10 +67,11 @@ module.exports =
 	exportGroupCsv: (req, res)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log user_id: user_id, "exporting group csv"
-		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
+		getManagedSubscription user_id, (err, subscription)->
+			return next(error) if error?
 			if !subscription.groupPlan
 				return res.redirect("/")
-			SubscriptionGroupHandler.getPopulatedListOfMembers user_id, (err, users)->
+			SubscriptionGroupHandler.getPopulatedListOfMembers subscription._id, (err, users)->
 				groupCsv = ""
 				for user in users
 					groupCsv += user.email + "\n"
@@ -70,3 +81,13 @@ module.exports =
 				)
 				res.contentType('text/csv')
 				res.send(groupCsv)
+
+
+getManagedSubscription = (managerId, callback) ->
+	SubscriptionLocator.findManagedSubscription managerId, (err, subscription)->
+		if subscription?
+			logger.log managerId: managerId, "got managed subscription"
+		else
+			err ||= new Error("No subscription found managed by user #{managerId}")
+
+		return callback(err, subscription)

+ 32 - 19
services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee

@@ -14,19 +14,19 @@ NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 
 module.exports = SubscriptionGroupHandler =
 
-	addUserToGroup: (adminUserId, newEmail, callback)->
-		logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group"
-		LimitationsManager.hasGroupMembersLimitReached adminUserId, (err, limitReached, subscription)->
+	addUserToGroup: (subscriptionId, newEmail, callback)->
+		logger.log subscriptionId:subscriptionId, newEmail:newEmail, "adding user to group"
+		LimitationsManager.hasGroupMembersLimitReached subscriptionId, (err, limitReached, subscription)->
 			if err?
-				logger.err err:err, adminUserId:adminUserId, newEmail:newEmail, "error checking if limit reached for group plan"
+				logger.err err:err, subscriptionId:subscriptionId, newEmail:newEmail, "error checking if limit reached for group plan"
 				return callback(err)
 			if limitReached
-				logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group"
+				logger.err subscriptionId:subscriptionId, newEmail:newEmail, "group subscription limit reached not adding user to group"
 				return callback(limitReached:limitReached)
 			UserGetter.getUserByAnyEmail newEmail, (err, user)->
 				return callback(err) if err?
 				if user?
-					SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)->
+					SubscriptionUpdater.addUserToGroup subscriptionId, user._id, (err)->
 						if err?
 							logger.err err:err, "error adding user to group"
 							return callback(err)
@@ -34,30 +34,25 @@ module.exports = SubscriptionGroupHandler =
 						userViewModel = buildUserViewModel(user)
 						callback(err, userViewModel)
 				else
-					TeamInvitesHandler.createInvite adminUserId, newEmail, (err) ->
+					TeamInvitesHandler.createInvite subscriptionId, newEmail, (err) ->
 						return callback(err) if err?
 						userViewModel = buildEmailInviteViewModel(newEmail)
 						callback(err, userViewModel)
 
-	removeUserFromGroup: (adminUser_id, userToRemove_id, callback)->
-		SubscriptionUpdater.removeUserFromGroup adminUser_id, userToRemove_id, callback
+	removeUserFromGroup: (subscriptionId, userToRemove_id, callback)->
+		SubscriptionUpdater.removeUserFromGroup subscriptionId, userToRemove_id, callback
 
 	replaceUserReferencesInGroups: (oldId, newId, callback) ->
 		Subscription.update {admin_id: oldId}, {admin_id: newId}, (error) ->
 			callback(error) if error?
 
-			# Mongo won't let us pull and addToSet in the same query, so do it in
-			# two. Note we need to add first, since the query is based on the old user.
-			query = { member_ids: oldId }
-			addNewUserUpdate = $addToSet: { member_ids: newId }
-			removeOldUserUpdate =  $pull: { member_ids: oldId }
+			replaceInArray Subscription, "manager_ids", oldId, newId, (error) ->
+				callback(error) if error?
 
-			Subscription.update query, addNewUserUpdate, { multi: true }, (error) ->
-				return callback(error) if error?
-				Subscription.update query, removeOldUserUpdate, { multi: true }, callback
+				replaceInArray Subscription, "member_ids", oldId, newId, callback
 
-	getPopulatedListOfMembers: (adminUser_id, callback)->
-		SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)->
+	getPopulatedListOfMembers: (subscriptionId, callback)->
+		SubscriptionLocator.getSubscription subscriptionId, (err, subscription)->
 			users = []
 
 			for email in subscription.invited_emails or []
@@ -87,6 +82,24 @@ module.exports = SubscriptionGroupHandler =
 			logger.log user_id:user_id, subscription_id:subscription_id, partOfGroup:partOfGroup, "checking if user is part of a group"
 			callback(err, partOfGroup)
 
+replaceInArray = (model, property, oldValue, newValue, callback) ->
+	logger.log "Replacing #{oldValue} with #{newValue} in #{property} of #{model}"
+
+	# Mongo won't let us pull and addToSet in the same query, so do it in
+	# two. Note we need to add first, since the query is based on the old user.
+	query = {}
+	query[property] = oldValue
+
+	setNewValue = {}
+	setNewValue[property] = newValue
+
+	setOldValue = {}
+	setOldValue[property] = oldValue
+
+	model.update query, { $addToSet: setNewValue }, { multi: true }, (error) ->
+		return callback(error) if error?
+		model.update query, { $pull: setOldValue }, { multi: true }, callback
+
 buildUserViewModel = (user)->
 	u =
 		email: user.email

+ 5 - 1
services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee

@@ -2,7 +2,7 @@ Subscription = require('../../models/Subscription').Subscription
 logger = require("logger-sharelatex")
 ObjectId = require('mongoose').Types.ObjectId
 
-module.exports =
+module.exports = SubscriptionLocator =
 
 	getUsersSubscription: (user_or_id, callback)->
 		if user_or_id? and user_or_id._id?
@@ -14,6 +14,10 @@ module.exports =
 			logger.log user_id:user_id, "got users subscription"
 			callback(err, subscription)
 
+	findManagedSubscription: (managerId, callback)->
+		logger.log managerId: managerId, "finding managed subscription"
+		Subscription.findOne manager_ids: managerId, callback
+
 	getMemberSubscriptions: (user_or_id, callback) ->
 		if user_or_id? and user_or_id._id?
 			user_id = user_or_id._id

+ 17 - 17
services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee

@@ -25,13 +25,13 @@ module.exports = SubscriptionUpdater =
 					return callback(err) if err?
 					SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback
 
-	addUserToGroup: (adminUserId, userId, callback)->
-		@addUsersToGroup(adminUserId, [userId], callback)
+	addUserToGroup: (subscriptionId, userId, callback)->
+		@addUsersToGroup(subscriptionId, [userId], callback)
 
-	addUsersToGroup: (adminUserId, memberIds, callback)->
-		logger.log adminUserId: adminUserId, memberIds: memberIds, "adding members into mongo subscription"
+	addUsersToGroup: (subscriptionId, memberIds, callback)->
+		logger.log subscriptionId: subscriptionId, memberIds: memberIds, "adding members into mongo subscription"
 		searchOps =
-			admin_id: adminUserId
+			_id: subscriptionId
 		insertOperation =
 			{ $push: { member_ids: { $each: memberIds } } }
 
@@ -46,9 +46,9 @@ module.exports = SubscriptionUpdater =
 				async.map userIds, FeaturesUpdater.refreshFeatures, callback
 
 
-	removeUserFromGroup: (adminUser_id, user_id, callback)->
+	removeUserFromGroup: (subscriptionId, user_id, callback)->
 		searchOps =
-			admin_id: adminUser_id
+			_id: subscriptionId
 		removeOperation =
 			"$pull": {member_ids:user_id}
 		Subscription.update searchOps, removeOperation, (err)->
@@ -71,23 +71,23 @@ module.exports = SubscriptionUpdater =
 
 	_createNewSubscription: (adminUser_id, callback)->
 		logger.log adminUser_id:adminUser_id, "creating new subscription"
-		subscription = new Subscription(admin_id:adminUser_id)
+		subscription = new Subscription(admin_id:adminUser_id, manager_ids: [adminUser_id])
 		subscription.freeTrial.allowed = false
 		subscription.save (err)->
 			callback err, subscription
 
 	_updateSubscriptionFromRecurly: (recurlySubscription, subscription, callback)->
 		logger.log recurlySubscription:recurlySubscription, subscription:subscription, "updaing subscription"
-		plan = PlansLocator.findLocalPlanInSettings(recurlySubscription.plan.plan_code)
 		if recurlySubscription.state == "expired"
-			subscription.recurlySubscription_id = undefined
-			subscription.planCode = Settings.defaultPlanCode
-		else
-			subscription.recurlySubscription_id = recurlySubscription.uuid
-			subscription.freeTrial.expiresAt = undefined
-			subscription.freeTrial.planCode = undefined
-			subscription.freeTrial.allowed = true
-			subscription.planCode = recurlySubscription.plan.plan_code
+			return SubscriptionUpdater.deleteSubscription subscription._id, callback
+		subscription.recurlySubscription_id = recurlySubscription.uuid
+		subscription.freeTrial.expiresAt = undefined
+		subscription.freeTrial.planCode = undefined
+		subscription.freeTrial.allowed = true
+		subscription.planCode = recurlySubscription.plan.plan_code
+		plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
+		if !plan?
+			return callback(new Error("plan code not found: #{subscription.planCode}"))
 		if plan.groupPlan
 			subscription.groupPlan = true
 			subscription.membersLimit = plan.membersLimit

+ 1 - 1
services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee

@@ -76,7 +76,7 @@ module.exports = TeamInvitesHandler =
 			return callback(err) if err?
 			return callback(new Errors.NotFoundError('invite not found')) unless invite?
 
-			SubscriptionUpdater.addUserToGroup subscription.admin_id, userId, (err) ->
+			SubscriptionUpdater.addUserToGroup subscription._id, userId, (err) ->
 				return callback(err) if err?
 
 				removeInviteFromTeam(subscription.id, invite.email, callback)

+ 2 - 1
services/web/app/coffee/Features/User/UserController.coffee

@@ -13,6 +13,7 @@ UserSessionsManager = require("./UserSessionsManager")
 UserUpdater = require("./UserUpdater")
 SudoModeHandler = require('../SudoMode/SudoModeHandler')
 settings = require "settings-sharelatex"
+Errors = require "../Errors/Errors"
 
 module.exports = UserController =
 
@@ -100,7 +101,7 @@ module.exports = UserController =
 					UserUpdater.changeEmailAddress user_id, newEmail, (err)->
 						if err?
 							logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address"
-							if err.message == "alread_exists"
+							if err instanceof Errors.EmailExistsError
 								message = req.i18n.translate("email_already_registered")
 							else
 								message = req.i18n.translate("problem_changing_email_address")

+ 11 - 7
services/web/app/coffee/Features/User/UserCreator.coffee

@@ -1,20 +1,23 @@
 User = require("../../models/User").User
 logger = require("logger-sharelatex")
 metrics = require('metrics-sharelatex')
-{ addAffiliation } = require("./UserAffiliationsManager")
+{ addAffiliation } = require("../Institutions/InstitutionsAPI")
 
 
 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) ->

+ 1 - 1
services/web/app/coffee/Features/User/UserDeleter.coffee

@@ -4,7 +4,7 @@ ProjectDeleter = require("../Project/ProjectDeleter")
 logger = require("logger-sharelatex")
 SubscriptionHandler = require("../Subscription/SubscriptionHandler")
 async = require("async")
-{ deleteAffiliations } = require("./UserAffiliationsManager")
+{ deleteAffiliations } = require("../Institutions/InstitutionsAPI")
 
 module.exports =
 

+ 33 - 5
services/web/app/coffee/Features/User/UserEmailsController.coffee

@@ -3,7 +3,7 @@ UserGetter = require("./UserGetter")
 UserUpdater = require("./UserUpdater")
 EmailHelper = require("../Helpers/EmailHelper")
 UserEmailsConfirmationHandler = require "./UserEmailsConfirmationHandler"
-{ endorseAffiliation } = require("./UserAffiliationsManager")
+{ endorseAffiliation } = require("../Institutions/InstitutionsAPI")
 logger = require("logger-sharelatex")
 Errors = require "../Errors/Errors"
 
@@ -26,7 +26,8 @@ module.exports = UserEmailsController =
 			role: req.body.role
 			department: req.body.department
 		UserUpdater.addEmailAddress userId, email, affiliationOptions, (error)->
-			return next(error) if error?
+			if error?
+				return UserEmailsController._handleEmailError error, req, res, next
 			UserEmailsConfirmationHandler.sendConfirmationEmail userId, email, (err) ->
 				return next(error) if error?
 				res.sendStatus 204
@@ -47,9 +48,11 @@ module.exports = UserEmailsController =
 		email = EmailHelper.parseEmail(req.body.email)
 		return res.sendStatus 422 unless email?
 
-		UserUpdater.setDefaultEmailAddress userId, email, (error)->
-			return next(error) if error?
-			res.sendStatus 200
+		UserUpdater.updateV1AndSetDefaultEmailAddress userId, email, (error)->
+			if error?
+				return UserEmailsController._handleEmailError error, req, res, next
+			else
+				return res.sendStatus 200
 
 
 	endorse: (req, res, next) ->
@@ -61,6 +64,19 @@ module.exports = UserEmailsController =
 			return next(error) if error?
 			res.sendStatus 204
 
+	resendConfirmation: (req, res, next) ->
+		userId = AuthenticationController.getLoggedInUserId(req)
+		email = EmailHelper.parseEmail(req.body.email)
+		return res.sendStatus 422 unless email?
+		UserGetter.getUserByAnyEmail email, {_id:1}, (error, user) ->
+			return next(error) if error?
+			if !user? or user?._id?.toString() != userId
+				logger.log {userId, email, foundUserId: user?._id}, "email doesn't match logged in user"
+				return res.sendStatus 422
+			logger.log {userId, email}, 'resending email confirmation token'
+			UserEmailsConfirmationHandler.sendConfirmationEmail userId, email, (error) ->
+				return next(error) if error?
+				res.sendStatus 200
 
 	showConfirm: (req, res, next) ->
 		res.render 'user/confirm_email', {
@@ -82,3 +98,15 @@ module.exports = UserEmailsController =
 					next(error)
 			else
 				res.sendStatus 200
+
+	_handleEmailError: (error, req, res, next) ->
+		if error instanceof Errors.UnconfirmedEmailError
+			return res.status(409).json {
+				message: 'email must be confirmed'
+			}
+		else if error instanceof Errors.EmailExistsError
+			return res.status(409).json {
+				message: req.i18n.translate("email_already_registered")
+			}
+		else
+			return next(error)

+ 4 - 3
services/web/app/coffee/Features/User/UserGetter.coffee

@@ -3,7 +3,8 @@ metrics = require('metrics-sharelatex')
 logger = require('logger-sharelatex')
 db = mongojs.db
 ObjectId = mongojs.ObjectId
-{ getAffiliations } = require("./UserAffiliationsManager")
+{ getUserAffiliations } = require("../Institutions/InstitutionsAPI")
+Errors = require("../Errors/Errors")
 
 module.exports = UserGetter =
 	getUser: (query, projection, callback = (error, user) ->) ->
@@ -31,7 +32,7 @@ module.exports = UserGetter =
 			return callback error if error?
 			return callback new Error('User not Found') unless user
 
-			getAffiliations userId, (error, affiliationsData) ->
+			getUserAffiliations userId, (error, affiliationsData) ->
 				return callback error if error?
 				callback null, decorateFullEmails(user.email, user.emails, affiliationsData)
 
@@ -77,7 +78,7 @@ module.exports = UserGetter =
 	# check for duplicate email address. This is also enforced at the DB level
 	ensureUniqueEmailAddress: (newEmail, callback) ->
 		@getUserByAnyEmail newEmail, (error, user) ->
-			return callback(message: 'alread_exists') if user?
+			return callback(new Errors.EmailExistsError('alread_exists')) if user?
 			callback(error)
 
 decorateFullEmails = (defaultEmail, emailsData, affiliationsData) ->

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

+ 68 - 12
services/web/app/coffee/Features/User/UserUpdater.coffee

@@ -5,9 +5,13 @@ db = mongojs.db
 async = require("async")
 ObjectId = mongojs.ObjectId
 UserGetter = require("./UserGetter")
-{ addAffiliation, removeAffiliation } = require("./UserAffiliationsManager")
+{ addAffiliation, removeAffiliation } = require("../Institutions/InstitutionsAPI")
+FeaturesUpdater = require("../Subscription/FeaturesUpdater")
 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) ->) ->
@@ -96,17 +100,69 @@ 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
+
+
 
-	confirmEmail: (userId, email, callback) ->
+	updateV1AndSetDefaultEmailAddress: (userId, email, callback) ->
+		@updateEmailAddressInV1 userId, email, (error) =>
+			return callback(error) if error?
+			@setDefaultEmailAddress userId, email, callback
+
+	updateEmailAddressInV1: (userId, newEmail, callback) ->
+		if !Settings.apis?.v1?.url?
+			return callback()
+		UserGetter.getUser userId, { 'overleaf.id': 1, emails: 1 }, (error, user) ->
+			return callback(error) if error?
+			return callback(new Errors.NotFoundError('no user found')) if !user?
+			if !user.overleaf?.id?
+				return callback()
+			newEmailIsConfirmed = false
+			for email in user.emails
+				if email.email == newEmail and email.confirmedAt?
+					newEmailIsConfirmed = true
+					break
+			if !newEmailIsConfirmed
+				return callback(new Errors.UnconfirmedEmailError("can't update v1 with unconfirmed email"))
+			request {
+				baseUrl: Settings.apis.v1.url
+				url: "/api/v1/sharelatex/users/#{user.overleaf.id}/email"
+				method: 'PUT'
+				auth:
+					user: Settings.apis.v1.user
+					pass: Settings.apis.v1.pass
+					sendImmediately: true
+				json:
+					user:
+						email: newEmail
+				timeout: 5 * 1000
+			}, (error, response, body) ->
+				if error?
+					error = new Errors.V1ConnectionError('No V1 connection') if error.code == 'ECONNREFUSED'
+					return callback(error)
+				if response.statusCode == 409 # Conflict
+					return callback(new Errors.EmailExistsError('email exists in v1'))
+				else if 200 <= response.statusCode < 300
+					return callback()
+				else
+					return callback new Error("non-success code from v1: #{response.statusCode}")
+
+	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'
@@ -120,13 +176,13 @@ 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"
 				if res.n == 0
 					return callback(new Errors.NotFoundError('user id and email do no match'))
-				callback()
+				FeaturesUpdater.refreshFeatures userId, true, callback
 
 [
 	'updateUser'

+ 11 - 1
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -109,7 +109,7 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 			logger.log user_id:user_id, ip:req?.ip, "cdnBlocked for user, not using it and turning it off for future requets"
 			req.session.cdnBlocked = true
 
-		isDark = req.headers?.host?.slice(0,4)?.toLowerCase() == "dark"
+		isDark = req.headers?.host?.slice(0,7)?.toLowerCase().indexOf("dark") != -1
 		isSmoke = req.headers?.host?.slice(0,5)?.toLowerCase() == "smoke"
 		isLive = !isDark and !isSmoke
 
@@ -167,6 +167,11 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 			path = Path.join("/img/", imgFile)
 			return Url.resolve(staticFilesBase, path)
 
+		res.locals.mathJaxPath = res.locals.buildJsPath(
+			'libs/mathjax/MathJax.js',
+			{cdn:false, qs:{config:'TeX-AMS_HTML'}}
+		)
+
 		next()
 
 
@@ -328,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()

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

@@ -20,5 +20,12 @@ module.exports = Features =
 				return Settings.overleaf?
 			when 'templates'
 				return !Settings.overleaf?
+			when 'affiliations'
+				return Settings?.apis?.v1?.url?
+			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 - 0
services/web/app/coffee/models/Subscription.coffee

@@ -7,10 +7,12 @@ ObjectId = Schema.ObjectId
 
 SubscriptionSchema = new Schema
 	admin_id     :   {type:ObjectId, ref:'User', index: {unique: true, dropDups: true}}
+	manager_ids  :   [ type:ObjectId, ref:'User' ]
 	member_ids   :   [ type:ObjectId, ref:'User' ]
 	invited_emails:  [ String ]
 	teamInvites  :   [ TeamInviteSchema ]
 	recurlySubscription_id : String
+	teamName 	 : {type: String}
 	planCode 	 : {type: String}
 	groupPlan	 : {type: Boolean, default: false}
 	membersLimit: {type:Number, default:0}

+ 10 - 2
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")
@@ -115,8 +115,11 @@ module.exports = class Router
 			UserEmailsController.showConfirm
 		webRouter.post '/user/emails/confirm',
 			UserEmailsController.confirm
+		webRouter.post '/user/emails/resend_confirmation',
+			AuthenticationController.requireLogin(),
+			UserEmailsController.resendConfirmation
 
-		unless Features.externalAuthenticationSystemUsed()
+		if Features.hasFeature 'affiliations'
 			webRouter.post '/user/emails',
 				AuthenticationController.requireLogin(),
 				UserEmailsController.add
@@ -236,8 +239,13 @@ module.exports = class Router
 		webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
 		privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory
 
+		webRouter.get "/project/:Project_id/labels", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.ensureProjectHistoryEnabled, HistoryController.getLabels
+		webRouter.post "/project/:Project_id/labels", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.ensureProjectHistoryEnabled, HistoryController.createLabel
+		webRouter.delete "/project/:Project_id/labels/:label_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.ensureProjectHistoryEnabled, HistoryController.deleteLabel
+
 		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

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

+ 4 - 2
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"
@@ -103,6 +103,8 @@ block content
 			h3 {{ title }}
 		.modal-body(ng-bind-html="message")
 
+	script(src=mathJaxPath)
+
 block requirejs
 	script(type="text/javascript" src='/socket.io/socket.io.js')
 
@@ -131,9 +133,9 @@ block requirejs
 		window.maxDocLength = #{maxDocLength};
 		window.trackChangesState = data.trackChangesState;
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
+		window.richTextEnabled = #{richTextEnabled}
 		window.requirejs = {
 			"paths" : {
-				"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, qs:{config:'TeX-AMS_HTML'}})}",
 				"moment": "libs/#{lib('moment')}",
 				"pdfjs-dist/build/pdf": "libs/#{lib('pdfjs')}/pdf",
 				"pdfjs-dist/build/pdf.worker": "#{pdfWorkerPath}",

+ 6 - 2
services/web/app/views/project/editor/editor.pug

@@ -15,6 +15,7 @@ div.full-size(
 	.ui-layout-center(
 		ng-controller="ReviewPanelController",
 		ng-class="{\
+			'rp-unsupported': editor.showRichText,\
 			'rp-state-current-file': (reviewPanel.subView === SubViews.CUR_FILE),\
 			'rp-state-current-file-expanded': (reviewPanel.subView === SubViews.CUR_FILE && ui.reviewPanelOpen),\
 			'rp-state-current-file-mini': (reviewPanel.subView === SubViews.CUR_FILE && !ui.reviewPanelOpen),\
@@ -25,7 +26,10 @@ div.full-size(
 			'rp-loading-threads': reviewPanel.loadingThreads,\
 		}"
 	)
-		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
+		.loading-panel(
+			ng-show="!editor.sharejs_doc || editor.opening",
+			style=showRichText ? "top: 32px" : "",
+		)
 			span(ng-show="editor.open_doc_id")
 				i.fa.fa-spin.fa-refresh
 				| &nbsp;&nbsp;#{translate("loading")}...
@@ -39,7 +43,7 @@ div.full-size(
 			ace-editor="editor",
 			ng-if="!editor.showRichText",
 			ng-show="!!editor.sharejs_doc && !editor.opening",
-			style=showRichText ? "top: 32px" : "",
+			style=richTextEnabled ? "top: 32px" : "",
 			theme="settings.theme",
 			keybindings="settings.mode",
 			font-size="settings.fontSize",

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

@@ -67,3 +67,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")}' }}

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

@@ -3,13 +3,26 @@ aside.change-list(
 	ng-controller="HistoryV2ListController"
 )
 	history-entries-list(
+		ng-if="!history.showOnlyLabels && !history.error"
 		entries="history.updates"
 		current-user="user"
+		users="projectUsers"
 		load-entries="loadMore()"
 		load-disabled="history.loading || history.atEnd"
 		load-initialize="ui.view == 'history'"
 		is-loading="history.loading"
 		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 +78,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,8 +127,9 @@ 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
@@ -129,6 +151,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 +179,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 +203,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.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"
+		) 
+			i.fa.fa-tag
+			| &nbsp;#{translate("history_label_this_version")}
+		button.history-toolbar-btn(
+			ng-click="toggleHistoryViewMode();"
+			ng-disabled="history.loadingFileTree"
+		) 
+			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

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

@@ -1,4 +1,4 @@
-#review-panel
+#review-panel(style=richTextEnabled ? "top: 32px" : "")
 	.rp-in-editor-widgets
 		a.rp-track-changes-indicator(
 			href
@@ -235,6 +235,13 @@
 			i.fa.fa-list
 			span.rp-nav-label #{translate("overview")}
 
+	.rp-unsupported-msg-wrapper
+		.rp-unsupported-msg
+			i.fa.fa-5x.fa-exclamation-triangle
+			p.rp-unsupported-msg-title Sorry, Track Changes is not supported in Rich Text mode (yet).
+			p We didn't want to delay your ability to use Rich Text mode so we've launched without support for Track Changes.
+			p We're working hard to include Track Changes as soon as possible.
+
 
 script(type='text/ng-template', id='changeEntryTemplate')
 	div

+ 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

+ 27 - 21
services/web/app/views/user/settings.pug

@@ -9,7 +9,7 @@ block content
 						.page-header
 							h1  #{translate("account_settings")}
 						.account-settings(ng-controller="AccountSettingsController", ng-cloak)
-							if locals.showAffiliationsUI
+							if hasFeature('affiliations')
 								include settings/user-affiliations
 								
 							form-messages(for="settingsForm")
@@ -22,25 +22,26 @@ block content
 									h3 #{translate("update_account_info")}
 									form(async-form="settings", name="settingsForm", method="POST", action="/user/settings", novalidate)
 										input(type="hidden", name="_csrf", value=csrfToken)
-										if !externalAuthenticationSystemUsed()
-											.form-group
-												label(for='email')  #{translate("email")}
-												input.form-control(
-													type='email',
-													name='email',
-													placeholder="email@example.com"
-													required,
-													ng-model="email",
-													ng-init="email = "+JSON.stringify(user.email),
-													ng-model-options="{ updateOn: 'blur' }"
-												)
-												span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty")
-													|  #{translate("must_be_email_address")}
-										else
-											// show the email, non-editable
-											.form-group
-												label.control-label #{translate("email")}
-												div.form-control(readonly="true") #{user.email}
+										if !hasFeature('affiliations')
+											if !externalAuthenticationSystemUsed() 
+												.form-group
+													label(for='email')  #{translate("email")}
+													input.form-control(
+														type='email',
+														name='email',
+														placeholder="email@example.com"
+														required,
+														ng-model="email",
+														ng-init="email = "+JSON.stringify(user.email),
+														ng-model-options="{ updateOn: 'blur' }"
+													)
+													span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty")
+														|  #{translate("must_be_email_address")}
+											else
+												// show the email, non-editable
+												.form-group
+													label.control-label #{translate("email")}
+													div.form-control(readonly="true") #{user.email}
 
 										if shouldAllowEditingDetails
 											.form-group
@@ -166,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")}
 

+ 188 - 119
services/web/app/views/user/settings/user-affiliations.pug

@@ -3,55 +3,101 @@ form.row(
 	name="affiliationsForm"
 )
 	.col-md-12
-		h3 Emails and Affiliations
-		p.small Add additional email addresses to your account to access any upgrades your university or institution has, to make it easier for collaborators to find you, and to make sure you can recover your account.
+		h3 #{translate("emails_and_affiliations_title")}
+		p.small #{translate("emails_and_affiliations_explanation")}
 		table.table.affiliations-table
 			thead
 				tr
-					th.affiliations-table-email Email
-					th.affiliations-table-institution Institution and role
+					th.affiliations-table-email  #{translate("email")}
+					th.affiliations-table-institution  #{translate("institution_and_role")}
 					th.affiliations-table-inline-actions
 			tbody
 				tr(
 					ng-repeat="userEmail in userEmails"
 				)
-					td {{ userEmail.email + (userEmail.default ? ' (default)' : '') }}
-					td 
-						div(ng-if="userEmail.affiliation.institution") {{ userEmail.affiliation.institution.name }}
-						div(ng-if="userEmail.affiliation.role || userEmail.affiliation.department") 
-							span(ng-if="userEmail.affiliation.role") {{ userEmail.affiliation.role }}
-							span(ng-if="userEmail.affiliation.role && userEmail.affiliation.department") , 
-							span(ng-if="userEmail.affiliation.department") {{ userEmail.affiliation.department }}
+					td
+						| {{ userEmail.email + (userEmail.default ? ' (default)' : '') }}
+						div(ng-if="!userEmail.confirmedAt").small
+							strong #{translate('unconfirmed')}.
+							|
+							| #{translate('please_check_your_inbox')}.
+							br
+							a(
+								href,
+								ng-click="resendConfirmationEmail(userEmail)"
+							) #{translate('resend_confirmation_email')}
 					td 
-						a(
-							href
-							ng-if="!userEmail.default"
-							ng-click="setDefaultUserEmail(userEmail.email)"
-						) Make default
-						br
-						a(
-							href
+						div(ng-if="userEmail.affiliation.institution") 
+							div {{ userEmail.affiliation.institution.name }}
+							span.small
+								a(
+									href
+									ng-if="!isChangingAffiliation(userEmail.email) && !userEmail.affiliation.role && !userEmail.affiliation.department"
+									ng-click="changeAffiliation(userEmail);"
+								)  #{translate("add_role_and_department")}
+							div.small(
+								ng-if="!isChangingAffiliation(userEmail.email) && (userEmail.affiliation.role || userEmail.affiliation.department)"
+							)
+								span(ng-if="userEmail.affiliation.role") {{ userEmail.affiliation.role }}
+								span(ng-if="userEmail.affiliation.role && userEmail.affiliation.department") , 
+								span(ng-if="userEmail.affiliation.department") {{ userEmail.affiliation.department }}
+								br
+								a(
+									href
+									ng-click="changeAffiliation(userEmail);"
+								) #{translate("change")}
+							.affiliation-change-container(
+								ng-if="isChangingAffiliation(userEmail.email)"
+							)
+								affiliation-form(
+									affiliation-data="affiliationToChange"
+									show-university-and-country="false"
+									show-role-and-department="true"
+								)
+								.affiliation-change-actions.small
+									a(
+										href
+										ng-click="saveAffiliationChange(userEmail);"
+									) #{translate("save_or_cancel-save")}
+									| &nbsp;#{translate("save_or_cancel-or" )}&nbsp;
+									a(
+										href
+										ng-click="cancelAffiliationChange();"
+									) #{translate("save_or_cancel-cancel")}
+					td.affiliations-table-inline-actions
+						// Disabled buttons don't work with tooltips, due to pointer-events: none,
+						// so create a wrapper for the tooltip
+						div.affiliations-table-inline-action-disabled-wrapper(
+							tooltip=translate("please_confirm_your_email_before_making_it_default")
+							tooltip-enable="!ui.isMakingRequest"
+							ng-if="!userEmail.default && (!userEmail.confirmedAt || ui.isMakingRequest)"
+						)
+							button.btn.btn-sm.btn-success.affiliations-table-inline-action(
+								disabled
+							) #{translate("make_default")}
+						button.btn.btn-sm.btn-success.affiliations-table-inline-action(
+							ng-if="!userEmail.default && (userEmail.confirmedAt && !ui.isMakingRequest)"
+							ng-click="setDefaultUserEmail(userEmail)"
+						) #{translate("make_default")}
+						| &nbsp;
+						button.btn.btn-sm.btn-danger.affiliations-table-inline-action(
 							ng-if="!userEmail.default"
-							ng-click="removeUserEmail(userEmail.email)"
-						) Remove
-				tr.affiliations-table-highlighted-row(
-					ng-if="ui.isLoadingEmails"
-				)
-					td.text-center(colspan="3") 
-						i.fa.fa-fw.fa-spin.fa-refresh
-						| &nbsp;Loading...
-				
+							ng-click="removeUserEmail(userEmail)"
+							ng-disabled="ui.isMakingRequest"
+							tooltip=translate("remove")
+						)
+							i.fa.fa-fw.fa-trash
 				tr.affiliations-table-highlighted-row(
-					ng-if="!ui.showAddEmailUI && !ui.isLoadingEmails"
+					ng-if="!ui.showAddEmailUI && !ui.isMakingRequest"
 				)
 					td(colspan="3")
 						a(
 							href
 							ng-click="showAddEmailForm()"
-						) Add another email
+						) #{translate("add_another_email")}
 
 				tr.affiliations-table-highlighted-row(
-					ng-if="ui.showAddEmailUI"
+					ng-if="ui.showAddEmailUI && !ui.isLoadingEmails"
 				)
 					td
 						.affiliations-form-group
@@ -67,106 +113,129 @@ form.row(
 								input-required="true"
 							)
 					td
-						.affiliations-table-label(
+						p.affiliations-table-label(
 							ng-if="newAffiliation.university && !ui.showManualUniversitySelectionUI"
 						)
-							| {{ newAffiliation.university.name }} (
-							a(
-								href
-								ng-click="selectUniversityManually();"
-							) change
-							| )
+							| {{ newAffiliation.university.name }}
+							span.small
+								|  (
+								a(
+									href
+									ng-click="selectUniversityManually();"
+								) #{translate("change")}
+								| )
 						.affiliations-table-label(
 							ng-if="!newAffiliation.university && !ui.isValidEmail && !ui.showManualUniversitySelectionUI"
-						) Start by adding your email address.
+						) #{translate("start_by_adding_your_email")}
 						.affiliations-table-label(
 							ng-if="!newAffiliation.university && ui.isValidEmail && !ui.isBlacklistedEmail && !ui.showManualUniversitySelectionUI"
 						)
-							| Is your email affiliated with an institution? 
+							| #{translate("is_email_affiliated")}
 							br
 							a(
 								href
 								ng-click="selectUniversityManually();"
-							) Let us know
-						.affiliations-form-group(
-							ng-if="ui.showManualUniversitySelectionUI"
-						)
-							ui-select(
-								ng-model="newAffiliation.country"
-							)
-								ui-select-match(
-									placeholder="Country"
-								) {{ $select.selected.name }}
-								ui-select-choices(
-									repeat="country in countries | filter: $select.search"
-								)
-									span(
-										ng-bind="country.name"
-									s)
-						.affiliations-form-group(
-							ng-if="ui.showManualUniversitySelectionUI"
-						)
-							ui-select(
-								ng-model="newAffiliation.university"
-								ng-disabled="!newAffiliation.country"
-								tagging="addUniversityToSelection"
-								tagging-label="false"
-							)
-								ui-select-match(
-									placeholder="Institution"
-								) {{ $select.selected.name }}
-								ui-select-choices(
-									repeat="university in universities | filter: $select.search"
-								)
-									span(
-										ng-bind="university.name"
-									)
-						.affiliations-form-group(
-							ng-if="ui.isValidEmail && newAffiliation.university"
+							) #{translate("let_us_know")}
+						affiliation-form(
+							affiliation-data="newAffiliation"
+							show-university-and-country="ui.showManualUniversitySelectionUI"
+							show-role-and-department="ui.isValidEmail && newAffiliation.university"
 						)
-							ui-select(
-								ng-model="newAffiliation.role"
-								tagging
-								tagging-label="false"
-							)
-								ui-select-match(
-									placeholder="Role"
-								) {{ $select.selected }}
-								ui-select-choices(
-									repeat="role in roles | filter: $select.search"
-								)
-									span(
-										ng-bind="role"
-									)
-
-						.affiliations-form-group(
-							ng-if="ui.isValidEmail && newAffiliation.university"
-						)
-							ui-select(
-								ng-model="newAffiliation.department"
-								tagging
-								tagging-label="false"
-							)
-								ui-select-match(
-									placeholder="Department"
-								) {{ $select.selected }}
-								ui-select-choices(
-									repeat="department in departments | filter: $select.search"
-								)
-									span(
-										ng-bind="department"
-									)
 					td
-						button.btn.btn-primary(
-							ng-disabled="affiliationsForm.$invalid || ui.isAddingNewEmail"
+						button.btn.btn-sm.btn-primary(
+							ng-disabled="affiliationsForm.$invalid || ui.isMakingRequest"
 							ng-click="addNewEmail()"
 						)
-							span(
-								ng-if="!ui.isAddingNewEmail"
-							) Add new email
-							span(
-								ng-if="ui.isAddingNewEmail"
-							)
-								i.fa.fa-fw.fa-spin.fa-refresh
-								| &nbsp;Adding...
-		hr
+							| #{translate("add_new_email")}
+				tr.affiliations-table-highlighted-row(
+					ng-if="ui.isMakingRequest"
+				)
+					td.text-center(colspan="3", ng-if="ui.isLoadingEmails") 
+						i.fa.fa-fw.fa-spin.fa-refresh
+						| &nbsp;#{translate("loading")}...
+					td.text-center(colspan="3", ng-if="ui.isResendingConfirmation") 
+						i.fa.fa-fw.fa-spin.fa-refresh
+						| &nbsp;#{translate("sending")}...
+					td.text-center(colspan="3", ng-if="!ui.isLoadingEmails && !ui.isResendingConfirmation") 
+						i.fa.fa-fw.fa-spin.fa-refresh
+						| &nbsp;#{translate("saving")}...
+				tr.affiliations-table-error-row(
+					ng-if="ui.hasError"
+				)
+					td.text-center(colspan="3") 
+						div
+							i.fa.fa-fw.fa-exclamation-triangle 
+							span(ng-if="!ui.errorMessage") &nbsp;#{translate("error_performing_request")}
+							span(ng-if="ui.errorMessage") &nbsp;{{ui.errorMessage}}
+				
+		hr
+
+script(type="text/ng-template", id="affiliationFormTpl")
+	.affiliations-form-group(
+		ng-if="$ctrl.showUniversityAndCountry"
+	)
+		ui-select(
+			ng-model="$ctrl.affiliationData.country"
+		)
+			ui-select-match(
+				placeholder="Country"
+			) {{ $select.selected.name }}
+			ui-select-choices(
+				repeat="country in $ctrl.countries | filter: $select.search"
+			)
+				span(
+					ng-bind="country.name"
+				)
+	.affiliations-form-group(
+		ng-if="$ctrl.showUniversityAndCountry"
+	)
+		ui-select(
+			ng-model="$ctrl.affiliationData.university"
+			ng-disabled="!$ctrl.affiliationData.country"
+			tagging="$ctrladdUniversityToSelection"
+			tagging-label="false"
+		)
+			ui-select-match(
+				placeholder="Institution"
+			) {{ $select.selected.name }}
+			ui-select-choices(
+				repeat="university in $ctrl.universities | filter: $select.search"
+			)
+				span(
+					ng-bind="university.name"
+				)
+	.affiliations-form-group(
+		ng-if="$ctrl.showRoleAndDepartment"
+	)
+		ui-select(
+			ng-model="$ctrl.affiliationData.role"
+			tagging
+			tagging-label="false"
+		)
+			ui-select-match(
+				placeholder="Role"
+			) {{ $select.selected }}
+			ui-select-choices(
+				repeat="role in $ctrl.roles | filter: $select.search"
+			)
+				span(
+					ng-bind="role"
+				)
+
+	.affiliations-form-group(
+		ng-if="$ctrl.showRoleAndDepartment"
+	)
+		ui-select(
+			ng-model="$ctrl.affiliationData.department"
+			tagging
+			tagging-label="false"
+		)
+			ui-select-match(
+				placeholder="Department"
+			) {{ $select.selected }}
+			ui-select-choices(
+				repeat="department in $ctrl.departments | filter: $select.search"
+			)
+				span(
+					ng-bind="department"
+				)

+ 18 - 5
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
@@ -425,7 +427,7 @@ module.exports = settings =
 	redirects:
 		"/templates/index": "/templates/"
 
-	reloadModuleViewsOnEachRequest: true
+	reloadModuleViewsOnEachRequest: process.env['NODE_ENV'] != 'production'
 
 	domainLicences: [
 
@@ -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'}
+	# ]

+ 1 - 0
services/web/docker-compose.yml

@@ -19,6 +19,7 @@ services:
       LINKED_URL_PROXY: 'http://localhost:6543'
       ENABLED_LINKED_FILE_TYPES: 'url,project_file,project_output_file,mendeley'
       SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
+      NODE_ENV: production
     depends_on:
       - redis
       - mongo

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

+ 23 - 1
services/web/public/coffee/base.coffee

@@ -26,7 +26,29 @@ define [
 			baseUrl: window.sharelatex.sixpackDomain
 			client_id: window.user_id
 		})
-	
+
+		MathJax?.Hub?.Config(
+			extensions: ["Safe.js"]
+			messageStyle: "none"
+			imageFont:null
+			"HTML-CSS":
+				availableFonts: ["TeX"]
+				# MathJax's automatic font scaling does not work well when we render math
+				# that isn't yet on the page, so we disable it and set a global font
+				# scale factor
+				scale: 110
+				matchFontHeight: false
+			TeX:
+				equationNumbers: { autoNumber: "AMS" }
+				useLabelIDs: false
+			skipStartupTypeset: true
+			tex2jax:
+				processEscapes: true
+				# Dollar delimiters are added by the mathjax directive
+				inlineMath: [ ["\\(","\\)"] ]
+				displayMath: [ ['$$','$$'], ["\\[","\\]"] ]
+		)
+
 	App.run ($templateCache) ->
 		# UI Select templates are hard-coded and use Glyphicon icons (which we don't import). 
 		# The line below simply overrides the hard-coded template with our own, which is 

+ 21 - 0
services/web/public/coffee/directives/mathjax.coffee

@@ -0,0 +1,21 @@
+define [
+	"base"
+], (App) ->	
+	App.directive "mathjax", () ->
+		return {
+			link: (scope, element, attrs) ->
+				if attrs.delimiter != 'no-single-dollar'
+					inlineMathConfig = MathJax?.Hub?.config?.tex2jax.inlineMath
+					alreadyConfigured = _.find inlineMathConfig, (c) ->
+						c[0] == '$' and c[1] == '$'
+
+					if !alreadyConfigured?
+						MathJax?.Hub?.Config(
+							tex2jax:
+								inlineMath: inlineMathConfig.concat([['$', '$']])
+						)
+
+				setTimeout () ->
+					MathJax?.Hub?.Queue(["Typeset", MathJax?.Hub, element.get(0)])
+				, 0
+		}

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

@@ -136,7 +136,7 @@ define [
 		ide.referencesSearchManager = new ReferencesManager(ide, $scope)
 		ide.connectionManager = new ConnectionManager(ide, $scope)
 		ide.fileTreeManager = new FileTreeManager(ide, $scope)
-		ide.editorManager = new EditorManager(ide, $scope)
+		ide.editorManager = new EditorManager(ide, $scope, localStorage)
 		ide.onlineUsersManager = new OnlineUsersManager(ide, $scope)
 		if window.data.useV2History
 			ide.historyManager = new HistoryV2Manager(ide, $scope)

+ 0 - 26
services/web/public/coffee/ide/chat/directives/mathjax.coffee

@@ -1,26 +0,0 @@
-define [
-	"base"
-	"mathjax"
-], (App) ->
-	mathjaxConfig =
-		messageStyle: "none"
-		imageFont:null
-		"HTML-CSS": { availableFonts: ["TeX"] },
-		TeX:
-			equationNumbers: { autoNumber: "AMS" },
-			useLabelIDs: false
-		tex2jax:
-			inlineMath: [ ['$','$'], ["\\(","\\)"] ],
-			displayMath: [ ['$$','$$'], ["\\[","\\]"] ],
-			processEscapes: true
-		skipStartupTypeset: true
-
-	MathJax?.Hub?.Config(mathjaxConfig);
-	
-	App.directive "mathjax", () ->
-		return {
-			link: (scope, element, attrs) ->
-				setTimeout () ->
-					MathJax?.Hub?.Queue(["Typeset", MathJax?.Hub, element.get(0)])
-				, 0
-		}

+ 1 - 1
services/web/public/coffee/ide/chat/index.coffee

@@ -2,6 +2,6 @@ define [
 	"ide/chat/controllers/ChatButtonController"
 	"ide/chat/controllers/ChatController"
 	"ide/chat/controllers/ChatMessageController"
-	"ide/chat/directives/mathjax"
+	"directives/mathjax"
 	"filters/wrapLongWords"
 ], () ->

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

+ 7 - 2
services/web/public/coffee/ide/editor/EditorManager.coffee

@@ -6,7 +6,7 @@ define [
 	"ide/editor/controllers/SavingNotificationController"
 ], (Document) ->
 	class EditorManager
-		constructor: (@ide, @$scope) ->
+		constructor: (@ide, @$scope, @localStorage) ->
 			@$scope.editor = {
 				sharejs_doc: null
 				open_doc_id: null
@@ -14,7 +14,7 @@ define [
 				opening: true
 				trackChanges: false
 				wantTrackChanges: false
-				showRichText: false
+				showRichText: @showRichText()
 			}
 
 			@$scope.$on "entity:selected", (event, entity) =>
@@ -41,6 +41,11 @@ define [
 				return if !value?
 				@_syncTrackChangesState(@$scope.editor.sharejs_doc)
 
+		showRichText: () ->
+			if !window.richTextEnabled
+				return false
+			@localStorage("editor.mode.#{@$scope.project_id}") == 'rich-text'
+
 		autoOpenDoc: () ->
 			open_doc_id = 
 				@ide.localStorage("doc.open_id.#{@$scope.project_id}") or

+ 35 - 4
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -10,13 +10,14 @@ define [
 	"ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter"
 	"ide/editor/directives/aceEditor/highlights/HighlightsManager"
 	"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager"
+	"ide/editor/directives/aceEditor/cursor-position/CursorPositionAdapter"
 	"ide/editor/directives/aceEditor/track-changes/TrackChangesManager"
 	"ide/editor/directives/aceEditor/metadata/MetadataManager"
 	"ide/metadata/services/metadata"
 	"ide/graphics/services/graphics"
 	"ide/preamble/services/preamble"
-    "ide/files/services/files"
-], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, SpellCheckAdapter, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) ->
+	"ide/files/services/files"
+], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, SpellCheckAdapter, HighlightsManager, CursorPositionManager, CursorPositionAdapter, TrackChangesManager, MetadataManager) ->
 	EditSession = ace.require('ace/edit_session').EditSession
 	ModeList = ace.require('ace/ext/modelist')
 	Vim = ace.require('ace/keyboard/vim').Vim
@@ -108,7 +109,7 @@ define [
 
 				undoManager           = new UndoManager(scope, editor, element)
 				highlightsManager     = new HighlightsManager(scope, editor, element)
-				cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage)
+				cursorPositionManager = new CursorPositionManager(scope, new CursorPositionAdapter(editor), localStorage)
 				trackChangesManager   = new TrackChangesManager(scope, editor, element)
 				metadataManager = new MetadataManager(scope, editor, element, metadata)
 				autoCompleteManager = new AutoCompleteManager(scope, editor, element, metadataManager, graphics, preamble, files)
@@ -308,10 +309,12 @@ define [
 
 				scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) ->
 					if old_sharejs_doc?
+						scope.$broadcast('beforeChangeDocument')
 						detachFromAce(old_sharejs_doc)
-
 					if sharejs_doc?
 						attachToAce(sharejs_doc)
+					if sharejs_doc? and old_sharejs_doc?
+						scope.$broadcast('afterChangeDocument')
 
 				scope.$watch "text", (text) ->
 					if text?
@@ -380,6 +383,30 @@ define [
 					editor.off 'changeSession', onSessionChangeForSpellCheck
 					editor.off 'nativecontextmenu', spellCheckManager.onContextMenu
 
+				onSessionChangeForCursorPosition = (e) ->
+					e.oldSession?.selection.off 'changeCursor', cursorPositionManager.onCursorChange
+					e.session.selection.on 'changeCursor', cursorPositionManager.onCursorChange
+
+				onUnloadForCursorPosition = () ->
+					cursorPositionManager.onUnload(editor.getSession())
+
+				initCursorPosition = () ->
+					editor.on 'changeSession', onSessionChangeForCursorPosition
+					onSessionChangeForCursorPosition({ session: editor.getSession() }) # Force initial setup
+					$(window).on "unload", onUnloadForCursorPosition
+
+				tearDownCursorPosition = () ->
+					editor.off 'changeSession', onSessionChangeForCursorPosition
+					$(window).off "unload", onUnloadForCursorPosition
+
+				initCursorPosition()
+
+				# Trigger the event once *only* - this is called after Ace is connected
+				# to the ShareJs instance but this event should only be triggered the
+				# first time the editor is opened. Not every time the docs opened
+				triggerEditorInitEvent = _.once () ->
+					scope.$broadcast('editorInit')
+
 				attachToAce = (sharejs_doc) ->
 					lines = sharejs_doc.getSnapshot().split("\n")
 					session = editor.getSession()
@@ -425,6 +452,7 @@ define [
 					editor.initing = false
 					# now ready to edit document
 					editor.setReadOnly(scope.readOnly) # respect the readOnly setting, normally false
+					triggerEditorInitEvent()
 					initSpellCheck()
 
 					resetScrollMargins()
@@ -468,6 +496,7 @@ define [
 					editor.focus()
 
 				detachFromAce = (sharejs_doc) ->
+					tearDownSpellCheck()
 					sharejs_doc.detachFromAce()
 					sharejs_doc.off "remoteop.recordRemote"
 
@@ -488,9 +517,11 @@ define [
 				scope.$on '$destroy', () ->
 					if scope.sharejsDoc?
 						tearDownSpellCheck()
+						tearDownCursorPosition()
 						detachFromAce(scope.sharejsDoc)
 						session = editor.getSession()
 						session?.destroy()
+						scope.eventsBridge.emit "aceScrollbarVisibilityChanged", false, 0
 
 				scope.$emit "#{scope.name}:inited", editor
 

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

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

+ 32 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionAdapter.coffee

@@ -0,0 +1,32 @@
+define [
+	"ide/editor/AceShareJsCodec"
+], (AceShareJsCodec) ->
+	class CursorPositionAdapter
+		constructor: (@editor) ->
+
+		getCursor: () ->
+			@editor.getCursorPosition()
+
+		getEditorScrollPosition: () ->
+			@editor.getFirstVisibleRow()
+
+		setCursor: (pos) ->
+			pos = pos.cursorPosition or { row: 0, column: 0 }
+			@editor.moveCursorToPosition(pos)
+
+		setEditorScrollPosition: (pos) ->
+			pos = pos.firstVisibleLine or 0
+			@editor.scrollToLine(pos)
+
+		clearSelection: () ->
+			@editor.selection.clearSelection()
+
+		gotoLine: (line, column) ->
+			@editor.gotoLine(line, column)
+			@editor.scrollToLine(line, true, true) # centre and animate
+			@editor.focus()
+
+		gotoOffset: (offset) ->
+			lines = @editor.getSession().getDocument().getAllLines()
+			position = AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines)
+			@gotoLine(position.row + 1, position.column)

+ 34 - 46
services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee

@@ -1,75 +1,63 @@
-define [
-	"ide/editor/AceShareJsCodec"
-], (AceShareJsCodec) ->
+define [], () ->
 	class CursorPositionManager
-		constructor: (@$scope, @editor, @element, @localStorage) ->
+		constructor: (@$scope, @adapter, @localStorage) ->
+			@$scope.$on 'editorInit', @jumpToPositionInNewDoc
 
-			onChangeCursor = (e) =>
-				@emitCursorUpdateEvent(e)
+			@$scope.$on 'beforeChangeDocument', () =>
+				@storeCursorPosition()
+				@storeFirstVisibleLine()
 
-			@editor.on "changeSession", (e) =>
-				if e.oldSession?
-					@storeCursorPosition(e.oldSession)
-					@storeScrollTopPosition(e.oldSession)
-
-				@doc_id = @$scope.sharejsDoc?.doc_id
-
-				e.oldSession?.selection.off 'changeCursor', onChangeCursor
-				e.session.selection.on 'changeCursor', onChangeCursor
-
-				setTimeout () =>
-					@gotoStoredPosition()
-				, 0
-
-			$(window).on "unload", () =>
-				@storeCursorPosition(@editor.getSession())
-				@storeScrollTopPosition(@editor.getSession())
+			@$scope.$on 'afterChangeDocument', @jumpToPositionInNewDoc
 
 			@$scope.$on "#{@$scope.name}:gotoLine", (e, line, column) =>
 				if line?
 					setTimeout () =>
-						@gotoLine(line, column)
+						@adapter.gotoLine(line, column)
 					, 10 # Hack: Must happen after @gotoStoredPosition
 			
 			@$scope.$on "#{@$scope.name}:gotoOffset", (e, offset) =>
 				if offset?
 					setTimeout () =>
-						@gotoOffset(offset)
+						@adapter.gotoOffset(offset)
 					, 10 # Hack: Must happen after @gotoStoredPosition
 
 			@$scope.$on "#{@$scope.name}:clearSelection", (e) =>
-				@editor.selection.clearSelection()
+				@adapter.clearSelection()
+
+		jumpToPositionInNewDoc: () =>
+			@doc_id = @$scope.sharejsDoc?.doc_id
+			setTimeout () =>
+				@gotoStoredPosition()
+			, 0
+
+		onUnload: () =>
+			@storeCursorPosition()
+			@storeFirstVisibleLine()
 
-		storeScrollTopPosition: (session) ->
+		onCursorChange: () =>
+			@emitCursorUpdateEvent()
+
+		onSyncToPdf: () =>
+			@$scope.$emit "cursor:#{@$scope.name}:syncToPdf"
+
+		storeFirstVisibleLine: () ->
 			if @doc_id?
 				docPosition = @localStorage("doc.position.#{@doc_id}") || {}
-				docPosition.scrollTop = session.getScrollTop()
+				docPosition.firstVisibleLine = @adapter.getEditorScrollPosition()
 				@localStorage("doc.position.#{@doc_id}", docPosition)
 
-		storeCursorPosition: (session) ->
+		storeCursorPosition: () ->
 			if @doc_id?
 				docPosition = @localStorage("doc.position.#{@doc_id}") || {}
-				docPosition.cursorPosition = session.selection.getCursor()
+				docPosition.cursorPosition = @adapter.getCursor()
 				@localStorage("doc.position.#{@doc_id}", docPosition)
-			
+
 		emitCursorUpdateEvent: () ->
-			cursor = @editor.getCursorPosition()
+			cursor = @adapter.getCursor()
 			@$scope.$emit "cursor:#{@$scope.name}:update", cursor
 
 		gotoStoredPosition: () ->
 			return if !@doc_id?
 			pos = @localStorage("doc.position.#{@doc_id}") || {}
-			@ignoreCursorPositionChanges = true
-			@editor.moveCursorToPosition(pos.cursorPosition or {row: 0, column: 0})
-			@editor.getSession().setScrollTop(pos.scrollTop or 0)
-			delete @ignoreCursorPositionChanges
-
-		gotoLine: (line, column) ->
-			@editor.gotoLine(line, column)
-			@editor.scrollToLine(line,true,true) # centre and animate
-			@editor.focus()
-
-		gotoOffset: (offset) ->
-			lines = @editor.getSession().getDocument().getAllLines()
-			position = AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines)
-			@gotoLine(position.row + 1, position.column)
+			@adapter.setCursor(pos)
+			@adapter.setEditorScrollPosition(pos)

+ 169 - 14
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()
@@ -60,6 +83,7 @@ define [
 				nextBeforeTimestamp: null
 				atEnd: false
 				selection: {
+					label: null
 					updates: []
 					docs: {}
 					pathname: null
@@ -68,6 +92,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 +108,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 +139,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 +152,107 @@ 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?
 						@$scope.history.atEnd = true
 					@$scope.history.loading = 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 +308,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 }
@@ -276,7 +411,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

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

@@ -3,17 +3,35 @@ 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: "<"
 			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]
+			)
+	]

+ 23 - 17
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -93,7 +93,7 @@ define [
 					# don't want to compile as soon as it is fixed, so reset the timeout.
 					$scope.startedTryingAutoCompileAt = Date.now()
 					$scope.docLastChangedAt = Date.now()
-			if autoCompileLintingError
+			if autoCompileLintingError and $scope.stop_on_validation_error
 				return
 
 			# If there's a longish compile, don't compile immediately after if user is still typing
@@ -224,6 +224,13 @@ define [
 				_csrf: window.csrfToken
 			}, {params: params}
 
+		buildPdfDownloadUrl = (pdfDownloadDomain, path)->
+			 #we only download builds from compiles server for security reasons
+			if pdfDownloadDomain? and path.indexOf("build") != -1
+				return "#{pdfDownloadDomain}#{path}"
+			else
+				return path
+
 		parseCompileResponse = (response) ->
 
 			# keep last url
@@ -244,11 +251,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 +270,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 +303,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 +323,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 +338,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 +387,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
@@ -718,6 +722,8 @@ define [
 				.then (highlights) ->
 					$scope.pdf.highlights = highlights
 
+		ide.$scope.$on "cursor:editor:syncToPdf", $scope.syncToPdf
+
 		$scope.syncToCode = () ->
 			synctex
 				.syncToCode($scope.pdf.position, includeVisualOffset: true, fromPdfPosition: true)

+ 1 - 1
services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee

@@ -164,7 +164,7 @@ define [
 			updateScrollbar()
 
 		updateScrollbar = () ->
-			if scrollbar.isVisible and $scope.reviewPanel.subView == $scope.SubViews.CUR_FILE
+			if scrollbar.isVisible and $scope.reviewPanel.subView == $scope.SubViews.CUR_FILE and !$scope.editor.showRichText
 				$reviewPanelEl.css "right", "#{ scrollbar.scrollbarWidth }px"
 			else
 				$reviewPanelEl.css "right", "0"

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

+ 2 - 0
services/web/public/coffee/main.coffee

@@ -20,9 +20,11 @@ define [
 	"main/subscription/team-invite-controller"
 	"main/contact-us"
 	"main/learn"
+	"main/affiliations/components/affiliationForm"
 	"main/affiliations/controllers/UserAffiliationsController"
 	"main/affiliations/factories/UserAffiliationsDataService"
 	"main/keys"
+	"main/cms/index"
 	"analytics/AbTestingManager"
 	"directives/asyncForm"
 	"directives/stopPropagation"

+ 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

+ 52 - 0
services/web/public/coffee/main/affiliations/components/affiliationForm.coffee

@@ -0,0 +1,52 @@
+define [
+	"base"
+], (App) ->
+	affiliationFormController = ($scope, $element, $attrs, UserAffiliationsDataService) ->
+		ctrl = @
+		ctrl.roles = []
+		ctrl.departments = []
+		ctrl.countries = []
+		ctrl.universities = []
+		_defaultDepartments = []
+
+		ctrl.addUniversityToSelection = (universityName) -> 
+			{ name: universityName, isUserSuggested: true }
+		# Populates the countries dropdown
+		UserAffiliationsDataService
+			.getCountries()
+			.then (countries) -> ctrl.countries = countries
+		# Populates the roles dropdown
+		UserAffiliationsDataService
+			.getDefaultRoleHints()
+			.then (roles) -> ctrl.roles = roles 
+		# Fetches the default department hints
+		UserAffiliationsDataService
+			.getDefaultDepartmentHints()
+			.then (departments) -> 
+				_defaultDepartments = departments
+		# Populates the universities dropdown (after selecting a country)
+		$scope.$watch "$ctrl.affiliationData.country", (newSelectedCountry, prevSelectedCountry) ->
+			if newSelectedCountry? and newSelectedCountry != prevSelectedCountry
+				ctrl.affiliationData.university = null
+				ctrl.affiliationData.role = null
+				ctrl.affiliationData.department = null
+				UserAffiliationsDataService
+					.getUniversitiesFromCountry(newSelectedCountry)
+					.then (universities) -> ctrl.universities = universities
+		# Populates the departments dropdown (after selecting a university)
+		$scope.$watch "$ctrl.affiliationData.university", (newSelectedUniversity, prevSelectedUniversity) ->
+			if newSelectedUniversity? and newSelectedUniversity != prevSelectedUniversity and newSelectedUniversity.departments?.length > 0
+				ctrl.departments = _.uniq newSelectedUniversity.departments
+			else 
+				ctrl.departments = _defaultDepartments
+
+		return
+
+	App.component "affiliationForm", {
+		bindings:
+			affiliationData: "="
+			showUniversityAndCountry: "<"
+			showRoleAndDepartment: "<"
+		controller: affiliationFormController
+		templateUrl: "affiliationFormTpl"
+	}

+ 107 - 69
services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee

@@ -3,12 +3,6 @@ define [
 ], (App) ->
 	App.controller "UserAffiliationsController", ["$scope", "UserAffiliationsDataService", "$q", "_", ($scope, UserAffiliationsDataService, $q, _) ->
 		$scope.userEmails = []
-		$scope.countries = []
-		$scope.universities = []
-		$scope.roles = []
-		$scope.departments = []
-
-		_defaultDepartments = []
 
 		LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/
 		EMAIL_REGEX = /^([A-Za-z0-9_\-\.]+)@([^\.]+)\.([A-Za-z0-9_\-\.]+)([^\.])$/
@@ -20,9 +14,6 @@ define [
 			else
 				{ local: null, domain: null }
 
-		$scope.addUniversityToSelection = (universityName) -> 
-			{ name: universityName, isUserSuggested: true }
-
 		$scope.getEmailSuggestion = (userInput) ->
 			userInputLocalAndDomain = _matchLocalAndDomain(userInput)
 			$scope.ui.isValidEmail = EMAIL_REGEX.test userInput
@@ -55,11 +46,40 @@ define [
 			$scope.newAffiliation.department = null
 			$scope.ui.showManualUniversitySelectionUI = true
 
+		$scope.changeAffiliation = (userEmail) ->
+			if userEmail.affiliation?.institution?.id?
+				UserAffiliationsDataService.getUniversityDetails userEmail.affiliation.institution.id
+					.then (universityDetails) -> $scope.affiliationToChange.university = universityDetails
+
+			$scope.affiliationToChange.email = userEmail.email
+			$scope.affiliationToChange.role = userEmail.affiliation.role
+			$scope.affiliationToChange.department = userEmail.affiliation.department
+
+		$scope.saveAffiliationChange = (userEmail) ->
+			userEmail.affiliation.role = $scope.affiliationToChange.role
+			userEmail.affiliation.department = $scope.affiliationToChange.department
+			_resetAffiliationToChange()
+			_monitorRequest(
+				UserAffiliationsDataService
+					.addRoleAndDepartment(
+						userEmail.email,
+						userEmail.affiliation.role,
+						userEmail.affiliation.department
+					)
+			)
+				.then () ->
+					setTimeout () -> _getUserEmails()
+
+		$scope.cancelAffiliationChange = (email) ->
+			_resetAffiliationToChange()
+		
+		$scope.isChangingAffiliation = (email) ->
+			$scope.affiliationToChange.email == email
+
 		$scope.showAddEmailForm = () ->
 			$scope.ui.showAddEmailUI = true
 
 		$scope.addNewEmail = () ->
-			$scope.ui.isAddingNewEmail = true
 			if !$scope.newAffiliation.university?
 				addEmailPromise = UserAffiliationsDataService
 					.addUserEmail $scope.newAffiliation.email
@@ -81,86 +101,104 @@ define [
 							$scope.newAffiliation.role,
 							$scope.newAffiliation.department
 						)
-			addEmailPromise.then () -> 
-				_reset()
-				_getUserEmails()
 
-		$scope.setDefaultUserEmail = (email) ->
-			$scope.ui.isLoadingEmails = true
-			UserAffiliationsDataService
-				.setDefaultUserEmail email
-				.then () -> _getUserEmails()
+			$scope.ui.isAddingNewEmail = true
+			$scope.ui.showAddEmailUI = false
+			_monitorRequest(addEmailPromise)
+				.then () ->
+					_resetNewAffiliation()
+					_resetAddingEmail()
+					setTimeout () -> _getUserEmails()
+				.finally () ->
+					$scope.ui.isAddingNewEmail = false
+
+		$scope.setDefaultUserEmail = (userEmail) ->
+			_monitorRequest(
+				UserAffiliationsDataService
+					.setDefaultUserEmail userEmail.email
+			)
+				.then () ->
+					for email in $scope.userEmails or []
+						email.default = false
+					userEmail.default = true
+
+		$scope.removeUserEmail = (userEmail) ->
+			$scope.userEmails = $scope.userEmails.filter (ue) -> ue != userEmail
+			_monitorRequest(
+				UserAffiliationsDataService
+					.removeUserEmail userEmail.email
+			)
 
-		$scope.removeUserEmail = (email) ->
-			$scope.ui.isLoadingEmails = true
-			UserAffiliationsDataService
-				.removeUserEmail email
-				.then () -> _getUserEmails()
+		$scope.resendConfirmationEmail = (userEmail) ->
+			$scope.ui.isResendingConfirmation = true
+			_monitorRequest(
+				UserAffiliationsDataService
+					.resendConfirmationEmail userEmail.email
+			)
+				.finally () ->
+					$scope.ui.isResendingConfirmation = false
 
-		$scope.getDepartments = () ->
-			if $scope.newAffiliation.university?.departments.length > 0
-				_.uniq $scope.newAffiliation.university.departments
-			else
-				UserAffiliationsDataService.getDefaultDepartmentHints()
+		$scope.acknowledgeError = () ->
+			_reset()
+			_getUserEmails()
 
-		_reset = () ->
+		_resetAffiliationToChange = () ->
+			$scope.affiliationToChange = 
+				email: ""
+				university: null
+				role: null
+				department: null
+
+		_resetNewAffiliation = () ->
 			$scope.newAffiliation =
 				email: ""
 				country: null
 				university: null
 				role: null
 				department: null
+
+		_resetAddingEmail = () ->
+			$scope.ui.showAddEmailUI = false
+			$scope.ui.isValidEmail = false
+			$scope.ui.isBlacklistedEmail = false
+			$scope.ui.showManualUniversitySelectionUI = false
+
+		_reset = () ->
 			$scope.ui = 
-				showManualUniversitySelectionUI: false
+				hasError: false
+				errorMessage: ""
+				showChangeAffiliationUI: false
+				isMakingRequest: false
 				isLoadingEmails: false
 				isAddingNewEmail: false
-				showAddEmailUI: false
-				isValidEmail: false
-				isBlacklistedEmail: false
+				isResendingConfirmation: false
+			_resetAffiliationToChange()
+			_resetNewAffiliation()
+			_resetAddingEmail()
 		_reset()
 
+		_monitorRequest = (promise) ->
+			$scope.ui.hasError = false
+			$scope.ui.isMakingRequest = true
+			promise
+				.catch (response) ->
+					$scope.ui.hasError = true
+					$scope.ui.errorMessage = response?.data?.message
+				.finally () ->
+					$scope.ui.isMakingRequest = false
+			return promise
+
 		# Populates the emails table
 		_getUserEmails = () ->
 			$scope.ui.isLoadingEmails = true
-			UserAffiliationsDataService
-				.getUserEmails() 
+			_monitorRequest(
+				UserAffiliationsDataService
+					.getUserEmails()
+			)
 				.then (emails) -> 
 					$scope.userEmails = emails
+				.finally () ->
 					$scope.ui.isLoadingEmails = false
 		_getUserEmails()
 
-		# Populates the countries dropdown
-		UserAffiliationsDataService
-			.getCountries()
-			.then (countries) -> $scope.countries = countries
-
-		# Populates the roles dropdown
-		UserAffiliationsDataService
-			.getDefaultRoleHints()
-			.then (roles) -> $scope.roles = roles 
-
-		# Fetches the default department hints
-		UserAffiliationsDataService
-			.getDefaultDepartmentHints()
-			.then (departments) -> 
-				_defaultDepartments = departments
-
-		# Populates the universities dropdown (after selecting a country)
-		$scope.$watch "newAffiliation.country", (newSelectedCountry, prevSelectedCountry) ->
-			if newSelectedCountry? and newSelectedCountry != prevSelectedCountry
-				$scope.newAffiliation.university = null
-				$scope.newAffiliation.role = null
-				$scope.newAffiliation.department = null
-				UserAffiliationsDataService
-					.getUniversitiesFromCountry(newSelectedCountry)
-					.then (universities) -> $scope.universities = universities
-
-		# Populates the departments dropdown (after selecting a university)
-		$scope.$watch "newAffiliation.university", (newSelectedUniversity, prevSelectedUniversity) ->
-			if newSelectedUniversity? and newSelectedUniversity != prevSelectedUniversity
-				if newSelectedUniversity.departments?.length > 0
-					$scope.departments = _.uniq newSelectedUniversity.departments
-				else 
-					$scope.departments = _defaultDepartments
-
 	]

+ 28 - 1
services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee

@@ -27,10 +27,14 @@ define [
 		getDefaultDepartmentHints = () ->
 			$q.resolve defaultDepartmentHints
 
-		getUserEmails = () ->
+		getUserEmails = () ->			
 			$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]
@@ -53,6 +57,10 @@ define [
 						else
 							$q.reject null
 
+		getUniversityDetails = (universityId) ->
+			$http.get "/institutions/list/#{ universityId }"
+				.then (response) -> response.data
+
 		addUserEmail = (email) ->
 			$http.post "/user/emails", {
 				email,
@@ -80,8 +88,17 @@ define [
 				_csrf: window.csrfToken
 			}
 
+		addRoleAndDepartment = (email, role, department) ->
+			$http.post "/user/emails/endorse", {
+				email,
+				role,
+				department,
+				_csrf: window.csrfToken
+			}
+
 		setDefaultUserEmail = (email) ->
 			$http.post "/user/emails/default", {
+				email,
 				_csrf: window.csrfToken
 			}
 
@@ -91,6 +108,12 @@ define [
 				_csrf: window.csrfToken
 			}
 
+		resendConfirmationEmail = (email) ->
+			$http.post "/user/emails/resend_confirmation", {
+				email,
+				_csrf: window.csrfToken
+			}
+
 		isDomainBlacklisted = (domain) ->
 			domain.toLowerCase() of domainsBlackList
 
@@ -99,13 +122,17 @@ define [
 			getDefaultRoleHints
 			getDefaultDepartmentHints
 			getUserEmails
+			getUserDefaultEmail
 			getUniversitiesFromCountry
 			getUniversityDomainFromPartialDomainInput
+			getUniversityDetails
 			addUserEmail
 			addUserAffiliationWithUnknownUniversity
 			addUserAffiliation
+			addRoleAndDepartment
 			setDefaultUserEmail
 			removeUserEmail
+			resendConfirmationEmail
 			isDomainBlacklisted
 		}
 	]

+ 2 - 1
services/web/public/coffee/main/learn.coffee

@@ -1,5 +1,6 @@
 define [
 	"base"
+	"directives/mathjax"
 	"services/algolia-search"
 ], (App) ->
 
@@ -55,4 +56,4 @@ define [
 					hits = _.map response.hits, buildHitViewModel
 					updateHits hits
 
-
+	App.controller "LearnController", () -> 

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

+ 20 - 2
services/web/public/stylesheets/app/account-settings.less

@@ -24,18 +24,36 @@
 		width: 40%;
 	}
 	.affiliations-table-inline-actions {
-		width: 20%;
+		text-align: right;
 	}
+		.affiliations-table-inline-action {
+			text-transform: capitalize;
+		}
+		.affiliations-table-inline-action-disabled-wrapper {
+			display: inline-block;
+		}
 	.affiliations-table-highlighted-row {
 		background-color: tint(@content-alt-bg-color, 6%);
 	}
-
+	.affiliations-table-error-row {
+		background-color: @alert-danger-bg;
+		color: @alert-danger-text;
+		.btn {
+			margin-top: @table-cell-padding;
+			.button-variant(@btn-danger-color; darken(@btn-danger-bg, 8%); @btn-danger-border);
+		}
+	}
 	.affiliations-form-group {
 		margin-top: @table-cell-padding;
 		&:first-child {
 			margin-top: 0;
 		}
 	}
+	.affiliation-change-container,
+	.affiliation-change-actions {
+		margin-top: @table-cell-padding;
+	}
+
 	.affiliations-table-label {
 		padding-top: 4px;
 	}

+ 2 - 0
services/web/public/stylesheets/app/editor.less

@@ -93,6 +93,8 @@
 	height: @editor-toolbar-height;
 	background-color: @editor-toolbar-bg;
 	overflow: hidden;
+	position: relative;
+	z-index: 10; // Prevent track changes showing over toolbar
 }
 
 .loading-screen {

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

@@ -15,16 +15,30 @@
 .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;
 	}
-	.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-left: (@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 +62,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 +147,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 +173,19 @@
 				}
 			}
 
+.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 +262,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;
-// 					}
-// 				}
-// 			}
-// 		}
-// 	}
-// }

+ 3 - 0
services/web/public/stylesheets/app/editor/publish-modal.less

@@ -23,6 +23,9 @@
 		.wl-icon:before{
 				font-size: 14px;
 		}
+		.btn-wrapping{
+				white-space: normal;
+		}
 		.button-as-link{
          color: green;
          text-transform: none;

+ 55 - 6
services/web/public/stylesheets/app/editor/review-panel.less

@@ -139,6 +139,10 @@
 		padding: 0 5px;
 	}
 
+	.rp-unsupported & {
+		display: none;
+	}
+
 	position: relative;
 	border-bottom: 1px solid @rp-border-grey;
 	background-color: @rp-bg-dim-blue;
@@ -217,6 +221,10 @@
 	.rp-size-mini & {
 		display: block;
 	}
+
+	.rp-unsupported & {
+		display: none;
+	}
 	
 	.rp-state-current-file & {
 		position: absolute;
@@ -714,6 +722,10 @@
 		display: flex;
 	}
 
+	.rp-unsupported & {
+		display: none;
+	}
+
 	.rp-state-current-file & {
 		position: absolute;
 		bottom: 0;
@@ -834,6 +846,31 @@
 		}
 	}
 
+.rp-unsupported-msg-wrapper {
+	display: none;
+	.rp-size-expanded.rp-unsupported & {
+		display: block;
+	}
+
+	height: 100%;
+
+	.rp-unsupported-msg {
+		display: flex;
+		width: @review-panel-width - 40px;
+		height: 100%;
+		flex-direction: column;
+		justify-content: center;
+		align-items: center;
+		margin: 0 auto;
+		text-align: center;
+
+		.rp-unsupported-msg-title {
+			font-size: 1.3em;
+			margin-top: 13px;
+		}
+	}
+}
+
 .ace-editor-wrapper {
 	.track-changes-marker-callout {
 		border-radius: 0;
@@ -885,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;
@@ -908,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');
 	}
 }
 
@@ -1003,7 +1047,8 @@
 	.rp-size-mini & {
 		right: @review-off-width;
 	}
-	.rp-size-expanded & {
+	.rp-size-expanded &,
+	.rp-unsupported & {
 		display: none;
 	}
 }
@@ -1053,6 +1098,10 @@
 		display: block;
 	}
 
+	.rp-unsupported & {
+		display: none;
+	}
+
 	.rp-size-expanded & {
 		&::after {
 			content: "\f105";

+ 28 - 15
services/web/public/stylesheets/app/editor/rich-text.less

@@ -1,12 +1,21 @@
-@rt-font-family: 'Source Sans Pro', 'Helvetica', 'Arial', sans-serif;
-// @rt-font-family-serif: 'Palatino Linotype', 'Book Antiqua', Palatino, serif;
+@rt-font-family-serif: 'Palatino Linotype', 'Book Antiqua', Palatino, serif;
+@rt-line-padding: 8%;
 
 .rich-text .CodeMirror {
-  font-family: @rt-font-family;
+  font-family: @rt-font-family-serif;
   font-size: 1.15em;
 
   pre {
-    font-family: @rt-font-family;
+    font-family: @rt-font-family-serif;
+  }
+
+  .CodeMirror-line {
+    // Add horizontal padding, to emulate a manuscript more closely
+    padding: 0 @rt-line-padding;
+  }
+
+  .CodeMirror-linenumber {
+    font-size: 0.8em;
   }
 
   // TODO: Change prefix away from wl- ?
@@ -32,44 +41,48 @@
 
   /****************************************************************************/
 
+  // wl-indent-X is used to add extra left padding to nested itemize/enumerate
+  // environments, so that the inner list appears more indented than the outer
   .wl-indent-0 {
-    padding-left: 2.5em !important;
+    padding-left: calc(~"2.5em + @{rt-line-padding}") !important;
   }
 
   .wl-indent-1 {
-    padding-left: 3.5em !important;
+    padding-left: calc(~"3.5em + @{rt-line-padding}") !important;
   }
 
   .wl-indent-2 {
-    padding-left: 4.5em !important;
+    padding-left: calc(~"4.5em + @{rt-line-padding}") !important;
   }
 
   .wl-indent-3 {
-    padding-left: 5.5em !important;
+    padding-left: calc(~"5.5em + @{rt-line-padding}") !important;
   }
 
   .wl-indent-4 {
-    padding-left: 6.5em !important;
+    padding-left: calc(~"6.5em + @{rt-line-padding}") !important;
   }
 
+  // wl-indent-env-X is used to add extra left padding to empty nested itemize/
+  // enumerate environments
   .wl-indent-env-0 {
-    padding-left: 4px !important;
+    padding-left: calc(~"4px + @{rt-line-padding}") !important;
   }
 
   .wl-indent-env-1 {
-    padding-left: 1.5em !important;
+    padding-left: calc(~"1.5em + @{rt-line-padding}") !important;
   }
 
   .wl-indent-env-2 {
-    padding-left: 2.5em !important;
+    padding-left: calc(~"2.5em + @{rt-line-padding}") !important;
   }
 
   .wl-indent-env-3 {
-    padding-left: 3.5em !important;
+    padding-left: calc(~"3.5em + @{rt-line-padding}") !important;
   }
 
   .wl-indent-env-4 {
-    padding-left: 4.5em !important;
+    padding-left: calc(~"4.5em + @{rt-line-padding}") !important;
   }
 
   .wl-enumerate-item-open {
@@ -108,7 +121,7 @@
   .wl-figure-wrap {
     padding: 10px 0;
     background-color: #f5f5f5;
-    box-shadow: 2px 2px 2px #DFDFDF;
+    box-shadow: 1.3px 2px 2px #DFDFDF;
     width: 96%;
     margin: 0 auto;
     text-align: center;

Някои файлове не бяха показани, защото твърде много файлове са промени