Prechádzať zdrojové kódy

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

Paulo Reis 8 rokov pred
rodič
commit
a15706ce24
100 zmenil súbory, kde vykonal 2272 pridanie a 1041 odobranie
  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" : ""
 String cron_string = BRANCH_NAME == "master" ? "@daily" : ""
 
 
 pipeline {
 pipeline {
-  
+
   agent any
   agent any
-  
+
   environment  {
   environment  {
       HOME = "/tmp"
       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 {
   triggers {
     pollSCM('* * * * *')
     pollSCM('* * * * *')
     cron(cron_string)
     cron(cron_string)
   }
   }
-  
+
   stages {
   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') {
     stage('Install modules') {
       steps {
       steps {
         sshagent (credentials: ['GIT_DEPLOY_KEY']) {
         sshagent (credentials: ['GIT_DEPLOY_KEY']) {
@@ -21,7 +39,7 @@ pipeline {
         }
         }
       }
       }
     }
     }
-    
+
     stage('Install') {
     stage('Install') {
       agent {
       agent {
         docker {
         docker {
@@ -66,16 +84,39 @@ pipeline {
         sh 'make --no-print-directory lint'
         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'
         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') {
     stage('Package') {
       steps {
       steps {
         sh 'rm -rf ./node_modules/grunt*'
         sh 'rm -rf ./node_modules/grunt*'
@@ -113,7 +135,7 @@ pipeline {
         sh 'tar -czf build.tar.gz --exclude=build.tar.gz --exclude-vcs .'
         sh 'tar -czf build.tar.gz --exclude=build.tar.gz --exclude-vcs .'
       }
       }
     }
     }
-    
+
     stage('Publish') {
     stage('Publish') {
       steps {
       steps {
         withAWS(credentials:'S3_CI_BUILDS_AWS_KEYS', region:"${S3_REGION_BUILD_ARTEFACTS}") {
         withAWS(credentials:'S3_CI_BUILDS_AWS_KEYS', region:"${S3_REGION_BUILD_ARTEFACTS}") {
@@ -123,8 +145,8 @@ pipeline {
         }
         }
       }
       }
     }
     }
-    
-    
+
+
     stage('Sync OSS') {
     stage('Sync OSS') {
       when {
       when {
         branch 'master'
         branch 'master'
@@ -136,29 +158,50 @@ pipeline {
       }
       }
     }
     }
   }
   }
-  
+
   post {
   post {
     always {
     always {
       sh 'make clean_ci'
       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 {
     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}",
            subject: "Jenkins build failed: ${JOB_NAME}:${BUILD_NUMBER}",
            body: "Build: ${BUILD_URL}")
            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.
   // The options directive is for configuration that applies to the whole job.
   options {
   options {
     // Only build one at a time
     // Only build one at a time
     disableConcurrentBuilds()
     disableConcurrentBuilds()
-    
+
     // we'd like to make sure remove old builds, so we don't fill up our storage!
     // we'd like to make sure remove old builds, so we don't fill up our storage!
     buildDiscarder(logRotator(numToKeepStr:'50'))
     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:
     // 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')
     timeout(time: 30, unit: 'MINUTES')
   }
   }

+ 52 - 31
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -1,6 +1,5 @@
 AuthenticationManager = require ("./AuthenticationManager")
 AuthenticationManager = require ("./AuthenticationManager")
 LoginRateLimiter = require("../Security/LoginRateLimiter")
 LoginRateLimiter = require("../Security/LoginRateLimiter")
-UserGetter = require "../User/UserGetter"
 UserUpdater = require "../User/UserUpdater"
 UserUpdater = require "../User/UserUpdater"
 Metrics = require('metrics-sharelatex')
 Metrics = require('metrics-sharelatex')
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
@@ -62,41 +61,63 @@ module.exports = AuthenticationController =
 			if err?
 			if err?
 				return next(err)
 				return next(err)
 			if user # `user` is either a user object or false
 			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
 			else
-				res.json message: info
+				if info.redir?
+					res.json {redir: info.redir}
+				else
+					res.json message: info
 		)(req, res, next)
 		)(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) ->
 	doPassportLogin: (req, username, password, done) ->
 		email = username.toLowerCase()
 		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) ->
 	setInSessionUser: (req, props) ->
 		for key, value of 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()
 			return callback()
 		serverId = @_parseServerIdFromResponse(response)
 		serverId = @_parseServerIdFromResponse(response)
 		if !serverId? # We don't get a cookie back if it hasn't changed
 		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?
 		if rclient_secondary?
 			@_setServerIdInRedis rclient_secondary, project_id, serverId
 			@_setServerIdInRedis rclient_secondary, project_id, serverId
 		@_setServerIdInRedis rclient, project_id, serverId, (err) ->
 		@_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")
 ProjectEntityHandler = require("../Project/ProjectEntityHandler")
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
 Url = require("url")
 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")
 ClsiStateManager = require("./ClsiStateManager")
 _ = require("underscore")
 _ = require("underscore")
 async = require("async")
 async = require("async")
@@ -100,6 +100,7 @@ module.exports = ClsiManager =
 					timer = new Metrics.Timer("compile.currentBackend")
 					timer = new Metrics.Timer("compile.currentBackend")
 					request opts, (err, response, body)->
 					request opts, (err, response, body)->
 						timer.done()
 						timer.done()
+						Metrics.inc "compile.currentBackend.response.#{response?.statusCode}"
 						if err?
 						if err?
 							logger.err err:err, project_id:project_id, url:opts?.url, "error making request to clsi"
 							logger.err err:err, project_id:project_id, url:opts?.url, "error making request to clsi"
 							return callback(err)
 							return callback(err)
@@ -111,13 +112,14 @@ module.exports = ClsiManager =
 			newBackend: (cb)-> 
 			newBackend: (cb)-> 
 				startTime = new Date()
 				startTime = new Date()
 				ClsiManager._makeNewBackendRequest project_id, opts, (err, response, body)->
 				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})
 					cb(err, {response:response, body:body, finishTime:new Date() - startTime})
 		}, (err, results)->
 		}, (err, results)->
 			timeDifference = results.newBackend?.finishTime - results.currentBackend?.finishTime 
 			timeDifference = results.newBackend?.finishTime - results.currentBackend?.finishTime 
 			statusCodeSame = results.newBackend?.response?.statusCode == results.currentBackend?.response?.statusCode
 			statusCodeSame = results.newBackend?.response?.statusCode == results.currentBackend?.response?.statusCode
 			currentCompileTime = results.currentBackend?.finishTime
 			currentCompileTime = results.currentBackend?.finishTime
 			newBackendCompileTime = results.newBackend?.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"
 AuthenticationController = require "../Authentication/AuthenticationController"
 UserGetter = require "../User/UserGetter"
 UserGetter = require "../User/UserGetter"
 RateLimiter = require("../../infrastructure/RateLimiter")
 RateLimiter = require("../../infrastructure/RateLimiter")
-ClsiCookieManager = require("./ClsiCookieManager")()
+ClsiCookieManager = require("./ClsiCookieManager")(Settings.apis.clsi?.backendGroupName)
 Path = require("path")
 Path = require("path")
 
 
 module.exports = CompileController =
 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
 			EditorRealTimeController.emitToRoom project_id, 'compilerUpdated', compiler
 			callback()
 			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) ->) ->
 	setSpellCheckLanguage : (project_id, languageCode, callback = (err) ->) ->
 		ProjectOptionsHandler.setSpellCheckLanguage project_id, languageCode, (err) ->
 		ProjectOptionsHandler.setSpellCheckLanguage project_id, languageCode, (err) ->
 			return callback(err) if err?
 			return callback(err) if err?

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

@@ -68,6 +68,20 @@ V1ConnectionError = (message) ->
 	return error
 	return error
 V1ConnectionError.prototype.__proto___ = Error.prototype
 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 =
 module.exports = Errors =
 	NotFoundError: NotFoundError
 	NotFoundError: NotFoundError
 	ServiceNotConfiguredError: ServiceNotConfiguredError
 	ServiceNotConfiguredError: ServiceNotConfiguredError
@@ -79,3 +93,5 @@ module.exports = Errors =
 	V1HistoryNotSyncedError: V1HistoryNotSyncedError
 	V1HistoryNotSyncedError: V1HistoryNotSyncedError
 	ProjectHistoryDisabledError: ProjectHistoryDisabledError
 	ProjectHistoryDisabledError: ProjectHistoryDisabledError
 	V1ConnectionError: V1ConnectionError
 	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
 				status_detail: parsed_export.status_detail
 			}
 			}
 			res.send export_json: json
 			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
 					historyId: project.overleaf?.history?.id
 					historyVersion: historyVersion
 					historyVersion: historyVersion
 					v1ProjectId: project.overleaf?.id
 					v1ProjectId: project.overleaf?.id
+					metadata:
+						compiler: project.compiler
+						imageName: project.imageName
 				user:
 				user:
 					id: user_id
 					id: user_id
 					firstName: user.first_name
 					firstName: user.first_name
@@ -115,3 +118,19 @@ module.exports = ExportsHandler = self =
 				err = new Error("v1 export returned a failure status code: #{res.statusCode}")
 				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}"
 				logger.err err:err, export:export_id, "v1 export returned failure status code: #{res.statusCode}"
 				callback err
 				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
 				req.useProjectHistory = false
 			next()
 			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) ->) ->
 	proxyToHistoryApi: (req, res, next = (error) ->) ->
 		user_id = AuthenticationController.getLoggedInUserId req
 		user_id = AuthenticationController.getLoggedInUserId req
 		url = HistoryController.buildHistoryServiceUrl(req.useProjectHistory) + req.url
 		url = HistoryController.buildHistoryServiceUrl(req.useProjectHistory) + req.url
@@ -41,22 +48,17 @@ module.exports = HistoryController =
 		user_id = AuthenticationController.getLoggedInUserId req
 		user_id = AuthenticationController.getLoggedInUserId req
 		url = HistoryController.buildHistoryServiceUrl(req.useProjectHistory) + req.url
 		url = HistoryController.buildHistoryServiceUrl(req.useProjectHistory) + req.url
 		logger.log url: url, "proxying to history api"
 		logger.log url: url, "proxying to history api"
-		request {
+		HistoryController._makeRequest {
 			url: url
 			url: url
 			method: req.method
 			method: req.method
 			json: true
 			json: true
 			headers:
 			headers:
 				"X-User-Id": user_id
 				"X-User-Id": user_id
-		}, (error, response, body) ->
+		}, (error, body) ->
 			return next(error) if error?
 			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) ->
 	buildHistoryServiceUrl: (useProjectHistory) ->
 		# choose a history service, either document-level (trackchanges)
 		# choose a history service, either document-level (trackchanges)
@@ -98,3 +100,46 @@ module.exports = HistoryController =
 				doc_id: doc._id
 				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"
 settings = require "settings-sharelatex"
 request = require "request"
 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 {
 		makeAffiliationRequest {
 			method: 'GET'
 			method: 'GET'
 			path: "/api/v2/users/#{userId.toString()}/affiliations"
 			path: "/api/v2/users/#{userId.toString()}/affiliations"
-			defaultErrorMessage: "Couldn't get affiliations"
+			defaultErrorMessage: "Couldn't get user affiliations"
 		}, callback
 		}, callback
 
 
 
 
@@ -72,15 +80,18 @@ makeAffiliationRequest = (requestOptions, callback = (error) ->) ->
 				errorMessage = "#{response.statusCode}: #{body.errors}"
 				errorMessage = "#{response.statusCode}: #{body.errors}"
 			else
 			else
 				errorMessage = "#{requestOptions.defaultErrorMessage}: #{response.statusCode}"
 				errorMessage = "#{requestOptions.defaultErrorMessage}: #{response.statusCode}"
+
+			logger.err path: requestOptions.path, body: requestOptions.body, errorMessage
 			return callback(new Error(errorMessage))
 			return callback(new Error(errorMessage))
 
 
 		callback(null, body)
 		callback(null, body)
 
 
 [
 [
-	'getAffiliations',
+	'getInstitutionAffiliations'
+	'getUserAffiliations',
 	'addAffiliation',
 	'addAffiliation',
 	'removeAffiliation',
 	'removeAffiliation',
 ].map (method) ->
 ].map (method) ->
 	metrics.timeAsyncMethod(
 	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')
 async = require('async')
-Request = require('request')
 logger = require 'logger-sharelatex'
 logger = require 'logger-sharelatex'
 Settings = require 'settings-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 =
 module.exports =
+
 	subscribe: (user, callback = () ->)->
 	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)
 		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)
 			callback(err)
 
 
 	unsubscribe: (user, callback = () ->)->
 	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"
 		logger.log user:user, email:user.email, "trying to unsubscribe user to the mailing list"
 		options = buildOptions(user, false)
 		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)
 			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)->
 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'
 ProjectEntityHandler = require './ProjectEntityHandler'
 crypto = require 'crypto'
 crypto = require 'crypto'
 { V1ConnectionError } = require '../Errors/Errors'
 { V1ConnectionError } = require '../Errors/Errors'
+Features = require('../../infrastructure/Features')
 
 
 module.exports = ProjectController =
 module.exports = ProjectController =
 
 
@@ -48,6 +49,10 @@ module.exports = ProjectController =
 			jobs.push (callback) ->
 			jobs.push (callback) ->
 				editorController.setCompiler project_id, req.body.compiler, 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?
 		if req.body.name?
 			jobs.push (callback) ->
 			jobs.push (callback) ->
 				editorController.renameProject project_id, req.body.name, callback
 				editorController.renameProject project_id, req.body.name, callback
@@ -344,9 +349,9 @@ module.exports = ProjectController =
 					themes: THEME_LIST
 					themes: THEME_LIST
 					maxDocLength: Settings.max_doc_length
 					maxDocLength: Settings.max_doc_length
 					useV2History: !!project.overleaf?.history?.display
 					useV2History: !!project.overleaf?.history?.display
-					showRichText: req.query?.rt == 'true'
+					richTextEnabled: Features.hasFeature('rich-text')
 					showTestControls: req.query?.tc == 'true' || user.isAdmin
 					showTestControls: req.query?.tc == 'true' || user.isAdmin
-					showPublishModal: req.query?.pm == 'true'
+					allowedImageNames: Settings.allowedImageNames || []
 				timer.done()
 				timer.done()
 
 
 	_buildProjectList: (allProjects, v1Projects = [])->
 	_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
 		if Settings.apis?.project_history?.displayHistoryForNewProjects
 			project.overleaf.history.display = true
 			project.overleaf.history.display = true
 		if Settings.currentImageName?
 		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
 		project.rootFolder[0] = rootFolder
 		User.findById owner_id, "ace.spellCheckLanguage", (err, user)->
 		User.findById owner_id, "ace.spellCheckLanguage", (err, user)->
 			project.spellCheckLanguage = user.ace.spellCheckLanguage
 			project.spellCheckLanguage = user.ace.spellCheckLanguage

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

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

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

@@ -17,6 +17,16 @@ module.exports =
 			if callback?
 			if callback?
 				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 = ()->)->
 	setSpellCheckLanguage: (project_id, languageCode, callback = ()->)->
 		logger.log project_id:project_id, languageCode:languageCode, "setting the spell check language"
 		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")
 logger = require("logger-sharelatex")
 ReferalFeatures = require("../Referal/ReferalFeatures")
 ReferalFeatures = require("../Referal/ReferalFeatures")
 V1SubscriptionManager = require("./V1SubscriptionManager")
 V1SubscriptionManager = require("./V1SubscriptionManager")
+InstitutionsFeatures = require '../Institutions/InstitutionsFeatures'
 
 
 oneMonthInSeconds = 60 * 60 * 24 * 30
 oneMonthInSeconds = 60 * 60 * 24 * 30
 
 
@@ -21,9 +22,11 @@ module.exports = FeaturesUpdater =
 				if error?
 				if error?
 					logger.err {err: error, user_id}, "error notifying v1 about updated features"
 					logger.err {err: error, user_id}, "error notifying v1 about updated features"
 
 
+
 		jobs =
 		jobs =
 			individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb
 			individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb
 			groupFeatureSets:   (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb
 			groupFeatureSets:   (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb
+			institutionFeatures:(cb) -> InstitutionsFeatures.getInstitutionsFeatures user_id, cb
 			v1Features:         (cb) -> FeaturesUpdater._getV1Features user_id, cb
 			v1Features:         (cb) -> FeaturesUpdater._getV1Features user_id, cb
 			bonusFeatures:      (cb) -> ReferalFeatures.getBonusFeatures user_id, cb
 			bonusFeatures:      (cb) -> ReferalFeatures.getBonusFeatures user_id, cb
 		async.series jobs, (err, results)->
 		async.series jobs, (err, results)->
@@ -32,9 +35,9 @@ module.exports = FeaturesUpdater =
 					"error getting subscription or group for refreshFeatures"
 					"error getting subscription or group for refreshFeatures"
 				return callback(err)
 				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)
 			features = _.reduce(featureSets, FeaturesUpdater._mergeFeatures, Settings.defaultFeatures)
 
 
 			logger.log {user_id, features}, 'updating user features'
 			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?
 			if user.features? and user.features.collaborators?
 				callback null, user.features.collaborators
 				callback null, user.features.collaborators
 			else
 			else
-				callback null, Settings.defaultPlanCode.collaborators
+				callback null, Settings.defaultFeatures.collaborators
 
 
 	canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) ->
 	canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) ->
 		@allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) =>
 		@allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) =>
@@ -89,13 +89,13 @@ module.exports = LimitationsManager =
 
 
 		return currentTotal >= subscription.membersLimit
 		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?
 			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)
 				return callback(err)
 			if !subscription?
 			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")
 				return callback("no subscription found")
 
 
 			limitReached = LimitationsManager.teamHasReachedMemberLimit(subscription)
 			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)
 		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
 		return licence
 		return licence
 
 
-	rejectInvitationToGroup: (user, subscription, callback)->
-		removeUserFromGroup(subscription.admin_id, user._id, callback)
-
 	getDomainLicencePage: (user)->
 	getDomainLicencePage: (user)->
 		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
 		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
 		if licence?.verifyEmail
 		if licence?.verifyEmail

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

@@ -9,46 +9,56 @@ async = require("async")
 
 
 module.exports =
 module.exports =
 
 
-	addUserToGroup: (req, res)->
+	addUserToGroup: (req, res, next)->
 		adminUserId = AuthenticationController.getLoggedInUserId(req)
 		adminUserId = AuthenticationController.getLoggedInUserId(req)
 		newEmail = req.body?.email?.toLowerCase()?.trim()
 		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)
 		adminUserId = AuthenticationController.getLoggedInUserId(req)
 		userToRemove_id = req.params.user_id
 		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
 		adminUserId = req.query.admin_user_id
 		userToRemove_id = AuthenticationController.getLoggedInUserId(req)
 		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)
 		user_id = AuthenticationController.getLoggedInUserId(req)
-		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
+		getManagedSubscription user_id, (error, subscription)->
+			return next(error) if error?
 			if !subscription?.groupPlan
 			if !subscription?.groupPlan
 				return res.redirect("/user/subscription")
 				return res.redirect("/user/subscription")
-			SubscriptionGroupHandler.getPopulatedListOfMembers user_id, (err, users)->
+			SubscriptionGroupHandler.getPopulatedListOfMembers subscription._id, (err, users)->
 				res.render "subscriptions/group_admin",
 				res.render "subscriptions/group_admin",
 					title: 'group_admin'
 					title: 'group_admin'
 					users: users
 					users: users
@@ -57,10 +67,11 @@ module.exports =
 	exportGroupCsv: (req, res)->
 	exportGroupCsv: (req, res)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log user_id: user_id, "exporting group csv"
 		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
 			if !subscription.groupPlan
 				return res.redirect("/")
 				return res.redirect("/")
-			SubscriptionGroupHandler.getPopulatedListOfMembers user_id, (err, users)->
+			SubscriptionGroupHandler.getPopulatedListOfMembers subscription._id, (err, users)->
 				groupCsv = ""
 				groupCsv = ""
 				for user in users
 				for user in users
 					groupCsv += user.email + "\n"
 					groupCsv += user.email + "\n"
@@ -70,3 +81,13 @@ module.exports =
 				)
 				)
 				res.contentType('text/csv')
 				res.contentType('text/csv')
 				res.send(groupCsv)
 				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 =
 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?
 			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)
 				return callback(err)
 			if limitReached
 			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)
 				return callback(limitReached:limitReached)
 			UserGetter.getUserByAnyEmail newEmail, (err, user)->
 			UserGetter.getUserByAnyEmail newEmail, (err, user)->
 				return callback(err) if err?
 				return callback(err) if err?
 				if user?
 				if user?
-					SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)->
+					SubscriptionUpdater.addUserToGroup subscriptionId, user._id, (err)->
 						if err?
 						if err?
 							logger.err err:err, "error adding user to group"
 							logger.err err:err, "error adding user to group"
 							return callback(err)
 							return callback(err)
@@ -34,30 +34,25 @@ module.exports = SubscriptionGroupHandler =
 						userViewModel = buildUserViewModel(user)
 						userViewModel = buildUserViewModel(user)
 						callback(err, userViewModel)
 						callback(err, userViewModel)
 				else
 				else
-					TeamInvitesHandler.createInvite adminUserId, newEmail, (err) ->
+					TeamInvitesHandler.createInvite subscriptionId, newEmail, (err) ->
 						return callback(err) if err?
 						return callback(err) if err?
 						userViewModel = buildEmailInviteViewModel(newEmail)
 						userViewModel = buildEmailInviteViewModel(newEmail)
 						callback(err, userViewModel)
 						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) ->
 	replaceUserReferencesInGroups: (oldId, newId, callback) ->
 		Subscription.update {admin_id: oldId}, {admin_id: newId}, (error) ->
 		Subscription.update {admin_id: oldId}, {admin_id: newId}, (error) ->
 			callback(error) if 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 = []
 			users = []
 
 
 			for email in subscription.invited_emails or []
 			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"
 			logger.log user_id:user_id, subscription_id:subscription_id, partOfGroup:partOfGroup, "checking if user is part of a group"
 			callback(err, partOfGroup)
 			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)->
 buildUserViewModel = (user)->
 	u =
 	u =
 		email: user.email
 		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")
 logger = require("logger-sharelatex")
 ObjectId = require('mongoose').Types.ObjectId
 ObjectId = require('mongoose').Types.ObjectId
 
 
-module.exports =
+module.exports = SubscriptionLocator =
 
 
 	getUsersSubscription: (user_or_id, callback)->
 	getUsersSubscription: (user_or_id, callback)->
 		if user_or_id? and user_or_id._id?
 		if user_or_id? and user_or_id._id?
@@ -14,6 +14,10 @@ module.exports =
 			logger.log user_id:user_id, "got users subscription"
 			logger.log user_id:user_id, "got users subscription"
 			callback(err, 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) ->
 	getMemberSubscriptions: (user_or_id, callback) ->
 		if user_or_id? and user_or_id._id?
 		if user_or_id? and user_or_id._id?
 			user_id = 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?
 					return callback(err) if err?
 					SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback
 					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 =
 		searchOps =
-			admin_id: adminUserId
+			_id: subscriptionId
 		insertOperation =
 		insertOperation =
 			{ $push: { member_ids: { $each: memberIds } } }
 			{ $push: { member_ids: { $each: memberIds } } }
 
 
@@ -46,9 +46,9 @@ module.exports = SubscriptionUpdater =
 				async.map userIds, FeaturesUpdater.refreshFeatures, callback
 				async.map userIds, FeaturesUpdater.refreshFeatures, callback
 
 
 
 
-	removeUserFromGroup: (adminUser_id, user_id, callback)->
+	removeUserFromGroup: (subscriptionId, user_id, callback)->
 		searchOps =
 		searchOps =
-			admin_id: adminUser_id
+			_id: subscriptionId
 		removeOperation =
 		removeOperation =
 			"$pull": {member_ids:user_id}
 			"$pull": {member_ids:user_id}
 		Subscription.update searchOps, removeOperation, (err)->
 		Subscription.update searchOps, removeOperation, (err)->
@@ -71,23 +71,23 @@ module.exports = SubscriptionUpdater =
 
 
 	_createNewSubscription: (adminUser_id, callback)->
 	_createNewSubscription: (adminUser_id, callback)->
 		logger.log adminUser_id:adminUser_id, "creating new subscription"
 		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.freeTrial.allowed = false
 		subscription.save (err)->
 		subscription.save (err)->
 			callback err, subscription
 			callback err, subscription
 
 
 	_updateSubscriptionFromRecurly: (recurlySubscription, subscription, callback)->
 	_updateSubscriptionFromRecurly: (recurlySubscription, subscription, callback)->
 		logger.log recurlySubscription:recurlySubscription, subscription:subscription, "updaing subscription"
 		logger.log recurlySubscription:recurlySubscription, subscription:subscription, "updaing subscription"
-		plan = PlansLocator.findLocalPlanInSettings(recurlySubscription.plan.plan_code)
 		if recurlySubscription.state == "expired"
 		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
 		if plan.groupPlan
 			subscription.groupPlan = true
 			subscription.groupPlan = true
 			subscription.membersLimit = plan.membersLimit
 			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(err) if err?
 			return callback(new Errors.NotFoundError('invite not found')) unless invite?
 			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?
 				return callback(err) if err?
 
 
 				removeInviteFromTeam(subscription.id, invite.email, callback)
 				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")
 UserUpdater = require("./UserUpdater")
 SudoModeHandler = require('../SudoMode/SudoModeHandler')
 SudoModeHandler = require('../SudoMode/SudoModeHandler')
 settings = require "settings-sharelatex"
 settings = require "settings-sharelatex"
+Errors = require "../Errors/Errors"
 
 
 module.exports = UserController =
 module.exports = UserController =
 
 
@@ -100,7 +101,7 @@ module.exports = UserController =
 					UserUpdater.changeEmailAddress user_id, newEmail, (err)->
 					UserUpdater.changeEmailAddress user_id, newEmail, (err)->
 						if err?
 						if err?
 							logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address"
 							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")
 								message = req.i18n.translate("email_already_registered")
 							else
 							else
 								message = req.i18n.translate("problem_changing_email_address")
 								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
 User = require("../../models/User").User
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 metrics = require('metrics-sharelatex')
 metrics = require('metrics-sharelatex')
-{ addAffiliation } = require("./UserAffiliationsManager")
+{ addAffiliation } = require("../Institutions/InstitutionsAPI")
 
 
 
 
 module.exports = UserCreator =
 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()
 		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[key] = value
 			
 			
 		user.ace.syntaxValidation = true
 		user.ace.syntaxValidation = true
@@ -27,6 +30,7 @@ module.exports = UserCreator =
 		user.save (err)->
 		user.save (err)->
 			callback(err, user)
 			callback(err, user)
 
 
+			return if options?.skip_affiliation
 			# call addaffiliation after the main callback so it runs in the
 			# 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
 			# background. There is no guaranty this will run so we must no rely on it
 			addAffiliation user._id, user.email, (error) ->
 			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")
 logger = require("logger-sharelatex")
 SubscriptionHandler = require("../Subscription/SubscriptionHandler")
 SubscriptionHandler = require("../Subscription/SubscriptionHandler")
 async = require("async")
 async = require("async")
-{ deleteAffiliations } = require("./UserAffiliationsManager")
+{ deleteAffiliations } = require("../Institutions/InstitutionsAPI")
 
 
 module.exports =
 module.exports =
 
 

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

@@ -3,7 +3,7 @@ UserGetter = require("./UserGetter")
 UserUpdater = require("./UserUpdater")
 UserUpdater = require("./UserUpdater")
 EmailHelper = require("../Helpers/EmailHelper")
 EmailHelper = require("../Helpers/EmailHelper")
 UserEmailsConfirmationHandler = require "./UserEmailsConfirmationHandler"
 UserEmailsConfirmationHandler = require "./UserEmailsConfirmationHandler"
-{ endorseAffiliation } = require("./UserAffiliationsManager")
+{ endorseAffiliation } = require("../Institutions/InstitutionsAPI")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 Errors = require "../Errors/Errors"
 Errors = require "../Errors/Errors"
 
 
@@ -26,7 +26,8 @@ module.exports = UserEmailsController =
 			role: req.body.role
 			role: req.body.role
 			department: req.body.department
 			department: req.body.department
 		UserUpdater.addEmailAddress userId, email, affiliationOptions, (error)->
 		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) ->
 			UserEmailsConfirmationHandler.sendConfirmationEmail userId, email, (err) ->
 				return next(error) if error?
 				return next(error) if error?
 				res.sendStatus 204
 				res.sendStatus 204
@@ -47,9 +48,11 @@ module.exports = UserEmailsController =
 		email = EmailHelper.parseEmail(req.body.email)
 		email = EmailHelper.parseEmail(req.body.email)
 		return res.sendStatus 422 unless 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) ->
 	endorse: (req, res, next) ->
@@ -61,6 +64,19 @@ module.exports = UserEmailsController =
 			return next(error) if error?
 			return next(error) if error?
 			res.sendStatus 204
 			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) ->
 	showConfirm: (req, res, next) ->
 		res.render 'user/confirm_email', {
 		res.render 'user/confirm_email', {
@@ -82,3 +98,15 @@ module.exports = UserEmailsController =
 					next(error)
 					next(error)
 			else
 			else
 				res.sendStatus 200
 				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')
 logger = require('logger-sharelatex')
 db = mongojs.db
 db = mongojs.db
 ObjectId = mongojs.ObjectId
 ObjectId = mongojs.ObjectId
-{ getAffiliations } = require("./UserAffiliationsManager")
+{ getUserAffiliations } = require("../Institutions/InstitutionsAPI")
+Errors = require("../Errors/Errors")
 
 
 module.exports = UserGetter =
 module.exports = UserGetter =
 	getUser: (query, projection, callback = (error, user) ->) ->
 	getUser: (query, projection, callback = (error, user) ->) ->
@@ -31,7 +32,7 @@ module.exports = UserGetter =
 			return callback error if error?
 			return callback error if error?
 			return callback new Error('User not Found') unless user
 			return callback new Error('User not Found') unless user
 
 
-			getAffiliations userId, (error, affiliationsData) ->
+			getUserAffiliations userId, (error, affiliationsData) ->
 				return callback error if error?
 				return callback error if error?
 				callback null, decorateFullEmails(user.email, user.emails, affiliationsData)
 				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
 	# check for duplicate email address. This is also enforced at the DB level
 	ensureUniqueEmailAddress: (newEmail, callback) ->
 	ensureUniqueEmailAddress: (newEmail, callback) ->
 		@getUserByAnyEmail newEmail, (error, user) ->
 		@getUserByAnyEmail newEmail, (error, user) ->
-			return callback(message: 'alread_exists') if user?
+			return callback(new Errors.EmailExistsError('alread_exists')) if user?
 			callback(error)
 			callback(error)
 
 
 decorateFullEmails = (defaultEmail, emailsData, affiliationsData) ->
 decorateFullEmails = (defaultEmail, emailsData, affiliationsData) ->

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

@@ -68,7 +68,6 @@ module.exports =
 				shouldAllowEditingDetails: shouldAllowEditingDetails
 				shouldAllowEditingDetails: shouldAllowEditingDetails
 				languages: Settings.languages,
 				languages: Settings.languages,
 				accountSettingsTabActive: true
 				accountSettingsTabActive: true
-				showAffiliationsUI: (req.query?.aff == "true") or false
 
 
 	sessionsPage: (req, res, next) ->
 	sessionsPage: (req, res, next) ->
 		user = AuthenticationController.getSessionUser(req)
 		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
 User = require("../../models/User").User
 UserCreator = require("./UserCreator")
 UserCreator = require("./UserCreator")
 UserGetter = require("./UserGetter")
 UserGetter = require("./UserGetter")
@@ -54,7 +53,8 @@ module.exports = UserRegistrationHandler =
 					(cb)-> User.update {_id: user._id}, {"$set":{holdingAccount:false}}, cb
 					(cb)-> User.update {_id: user._id}, {"$set":{holdingAccount:false}}, cb
 					(cb)-> AuthenticationManager.setUserPassword user._id, userDetails.password, cb
 					(cb)-> AuthenticationManager.setUserPassword user._id, userDetails.password, cb
 					(cb)->
 					(cb)->
-						NewsLetterManager.subscribe user, ->
+						if userDetails.subscribeToNewsletter == "true"
+							NewsLetterManager.subscribe user, ->
 						cb() #this can be slow, just fire it off
 						cb() #this can be slow, just fire it off
 				], (err)->
 				], (err)->
 					logger.log user: user, "registered"
 					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")
 async = require("async")
 ObjectId = mongojs.ObjectId
 ObjectId = mongojs.ObjectId
 UserGetter = require("./UserGetter")
 UserGetter = require("./UserGetter")
-{ addAffiliation, removeAffiliation } = require("./UserAffiliationsManager")
+{ addAffiliation, removeAffiliation } = require("../Institutions/InstitutionsAPI")
+FeaturesUpdater = require("../Subscription/FeaturesUpdater")
 EmailHelper = require "../Helpers/EmailHelper"
 EmailHelper = require "../Helpers/EmailHelper"
 Errors = require "../Errors/Errors"
 Errors = require "../Errors/Errors"
+Settings = require "settings-sharelatex"
+request = require 'request'
+NewsletterManager = require "../Newsletter/NewsletterManager"
 
 
 module.exports = UserUpdater =
 module.exports = UserUpdater =
 	updateUser: (query, update, callback = (error) ->) ->
 	updateUser: (query, update, callback = (error) ->) ->
@@ -96,17 +100,69 @@ module.exports = UserUpdater =
 	setDefaultEmailAddress: (userId, email, callback) ->
 	setDefaultEmailAddress: (userId, email, callback) ->
 		email = EmailHelper.parseEmail(email)
 		email = EmailHelper.parseEmail(email)
 		return callback(new Error('invalid email')) if !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)
 				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)
 		email = EmailHelper.parseEmail(email)
 		return callback(new Error('invalid email')) if !email?
 		return callback(new Error('invalid email')) if !email?
 		logger.log {userId, email}, 'confirming user email'
 		logger.log {userId, email}, 'confirming user email'
@@ -120,13 +176,13 @@ module.exports = UserUpdater =
 				'emails.email': email
 				'emails.email': email
 			update =
 			update =
 				$set:
 				$set:
-					'emails.$.confirmedAt': new Date()
+					'emails.$.confirmedAt': confirmedAt
 			@updateUser query, update, (error, res) ->
 			@updateUser query, update, (error, res) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				logger.log {res, userId, email}, "tried to confirm email"
 				logger.log {res, userId, email}, "tried to confirm email"
 				if res.n == 0
 				if res.n == 0
 					return callback(new Errors.NotFoundError('user id and email do no match'))
 					return callback(new Errors.NotFoundError('user id and email do no match'))
-				callback()
+				FeaturesUpdater.refreshFeatures userId, true, callback
 
 
 [
 [
 	'updateUser'
 	'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"
 			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
 			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"
 		isSmoke = req.headers?.host?.slice(0,5)?.toLowerCase() == "smoke"
 		isLive = !isDark and !isSmoke
 		isLive = !isDark and !isSmoke
 
 
@@ -167,6 +167,11 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 			path = Path.join("/img/", imgFile)
 			path = Path.join("/img/", imgFile)
 			return Url.resolve(staticFilesBase, path)
 			return Url.resolve(staticFilesBase, path)
 
 
+		res.locals.mathJaxPath = res.locals.buildJsPath(
+			'libs/mathjax/MathJax.js',
+			{cdn:false, qs:{config:'TeX-AMS_HTML'}}
+		)
+
 		next()
 		next()
 
 
 
 
@@ -328,3 +333,8 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 			defaultLineHeight          : if isOl then 'normal' else 'compact'
 			defaultLineHeight          : if isOl then 'normal' else 'compact'
 			renderAnnouncements        : !isOl
 			renderAnnouncements        : !isOl
 		next()
 		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?
 				return Settings.overleaf?
 			when 'templates'
 			when 'templates'
 				return !Settings.overleaf?
 				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
 			else
 				throw new Error("unknown feature: #{feature}")
 				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"})
 app.use bodyParser.urlencoded({ extended: true, limit: "2mb"})
 # Make sure we can process the max doc length plus some overhead for JSON encoding
 # 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 multer(dest: Settings.path.uploadFolder)
 app.use methodOverride()
 app.use methodOverride()
 
 

+ 2 - 0
services/web/app/coffee/models/Subscription.coffee

@@ -7,10 +7,12 @@ ObjectId = Schema.ObjectId
 
 
 SubscriptionSchema = new Schema
 SubscriptionSchema = new Schema
 	admin_id     :   {type:ObjectId, ref:'User', index: {unique: true, dropDups: true}}
 	admin_id     :   {type:ObjectId, ref:'User', index: {unique: true, dropDups: true}}
+	manager_ids  :   [ type:ObjectId, ref:'User' ]
 	member_ids   :   [ type:ObjectId, ref:'User' ]
 	member_ids   :   [ type:ObjectId, ref:'User' ]
 	invited_emails:  [ String ]
 	invited_emails:  [ String ]
 	teamInvites  :   [ TeamInviteSchema ]
 	teamInvites  :   [ TeamInviteSchema ]
 	recurlySubscription_id : String
 	recurlySubscription_id : String
+	teamName 	 : {type: String}
 	planCode 	 : {type: String}
 	planCode 	 : {type: String}
 	groupPlan	 : {type: Boolean, default: false}
 	groupPlan	 : {type: Boolean, default: false}
 	membersLimit: {type:Number, default:0}
 	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')
 DocumentController = require('./Features/Documents/DocumentController')
 CompileManager = require("./Features/Compile/CompileManager")
 CompileManager = require("./Features/Compile/CompileManager")
 CompileController = require("./Features/Compile/CompileController")
 CompileController = require("./Features/Compile/CompileController")
-ClsiCookieManager = require("./Features/Compile/ClsiCookieManager")
+ClsiCookieManager = require("./Features/Compile/ClsiCookieManager")(Settings.apis.clsi?.backendGroupName)
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
 FileStoreController = require("./Features/FileStore/FileStoreController")
@@ -115,8 +115,11 @@ module.exports = class Router
 			UserEmailsController.showConfirm
 			UserEmailsController.showConfirm
 		webRouter.post '/user/emails/confirm',
 		webRouter.post '/user/emails/confirm',
 			UserEmailsController.confirm
 			UserEmailsController.confirm
+		webRouter.post '/user/emails/resend_confirmation',
+			AuthenticationController.requireLogin(),
+			UserEmailsController.resendConfirmation
 
 
-		unless Features.externalAuthenticationSystemUsed()
+		if Features.hasFeature 'affiliations'
 			webRouter.post '/user/emails',
 			webRouter.post '/user/emails',
 				AuthenticationController.requireLogin(),
 				AuthenticationController.requireLogin(),
 				UserEmailsController.add
 				UserEmailsController.add
@@ -236,8 +239,13 @@ module.exports = class Router
 		webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
 		webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
 		privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory
 		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.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', 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/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
 		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.systemMessages = !{JSON.stringify(systemMessages).replace(/\//g, '\\/')};
 			window.ab = {};
 			window.ab = {};
 			window.user_id = '#{getLoggedInUserId()}';
 			window.user_id = '#{getLoggedInUserId()}';
+			window.ExposedSettings = JSON.parse('!{JSON.stringify(ExposedSettings).replace(/\//g, "\\/")}');
 
 
 		- if (typeof(settings.algolia) != "undefined")
 		- if (typeof(settings.algolia) != "undefined")
 			script.
 			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) }",
 					ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME) }",
 					layout="main",
 					layout="main",
 					ng-hide="state.loading",
 					ng-hide="state.loading",
-					resize-on="layout:chat:resize",
+					resize-on="layout:chat:resize,history:toggle",
 					minimum-restore-size-west="130"
 					minimum-restore-size-west="130"
 					custom-toggler-pane=hasFeature('custom-togglers') ? "'west'" : "false"
 					custom-toggler-pane=hasFeature('custom-togglers') ? "'west'" : "false"
 					custom-toggler-msg-when-open=hasFeature('custom-togglers') ? "'" + translate("tooltip_hide_filetree") + "'" : "false"
 					custom-toggler-msg-when-open=hasFeature('custom-togglers') ? "'" + translate("tooltip_hide_filetree") + "'" : "false"
@@ -103,6 +103,8 @@ block content
 			h3 {{ title }}
 			h3 {{ title }}
 		.modal-body(ng-bind-html="message")
 		.modal-body(ng-bind-html="message")
 
 
+	script(src=mathJaxPath)
+
 block requirejs
 block requirejs
 	script(type="text/javascript" src='/socket.io/socket.io.js')
 	script(type="text/javascript" src='/socket.io/socket.io.js')
 
 
@@ -131,9 +133,9 @@ block requirejs
 		window.maxDocLength = #{maxDocLength};
 		window.maxDocLength = #{maxDocLength};
 		window.trackChangesState = data.trackChangesState;
 		window.trackChangesState = data.trackChangesState;
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
+		window.richTextEnabled = #{richTextEnabled}
 		window.requirejs = {
 		window.requirejs = {
 			"paths" : {
 			"paths" : {
-				"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, qs:{config:'TeX-AMS_HTML'}})}",
 				"moment": "libs/#{lib('moment')}",
 				"moment": "libs/#{lib('moment')}",
 				"pdfjs-dist/build/pdf": "libs/#{lib('pdfjs')}/pdf",
 				"pdfjs-dist/build/pdf": "libs/#{lib('pdfjs')}/pdf",
 				"pdfjs-dist/build/pdf.worker": "#{pdfWorkerPath}",
 				"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(
 	.ui-layout-center(
 		ng-controller="ReviewPanelController",
 		ng-controller="ReviewPanelController",
 		ng-class="{\
 		ng-class="{\
+			'rp-unsupported': editor.showRichText,\
 			'rp-state-current-file': (reviewPanel.subView === SubViews.CUR_FILE),\
 			'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-expanded': (reviewPanel.subView === SubViews.CUR_FILE && ui.reviewPanelOpen),\
 			'rp-state-current-file-mini': (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,\
 			'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")
 			span(ng-show="editor.open_doc_id")
 				i.fa.fa-spin.fa-refresh
 				i.fa.fa-spin.fa-refresh
 				| &nbsp;&nbsp;#{translate("loading")}...
 				| &nbsp;&nbsp;#{translate("loading")}...
@@ -39,7 +43,7 @@ div.full-size(
 			ace-editor="editor",
 			ace-editor="editor",
 			ng-if="!editor.showRichText",
 			ng-if="!editor.showRichText",
 			ng-show="!!editor.sharejs_doc && !editor.opening",
 			ng-show="!!editor.sharejs_doc && !editor.opening",
-			style=showRichText ? "top: 32px" : "",
+			style=richTextEnabled ? "top: 32px" : "",
 			theme="settings.theme",
 			theme="settings.theme",
 			keybindings="settings.mode",
 			keybindings="settings.mode",
 			font-size="settings.fontSize",
 			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("restore")}
 			span(ng-show="state.inflight") #{translate("restoring")} ...
 			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"
 	ng-controller="HistoryV2ListController"
 )
 )
 	history-entries-list(
 	history-entries-list(
+		ng-if="!history.showOnlyLabels && !history.error"
 		entries="history.updates"
 		entries="history.updates"
 		current-user="user"
 		current-user="user"
+		users="projectUsers"
 		load-entries="loadMore()"
 		load-entries="loadMore()"
 		load-disabled="history.loading || history.atEnd"
 		load-disabled="history.loading || history.atEnd"
 		load-initialize="ui.view == 'history'"
 		load-initialize="ui.view == 'history'"
 		is-loading="history.loading"
 		is-loading="history.loading"
 		on-entry-select="handleEntrySelect(selectedEntry)"
 		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(
 aside.change-list(
@@ -65,6 +78,14 @@ aside.change-list(
 						)
 						)
 
 
 				div.description(ng-click="select()")
 				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.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
 					div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
 					div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
 						| #{translate("file_action_edited")}
 						| #{translate("file_action_edited")}
@@ -106,8 +127,9 @@ script(type="text/ng-template", id="historyEntriesListTpl")
 				ng-repeat="entry in $ctrl.entries"
 				ng-repeat="entry in $ctrl.entries"
 				entry="entry"
 				entry="entry"
 				current-user="$ctrl.currentUser"
 				current-user="$ctrl.currentUser"
+				users="$ctrl.users"
 				on-select="$ctrl.onEntrySelect({ selectedEntry: selectedEntry })"
 				on-select="$ctrl.onEntrySelect({ selectedEntry: selectedEntry })"
-				ng-show="!$ctrl.isLoading"
+				on-label-delete="$ctrl.onLabelDelete({ label: label })"
 			)
 			)
 			.loading(ng-show="$ctrl.isLoading")
 			.loading(ng-show="$ctrl.isLoading")
 				i.fa.fa-spin.fa-refresh
 				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 }}
 		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-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
 			ol.history-entry-changes
 				li.history-entry-change(
 				li.history-entry-change(
 					ng-repeat="pathname in ::$ctrl.entry.pathnames"
 					ng-repeat="pathname in ::$ctrl.entry.pathnames"
@@ -148,6 +179,7 @@ script(type="text/ng-template", id="historyEntryTpl")
 						ng-if="::project_op.remove"
 						ng-if="::project_op.remove"
 					) #{translate("file_action_deleted")}
 					) #{translate("file_action_deleted")}
 					span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }}
 					span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }}
+
 			.history-entry-metadata
 			.history-entry-metadata
 				time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }}
 				time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }}
 				span
 				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")
 					li.history-entry-metadata-user(ng-if="::$ctrl.entry.meta.users.length == 0")
 						span.name(
 						span.name(
 							ng-style="$ctrl.getUserCSSStyle();"
 							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",
 			text="history.diff.text",
 			highlights="history.diff.highlights",
 			highlights="history.diff.highlights",
 			read-only="true",
 			read-only="true",
-			resize-on="layout:main:resize",
+			resize-on="layout:main:resize,history:toggle",
 			navigate-highlights="true"
 			navigate-highlights="true"
 		)
 		)
 		.alert.alert-info(ng-if="history.diff.binary")
 		.alert.alert-info(ng-if="history.diff.binary")
@@ -70,12 +70,24 @@
 			font-size="settings.fontSize",
 			font-size="settings.fontSize",
 			text="history.selectedFile.text",
 			text="history.selectedFile.text",
 			read-only="true",
 			read-only="true",
-			resize-on="layout:main:resize",
+			resize-on="layout:main:resize,history:toggle",
 		)
 		)
 		.alert.alert-info(ng-if="history.selectedFile.binary")
 		.alert.alert-info(ng-if="history.selectedFile.binary")
 			| We're still working on showing image and binary changes, sorry. Stay tuned!
 			| We're still working on showing image and binary changes, sorry. Stay tuned!
 	.loading-panel(ng-show="history.selectedFile.loading")
 	.loading-panel(ng-show="history.selectedFile.loading")
 		i.fa.fa-spin.fa-refresh
 		i.fa.fa-spin.fa-refresh
 		| &nbsp;&nbsp;#{translate("loading")}...
 		| &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")
 	.error-panel(ng-show="history.selectedFile.error")
 		.alert.alert-danger #{translate("generic_something_went_wrong")}
 		.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(
 .history-toolbar(
+	ng-controller="HistoryV2ToolbarController"
 	ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
 	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
 		i.fa.fa-spin.fa-refresh
 		| &nbsp;&nbsp; #{translate("loading")}...
 		| &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' }}
 		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="pdfjs") #{translate("built_in")}
 					option(value="native") #{translate("native")}
 					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")}
 	h4 #{translate("hotkeys")}
 	ul.list-unstyled.nav
 	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
 	.rp-in-editor-widgets
 		a.rp-track-changes-indicator(
 		a.rp-track-changes-indicator(
 			href
 			href
@@ -235,6 +235,13 @@
 			i.fa.fa-list
 			i.fa.fa-list
 			span.rp-nav-label #{translate("overview")}
 			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')
 script(type='text/ng-template', id='changeEntryTemplate')
 	div
 	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()"
 			ng-click="cancel()"
 		) &times;
 		) &times;
 		h3(ng-if="action == 'delete'") #{translate("delete_projects")}
 		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 == 'leave'") #{translate("leave_projects")}
 		h3(ng-if="action == 'delete-and-leave'") #{translate("delete_and_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
 	.modal-body
 		div(ng-show="projectsToDelete.length > 0")
 		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
 			ul
 				li(ng-repeat="project in projectsToDelete | orderBy:'name'")
 				li(ng-repeat="project in projectsToDelete | orderBy:'name'")
 					strong {{project.name}}
 					strong {{project.name}}
@@ -345,13 +348,11 @@ script(type="text/ng-template", id="v1ImportModalTemplate")
 					i.fa.fa-flask
 					i.fa.fa-flask
 				.v1-import-col
 				.v1-import-col
 					h2.v1-import-title #[strong Warning:] Overleaf v2 is Experimental
 					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
 					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
 					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.
 						| 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")
 		.content.plans(ng-controller="PlansController")
 			.container(class="more-details" ng-cloak ng-if="plansVariant === 'more-details'")
 			.container(class="more-details" ng-cloak ng-if="plansVariant === 'more-details'")
 				include _plans_page_details_more
 				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
 				include _plans_page_details_less

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

@@ -9,7 +9,7 @@ block content
 						.page-header
 						.page-header
 							h1  #{translate("account_settings")}
 							h1  #{translate("account_settings")}
 						.account-settings(ng-controller="AccountSettingsController", ng-cloak)
 						.account-settings(ng-controller="AccountSettingsController", ng-cloak)
-							if locals.showAffiliationsUI
+							if hasFeature('affiliations')
 								include settings/user-affiliations
 								include settings/user-affiliations
 								
 								
 							form-messages(for="settingsForm")
 							form-messages(for="settingsForm")
@@ -22,25 +22,26 @@ block content
 									h3 #{translate("update_account_info")}
 									h3 #{translate("update_account_info")}
 									form(async-form="settings", name="settingsForm", method="POST", action="/user/settings", novalidate)
 									form(async-form="settings", name="settingsForm", method="POST", action="/user/settings", novalidate)
 										input(type="hidden", name="_csrf", value=csrfToken)
 										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
 										if shouldAllowEditingDetails
 											.form-group
 											.form-group
@@ -166,7 +167,12 @@ block content
 									)
 									)
 										i.fa.fa-check
 										i.fa.fa-check
 										| #{translate("unsubscribed")}
 										| #{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")} 
 									p #{translate("need_to_leave")} 
 										a(href, ng-click="deleteAccount()")  #{translate("delete_your_account")}
 										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"
 	name="affiliationsForm"
 )
 )
 	.col-md-12
 	.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
 		table.table.affiliations-table
 			thead
 			thead
 				tr
 				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
 					th.affiliations-table-inline-actions
 			tbody
 			tbody
 				tr(
 				tr(
 					ng-repeat="userEmail in userEmails"
 					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 
 					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-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(
 				tr.affiliations-table-highlighted-row(
-					ng-if="!ui.showAddEmailUI && !ui.isLoadingEmails"
+					ng-if="!ui.showAddEmailUI && !ui.isMakingRequest"
 				)
 				)
 					td(colspan="3")
 					td(colspan="3")
 						a(
 						a(
 							href
 							href
 							ng-click="showAddEmailForm()"
 							ng-click="showAddEmailForm()"
-						) Add another email
+						) #{translate("add_another_email")}
 
 
 				tr.affiliations-table-highlighted-row(
 				tr.affiliations-table-highlighted-row(
-					ng-if="ui.showAddEmailUI"
+					ng-if="ui.showAddEmailUI && !ui.isLoadingEmails"
 				)
 				)
 					td
 					td
 						.affiliations-form-group
 						.affiliations-form-group
@@ -67,106 +113,129 @@ form.row(
 								input-required="true"
 								input-required="true"
 							)
 							)
 					td
 					td
-						.affiliations-table-label(
+						p.affiliations-table-label(
 							ng-if="newAffiliation.university && !ui.showManualUniversitySelectionUI"
 							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(
 						.affiliations-table-label(
 							ng-if="!newAffiliation.university && !ui.isValidEmail && !ui.showManualUniversitySelectionUI"
 							ng-if="!newAffiliation.university && !ui.isValidEmail && !ui.showManualUniversitySelectionUI"
-						) Start by adding your email address.
+						) #{translate("start_by_adding_your_email")}
 						.affiliations-table-label(
 						.affiliations-table-label(
 							ng-if="!newAffiliation.university && ui.isValidEmail && !ui.isBlacklistedEmail && !ui.showManualUniversitySelectionUI"
 							ng-if="!newAffiliation.university && ui.isValidEmail && !ui.isBlacklistedEmail && !ui.showManualUniversitySelectionUI"
 						)
 						)
-							| Is your email affiliated with an institution? 
+							| #{translate("is_email_affiliated")}
 							br
 							br
 							a(
 							a(
 								href
 								href
 								ng-click="selectUniversityManually();"
 								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
 					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()"
 							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"
 			url: "http://#{process.env['FILESTORE_HOST'] or 'localhost'}:3009"
 		clsi:
 		clsi:
 			url: "http://#{process.env['CLSI_HOST'] or 'localhost'}:3013"
 			url: "http://#{process.env['CLSI_HOST'] or 'localhost'}:3013"
+			# url: "http://#{process.env['CLSI_LB_HOST']}:3014"
+			backendGroupName: undefined
 		templates:
 		templates:
 			url: "http://#{process.env['TEMPLATES_HOST'] or 'localhost'}:3007"
 			url: "http://#{process.env['TEMPLATES_HOST'] or 'localhost'}:3007"
 		githubSync:
 		githubSync:
@@ -277,10 +279,10 @@ module.exports = settings =
 	# Third party services
 	# 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.
 	# credentials here to integrate with this.
-	# markdownmail:
-	# 	secret: ""
+	# mailchimp:
+	# 	api_key: ""
 	# 	list_id: ""
 	# 	list_id: ""
 	#
 	#
 	# Fill in your unique token from various analytics services to enable
 	# Fill in your unique token from various analytics services to enable
@@ -339,7 +341,7 @@ module.exports = settings =
 	# disablePerUserCompiles: true
 	# disablePerUserCompiles: true
 
 
 	# Domain the client (pdfjs) should download the compiled pdf from
 	# 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.
 	# Maximum size of text documents in the real-time editing system.
 	max_doc_length: 2 * 1024 * 1024 # 2mb
 	max_doc_length: 2 * 1024 * 1024 # 2mb
@@ -425,7 +427,7 @@ module.exports = settings =
 	redirects:
 	redirects:
 		"/templates/index": "/templates/"
 		"/templates/index": "/templates/"
 
 
-	reloadModuleViewsOnEachRequest: true
+	reloadModuleViewsOnEachRequest: process.env['NODE_ENV'] != 'production'
 
 
 	domainLicences: [
 	domainLicences: [
 
 
@@ -472,3 +474,14 @@ module.exports = settings =
 		autoCompile:
 		autoCompile:
 			everyone: 100
 			everyone: 100
 			standard: 25
 			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'
       LINKED_URL_PROXY: 'http://localhost:6543'
       ENABLED_LINKED_FILE_TYPES: 'url,project_file,project_output_file,mendeley'
       ENABLED_LINKED_FILE_TYPES: 'url,project_file,project_output_file,mendeley'
       SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
       SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
+      NODE_ENV: production
     depends_on:
     depends_on:
       - redis
       - redis
       - mongo
       - mongo

+ 3 - 1
services/web/package.json

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

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

@@ -26,7 +26,29 @@ define [
 			baseUrl: window.sharelatex.sixpackDomain
 			baseUrl: window.sharelatex.sixpackDomain
 			client_id: window.user_id
 			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) ->
 	App.run ($templateCache) ->
 		# UI Select templates are hard-coded and use Glyphicon icons (which we don't import). 
 		# 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 
 		# 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.referencesSearchManager = new ReferencesManager(ide, $scope)
 		ide.connectionManager = new ConnectionManager(ide, $scope)
 		ide.connectionManager = new ConnectionManager(ide, $scope)
 		ide.fileTreeManager = new FileTreeManager(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)
 		ide.onlineUsersManager = new OnlineUsersManager(ide, $scope)
 		if window.data.useV2History
 		if window.data.useV2History
 			ide.historyManager = new HistoryV2Manager(ide, $scope)
 			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/ChatButtonController"
 	"ide/chat/controllers/ChatController"
 	"ide/chat/controllers/ChatController"
 	"ide/chat/controllers/ChatMessageController"
 	"ide/chat/controllers/ChatMessageController"
-	"ide/chat/directives/mathjax"
+	"directives/mathjax"
 	"filters/wrapLongWords"
 	"filters/wrapLongWords"
 ], () ->
 ], () ->

+ 2 - 1
services/web/public/coffee/ide/directives/layout.coffee

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

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

@@ -6,7 +6,7 @@ define [
 	"ide/editor/controllers/SavingNotificationController"
 	"ide/editor/controllers/SavingNotificationController"
 ], (Document) ->
 ], (Document) ->
 	class EditorManager
 	class EditorManager
-		constructor: (@ide, @$scope) ->
+		constructor: (@ide, @$scope, @localStorage) ->
 			@$scope.editor = {
 			@$scope.editor = {
 				sharejs_doc: null
 				sharejs_doc: null
 				open_doc_id: null
 				open_doc_id: null
@@ -14,7 +14,7 @@ define [
 				opening: true
 				opening: true
 				trackChanges: false
 				trackChanges: false
 				wantTrackChanges: false
 				wantTrackChanges: false
-				showRichText: false
+				showRichText: @showRichText()
 			}
 			}
 
 
 			@$scope.$on "entity:selected", (event, entity) =>
 			@$scope.$on "entity:selected", (event, entity) =>
@@ -41,6 +41,11 @@ define [
 				return if !value?
 				return if !value?
 				@_syncTrackChangesState(@$scope.editor.sharejs_doc)
 				@_syncTrackChangesState(@$scope.editor.sharejs_doc)
 
 
+		showRichText: () ->
+			if !window.richTextEnabled
+				return false
+			@localStorage("editor.mode.#{@$scope.project_id}") == 'rich-text'
+
 		autoOpenDoc: () ->
 		autoOpenDoc: () ->
 			open_doc_id = 
 			open_doc_id = 
 				@ide.localStorage("doc.open_id.#{@$scope.project_id}") or
 				@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/spell-check/SpellCheckAdapter"
 	"ide/editor/directives/aceEditor/highlights/HighlightsManager"
 	"ide/editor/directives/aceEditor/highlights/HighlightsManager"
 	"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager"
 	"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/track-changes/TrackChangesManager"
 	"ide/editor/directives/aceEditor/metadata/MetadataManager"
 	"ide/editor/directives/aceEditor/metadata/MetadataManager"
 	"ide/metadata/services/metadata"
 	"ide/metadata/services/metadata"
 	"ide/graphics/services/graphics"
 	"ide/graphics/services/graphics"
 	"ide/preamble/services/preamble"
 	"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
 	EditSession = ace.require('ace/edit_session').EditSession
 	ModeList = ace.require('ace/ext/modelist')
 	ModeList = ace.require('ace/ext/modelist')
 	Vim = ace.require('ace/keyboard/vim').Vim
 	Vim = ace.require('ace/keyboard/vim').Vim
@@ -108,7 +109,7 @@ define [
 
 
 				undoManager           = new UndoManager(scope, editor, element)
 				undoManager           = new UndoManager(scope, editor, element)
 				highlightsManager     = new HighlightsManager(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)
 				trackChangesManager   = new TrackChangesManager(scope, editor, element)
 				metadataManager = new MetadataManager(scope, editor, element, metadata)
 				metadataManager = new MetadataManager(scope, editor, element, metadata)
 				autoCompleteManager = new AutoCompleteManager(scope, editor, element, metadataManager, graphics, preamble, files)
 				autoCompleteManager = new AutoCompleteManager(scope, editor, element, metadataManager, graphics, preamble, files)
@@ -308,10 +309,12 @@ define [
 
 
 				scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) ->
 				scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) ->
 					if old_sharejs_doc?
 					if old_sharejs_doc?
+						scope.$broadcast('beforeChangeDocument')
 						detachFromAce(old_sharejs_doc)
 						detachFromAce(old_sharejs_doc)
-
 					if sharejs_doc?
 					if sharejs_doc?
 						attachToAce(sharejs_doc)
 						attachToAce(sharejs_doc)
+					if sharejs_doc? and old_sharejs_doc?
+						scope.$broadcast('afterChangeDocument')
 
 
 				scope.$watch "text", (text) ->
 				scope.$watch "text", (text) ->
 					if text?
 					if text?
@@ -380,6 +383,30 @@ define [
 					editor.off 'changeSession', onSessionChangeForSpellCheck
 					editor.off 'changeSession', onSessionChangeForSpellCheck
 					editor.off 'nativecontextmenu', spellCheckManager.onContextMenu
 					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) ->
 				attachToAce = (sharejs_doc) ->
 					lines = sharejs_doc.getSnapshot().split("\n")
 					lines = sharejs_doc.getSnapshot().split("\n")
 					session = editor.getSession()
 					session = editor.getSession()
@@ -425,6 +452,7 @@ define [
 					editor.initing = false
 					editor.initing = false
 					# now ready to edit document
 					# now ready to edit document
 					editor.setReadOnly(scope.readOnly) # respect the readOnly setting, normally false
 					editor.setReadOnly(scope.readOnly) # respect the readOnly setting, normally false
+					triggerEditorInitEvent()
 					initSpellCheck()
 					initSpellCheck()
 
 
 					resetScrollMargins()
 					resetScrollMargins()
@@ -468,6 +496,7 @@ define [
 					editor.focus()
 					editor.focus()
 
 
 				detachFromAce = (sharejs_doc) ->
 				detachFromAce = (sharejs_doc) ->
+					tearDownSpellCheck()
 					sharejs_doc.detachFromAce()
 					sharejs_doc.detachFromAce()
 					sharejs_doc.off "remoteop.recordRemote"
 					sharejs_doc.off "remoteop.recordRemote"
 
 
@@ -488,9 +517,11 @@ define [
 				scope.$on '$destroy', () ->
 				scope.$on '$destroy', () ->
 					if scope.sharejsDoc?
 					if scope.sharejsDoc?
 						tearDownSpellCheck()
 						tearDownSpellCheck()
+						tearDownCursorPosition()
 						detachFromAce(scope.sharejsDoc)
 						detachFromAce(scope.sharejsDoc)
 						session = editor.getSession()
 						session = editor.getSession()
 						session?.destroy()
 						session?.destroy()
+						scope.eventsBridge.emit "aceScrollbarVisibilityChanged", false, 0
 
 
 				scope.$emit "#{scope.name}:inited", editor
 				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()
 			cursorPosition = @editor.getCursorPosition()
 			end = change.end
 			end = change.end
 			{lineUpToCursor, commandFragment} = Helpers.getContext(@editor, end)
 			{lineUpToCursor, commandFragment} = Helpers.getContext(@editor, end)
-			if lineUpToCursor.match(/.*%.*/)
+			if ((i = lineUpToCursor.indexOf('%')) > -1 and lineUpToCursor[i-1] != '\\')
+				console.log lineUpToCursor, i
 				return
 				return
 			lastCharIsBackslash = lineUpToCursor.slice(-1) == "\\"
 			lastCharIsBackslash = lineUpToCursor.slice(-1) == "\\"
 			lastTwoChars = lineUpToCursor.slice(-2)
 			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
 	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) =>
 			@$scope.$on "#{@$scope.name}:gotoLine", (e, line, column) =>
 				if line?
 				if line?
 					setTimeout () =>
 					setTimeout () =>
-						@gotoLine(line, column)
+						@adapter.gotoLine(line, column)
 					, 10 # Hack: Must happen after @gotoStoredPosition
 					, 10 # Hack: Must happen after @gotoStoredPosition
 			
 			
 			@$scope.$on "#{@$scope.name}:gotoOffset", (e, offset) =>
 			@$scope.$on "#{@$scope.name}:gotoOffset", (e, offset) =>
 				if offset?
 				if offset?
 					setTimeout () =>
 					setTimeout () =>
-						@gotoOffset(offset)
+						@adapter.gotoOffset(offset)
 					, 10 # Hack: Must happen after @gotoStoredPosition
 					, 10 # Hack: Must happen after @gotoStoredPosition
 
 
 			@$scope.$on "#{@$scope.name}:clearSelection", (e) =>
 			@$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?
 			if @doc_id?
 				docPosition = @localStorage("doc.position.#{@doc_id}") || {}
 				docPosition = @localStorage("doc.position.#{@doc_id}") || {}
-				docPosition.scrollTop = session.getScrollTop()
+				docPosition.firstVisibleLine = @adapter.getEditorScrollPosition()
 				@localStorage("doc.position.#{@doc_id}", docPosition)
 				@localStorage("doc.position.#{@doc_id}", docPosition)
 
 
-		storeCursorPosition: (session) ->
+		storeCursorPosition: () ->
 			if @doc_id?
 			if @doc_id?
 				docPosition = @localStorage("doc.position.#{@doc_id}") || {}
 				docPosition = @localStorage("doc.position.#{@doc_id}") || {}
-				docPosition.cursorPosition = session.selection.getCursor()
+				docPosition.cursorPosition = @adapter.getCursor()
 				@localStorage("doc.position.#{@doc_id}", docPosition)
 				@localStorage("doc.position.#{@doc_id}", docPosition)
-			
+
 		emitCursorUpdateEvent: () ->
 		emitCursorUpdateEvent: () ->
-			cursor = @editor.getCursorPosition()
+			cursor = @adapter.getCursor()
 			@$scope.$emit "cursor:#{@$scope.name}:update", cursor
 			@$scope.$emit "cursor:#{@$scope.name}:update", cursor
 
 
 		gotoStoredPosition: () ->
 		gotoStoredPosition: () ->
 			return if !@doc_id?
 			return if !@doc_id?
 			pos = @localStorage("doc.position.#{@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/HistoryV2ListController"
 	"ide/history/controllers/HistoryV2DiffController"
 	"ide/history/controllers/HistoryV2DiffController"
 	"ide/history/controllers/HistoryV2FileTreeController"
 	"ide/history/controllers/HistoryV2FileTreeController"
+	"ide/history/controllers/HistoryV2ToolbarController"
+	"ide/history/controllers/HistoryV2AddLabelModalController"
+	"ide/history/controllers/HistoryV2DeleteLabelModalController"
 	"ide/history/directives/infiniteScroll"
 	"ide/history/directives/infiniteScroll"
 	"ide/history/components/historyEntriesList"
 	"ide/history/components/historyEntriesList"
 	"ide/history/components/historyEntry"
 	"ide/history/components/historyEntry"
+	"ide/history/components/historyLabelsList"
+	"ide/history/components/historyLabel"
 	"ide/history/components/historyFileTree"
 	"ide/history/components/historyFileTree"
 	"ide/history/components/historyFileEntity"
 	"ide/history/components/historyFileEntity"
 ], (moment, ColorManager, displayNameForUser, HistoryViewModes) ->
 ], (moment, ColorManager, displayNameForUser, HistoryViewModes) ->
@@ -22,6 +27,9 @@ define [
 					@hide()
 					@hide()
 				else
 				else
 					@show()
 					@show()
+				@ide.$timeout () =>
+					@$scope.$broadcast "history:toggle"
+				, 0
 
 
 			@$scope.toggleHistoryViewMode = () =>
 			@$scope.toggleHistoryViewMode = () =>
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
@@ -30,6 +38,9 @@ define [
 				else
 				else
 					@reset()
 					@reset()
 					@$scope.history.viewMode = HistoryViewModes.COMPARE
 					@$scope.history.viewMode = HistoryViewModes.COMPARE
+				@ide.$timeout () =>
+					@$scope.$broadcast "history:toggle"
+				, 0
 
 
 			@$scope.$watch "history.selection.updates", (updates) =>
 			@$scope.$watch "history.selection.updates", (updates) =>
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
@@ -44,6 +55,18 @@ define [
 				else 
 				else 
 					@reloadDiff()
 					@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: () ->
 		show: () ->
 			@$scope.ui.view = "history"
 			@$scope.ui.view = "history"
 			@reset()
 			@reset()
@@ -60,6 +83,7 @@ define [
 				nextBeforeTimestamp: null
 				nextBeforeTimestamp: null
 				atEnd: false
 				atEnd: false
 				selection: {
 				selection: {
+					label: null
 					updates: []
 					updates: []
 					docs: {}
 					docs: {}
 					pathname: null
 					pathname: null
@@ -68,6 +92,9 @@ define [
 						toV: null
 						toV: null
 					}
 					}
 				}
 				}
+				error: null
+				showOnlyLabels: false
+				labels: null
 				files: []
 				files: []
 				diff: null # When history.viewMode == HistoryViewModes.COMPARE
 				diff: null # When history.viewMode == HistoryViewModes.COMPARE
 				selectedFile: null # When history.viewMode == HistoryViewModes.POINT_IN_TIME
 				selectedFile: null # When history.viewMode == HistoryViewModes.POINT_IN_TIME
@@ -81,10 +108,9 @@ define [
 				_csrf: window.csrfToken
 				_csrf: window.csrfToken
 			})
 			})
 
 
-		loadFileTreeForUpdate: (update) ->
-			{fromV, toV} = update
+		loadFileTreeForVersion: (version) ->
 			url = "/project/#{@$scope.project_id}/filetree/diff"
 			url = "/project/#{@$scope.project_id}/filetree/diff"
-			query = [ "from=#{toV}", "to=#{toV}" ]
+			query = [ "from=#{version}", "to=#{version}" ]
 			url += "?" + query.join("&")
 			url += "?" + query.join("&")
 			@$scope.history.loadingFileTree = true
 			@$scope.history.loadingFileTree = true
 			@$scope.history.selectedFile = null
 			@$scope.history.selectedFile = null
@@ -113,6 +139,10 @@ define [
 			return if @$scope.history.updates.length == 0
 			return if @$scope.history.updates.length == 0
 			@selectUpdate @$scope.history.updates[0]
 			@selectUpdate @$scope.history.updates[0]
 
 
+		autoSelectLastLabel: () ->
+			return if @$scope.history.labels.length == 0
+			@selectLabel @$scope.history.labels[0]
+			
 		selectUpdate: (update) ->
 		selectUpdate: (update) ->
 			selectedUpdateIndex = @$scope.history.updates.indexOf update
 			selectedUpdateIndex = @$scope.history.updates.indexOf update
 			if selectedUpdateIndex == -1
 			if selectedUpdateIndex == -1
@@ -122,28 +152,107 @@ define [
 				update.selectedFrom = false
 				update.selectedFrom = false
 			@$scope.history.updates[selectedUpdateIndex].selectedTo = true
 			@$scope.history.updates[selectedUpdateIndex].selectedTo = true
 			@$scope.history.updates[selectedUpdateIndex].selectedFrom = 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
 		BATCH_SIZE: 10
 		fetchNextBatchOfUpdates: () ->
 		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?
 			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.loading = true
 			@$scope.history.loadingFileTree = 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) =>
 				.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.atEnd = true
 					@$scope.history.loading = false
 					@$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: () ->
 		loadFileAtPointInTime: () ->
 			pathname = @$scope.history.selection.pathname
 			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"
 			url = "/project/#{@$scope.project_id}/diff"
 			query = ["pathname=#{encodeURIComponent(pathname)}", "from=#{toV}", "to=#{toV}"]
 			query = ["pathname=#{encodeURIComponent(pathname)}", "from=#{toV}", "to=#{toV}"]
 			url += "?" + query.join("&")
 			url += "?" + query.join("&")
@@ -199,6 +308,32 @@ define [
 					diff.loading = false
 					diff.loading = false
 					diff.error = true
 					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) ->
 		_parseDiff: (diff) ->
 			if diff.binary
 			if diff.binary
 				return { binary: true }
 				return { binary: true }
@@ -276,7 +411,27 @@ define [
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
 					@autoSelectRecentUpdates()
 					@autoSelectRecentUpdates()
 				else 
 				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) ->
 		_perDocSummaryOfUpdates: (updates) ->
 			# Track current_pathname -> original_pathname
 			# Track current_pathname -> original_pathname

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

@@ -3,17 +3,35 @@ define [
 ], (App) ->
 ], (App) ->
 	historyEntriesListController = ($scope, $element, $attrs) ->
 	historyEntriesListController = ($scope, $element, $attrs) ->
 		ctrl = @ 
 		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
 		return
 
 
 	App.component "historyEntriesList", {
 	App.component "historyEntriesList", {
 		bindings:
 		bindings:
 			entries: "<"
 			entries: "<"
+			users: "<"
 			loadEntries: "&"
 			loadEntries: "&"
 			loadDisabled: "<"
 			loadDisabled: "<"
 			loadInitialize: "<"
 			loadInitialize: "<"
 			isLoading: "<"
 			isLoading: "<"
 			currentUser: "<"
 			currentUser: "<"
 			onEntrySelect: "&"
 			onEntrySelect: "&"
+			onLabelDelete: "&"
 		controller: historyEntriesListController
 		controller: historyEntriesListController
 		templateUrl: "historyEntriesListTpl"
 		templateUrl: "historyEntriesListTpl"
 	}
 	}

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

@@ -1,27 +1,44 @@
 define [
 define [
 	"base"
 	"base"
+	"ide/colors/ColorManager"
 	"ide/history/util/displayNameForUser"
 	"ide/history/util/displayNameForUser"
-], (App, displayNameForUser) ->
-	historyEntryController = ($scope, $element, $attrs) ->
+], (App, ColorManager, displayNameForUser) ->
+	historyEntryController = ($scope, $element, $attrs, _) ->
 		ctrl = @
 		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.displayName = displayNameForUser
+		ctrl.displayNameById = (id) ->
+			displayNameForUser(_getUserById(id))
 		ctrl.getProjectOpDoc = (projectOp) ->
 		ctrl.getProjectOpDoc = (projectOp) ->
 			if projectOp.rename? then "#{ projectOp.rename.pathname} → #{ projectOp.rename.newPathname }"
 			if projectOp.rename? then "#{ projectOp.rename.pathname} → #{ projectOp.rename.newPathname }"
 			else if projectOp.add? then "#{ projectOp.add.pathname}"
 			else if projectOp.add? then "#{ projectOp.add.pathname}"
 			else if projectOp.remove? then "#{ projectOp.remove.pathname}"
 			else if projectOp.remove? then "#{ projectOp.remove.pathname}"
 		ctrl.getUserCSSStyle = (user) ->
 		ctrl.getUserCSSStyle = (user) ->
-			hue = user?.hue or 100
+			curUserId = user?._id or user?.id
+			hue = ColorManager.getHueForUserId(curUserId) or 100
 			if ctrl.entry.inSelection 
 			if ctrl.entry.inSelection 
 				color : "#FFF" 
 				color : "#FFF" 
 			else 
 			else 
 				color: "hsl(#{ hue }, 70%, 50%)"
 				color: "hsl(#{ hue }, 70%, 50%)"
+		ctrl.$onInit = () ->
+			ctrl.historyEntriesList.onEntryLinked ctrl.entry, $element.find "> .history-entry"
 		return
 		return
 
 
 	App.component "historyEntry", {
 	App.component "historyEntry", {
 		bindings:
 		bindings:
 			entry: "<"
 			entry: "<"
 			currentUser: "<"
 			currentUser: "<"
+			users: "<"
 			onSelect: "&"
 			onSelect: "&"
+			onLabelDelete: "&"
+		require:
+			historyEntriesList: '^historyEntriesList'
 		controller: historyEntryController
 		controller: historyEntryController
 		templateUrl: "historyEntryTpl"
 		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"
 	"ide/history/util/displayNameForUser"
 ], (App, displayNameForUser) ->
 ], (App, displayNameForUser) ->
 
 
-	App.controller "HistoryListController", ["$scope", "ide", ($scope, ide) ->
+	App.controller "HistoryListController", ["$scope", "$modal", "ide", ($scope, $modal, ide) ->
 		$scope.hoveringOverListSelectors = false
 		$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 = () =>
 		$scope.loadMore = () =>
 			ide.historyManager.fetchNextBatchOfUpdates()
 			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"
 	"ide/history/util/displayNameForUser"
 ], (App, displayNameForUser) ->
 ], (App, displayNameForUser) ->
 
 
-	App.controller "HistoryV2ListController", ["$scope", "ide", ($scope, ide) ->
+	App.controller "HistoryV2ListController", ["$scope", "$modal", "ide", ($scope, $modal, ide) ->
 		$scope.hoveringOverListSelectors = false
 		$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 = () =>
 		$scope.loadMore = () =>
 			ide.historyManager.fetchNextBatchOfUpdates()
 			ide.historyManager.fetchNextBatchOfUpdates()
 
 
 		$scope.handleEntrySelect = (entry) ->
 		$scope.handleEntrySelect = (entry) ->
-			# $scope.$applyAsync () ->
 			ide.historyManager.selectUpdate(entry)
 			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.
 					# don't want to compile as soon as it is fixed, so reset the timeout.
 					$scope.startedTryingAutoCompileAt = Date.now()
 					$scope.startedTryingAutoCompileAt = Date.now()
 					$scope.docLastChangedAt = Date.now()
 					$scope.docLastChangedAt = Date.now()
-			if autoCompileLintingError
+			if autoCompileLintingError and $scope.stop_on_validation_error
 				return
 				return
 
 
 			# If there's a longish compile, don't compile immediately after if user is still typing
 			# If there's a longish compile, don't compile immediately after if user is still typing
@@ -224,6 +224,13 @@ define [
 				_csrf: window.csrfToken
 				_csrf: window.csrfToken
 			}, {params: params}
 			}, {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) ->
 		parseCompileResponse = (response) ->
 
 
 			# keep last url
 			# keep last url
@@ -244,11 +251,7 @@ define [
 			$scope.pdf.compileInProgress = false
 			$scope.pdf.compileInProgress = false
 			$scope.pdf.autoCompileDisabled = false
 			$scope.pdf.autoCompileDisabled = false
 
 
-			buildPdfDownloadUrl = (path)->
-				if pdfDownloadDomain?
-					return "#{pdfDownloadDomain}#{path}"
-				else
-					return path
+
 			# make a cache to look up files by name
 			# make a cache to look up files by name
 			fileByPath = {}
 			fileByPath = {}
 			if response?.outputFiles?
 			if response?.outputFiles?
@@ -267,24 +270,24 @@ define [
 			if response.status == "timedout"
 			if response.status == "timedout"
 				$scope.pdf.view = 'errors'
 				$scope.pdf.view = 'errors'
 				$scope.pdf.timedout = true
 				$scope.pdf.timedout = true
-				fetchLogs(fileByPath)
+				fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain})
 			else if response.status == "terminated"
 			else if response.status == "terminated"
 				$scope.pdf.view = 'errors'
 				$scope.pdf.view = 'errors'
 				$scope.pdf.compileTerminated = true
 				$scope.pdf.compileTerminated = true
-				fetchLogs(fileByPath)
+				fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain})
 			else if response.status in ["validation-fail", "validation-pass"]
 			else if response.status in ["validation-fail", "validation-pass"]
 				$scope.pdf.view = 'pdf'
 				$scope.pdf.view = 'pdf'
-				$scope.pdf.url = buildPdfDownloadUrl last_pdf_url
+				$scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, last_pdf_url
 				$scope.shouldShowLogs = true
 				$scope.shouldShowLogs = true
 				$scope.pdf.failedCheck = true if response.status is "validation-fail"
 				$scope.pdf.failedCheck = true if response.status is "validation-fail"
 				event_tracking.sendMB "syntax-check-#{response.status}"
 				event_tracking.sendMB "syntax-check-#{response.status}"
-				fetchLogs(fileByPath, { validation: true })
+				fetchLogs(fileByPath, { validation: true, pdfDownloadDomain:pdfDownloadDomain})
 			else if response.status == "exited"
 			else if response.status == "exited"
 				$scope.pdf.view = 'pdf'
 				$scope.pdf.view = 'pdf'
 				$scope.pdf.compileExited = true
 				$scope.pdf.compileExited = true
-				$scope.pdf.url = buildPdfDownloadUrl last_pdf_url
+				$scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, last_pdf_url
 				$scope.shouldShowLogs = true
 				$scope.shouldShowLogs = true
-				fetchLogs(fileByPath)
+				fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain})
 			else if response.status == "autocompile-backoff"
 			else if response.status == "autocompile-backoff"
 				if $scope.pdf.isAutoCompileOnLoad # initial autocompile
 				if $scope.pdf.isAutoCompileOnLoad # initial autocompile
 					$scope.pdf.view = 'uncompiled'
 					$scope.pdf.view = 'uncompiled'
@@ -300,7 +303,7 @@ define [
 				$scope.pdf.view = 'errors'
 				$scope.pdf.view = 'errors'
 				$scope.pdf.failure = true
 				$scope.pdf.failure = true
 				$scope.shouldShowLogs = true
 				$scope.shouldShowLogs = true
-				fetchLogs(fileByPath)
+				fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain})
 			else if response.status == 'clsi-maintenance'
 			else if response.status == 'clsi-maintenance'
 				$scope.pdf.view = 'errors'
 				$scope.pdf.view = 'errors'
 				$scope.pdf.clsiMaintenance = true
 				$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
 				# 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?
 				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?
 				else if fileByPath['output.pdf']?.build?
 					build = 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
 				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)
 				# 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?
 				if not fileByPath['output.pdf']?.build?
 					qs.cache_bust = "#{Date.now()}"
 					qs.cache_bust = "#{Date.now()}"
@@ -335,7 +338,7 @@ define [
 				qs.popupDownload = true
 				qs.popupDownload = true
 				$scope.pdf.downloadUrl = "/project/#{$scope.project_id}/output/output.pdf" + createQueryString(qs)
 				$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"]
 			IGNORE_FILES = ["output.fls", "output.fdb_latexmk"]
 			$scope.pdf.outputFiles = []
 			$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)
 				# check if we need to bust cache (build id is unique so don't need it in that case)
 				if not file?.build?
 				if not file?.build?
 					opts.params.cache_bust = "#{Date.now()}"
 					opts.params.cache_bust = "#{Date.now()}"
+				opts.url = buildPdfDownloadUrl options.pdfDownloadDomain, opts.url
 				return $http(opts)
 				return $http(opts)
 
 
 			# accumulate the log entries
 			# accumulate the log entries
@@ -718,6 +722,8 @@ define [
 				.then (highlights) ->
 				.then (highlights) ->
 					$scope.pdf.highlights = highlights
 					$scope.pdf.highlights = highlights
 
 
+		ide.$scope.$on "cursor:editor:syncToPdf", $scope.syncToPdf
+
 		$scope.syncToCode = () ->
 		$scope.syncToCode = () ->
 			synctex
 			synctex
 				.syncToCode($scope.pdf.position, includeVisualOffset: true, fromPdfPosition: true)
 				.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()
 
 
 		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"
 				$reviewPanelEl.css "right", "#{ scrollbar.scrollbarWidth }px"
 			else
 			else
 				$reviewPanelEl.css "right", "0"
 				$reviewPanelEl.css "right", "0"

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

@@ -3,11 +3,13 @@ define [
 ], (App) ->
 ], (App) ->
 	# We create and provide this as service so that we can access the global ide
 	# We create and provide this as service so that we can access the global ide
 	# from within other parts of the angular app.
 	# 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 = {}
 		ide.$http = $http
 		ide.$http = $http
 		ide.queuedHttp = queuedHttp
 		ide.queuedHttp = queuedHttp
 		ide.$q = $q
 		ide.$q = $q
+		ide.$filter = $filter
+		ide.$timeout = $timeout
 
 
 		@recentEvents = []
 		@recentEvents = []
 		ide.pushEvent = (type, meta = {}) =>
 		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
 			if oldCompiler? and compiler != oldCompiler
 				settings.saveProjectSettings({compiler: compiler})
 				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) =>
 		$scope.$watch "project.rootDoc_id", (rootDoc_id, oldRootDoc_id) =>
 			return if @ignoreUpdates
 			return if @ignoreUpdates
 			# don't save on initialisation, Angular passes oldRootDoc_id as
 			# don't save on initialisation, Angular passes oldRootDoc_id as
@@ -83,6 +88,12 @@ define [
 				$scope.project.compiler = compiler
 				$scope.project.compiler = compiler
 			delete @ignoreUpdates
 			delete @ignoreUpdates
 
 
+		ide.socket.on "imageNameUpdated", (imageName) =>
+			@ignoreUpdates = true
+			$scope.$apply () =>
+				$scope.project.imageName = imageName
+			delete @ignoreUpdates
+
 		ide.socket.on "spellCheckLanguageUpdated", (languageCode) =>
 		ide.socket.on "spellCheckLanguageUpdated", (languageCode) =>
 			@ignoreUpdates = true
 			@ignoreUpdates = true
 			$scope.$apply () =>
 			$scope.$apply () =>

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

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

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

@@ -1,7 +1,7 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (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.subscribed = true
 
 
 		$scope.unsubscribe = () ->
 		$scope.unsubscribe = () ->
@@ -21,8 +21,14 @@ define [
 		$scope.deleteAccount = () ->
 		$scope.deleteAccount = () ->
 			modalInstance = $modal.open(
 			modalInstance = $modal.open(
 				templateUrl: "deleteAccountModalTemplate"
 				templateUrl: "deleteAccountModalTemplate"
-				controller: "DeleteAccountModalController",
-				scope: $scope
+				controller: "DeleteAccountModalController"
+				resolve:
+					userDefaultEmail: () ->
+						UserAffiliationsDataService
+							.getUserDefaultEmail()
+							.then (defaultEmailDetails) ->
+								return defaultEmailDetails?.email or null
+							.catch () -> null
 			)
 			)
 
 
 		$scope.upgradeIntegration = (service) ->
 		$scope.upgradeIntegration = (service) ->
@@ -30,8 +36,8 @@ define [
 	]
 	]
 
 
 	App.controller "DeleteAccountModalController", [
 	App.controller "DeleteAccountModalController", [
-		"$scope", "$modalInstance", "$timeout", "$http",
-		($scope,   $modalInstance,   $timeout,   $http) ->
+		"$scope", "$modalInstance", "$timeout", "$http", "userDefaultEmail",
+		($scope,   $modalInstance,   $timeout,   $http,   userDefaultEmail) ->
 			$scope.state =
 			$scope.state =
 				isValid : false
 				isValid : false
 				deleteText: ""
 				deleteText: ""
@@ -46,7 +52,7 @@ define [
 				, 700
 				, 700
 
 
 			$scope.checkValidation = ->
 			$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.delete = () ->
 				$scope.state.inflight = true
 				$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) ->
 	App.controller "UserAffiliationsController", ["$scope", "UserAffiliationsDataService", "$q", "_", ($scope, UserAffiliationsDataService, $q, _) ->
 	App.controller "UserAffiliationsController", ["$scope", "UserAffiliationsDataService", "$q", "_", ($scope, UserAffiliationsDataService, $q, _) ->
 		$scope.userEmails = []
 		$scope.userEmails = []
-		$scope.countries = []
-		$scope.universities = []
-		$scope.roles = []
-		$scope.departments = []
-
-		_defaultDepartments = []
 
 
 		LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/
 		LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/
 		EMAIL_REGEX = /^([A-Za-z0-9_\-\.]+)@([^\.]+)\.([A-Za-z0-9_\-\.]+)([^\.])$/
 		EMAIL_REGEX = /^([A-Za-z0-9_\-\.]+)@([^\.]+)\.([A-Za-z0-9_\-\.]+)([^\.])$/
@@ -20,9 +14,6 @@ define [
 			else
 			else
 				{ local: null, domain: null }
 				{ local: null, domain: null }
 
 
-		$scope.addUniversityToSelection = (universityName) -> 
-			{ name: universityName, isUserSuggested: true }
-
 		$scope.getEmailSuggestion = (userInput) ->
 		$scope.getEmailSuggestion = (userInput) ->
 			userInputLocalAndDomain = _matchLocalAndDomain(userInput)
 			userInputLocalAndDomain = _matchLocalAndDomain(userInput)
 			$scope.ui.isValidEmail = EMAIL_REGEX.test userInput
 			$scope.ui.isValidEmail = EMAIL_REGEX.test userInput
@@ -55,11 +46,40 @@ define [
 			$scope.newAffiliation.department = null
 			$scope.newAffiliation.department = null
 			$scope.ui.showManualUniversitySelectionUI = true
 			$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.showAddEmailForm = () ->
 			$scope.ui.showAddEmailUI = true
 			$scope.ui.showAddEmailUI = true
 
 
 		$scope.addNewEmail = () ->
 		$scope.addNewEmail = () ->
-			$scope.ui.isAddingNewEmail = true
 			if !$scope.newAffiliation.university?
 			if !$scope.newAffiliation.university?
 				addEmailPromise = UserAffiliationsDataService
 				addEmailPromise = UserAffiliationsDataService
 					.addUserEmail $scope.newAffiliation.email
 					.addUserEmail $scope.newAffiliation.email
@@ -81,86 +101,104 @@ define [
 							$scope.newAffiliation.role,
 							$scope.newAffiliation.role,
 							$scope.newAffiliation.department
 							$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 =
 			$scope.newAffiliation =
 				email: ""
 				email: ""
 				country: null
 				country: null
 				university: null
 				university: null
 				role: null
 				role: null
 				department: null
 				department: null
+
+		_resetAddingEmail = () ->
+			$scope.ui.showAddEmailUI = false
+			$scope.ui.isValidEmail = false
+			$scope.ui.isBlacklistedEmail = false
+			$scope.ui.showManualUniversitySelectionUI = false
+
+		_reset = () ->
 			$scope.ui = 
 			$scope.ui = 
-				showManualUniversitySelectionUI: false
+				hasError: false
+				errorMessage: ""
+				showChangeAffiliationUI: false
+				isMakingRequest: false
 				isLoadingEmails: false
 				isLoadingEmails: false
 				isAddingNewEmail: false
 				isAddingNewEmail: false
-				showAddEmailUI: false
-				isValidEmail: false
-				isBlacklistedEmail: false
+				isResendingConfirmation: false
+			_resetAffiliationToChange()
+			_resetNewAffiliation()
+			_resetAddingEmail()
 		_reset()
 		_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
 		# Populates the emails table
 		_getUserEmails = () ->
 		_getUserEmails = () ->
 			$scope.ui.isLoadingEmails = true
 			$scope.ui.isLoadingEmails = true
-			UserAffiliationsDataService
-				.getUserEmails() 
+			_monitorRequest(
+				UserAffiliationsDataService
+					.getUserEmails()
+			)
 				.then (emails) -> 
 				.then (emails) -> 
 					$scope.userEmails = emails
 					$scope.userEmails = emails
+				.finally () ->
 					$scope.ui.isLoadingEmails = false
 					$scope.ui.isLoadingEmails = false
 		_getUserEmails()
 		_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 = () ->
 		getDefaultDepartmentHints = () ->
 			$q.resolve defaultDepartmentHints
 			$q.resolve defaultDepartmentHints
 
 
-		getUserEmails = () ->
+		getUserEmails = () ->			
 			$http.get "/user/emails"
 			$http.get "/user/emails"
 				.then (response) -> response.data
 				.then (response) -> response.data
 
 
+		getUserDefaultEmail = () ->
+			getUserEmails().then (userEmails) ->
+				_.find userEmails, (userEmail) -> userEmail.default
+
 		getUniversitiesFromCountry = (country) ->
 		getUniversitiesFromCountry = (country) ->
 			if universities[country.code]?
 			if universities[country.code]?
 				universitiesFromCountry = universities[country.code]
 				universitiesFromCountry = universities[country.code]
@@ -53,6 +57,10 @@ define [
 						else
 						else
 							$q.reject null
 							$q.reject null
 
 
+		getUniversityDetails = (universityId) ->
+			$http.get "/institutions/list/#{ universityId }"
+				.then (response) -> response.data
+
 		addUserEmail = (email) ->
 		addUserEmail = (email) ->
 			$http.post "/user/emails", {
 			$http.post "/user/emails", {
 				email,
 				email,
@@ -80,8 +88,17 @@ define [
 				_csrf: window.csrfToken
 				_csrf: window.csrfToken
 			}
 			}
 
 
+		addRoleAndDepartment = (email, role, department) ->
+			$http.post "/user/emails/endorse", {
+				email,
+				role,
+				department,
+				_csrf: window.csrfToken
+			}
+
 		setDefaultUserEmail = (email) ->
 		setDefaultUserEmail = (email) ->
 			$http.post "/user/emails/default", {
 			$http.post "/user/emails/default", {
+				email,
 				_csrf: window.csrfToken
 				_csrf: window.csrfToken
 			}
 			}
 
 
@@ -91,6 +108,12 @@ define [
 				_csrf: window.csrfToken
 				_csrf: window.csrfToken
 			}
 			}
 
 
+		resendConfirmationEmail = (email) ->
+			$http.post "/user/emails/resend_confirmation", {
+				email,
+				_csrf: window.csrfToken
+			}
+
 		isDomainBlacklisted = (domain) ->
 		isDomainBlacklisted = (domain) ->
 			domain.toLowerCase() of domainsBlackList
 			domain.toLowerCase() of domainsBlackList
 
 
@@ -99,13 +122,17 @@ define [
 			getDefaultRoleHints
 			getDefaultRoleHints
 			getDefaultDepartmentHints
 			getDefaultDepartmentHints
 			getUserEmails
 			getUserEmails
+			getUserDefaultEmail
 			getUniversitiesFromCountry
 			getUniversitiesFromCountry
 			getUniversityDomainFromPartialDomainInput
 			getUniversityDomainFromPartialDomainInput
+			getUniversityDetails
 			addUserEmail
 			addUserEmail
 			addUserAffiliationWithUnknownUniversity
 			addUserAffiliationWithUnknownUniversity
 			addUserAffiliation
 			addUserAffiliation
+			addRoleAndDepartment
 			setDefaultUserEmail
 			setDefaultUserEmail
 			removeUserEmail
 			removeUserEmail
+			resendConfirmationEmail
 			isDomainBlacklisted
 			isDomainBlacklisted
 		}
 		}
 	]
 	]

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

@@ -1,5 +1,6 @@
 define [
 define [
 	"base"
 	"base"
+	"directives/mathjax"
 	"services/algolia-search"
 	"services/algolia-search"
 ], (App) ->
 ], (App) ->
 
 
@@ -55,4 +56,4 @@ define [
 					hits = _.map response.hits, buildHitViewModel
 					hits = _.map response.hits, buildHitViewModel
 					updateHits hits
 					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"
 	"libs/recurly-4.8.5"
 ], (App)->
 ], (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"
 		throw new Error("Recurly API Library Missing.")  if typeof recurly is "undefined"
 
 
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
 		$scope.plans = MultiCurrencyPricing.plans
 		$scope.plans = MultiCurrencyPricing.plans
 		$scope.planCode = window.plan_code
 		$scope.planCode = window.plan_code
+		$scope.plansVariant = ipCookie('plansVariant')
 
 
 		$scope.switchToStudent = ()->
 		$scope.switchToStudent = ()->
 			currentPlanCode = window.plan_code
 			currentPlanCode = window.plan_code
 			planCode = currentPlanCode.replace('collaborator', 'student')
 			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}"
 			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 =
 		$scope.paymentMethod =
 			value: "credit_card"
 			value: "credit_card"
@@ -143,13 +144,14 @@ define [
 					currencyCode	: postData.subscriptionDetails.currencyCode,
 					currencyCode	: postData.subscriptionDetails.currencyCode,
 					plan_code		: postData.subscriptionDetails.plan_code,
 					plan_code		: postData.subscriptionDetails.plan_code,
 					coupon_code		: postData.subscriptionDetails.coupon_code,
 					coupon_code		: postData.subscriptionDetails.coupon_code,
-					isPaypal		: postData.subscriptionDetails.isPaypal
+					isPaypal		: postData.subscriptionDetails.isPaypal,
+					variant			: $scope.plansVariant
 				}
 				}
 
 
 
 
 				$http.post("/user/subscription/create", postData)
 				$http.post("/user/subscription/create", postData)
 					.then ()->
 					.then ()->
-						event_tracking.sendMB "subscription-submission-success"
+						event_tracking.sendMB "subscription-submission-success", { variant: $scope.plansVariant }
 						window.location.href = "/user/subscription/thank-you"
 						window.location.href = "/user/subscription/thank-you"
 					.catch ()->
 					.catch ()->
 						$scope.processing = false
 						$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:'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:'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:'}
 			{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.showPlans = false
 		$scope.shouldABTestPlans = window.shouldABTestPlans
 		$scope.shouldABTestPlans = window.shouldABTestPlans
 
 
 		if $scope.shouldABTestPlans
 		if $scope.shouldABTestPlans
 			sixpack.participate 'plans-details', ['default', 'more-details'], (chosenVariation, rawResponse)->
 			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
 		$scope.showPlans = true
 
 
@@ -184,9 +190,9 @@ define [
 			if $scope.ui.view == "annual"
 			if $scope.ui.view == "annual"
 				plan = "#{plan}_annual"
 				plan = "#{plan}_annual"
 			plan = eventLabel(plan, location)
 			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
 			event_tracking.send 'subscription-funnel', 'sign_up_now_button', plan
-			if $scope.shouldABTestPlans
+			if $scope.plansVariant
 				sixpack.convert 'plans-details'
 				sixpack.convert 'plans-details'
 
 
 		$scope.switchToMonthly = (e, location) ->
 		$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) ->
 	App.controller 'DeleteProjectsModalController', ($scope, $modalInstance, $timeout, projects) ->
 		$scope.projectsToDelete = projects.filter (project) -> project.accessLevel == "owner"
 		$scope.projectsToDelete = projects.filter (project) -> project.accessLevel == "owner"
 		$scope.projectsToLeave = 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
 		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
 		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
 		else
 			$scope.action = "leave"
 			$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/hover.less";
 @import "components/ui-select.less";
 @import "components/ui-select.less";
 @import "components/input-suggestions.less";
 @import "components/input-suggestions.less";
+@import "components/nvd3.less";
+@import "components/nvd3_override.less";
 
 
 // Components w/ JavaScript
 // Components w/ JavaScript
 @import "components/modals.less";
 @import "components/modals.less";

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

@@ -24,18 +24,36 @@
 		width: 40%;
 		width: 40%;
 	}
 	}
 	.affiliations-table-inline-actions {
 	.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 {
 	.affiliations-table-highlighted-row {
 		background-color: tint(@content-alt-bg-color, 6%);
 		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 {
 	.affiliations-form-group {
 		margin-top: @table-cell-padding;
 		margin-top: @table-cell-padding;
 		&:first-child {
 		&:first-child {
 			margin-top: 0;
 			margin-top: 0;
 		}
 		}
 	}
 	}
+	.affiliation-change-container,
+	.affiliation-change-actions {
+		margin-top: @table-cell-padding;
+	}
+
 	.affiliations-table-label {
 	.affiliations-table-label {
 		padding-top: 4px;
 		padding-top: 4px;
 	}
 	}

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

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

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

@@ -15,16 +15,30 @@
 .history-toolbar when (@is-overleaf = false) {
 .history-toolbar when (@is-overleaf = false) {
 	border-bottom: @toolbar-border-bottom;
 	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 {
 .history-entries {
@@ -48,11 +62,76 @@
 	padding: 5px 10px;
 	padding: 5px 10px;
 	cursor: pointer;
 	cursor: pointer;
 
 
-	.history-entry-selected & {
+	.history-entry-selected &,
+	.history-entry-label-selected & {
 		background-color: @history-entry-selected-bg;
 		background-color: @history-entry-selected-bg;
 		color: #FFF;
 		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 {
 	.history-entry-changes {
 		.list-unstyled;
 		.list-unstyled;
 		margin-bottom: 3px;
 		margin-bottom: 3px;
@@ -68,7 +147,8 @@
 				color: @history-highlight-color;
 				color: @history-highlight-color;
 				font-weight: bold;
 				font-weight: bold;
 				word-break: break-all;
 				word-break: break-all;
-				.history-entry-selected & {
+				.history-entry-selected &,
+				.history-entry-label-selected & {
 					color: #FFF;
 					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 {
 .history-file-tree-inner {
 	.full-size;
 	.full-size;
 	overflow-y: auto;
 	overflow-y: auto;
@@ -169,330 +262,3 @@
 				color: @brand-primary;
 				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{
 		.wl-icon:before{
 				font-size: 14px;
 				font-size: 14px;
 		}
 		}
+		.btn-wrapping{
+				white-space: normal;
+		}
 		.button-as-link{
 		.button-as-link{
          color: green;
          color: green;
          text-transform: none;
          text-transform: none;

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

@@ -139,6 +139,10 @@
 		padding: 0 5px;
 		padding: 0 5px;
 	}
 	}
 
 
+	.rp-unsupported & {
+		display: none;
+	}
+
 	position: relative;
 	position: relative;
 	border-bottom: 1px solid @rp-border-grey;
 	border-bottom: 1px solid @rp-border-grey;
 	background-color: @rp-bg-dim-blue;
 	background-color: @rp-bg-dim-blue;
@@ -217,6 +221,10 @@
 	.rp-size-mini & {
 	.rp-size-mini & {
 		display: block;
 		display: block;
 	}
 	}
+
+	.rp-unsupported & {
+		display: none;
+	}
 	
 	
 	.rp-state-current-file & {
 	.rp-state-current-file & {
 		position: absolute;
 		position: absolute;
@@ -714,6 +722,10 @@
 		display: flex;
 		display: flex;
 	}
 	}
 
 
+	.rp-unsupported & {
+		display: none;
+	}
+
 	.rp-state-current-file & {
 	.rp-state-current-file & {
 		position: absolute;
 		position: absolute;
 		bottom: 0;
 		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 {
 .ace-editor-wrapper {
 	.track-changes-marker-callout {
 	.track-changes-marker-callout {
 		border-radius: 0;
 		border-radius: 0;
@@ -885,7 +922,7 @@
 	}
 	}
 }
 }
 
 
-.review-icon {
+.review-icon when (@is-overleaf = false) {
 	display: inline-block;
 	display: inline-block;
 	background: url('/img/review-icon-sprite.png') top/30px no-repeat;
 	background: url('/img/review-icon-sprite.png') top/30px no-repeat;
 	width: 30px;
 	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 & {
 	.rp-size-mini & {
 		right: @review-off-width;
 		right: @review-off-width;
 	}
 	}
-	.rp-size-expanded & {
+	.rp-size-expanded &,
+	.rp-unsupported & {
 		display: none;
 		display: none;
 	}
 	}
 }
 }
@@ -1053,6 +1098,10 @@
 		display: block;
 		display: block;
 	}
 	}
 
 
+	.rp-unsupported & {
+		display: none;
+	}
+
 	.rp-size-expanded & {
 	.rp-size-expanded & {
 		&::after {
 		&::after {
 			content: "\f105";
 			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 {
 .rich-text .CodeMirror {
-  font-family: @rt-font-family;
+  font-family: @rt-font-family-serif;
   font-size: 1.15em;
   font-size: 1.15em;
 
 
   pre {
   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- ?
   // 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 {
   .wl-indent-0 {
-    padding-left: 2.5em !important;
+    padding-left: calc(~"2.5em + @{rt-line-padding}") !important;
   }
   }
 
 
   .wl-indent-1 {
   .wl-indent-1 {
-    padding-left: 3.5em !important;
+    padding-left: calc(~"3.5em + @{rt-line-padding}") !important;
   }
   }
 
 
   .wl-indent-2 {
   .wl-indent-2 {
-    padding-left: 4.5em !important;
+    padding-left: calc(~"4.5em + @{rt-line-padding}") !important;
   }
   }
 
 
   .wl-indent-3 {
   .wl-indent-3 {
-    padding-left: 5.5em !important;
+    padding-left: calc(~"5.5em + @{rt-line-padding}") !important;
   }
   }
 
 
   .wl-indent-4 {
   .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 {
   .wl-indent-env-0 {
-    padding-left: 4px !important;
+    padding-left: calc(~"4px + @{rt-line-padding}") !important;
   }
   }
 
 
   .wl-indent-env-1 {
   .wl-indent-env-1 {
-    padding-left: 1.5em !important;
+    padding-left: calc(~"1.5em + @{rt-line-padding}") !important;
   }
   }
 
 
   .wl-indent-env-2 {
   .wl-indent-env-2 {
-    padding-left: 2.5em !important;
+    padding-left: calc(~"2.5em + @{rt-line-padding}") !important;
   }
   }
 
 
   .wl-indent-env-3 {
   .wl-indent-env-3 {
-    padding-left: 3.5em !important;
+    padding-left: calc(~"3.5em + @{rt-line-padding}") !important;
   }
   }
 
 
   .wl-indent-env-4 {
   .wl-indent-env-4 {
-    padding-left: 4.5em !important;
+    padding-left: calc(~"4.5em + @{rt-line-padding}") !important;
   }
   }
 
 
   .wl-enumerate-item-open {
   .wl-enumerate-item-open {
@@ -108,7 +121,7 @@
   .wl-figure-wrap {
   .wl-figure-wrap {
     padding: 10px 0;
     padding: 10px 0;
     background-color: #f5f5f5;
     background-color: #f5f5f5;
-    box-shadow: 2px 2px 2px #DFDFDF;
+    box-shadow: 1.3px 2px 2px #DFDFDF;
     width: 96%;
     width: 96%;
     margin: 0 auto;
     margin: 0 auto;
     text-align: center;
     text-align: center;

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov