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

Merge branch 'master' into hb-v2-affiliations-callback

Hugh O'Brien 8 лет назад
Родитель
Сommit
b825f0b267
59 измененных файлов с 1464 добавлено и 481 удалено
  1. 19 5
      services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
  2. 5 0
      services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee
  3. 5 0
      services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee
  4. 0 4
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  5. 12 0
      services/web/app/coffee/Features/Errors/ErrorController.coffee
  6. 16 0
      services/web/app/coffee/Features/Errors/Errors.coffee
  7. 5 4
      services/web/app/coffee/Features/Exports/ExportsController.coffee
  8. 2 3
      services/web/app/coffee/Features/Exports/ExportsHandler.coffee
  9. 9 0
      services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee
  10. 17 0
      services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
  11. 16 0
      services/web/app/coffee/Features/Project/SafePath.coffee
  12. 0 12
      services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
  13. 3 39
      services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
  14. 2 1
      services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
  15. 4 1
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  16. 6 3
      services/web/app/coffee/Features/User/UserGetter.coffee
  17. 3 10
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  18. 65 0
      services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee
  19. 85 0
      services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee
  20. 26 0
      services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee
  21. 45 0
      services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee
  22. 11 0
      services/web/app/coffee/models/Institution.coffee
  23. 0 1
      services/web/app/coffee/models/User.coffee
  24. 3 1
      services/web/app/coffee/router.coffee
  25. 20 0
      services/web/app/views/_metadata.pug
  26. 11 0
      services/web/app/views/general/account-merge-error.pug
  27. 2 2
      services/web/app/views/project/editor/pdf.pug
  28. 2 2
      services/web/app/views/project/editor/share.pug
  29. 17 3
      services/web/app/views/project/list/modals.pug
  30. 19 15
      services/web/app/views/user/settings.pug
  31. 13 15
      services/web/app/views/user_membership/index.pug
  32. 2 2
      services/web/config/settings.defaults.coffee
  33. 3 3
      services/web/npm-shrinkwrap.json
  34. 1 1
      services/web/package.json
  35. 5 1
      services/web/public/coffee/directives/asyncForm.coffee
  36. 17 0
      services/web/public/coffee/ide.coffee
  37. 4 17
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  38. 1 1
      services/web/public/coffee/main.coffee
  39. 9 5
      services/web/public/coffee/main/project-list/modal-controllers.coffee
  40. 17 8
      services/web/public/coffee/main/project-list/project-list.coffee
  41. 1 1
      services/web/public/coffee/main/subscription-dashboard.coffee
  42. 9 6
      services/web/public/coffee/main/user-membership.coffee
  43. 8 0
      services/web/public/stylesheets/app/homepage.less
  44. 35 28
      services/web/public/stylesheets/app/templates-v2.less
  45. 2 0
      services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee
  46. 44 0
      services/web/test/unit/coffee/Authentication/AuthenticationManagerTests.coffee
  47. 0 1
      services/web/test/unit/coffee/Compile/CompileControllerTests.coffee
  48. 2 2
      services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee
  49. 29 0
      services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee
  50. 400 198
      services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee
  51. 56 3
      services/web/test/unit/coffee/Project/SafePathTests.coffee
  52. 0 19
      services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee
  53. 0 46
      services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee
  54. 9 0
      services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
  55. 1 1
      services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee
  56. 16 17
      services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
  57. 95 0
      services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee
  58. 172 0
      services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee
  59. 83 0
      services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee

+ 19 - 5
services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee

@@ -3,6 +3,7 @@ User = require("../../models/User").User
 {db, ObjectId} = require("../../infrastructure/mongojs")
 crypto = require 'crypto'
 bcrypt = require 'bcrypt'
+EmailHelper = require("../Helpers/EmailHelper")
 
 BCRYPT_ROUNDS = Settings?.security?.bcryptRounds or 12
 
@@ -28,13 +29,26 @@ module.exports = AuthenticationManager =
 			else
 				callback null, null
 
-	setUserPassword: (user_id, password, callback = (error) ->) ->
+	validateEmail: (email) ->
+		parsed = EmailHelper.parseEmail(email)
+		if !parsed?
+			return { message: 'email not valid' }
+		return null
+
+	validatePassword: (password) ->
+		if !password?
+			return { message: 'password not set' }
 		if (Settings.passwordStrengthOptions?.length?.max? and
-				Settings.passwordStrengthOptions?.length?.max < password.length)
-			return callback("password is too long")
+				password.length > Settings.passwordStrengthOptions?.length?.max)
+			return { message: "password is too long" }
 		if (Settings.passwordStrengthOptions?.length?.min? and
-				Settings.passwordStrengthOptions?.length?.min > password.length)
-			return callback("password is too short")
+				password.length < Settings.passwordStrengthOptions?.length?.min)
+			return { message: 'password is too short' }
+		return null
+
+	setUserPassword: (user_id, password, callback = (error) ->) ->
+		validation = @validatePassword(password)
+		return callback(validation.message) if validation?
 
 		bcrypt.genSalt BCRYPT_ROUNDS, (error, salt) ->
 			return callback(error) if error?

+ 5 - 0
services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee

@@ -32,6 +32,11 @@ module.exports = _.template """
 				<%= secondaryMessage %>
 			</p>
 		<% } %>
+		<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table>
+		<p class="avoid-auto-linking" style="Margin: 0; Margin-bottom: 10px; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 12px; font-weight: normal; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
+			If the button above does not appear, please open the link in your browser here:<br>
+			<a href="<%= ctaURL %>"><%= ctaURL %></a>
+		</p>
 	</th>
 	<th class="expander" style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0 !important; text-align: left; visibility: hidden; width: 0;"></th></tr></table></th>
 </tr></tbody></table>

+ 5 - 0
services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee

@@ -32,6 +32,11 @@ module.exports = _.template """
 				<%= secondaryMessage %>
 			</p>
 		<% } %>
+		<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table>
+		<p class="avoid-auto-linking" style="Margin: 0; Margin-bottom: 10px; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 12px; font-weight: normal; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
+			If the button above does not appear, please open the link in your browser here:<br>
+			<a href="<%= ctaURL %>"><%= ctaURL %></a>
+		</p>
 	</th>
 	<th class="expander" style="Margin: 0; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0 !important; text-align: left; visibility: hidden; width: 0;"></th></tr></table></th>
 </tr></tbody></table>

+ 0 - 4
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -51,8 +51,6 @@ templates.accountMergeToOverleafAddress = CTAEmailTemplate({
 		"""
 	ctaText: () -> "Confirm Account Merge"
 	ctaURL: (opts) -> opts.tokenLinkUrl
-	secondaryMessage: (opts) ->
-		"If the button does not appear, open this link in your browser: #{opts.tokenLinkUrl}"
 })
 
 templates.accountMergeToSharelatexAddress = templates.accountMergeToOverleafAddress
@@ -97,8 +95,6 @@ templates.confirmEmail = CTAEmailTemplate({
 	title: () -> "Confirm Email"
 	message: () -> "Please confirm your email on #{settings.appName}."
 	ctaText: () -> "Confirm Email"
-	secondaryMessage: (opts) ->
-		"If the button does not appear, open this link in your browser: #{opts.confirmEmailUrl}"
 	ctaURL: (opts) -> opts.confirmEmailUrl
 })
 

+ 12 - 0
services/web/app/coffee/Features/Errors/ErrorController.coffee

@@ -13,6 +13,11 @@ module.exports = ErrorController =
 		res.render 'general/500',
 			title: "Server Error"
 
+	accountMergeError: (req, res)->
+		res.status(500)
+		res.render 'general/account-merge-error',
+			title: "Account Access Error"
+
 	handleError: (error, req, res, next) ->
 		user = AuthenticationController.getSessionUser(req)
 		if error?.code is 'EBADCSRFTOKEN'
@@ -25,10 +30,17 @@ module.exports = ErrorController =
 		else if error instanceof Errors.TooManyRequestsError
 			logger.warn {err: error, url: req.url}, "too many requests error"
 			res.sendStatus(429)
+		else if error instanceof Errors.InvalidError
+			logger.warn {err: error, url: req.url}, "invalid error"
+			res.status(400)
+			res.send(error.message)
 		else if error instanceof Errors.InvalidNameError
 			logger.warn {err: error, url: req.url}, "invalid name error"
 			res.status(400)
 			res.send(error.message)
+		else if error instanceof Errors.AccountMergeError
+			logger.error err: error, "account merge error"
+			ErrorController.accountMergeError req, res
 		else
 			logger.error err: error, url:req.url, method:req.method, user:user, "error passed to top level next middlewear"
 			ErrorController.serverError req, res

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

@@ -82,6 +82,20 @@ EmailExistsError = (message) ->
 	return error
 EmailExistsError.prototype.__proto__ = Error.prototype
 
+InvalidError = (message) ->
+	error = new Error(message)
+	error.name = "InvalidError"
+	error.__proto__ = InvalidError.prototype
+	return error
+InvalidError.prototype.__proto__ = Error.prototype
+
+AccountMergeError = (message) ->
+	error = new Error(message)
+	error.name = "AccountMergeError"
+	error.__proto__ = AccountMergeError.prototype
+	return error
+AccountMergeError.prototype.__proto__ = Error.prototype
+
 module.exports = Errors =
 	NotFoundError: NotFoundError
 	ServiceNotConfiguredError: ServiceNotConfiguredError
@@ -95,3 +109,5 @@ module.exports = Errors =
 	V1ConnectionError: V1ConnectionError
 	UnconfirmedEmailError: UnconfirmedEmailError
 	EmailExistsError: EmailExistsError
+	InvalidError: InvalidError
+	AccountMergeError: AccountMergeError

+ 5 - 4
services/web/app/coffee/Features/Exports/ExportsController.coffee

@@ -46,10 +46,11 @@ module.exports =
 			}
 			res.send export_json: json
 
-	exportZip: (req, res) ->
-		{export_id} = req.params
+	exportDownload: (req, res) ->
+		{type, export_id} = req.params
+
 		AuthenticationController.getLoggedInUserId(req)
-		ExportsHandler.fetchZip export_id, (err, export_zip_url) ->
+		ExportsHandler.fetchDownload export_id, type, (err, export_file_url) ->
 			return err if err?
 
-			res.redirect export_zip_url
+			res.redirect export_file_url

+ 2 - 3
services/web/app/coffee/Features/Exports/ExportsHandler.coffee

@@ -122,10 +122,9 @@ module.exports = ExportsHandler = self =
 				logger.err err:err, export:export_id, "v1 export returned failure status code: #{res.statusCode}"
 				callback err
 
-	fetchZip: (export_id, callback=(err, zip_url) ->) ->
-		console.log("#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/zip_url")
+	fetchDownload: (export_id, type, callback=(err, file_url) ->) ->
 		request.get {
-			url: "#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/zip_url"
+			url: "#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/#{type}_url"
 			auth: {user: settings.apis.v1.user, pass: settings.apis.v1.pass }
 		}, (err, res, body) ->
 			if err?

+ 9 - 0
services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee

@@ -0,0 +1,9 @@
+Institution = require('../../models/Institution').Institution
+logger = require("logger-sharelatex")
+ObjectId = require('mongoose').Types.ObjectId
+
+module.exports = InstitutionLocator =
+
+	findManagedInstitution: (managerId, callback)->
+		logger.log managerId: managerId, "finding managed Institution"
+		Institution.findOne managerIds: managerId, callback

+ 17 - 0
services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee

@@ -129,6 +129,8 @@ module.exports = ProjectEntityUpdateHandler = self =
 		Project.update {_id:project_id}, {$unset: {rootDoc_id: true}}, {}, callback
 
 	addDoc: wrapWithLock (project_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
+		if not SafePath.isCleanFilename docName
+			return callback new Errors.InvalidNameError("invalid element name")
 		self.addDocWithoutUpdatingHistory.withoutLock project_id, folder_id, docName, docLines, userId, (error, doc, folder_id, path, project) ->
 			return callback(error) if error?
 			projectHistoryId = project.overleaf?.history?.id
@@ -166,6 +168,8 @@ module.exports = ProjectEntityUpdateHandler = self =
 	addFile: wrapWithLock
 		beforeLock: (next) ->
 			(project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback) ->
+				if not SafePath.isCleanFilename fileName
+					return callback new Errors.InvalidNameError("invalid element name")
 				ProjectEntityUpdateHandler._uploadFile project_id, folder_id, fileName, fsPath, linkedFileData, userId, (error, fileRef, fileStoreUrl) ->
 					return callback(error) if error?
 					next(project_id, folder_id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback)
@@ -241,6 +245,8 @@ module.exports = ProjectEntityUpdateHandler = self =
 		# the history unless you are making sure it is updated in some other way.
 		beforeLock: (next) ->
 			(project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback) ->
+				if not SafePath.isCleanFilename fileName
+					return callback(new Errors.InvalidNameError("invalid element name"))
 				ProjectEntityUpdateHandler._uploadFile project_id, folder_id, fileName, fsPath, linkedFileData, userId, (error, fileRef, fileStoreUrl) ->
 					return callback(error) if error?
 					next(project_id, folder_id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback)
@@ -250,6 +256,8 @@ module.exports = ProjectEntityUpdateHandler = self =
 				callback(null, fileRef, folder_id, result?.path?.fileSystem, fileStoreUrl)
 
 	upsertDoc: wrapWithLock (project_id, folder_id, docName, docLines, source, userId, callback = (err, doc, folder_id, isNewDoc)->)->
+		if not SafePath.isCleanFilename docName
+			return callback new Errors.InvalidNameError("invalid element name")
 		ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) ->
 			return callback(error) if error?
 			return callback(new Error("Couldn't find folder")) if !folder?
@@ -272,6 +280,8 @@ module.exports = ProjectEntityUpdateHandler = self =
 	upsertFile: wrapWithLock
 		beforeLock: (next) ->
 			(project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback)->
+				if not SafePath.isCleanFilename fileName
+					return callback new Errors.InvalidNameError("invalid element name")
 				# create a new file
 				fileRef = new File(
 					name: fileName
@@ -301,6 +311,8 @@ module.exports = ProjectEntityUpdateHandler = self =
 						callback null, newFileRef, !existingFile?, existingFile
 
 	upsertDocWithPath: wrapWithLock (project_id, elementPath, docLines, source, userId, callback) ->
+		if not SafePath.isCleanPath elementPath
+			return callback new Errors.InvalidNameError("invalid element name")
 		docName = path.basename(elementPath)
 		folderPath = path.dirname(elementPath)
 		self.mkdirp.withoutLock project_id, folderPath, (err, newFolders, folder) ->
@@ -312,6 +324,8 @@ module.exports = ProjectEntityUpdateHandler = self =
 	upsertFileWithPath: wrapWithLock
 		beforeLock: (next) ->
 			(project_id, elementPath, fsPath, linkedFileData, userId, callback)->
+				if not SafePath.isCleanPath elementPath
+					return callback new Errors.InvalidNameError("invalid element name")
 				fileName = path.basename(elementPath)
 				folderPath = path.dirname(elementPath)
 				# create a new file
@@ -351,6 +365,9 @@ module.exports = ProjectEntityUpdateHandler = self =
 			self.deleteEntity.withoutLock project_id, element._id, type, userId, callback
 
 	mkdirp: wrapWithLock (project_id, path, callback = (err, newlyCreatedFolders, lastFolderInPath)->)->
+		for folder in path.split('/')
+			if folder.length > 0 and not SafePath.isCleanFilename folder
+				return callback new Errors.InvalidNameError("invalid element name")
 		ProjectEntityMongoUpdateHandler.mkdirp project_id, path, callback
 
 	addFolder: wrapWithLock (project_id, parentFolder_id, folderName, callback) ->

+ 16 - 0
services/web/app/coffee/Features/Project/SafePath.coffee

@@ -52,6 +52,7 @@ load = () ->
 	MAX_PATH = 1024 # Maximum path length, in characters. This is fairly arbitrary.
 
 	SafePath =
+		# convert any invalid characters to underscores in the given filename
 		clean: (filename) ->
 			filename = filename.replace BADCHAR_RX, '_'
 			# for BADFILE_RX replace any matches with an equal number of underscores
@@ -61,12 +62,27 @@ load = () ->
 			filename = filename.replace BLOCKEDFILE_RX, "@$1"
 			return filename
 
+		# returns whether the filename is 'clean' (does not contain any invalid
+		# characters or reserved words)
 		isCleanFilename: (filename) ->
 			return SafePath.isAllowedLength(filename) &&
 				!BADCHAR_RX.test(filename) &&
 				!BADFILE_RX.test(filename) &&
 				!BLOCKEDFILE_RX.test(filename)
 
+		# returns whether a full path is 'clean' - e.g. is a full or relative path
+		# that points to a file, and each element passes the rules in 'isCleanFilename'
+		isCleanPath: (path) ->
+			elements = path.split('/')
+
+			lastElementIsEmpty = elements[elements.length - 1].length == 0
+			return false if lastElementIsEmpty
+
+			for element in elements
+				return false if element.length > 0 && !SafePath.isCleanFilename element
+
+			return true
+
 		isAllowedLength: (pathname) ->
 			return pathname.length > 0 && pathname.length <= MAX_PATH
 

+ 0 - 12
services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee

@@ -52,18 +52,6 @@ module.exports =
 					return res.sendStatus 500
 				res.send()
 
-	renderSubscriptionGroupAdminPage: (req, res, next)->
-		user_id = AuthenticationController.getLoggedInUserId(req)
-		getManagedSubscription user_id, (error, subscription)->
-			return next(error) if error?
-			if !subscription?.groupPlan
-				return res.redirect("/user/subscription")
-			SubscriptionGroupHandler.getPopulatedListOfMembers subscription._id, (err, users)->
-				res.render "subscriptions/group_admin",
-					title: 'group_admin'
-					users: users
-					subscription: subscription
-
 	exportGroupCsv: (req, res)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log user_id: user_id, "exporting group csv"

+ 3 - 39
services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee

@@ -11,6 +11,7 @@ TeamInvitesHandler = require("./TeamInvitesHandler")
 EmailHandler = require("../Email/EmailHandler")
 settings = require("settings-sharelatex")
 NotificationsBuilder = require("../Notifications/NotificationsBuilder")
+UserMembershipViewModel = require("../UserMembership/UserMembershipViewModel")
 
 module.exports = SubscriptionGroupHandler =
 
@@ -31,12 +32,12 @@ module.exports = SubscriptionGroupHandler =
 							logger.err err:err, "error adding user to group"
 							return callback(err)
 						NotificationsBuilder.groupPlan(user, {subscription_id:subscription._id}).read()
-						userViewModel = buildUserViewModel(user)
+						userViewModel = UserMembershipViewModel.build(user)
 						callback(err, userViewModel)
 				else
 					TeamInvitesHandler.createInvite subscriptionId, newEmail, (err) ->
 						return callback(err) if err?
-						userViewModel = buildEmailInviteViewModel(newEmail)
+						userViewModel = UserMembershipViewModel.build(newEmail)
 						callback(err, userViewModel)
 
 	removeUserFromGroup: (subscriptionId, userToRemove_id, callback)->
@@ -51,28 +52,6 @@ module.exports = SubscriptionGroupHandler =
 
 				replaceInArray Subscription, "member_ids", oldId, newId, callback
 
-	getPopulatedListOfMembers: (subscriptionId, callback)->
-		SubscriptionLocator.getSubscription subscriptionId, (err, subscription)->
-			users = []
-
-			for email in subscription.invited_emails or []
-				users.push buildEmailInviteViewModel(email)
-
-			for teamInvite in subscription.teamInvites or []
-				users.push buildEmailInviteViewModel(teamInvite.email)
-
-			jobs = _.map subscription.member_ids, (user_id)->
-				return (cb)->
-					UserGetter.getUser user_id, (err, user)->
-						if err? or !user?
-							users.push _id:user_id
-							return cb()
-						userViewModel = buildUserViewModel(user)
-						users.push(userViewModel)
-						cb()
-			async.series jobs, (err)->
-				callback(err, users)
-
 	isUserPartOfGroup: (user_id, subscription_id, callback=(err, partOfGroup)->)->
 		SubscriptionLocator.getSubscriptionByMemberIdAndId user_id, subscription_id, (err, subscription)->
 			if subscription?
@@ -99,18 +78,3 @@ replaceInArray = (model, property, oldValue, newValue, callback) ->
 	model.update query, { $addToSet: setNewValue }, { multi: true }, (error) ->
 		return callback(error) if error?
 		model.update query, { $pull: setOldValue }, { multi: true }, callback
-
-buildUserViewModel = (user)->
-	u =
-		email: user.email
-		first_name: user.first_name
-		last_name: user.last_name
-		invite: user.holdingAccount
-		_id: user._id
-	return u
-
-buildEmailInviteViewModel = (email) ->
-	return {
-		email: email
-		invite: true
-	}

+ 2 - 1
services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee

@@ -20,7 +20,8 @@ module.exports =
 		webRouter.get  '/user/subscription/thank-you', AuthenticationController.requireLogin(), SubscriptionController.successful_subscription
 
 
-		webRouter.get '/subscription/group',  AuthenticationController.requireLogin(), SubscriptionGroupController.renderSubscriptionGroupAdminPage
+		webRouter.get '/subscription/group',  AuthenticationController.requireLogin(), (req, res, next) ->
+			res.redirect('/manage/group/members') # legacy route
 		webRouter.post '/subscription/group/user', AuthenticationController.requireLogin(),  SubscriptionGroupController.addUserToGroup
 		webRouter.get '/subscription/group/export',  AuthenticationController.requireLogin(), SubscriptionGroupController.exportGroupCsv
 		webRouter.delete '/subscription/group/user/:user_id', AuthenticationController.requireLogin(), SubscriptionGroupController.removeUserFromGroup

+ 4 - 1
services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee

@@ -55,7 +55,10 @@ module.exports = SubscriptionUpdater =
 			if err?
 				logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group"
 				return callback(err)
-			FeaturesUpdater.refreshFeatures user_id, callback
+			UserGetter.getUserOrUserStubById user_id, {}, (error, user, isStub) ->
+				return callback(error) if error
+				return callback() if isStub
+				FeaturesUpdater.refreshFeatures user_id, callback
 
 	deleteWithV1Id: (v1TeamId, callback)->
 		Subscription.deleteOne { "overleaf.id": v1TeamId }, callback

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

@@ -71,15 +71,18 @@ module.exports = UserGetter =
 		
 		db.users.find { _id: { $in: user_ids} }, projection, callback
 
-	getUserOrUserStubById: (user_id, projection, callback = (error, user) ->) ->
+	getUserOrUserStubById: (user_id, projection, callback = (error, user, isStub) ->) ->
 		try
 			query = _id: ObjectId(user_id.toString())
 		catch e
 			return callback(new Error(e))
 		db.users.findOne query, projection, (error, user) ->
 			return callback(error) if error?
-			return callback(null, user) if user?
-			db.userstubs.findOne query, projection, callback
+			return callback(null, user, false) if user?
+			db.userstubs.findOne query, projection, (error, user) ->
+				return callback(error) if error
+				return callback() if !user?
+				callback(null, user, true)
 
 	# check for duplicate email address. This is also enforced at the DB level
 	ensureUniqueEmailAddress: (newEmail, callback) ->

+ 3 - 10
services/web/app/coffee/Features/User/UserRegistrationHandler.coffee

@@ -13,17 +13,10 @@ settings = require "settings-sharelatex"
 EmailHelper = require("../Helpers/EmailHelper")
 
 module.exports = UserRegistrationHandler =
-	hasZeroLengths : (props) ->
-		hasZeroLength = false
-		props.forEach (prop) ->
-			if prop.length == 0
-				hasZeroLength = true
-		return hasZeroLength
-
 	_registrationRequestIsValid : (body, callback)->
-		email = EmailHelper.parseEmail(body.email) or ''
-		password = body.password
-		if @hasZeroLengths([password, email])
+		invalidEmail = AuthenticationManager.validateEmail(body.email or '')
+		invalidPassword = AuthenticationManager.validatePassword(body.password or '')
+		if invalidEmail? or invalidPassword?
 			return false
 		else
 			return true

+ 65 - 0
services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee

@@ -0,0 +1,65 @@
+AuthenticationController = require('../Authentication/AuthenticationController')
+UserMembershipHandler = require('./UserMembershipHandler')
+logger = require("logger-sharelatex")
+
+module.exports =
+	index: (entityName, req, res, next)->
+		userId = AuthenticationController.getLoggedInUserId(req)
+
+		UserMembershipHandler.getEntity entityName, userId, (error, entity)->
+			return next(error) if error?
+			UserMembershipHandler.getUsers entityName, entity, (error, users)->
+				return next(error) if error?
+				res.render "user_membership/index",
+					users: users
+					entity: entity
+					translations: getTranslationsFor(entityName)
+					paths: getPathsFor(entityName)
+
+	add: (entityName, req, res, next)->
+		userId = AuthenticationController.getLoggedInUserId(req)
+		email = req.body.email
+		return res.sendStatus 422 unless email
+
+		UserMembershipHandler.getEntity entityName, userId, (error, entity)->
+			return next(error) if error?
+			UserMembershipHandler.addUser entityName, entity, email, (error, user)->
+				return next(error) if error?
+				res.json(user: user)
+
+	remove: (entityName, req, res, next)->
+		loggedInUserId = AuthenticationController.getLoggedInUserId(req)
+		userId = req.params.userId
+
+		UserMembershipHandler.getEntity entityName, loggedInUserId, (error, entity)->
+			return next(error) if error?
+			UserMembershipHandler.removeUser entityName, entity, userId, (error, user)->
+				return next(error) if error?
+				res.send()
+
+getTranslationsFor = (entityName) ->
+	switch entityName
+		when 'group'
+			title: 'group_account'
+			remove: 'remove_from_group'
+		when 'groupManagers'
+			title: 'group_managers'
+			remove: 'remove_manager'
+		when 'institution'
+			title: 'institution_managers'
+			remove: 'remove_manager'
+
+
+getPathsFor = (entityName) ->
+	switch entityName
+		when 'group'
+			addMember: '/subscription/invites'
+			removeMember: '/subscription/group/user'
+			removeInvite: '/subscription/invites'
+			exportMembers: '/subscription/group/export'
+		when 'groupManagers'
+			addMember: "/manage/group/managers"
+			removeMember: "/manage/group/managers"
+		when 'institution'
+			addMember: "/manage/institution/managers"
+			removeMember: "/manage/institution/managers"

+ 85 - 0
services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee

@@ -0,0 +1,85 @@
+async = require("async")
+Errors = require('../Errors/Errors')
+SubscriptionLocator = require('../Subscription/SubscriptionLocator')
+InstitutionsLocator = require('../Institutions/InstitutionsLocator')
+UserMembershipViewModel = require('./UserMembershipViewModel')
+UserGetter = require('../User/UserGetter')
+logger = require('logger-sharelatex')
+
+module.exports =
+	getEntity: (entityName, userId, callback = (error, entity) ->) ->
+		switch entityName
+			when 'group' then getGroupSubscription(userId, callback)
+			when 'groupManagers'
+				getGroupSubscription userId, (error, subscription) ->
+					subscription.membersLimit = null if subscription # managers are unlimited
+					callback(error, subscription)
+			when 'institution' then getInstitution(userId, callback)
+			else callback(new Errors.NotFoundError("No such entity: #{entityName}"))
+
+	getUsers: (entityName, entity, callback = (error, users) ->) ->
+		attributes = switch entityName
+			when 'group' then ['invited_emails', 'teamInvites', 'member_ids']
+			when 'groupManagers' then ['manager_ids']
+			when 'institution' then ['managerIds']
+		getPopulatedListOfMembers(entity, attributes, callback)
+
+	addUser: (entityName, entity, email, callback = (error, user) ->) ->
+		attribute = switch entityName
+			when 'groupManagers' then 'manager_ids'
+			when 'institution' then 'managerIds'
+		unless attribute
+			return callback(new Errors.NotFoundError("Cannot add user to entity: #{entityName}"))
+		UserGetter.getUserByAnyEmail email, (error, user) ->
+			error ||= new Errors.NotFoundError("No user found with email #{email}") unless user
+			return callback(error) if error?
+			addUserToEntity entity, attribute, user, (error) ->
+				callback(error, UserMembershipViewModel.build(user))
+
+	removeUser: (entityName, entity, userId, callback = (error) ->) ->
+		attribute = switch entityName
+			when 'groupManagers' then 'manager_ids'
+			when 'institution' then 'managerIds'
+			else callback(new Errors.NotFoundError("Cannot remove user from entity: #{entityName}"))
+		removeUserFromEntity entity, attribute, userId, callback
+
+getGroupSubscription = (managerId, callback = (error, subscription) ->) ->
+	SubscriptionLocator.findManagedSubscription managerId, (err, subscription)->
+		if subscription? and subscription.groupPlan
+			logger.log managerId: managerId, 'got managed subscription'
+		else
+			err ||= new Errors.NotFoundError("No subscription found managed by user #{managerId}")
+
+		callback(err, subscription)
+
+getInstitution = (managerId, callback = (error, institution) ->) ->
+	InstitutionsLocator.findManagedInstitution managerId, (err, institution)->
+		if institution?
+			logger.log managerId: managerId, 'got managed subscription'
+		else
+			err ||= new Errors.NotFoundError("No institution found managed by user #{managerId}")
+
+		callback(err, institution)
+
+getPopulatedListOfMembers = (entity, attributes, callback = (error, users)->)->
+		userObjects = []
+
+		for attribute in attributes
+			for userObject in entity[attribute] or []
+				# userObject can be an email as String, a user id as ObjectId or an
+				# invite as Object with an email attribute as String. We want to pass to
+				# UserMembershipViewModel either an email as (String) or a user id (ObjectId)
+				userIdOrEmail = userObject.email || userObject
+				userObjects.push userIdOrEmail
+
+		async.map userObjects, UserMembershipViewModel.buildAsync, callback
+
+addUserToEntity = (entity, attribute, user, callback = (error)->) ->
+	fieldUpdate = {}
+	fieldUpdate[attribute] = user._id
+	entity.update { $addToSet: fieldUpdate }, callback
+
+removeUserFromEntity = (entity, attribute, userId, callback = (error)->) ->
+	fieldUpdate = {}
+	fieldUpdate[attribute] = userId
+	entity.update { $pull: fieldUpdate }, callback

+ 26 - 0
services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee

@@ -0,0 +1,26 @@
+AuthenticationController = require('../Authentication/AuthenticationController')
+UserMembershipController = require './UserMembershipController'
+
+module.exports =
+	apply: (webRouter) ->
+		webRouter.get '/manage/group/members',
+			AuthenticationController.requireLogin(),
+			(req, res, next) -> UserMembershipController.index('group', req, res, next)
+
+
+		regularEntitites =
+			group: 'groupManagers'
+			institution: 'institution'
+		for pathName, entityName of regularEntitites
+			do (pathName, entityName) ->
+				webRouter.get "/manage/#{pathName}/managers",
+					AuthenticationController.requireLogin(),
+					(req, res, next) -> UserMembershipController.index(entityName, req, res, next)
+
+				webRouter.post "/manage/#{pathName}/managers",
+					AuthenticationController.requireLogin(),
+					(req, res, next) -> UserMembershipController.add(entityName, req, res, next)
+
+				webRouter.delete "/manage/#{pathName}/managers/:userId",
+					AuthenticationController.requireLogin(),
+					(req, res, next) -> UserMembershipController.remove(entityName, req, res, next)

+ 45 - 0
services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee

@@ -0,0 +1,45 @@
+ObjectId = require('mongojs').ObjectId
+UserGetter = require('../User/UserGetter')
+
+module.exports = UserMembershipViewModel =
+	build: (userOrEmail) ->
+		if userOrEmail._id
+			buildUserViewModel userOrEmail
+		else
+			buildUserViewModelWithEmail userOrEmail
+
+
+	buildAsync: (userOrIdOrEmail, callback = (error, viewModel)->) ->
+		unless userOrIdOrEmail instanceof ObjectId
+			# userOrIdOrEmail is a user or an email and can be parsed by #build
+			return callback(null, UserMembershipViewModel.build(userOrIdOrEmail))
+
+		userId = userOrIdOrEmail
+		projection = { email: 1, first_name: 1, last_name: 1 }
+		UserGetter.getUserOrUserStubById userId, projection, (error, user, isStub) ->
+			if error? or !user?
+				return callback(null, buildUserViewModelWithId(userId.toString()))
+			if isStub
+				return callback(null, buildUserViewModelWithStub(user))
+			callback(null, buildUserViewModel(user))
+
+
+buildUserViewModel = (user, isInvite = false) ->
+	_id: user._id or null
+	email: user.email or null
+	first_name: user.first_name or null
+	last_name: user.last_name or null
+	invite: isInvite
+
+
+buildUserViewModelWithEmail = (email) ->
+	buildUserViewModel({ email }, true)
+
+
+buildUserViewModelWithStub = (user) ->
+	# user stubs behave as invites
+	buildUserViewModel(user, true)
+
+
+buildUserViewModelWithId = (id) ->
+	buildUserViewModel({ _id: id }, false)

+ 11 - 0
services/web/app/coffee/models/Institution.coffee

@@ -0,0 +1,11 @@
+mongoose = require 'mongoose'
+Schema = mongoose.Schema
+ObjectId = Schema.ObjectId
+
+InstitutionSchema = new Schema
+	v1Id: { type: Number, required: true }
+	managerIds: [ type:ObjectId, ref:'User' ]
+
+mongoose.model 'Institution', InstitutionSchema
+exports.Institution = mongoose.model 'Institution'
+exports.InstitutionSchema = InstitutionSchema

+ 0 - 1
services/web/app/coffee/models/User.coffee

@@ -20,7 +20,6 @@ UserSchema = new Schema
 	institution       : {type : String, default : ''}
 	hashedPassword    : String
 	isAdmin           : {type : Boolean, default : false}
-	confirmed         : {type : Boolean, default : false}
 	signUpDate        : {type : Date, default: () -> new Date() }
 	lastLoggedIn      : {type : Date}
 	lastLoginIp       : {type : String, default : ''}

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

@@ -51,6 +51,7 @@ Features = require('./infrastructure/Features')
 LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter'
 TemplatesRouter = require './Features/Templates/TemplatesRouter'
 InstitutionsController = require './Features/Institutions/InstitutionsController'
+UserMembershipRouter = require './Features/UserMembership/UserMembershipRouter'
 
 logger = require("logger-sharelatex")
 _ = require("underscore")
@@ -85,6 +86,7 @@ module.exports = class Router
 		AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter)
 		LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter)
 		TemplatesRouter.apply(webRouter)
+		UserMembershipRouter.apply(webRouter)
 
 		Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
 
@@ -248,7 +250,7 @@ module.exports = class Router
 
 		webRouter.post '/project/:project_id/export/:brand_variation_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportProject
 		webRouter.get '/project/:project_id/export/:export_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportStatus
-		webRouter.get '/project/:project_id/export/:export_id/zip', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportZip
+		webRouter.get '/project/:project_id/export/:export_id/:type', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportDownload
 
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects

+ 20 - 0
services/web/app/views/_metadata.pug

@@ -25,10 +25,16 @@
 
 //- Image
 -if (metadata && metadata.image)
+	//- from the CMS
 	meta(itemprop="image", name="image", content=metadata.image.fields.file.url)
+-else if (metadata && metadata.image_src)
+	//- pages with custom metadata images, metadata.image_src is the full image URL
+	meta(itemprop="image", name="image", content=metadata.image_src)
 -else if (settings.overleaf)
+	//- the default image for Overleaf
 	meta(itemprop="image", name="image", content=buildImgPath('ol-brand/overleaf_og_logo.png'))
 -else
+	//- the default image for ShareLaTeX
 	meta(itemprop="image", name="image", content='/touch-icon-192x192.png')
 
 //- Keywords
@@ -45,8 +51,15 @@ meta(name="twitter:card", content=metadata.twitterCardType ? metadata.twitterCar
 -if (metadata && metadata.twitterDescription)
 	meta(itemprop="twitter:description", content=metadata.twitterDescription)
 -if (metadata && metadata.twitterImage)
+	//- from the CMS
 	meta(itemprop="image", name="twitter:image", content=metadata.twitterImage.fields.file.url)
 	meta(itemprop="image", name="twitter:image:alt", content=metadata.twitterImage.fields.title)
+-else if (settings.overleaf)
+	//- the default image for Overleaf
+	meta(itemprop="image", name="twitter:image", content=buildImgPath('ol-brand/overleaf_og_logo.png'))
+-else
+	//- the default image for ShareLaTeX
+	meta(itemprop="image", name="twitter:image", content='/touch-icon-192x192.png')
 
 //- Open Graph
 //- to do - add og:url
@@ -55,7 +68,14 @@ meta(name="twitter:card", content=metadata.twitterCardType ? metadata.twitterCar
 -if (metadata && metadata.openGraphDescription)
 	meta(itemprop="description", name="og:description", content=metadata.openGraphDescription)
 -if (metadata && metadata.openGraphImage)
+	//- from the CMS
 	meta(itemprop="image", name="og:image", content=metadata.openGraphImage.fields.file.url)
+-else if (settings.overleaf)
+	//- the default image for Overleaf
+	meta(itemprop="image", name="og:image", content=buildImgPath('ol-brand/overleaf_og_logo.png'))
+-else
+	//- the default image for ShareLaTeX
+	meta(itemprop="image", name="og:image", content='/touch-icon-192x192.png')
 -if (metadata && metadata.openGraphType)
 	meta(name="og:type", metadata.openGraphType)
 -else

+ 11 - 0
services/web/app/views/general/account-merge-error.pug

@@ -0,0 +1,11 @@
+extends ../layout
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-6.col-md-offset-3
+					.card
+						.page-header
+							h1 Account Access Error
+						p.text-danger Sorry, an error occurred accessing your account. Please #[a(href="" ng-controller="ContactModal" ng-click="contactUsModal()") contact support] and provide any email addresses that you have used to sign in to Overleaf and/or ShareLaTeX for assistance.

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

@@ -106,7 +106,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				| #{translate("code_check_failed")}
 			a(
 				href,
-				ng-click="switchToFlatLayout()"
+				ng-click="switchToFlatLayout('pdf')"
 				ng-show="ui.pdfLayout == 'sideBySide'"
 				tooltip=translate('full_screen')
 				tooltip-placement="bottom"
@@ -116,7 +116,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				i.full-screen
 			a(
 				href,
-				ng-click="switchToSideBySideLayout()"
+				ng-click="switchToSideBySideLayout('editor')"
 				ng-show="ui.pdfLayout == 'flat'"
 				tooltip=translate('split_screen')
 				tooltip-placement="bottom"

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

@@ -19,7 +19,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 					) #{translate('turn_on_link_sharing')}
 					span &nbsp;&nbsp;
 					a(
-						href="/learn/Kb/what_is_link_sharing"
+						href="/learn/how-to/What_is_Link_Sharing%3F"
 						target="_blank"
 					)
 						i.fa.fa-question-circle(
@@ -38,7 +38,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 					) #{translate('turn_off_link_sharing')}
 					span &nbsp;&nbsp;
 					a(
-						href="/learn/Kb/what_is_link_sharing"
+						href="/learn/how-to/What_is_Link_Sharing%3F"
 						target="_blank"
 					)
 						i.fa.fa-question-circle(

+ 17 - 3
services/web/app/views/project/list/modals.pug

@@ -98,7 +98,7 @@ script(type='text/ng-template', id='renameProjectModalTemplate')
 		) &times;
 		h3 #{translate("rename_project")}
 	.modal-body
-		.alert.alert-danger(ng-show="state.error.message") {{ state.error.message}} 
+		.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(name="renameProjectForm", novalidate)
 			input.form-control(
@@ -127,7 +127,7 @@ script(type='text/ng-template', id='cloneProjectModalTemplate')
 		) &times;
 		h3 #{translate("copy_project")}
 	.modal-body
-		.alert.alert-danger(ng-show="state.error.message") {{ state.error.message}} 
+		.alert.alert-danger(ng-show="state.error.message") {{state.error.message === "invalid element name" ? "#{translate("invalid_element_name")}" : state.error.message}}
 		.alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")}
 		form(name="cloneProjectForm", novalidate)
 			.form-group
@@ -161,7 +161,7 @@ script(type='text/ng-template', id='newProjectModalTemplate')
 		) &times;
 		h3 #{translate("new_project")}
 	.modal-body
-		.alert.alert-danger(ng-show="state.error.message") {{ state.error.message}} 
+		.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(novalidate, name="newProjectForm")
 			input.form-control(
@@ -262,6 +262,20 @@ script(type="text/ng-template", id="uploadProjectModalTemplate")
 	.modal-footer
 		button.btn.btn-default(ng-click="cancel()") #{translate("cancel")}
 
+script(type="text/ng-template", id="showErrorModalTemplate")
+	.modal-header
+		button.close(
+			type="button"
+			data-dismiss="modal"
+			ng-click="cancel()"
+		) &times;
+		h3 #{translate("generic_something_went_wrong")}
+	.modal-body
+		.alert.alert-danger(ng-show="error.message") {{error.message === "invalid element name" ? "#{translate("invalid_element_name")}" : error.message}}
+		.alert.alert-danger(ng-show="error && !error.message") #{translate("generic_something_went_wrong")}
+	.modal-footer
+		button.btn.btn-default(ng-click="cancel()") #{translate("cancel")}
+
 script(type="text/ng-template", id="userProfileModalTemplate")
 	.modal-header
 		button.close(

+ 19 - 15
services/web/app/views/user/settings.pug

@@ -33,7 +33,7 @@ block content
 														required,
 														ng-model="email",
 														ng-init="email = "+JSON.stringify(user.email),
-														ng-model-options="{ updateOn: 'blur' }"
+														ng-model-options="{ pdateOn: 'blur' }"
 													)
 													span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty")
 														|  #{translate("must_be_email_address")}
@@ -73,11 +73,24 @@ block content
 												label.control-label #{translate("last_name")}
 												div.form-control(readonly="true") #{user.last_name}
 
-
-								if !externalAuthenticationSystemUsed()
-									.col-md-5.col-md-offset-1
-										h3 #{translate("change_password")}
-										form(async-form="changepassword", name="changePasswordForm", action="/user/password/update", method="POST", novalidate)
+								.col-md-5.col-md-offset-1
+									h3 #{translate("change_password")}
+									if externalAuthenticationSystemUsed() && !settings.overleaf
+										p
+											Password settings are managed externally
+									else
+										- var submitAction
+										if settings.overleaf
+											- submitAction = '/user/change_password/v1'
+										else
+											- submitAction = '/user/password/update'
+										form(
+											async-form="changepassword"
+											name="changePasswordForm"
+											action=submitAction
+											method="POST"
+											novalidate
+										)
 											input(type="hidden", name="_csrf", value=csrfToken)
 											.form-group
 												label(for='currentPassword') #{translate("current_password")}
@@ -121,15 +134,6 @@ block content
 													ng-disabled="changePasswordForm.$invalid"
 												) #{translate("change")}
 
-								else
-									if settings.overleaf && settings.createV1AccountOnLogin
-										.col-md-5.col-md-offset-1
-											h3 #{translate("change_password")}
-											p
-												| To change your password,
-												| please go to #[a(href='/sign_in_to_v1?return_to=/users/edit%23details') Overleaf v1 settings]
-
-
 							| !{moduleIncludes("userSettings", locals)}
 
 							//- The beta program doesn't make much sense to include while v2 is going

+ 13 - 15
services/web/app/views/subscriptions/group_admin.pug → services/web/app/views/user_membership/index.pug

@@ -5,16 +5,16 @@ block content
 		.container
 			.row
 				.col-md-10.col-md-offset-1
-					.card(ng-controller="SubscriptionGroupMembersController")
+					.card(ng-controller="UserMembershipController")
 						.page-header
 							.pull-right(ng-cloak)
-								small(ng-show="selectedUsers.length == 0") !{translate("you_have_added_x_of_group_size_y", {addedUsersSize:"<strong>{{ users.length }}</strong>", groupSize:"<strong>{{ groupSize }}</strong>"})}
+								small(ng-show="groupSize && selectedUsers.length == 0") !{translate("you_have_added_x_of_group_size_y", {addedUsersSize:"<strong>{{ users.length }}</strong>", groupSize:"<strong>{{ groupSize }}</strong>"})}
 								a.btn.btn-danger(
 									href,
 									ng-show="selectedUsers.length > 0"
 									ng-click="removeMembers()"
-								) #{translate("remove_from_group")}
-							h1 #{translate("group_account")}
+								) #{translate(translations.remove)}
+							h1 #{translate(translations.title)}
 
 						.row-spaced-small
 							ul.list-unstyled.structured-list(
@@ -35,7 +35,7 @@ block content
 											span.header #{translate("accepted_invite")}
 								li.container-fluid(
 									ng-repeat="user in users | orderBy:'email':true",
-									ng-controller="SubscriptionGroupMemberListItemController"
+									ng-controller="UserMembershipListItemController"
 								)
 									.row
 										.col-md-5
@@ -60,7 +60,7 @@ block content
 											small #{translate("no_members")}
 
 						hr
-						div(ng-if="users.length < groupSize", ng-cloak)
+						div(ng-if="!groupSize || users.length < groupSize", ng-cloak)
 							p.small #{translate("add_more_members")}
 							form.form
 								.row
@@ -74,18 +74,16 @@ block content
 										)
 									.col-xs-4
 										button.btn.btn-primary(ng-click="addMembers()") #{translate("add")}
-									.col-xs-2
-										a(href="/subscription/group/export") Export CSV
+									.col-xs-2(ng-if="paths.exportMembers", ng-cloak)
+										a(href=paths.exportMembers) Export CSV
 											
-						div(ng-if="users.length >= groupSize && users.length > 0", ng-cloak)
+						div(ng-if="groupSize && users.length >= groupSize && users.length > 0", ng-cloak)
 							.row
-								.col-xs-2.col-xs-offset-10
-									a(href="/subscription/group/export") Export CSV
+								.col-xs-2.col-xs-offset-10(ng-if="paths.exportMembers", ng-cloak)
+									a(href=paths.exportMembers) Export CSV
 									
 
 	script(type="text/javascript").
 		window.users = !{JSON.stringify(users)};
-		window.groupSize = #{subscription.membersLimit};
-
-
-		
+		window.paths = !{JSON.stringify(paths)};
+		window.groupSize = #{entity.membersLimit || 'null'};

+ 2 - 2
services/web/config/settings.defaults.coffee

@@ -91,7 +91,7 @@ module.exports = settings =
 	# running which conflict, or want to run the web process on port 80.
 	internal:
 		web:
-			port: webPort = 3000
+			port: webPort = process.env['WEB_PORT'] or 3000
 			host: process.env['LISTEN_ADDRESS'] or 'localhost'
 		documentupdater:
 			port: docUpdaterPort = 3003
@@ -192,7 +192,7 @@ module.exports = settings =
 	#clsiCookieKey: "clsiserver"
 
 	# Same, but with http auth credentials.
-	httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@localhost:3000'
+	httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@#{siteUrl}'
 
 
 	maxEntitiesPerProject: 2000

+ 3 - 3
services/web/npm-shrinkwrap.json

@@ -6962,9 +6962,9 @@
       "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz"
     },
     "metrics-sharelatex": {
-      "version": "1.7.1",
-      "from": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.7.1",
-      "resolved": "git+https://github.com/sharelatex/metrics-sharelatex.git#166961924c599b1f9468f2e17846fa2a9d12372d",
+      "version": "1.8.0",
+      "from": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.8.0",
+      "resolved": "git+https://github.com/sharelatex/metrics-sharelatex.git#e57f1a84539cdf0398d0768b7f7af0c79ea5b05b",
       "dependencies": {
         "coffee-script": {
           "version": "1.6.0",

+ 1 - 1
services/web/package.json

@@ -62,7 +62,7 @@
     "mailchimp-api-v3": "^1.12.0",
     "marked": "^0.3.5",
     "method-override": "^2.3.3",
-    "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.7.1",
+    "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.8.0",
     "minimist": "1.2.0",
     "mocha": "^5.0.1",
     "mongojs": "2.4.0",

+ 5 - 1
services/web/public/coffee/directives/asyncForm.coffee

@@ -70,7 +70,11 @@ define [
 								onErrorHandler(httpResponse)
 								return
 
-							if status == 403 # Forbidden
+							if status == 400 # Bad Request
+								response.message =
+									text: "Invalid Request. Please correct the data and try again."
+									type: 'error'
+							else if status == 403 # Forbidden
 								response.message =
 									text: "Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies."
 									type: "error"

+ 17 - 0
services/web/public/coffee/ide.coffee

@@ -190,6 +190,23 @@ define [
 		ide.localStorage = localStorage
 
 		ide.browserIsSafari = false
+
+		$scope.switchToFlatLayout = (view) ->
+			$scope.ui.pdfLayout = 'flat'
+			$scope.ui.view = view
+			ide.localStorage "pdf.layout", "flat"
+
+		$scope.switchToSideBySideLayout = (view) ->
+			$scope.ui.pdfLayout = 'sideBySide'
+			$scope.ui.view = view
+			localStorage "pdf.layout", "split"
+
+		if pdfLayout = localStorage("pdf.layout")
+			$scope.switchToSideBySideLayout() if pdfLayout == "split"
+			$scope.switchToFlatLayout() if pdfLayout == "flat"
+		else
+			$scope.switchToSideBySideLayout()
+
 		try
 			userAgent = navigator.userAgent
 			ide.browserIsSafari = (

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

@@ -71,7 +71,7 @@ define [
 
 		autoCompileInterval = null
 		autoCompileIfReady = () ->
-			if $scope.pdf.compiling
+			if $scope.pdf.compiling or !$scope.autocompile_enabled
 				return
 
 			# Only checking linting if syntaxValidation is on and visible to the user
@@ -125,6 +125,9 @@ define [
 
 		$scope.uncompiledChanges = false
 		recalculateUncompiledChanges = () ->
+			if !$scope.autocompile_enabled
+				# Auto-compile was disabled
+				$scope.uncompiledChanges = false
 			if $scope.ui.pdfHidden
 				# Don't bother auto-compiling if pdf isn't visible
 				$scope.uncompiledChanges = false
@@ -588,22 +591,6 @@ define [
 					{doc, line} = data
 					ide.editorManager.openDoc(doc, gotoLine: line)
 
-		$scope.switchToFlatLayout = () ->
-			$scope.ui.pdfLayout = 'flat'
-			$scope.ui.view = 'pdf'
-			ide.localStorage "pdf.layout", "flat"
-
-		$scope.switchToSideBySideLayout = () ->
-			$scope.ui.pdfLayout = 'sideBySide'
-			$scope.ui.view = 'editor'
-			localStorage "pdf.layout", "split"
-
-		if pdfLayout = localStorage("pdf.layout")
-			$scope.switchToSideBySideLayout() if pdfLayout == "split"
-			$scope.switchToFlatLayout() if pdfLayout == "flat"
-		else
-			$scope.switchToSideBySideLayout()
-
 	App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->
 		# enable per-user containers by default
 		perUserCompile = true

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

@@ -5,7 +5,7 @@ define [
 	"main/clear-sessions"
 	"main/account-upgrade"
 	"main/plans"
-	"main/group-members"
+	"main/user-membership"
 	"main/scribtex-popup"
 	"main/event"
 	"main/bonus"

+ 9 - 5
services/web/public/coffee/main/project-list/modal-controllers.coffee

@@ -2,9 +2,9 @@ define [
 	"base"
 ], (App) ->
 	App.controller 'RenameProjectModalController', ($scope, $modalInstance, $timeout, project, queuedHttp) ->
-		$scope.inputs = 
+		$scope.inputs =
 			projectName: project.name
-		
+
 		$scope.state =
 			inflight: false
 			error: false
@@ -35,7 +35,7 @@ define [
 			$modalInstance.dismiss('cancel')
 
 	App.controller 'CloneProjectModalController', ($scope, $modalInstance, $timeout, project) ->
-		$scope.inputs = 
+		$scope.inputs =
 			projectName: project.name + " (Copy)"
 		$scope.state =
 			inflight: false
@@ -66,7 +66,7 @@ define [
 			$modalInstance.dismiss('cancel')
 
 	App.controller 'NewProjectModalController', ($scope, $modalInstance, $timeout, template) ->
-		$scope.inputs = 
+		$scope.inputs =
 			projectName: ""
 		$scope.state =
 			inflight: false
@@ -123,7 +123,6 @@ define [
 		$scope.cancel = () ->
 			$modalInstance.dismiss('cancel')
 
-
 	App.controller 'UploadProjectModalController', ($scope, $modalInstance, $timeout) ->
 		$scope.cancel = () ->
 			$modalInstance.dismiss('cancel')
@@ -137,3 +136,8 @@ define [
 
 		$scope.dismiss = () ->
 			$modalInstance.dismiss('cancel')
+
+	App.controller 'ShowErrorModalController', ($scope, $modalInstance, error) ->
+		$scope.error = error
+		$scope.cancel = () ->
+			$modalInstance.dismiss('cancel')

+ 17 - 8
services/web/public/coffee/main/project-list/project-list.coffee

@@ -13,7 +13,7 @@ define [
 		$scope.predicate = "lastUpdated"
 		$scope.nUntagged = 0
 		$scope.reverse = true
-		$scope.searchText = 
+		$scope.searchText =
 			value : ""
 
 		$timeout () ->
@@ -37,7 +37,7 @@ define [
 		angular.element($window).bind "resize", () ->
 			recalculateProjectListHeight()
 			$scope.$apply()
-			
+
 		# Allow tags to be accessed on projects as well
 		projectsById = {}
 		for project in $scope.projects
@@ -56,7 +56,7 @@ define [
 					tag.selected = true
 				else
 					tag.selected = false
-		
+
 		$scope.changePredicate = (newPredicate)->
 			if $scope.predicate == newPredicate
 				$scope.reverse = !$scope.reverse
@@ -145,7 +145,7 @@ define [
 					# We don't want hidden selections
 					project.selected = false
 
-			localStorage("project_list", JSON.stringify({ 
+			localStorage("project_list", JSON.stringify({
 				filter: $scope.filter,
 				selectedTagId: selectedTag?._id
 			}))
@@ -461,7 +461,7 @@ define [
 				resolve:
 					project: () -> project
 			)
-			
+
 		if storedUIOpts?.filter?
 			if storedUIOpts.filter == "tag" and storedUIOpts.selectedTagId?
 				markTagAsSelected(storedUIOpts.selectedTagId)
@@ -505,7 +505,16 @@ define [
 			$scope.project.isTableActionInflight = true
 			$scope.cloneProject($scope.project, "#{$scope.project.name} (Copy)")
 				.then () -> $scope.project.isTableActionInflight = false
-				.catch () -> $scope.project.isTableActionInflight = false
+				.catch (response) ->
+					{ data, status } = response
+					error = if status == 400 then message: data else true
+					modalInstance = $modal.open(
+						templateUrl: "showErrorModalTemplate"
+						controller: "ShowErrorModalController"
+						resolve:
+							error: () -> error
+					)
+					$scope.project.isTableActionInflight = false
 
 		$scope.download = (e) ->
 			e.stopPropagation()
@@ -535,11 +544,11 @@ define [
 					url: "/project/#{$scope.project.id}?forever=true"
 					headers:
 						"X-CSRF-Token": window.csrfToken
-				}).then () -> 
+				}).then () ->
 					$scope.project.isTableActionInflight = false
 					$scope._removeProjectFromList $scope.project
 					for tag in $scope.tags
 						$scope._removeProjectIdsFromTagArray(tag, [ $scope.project.id ])
 					$scope.updateVisibleProjects()
-				.catch () -> 
+				.catch () ->
 					$scope.project.isTableActionInflight = false

+ 1 - 1
services/web/public/coffee/main/subscription-dashboard.coffee

@@ -36,7 +36,7 @@ define [
 
 		$scope.pricing = MultiCurrencyPricing
 		# $scope.plans = MultiCurrencyPricing.plans
-		$scope.currencySymbol = MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode].symbol
+		$scope.currencySymbol = MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode]?.symbol
 
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
 

+ 9 - 6
services/web/public/coffee/main/group-members.coffee → services/web/public/coffee/main/user-membership.coffee

@@ -1,9 +1,10 @@
 define [
 	"base"
 ], (App) ->
-	App.controller "SubscriptionGroupMembersController", ($scope, queuedHttp) ->
+	App.controller "UserMembershipController", ($scope, queuedHttp) ->
 		$scope.users = window.users
 		$scope.groupSize = window.groupSize
+		$scope.paths = window.paths
 		$scope.selectedUsers = []
 
 		$scope.inputs =
@@ -22,7 +23,7 @@ define [
 			emails = parseEmails($scope.inputs.emails)
 			for email in emails
 				queuedHttp
-					.post("/subscription/invites", {
+					.post(paths.addMember, {
 						email: email,
 						_csrf: window.csrfToken
 					})
@@ -34,10 +35,12 @@ define [
 		$scope.removeMembers = () ->
 			for user in $scope.selectedUsers
 				do (user) ->
-					if user.invite and !user._id?
-						url = "/subscription/invites/#{encodeURIComponent(user.email)}"
+					if paths.removeInvite and user.invite and !user._id?
+						url = "#{paths.removeInvite}/#{encodeURIComponent(user.email)}"
+					else if paths.removeMember and user._id?
+						url = "#{paths.removeMember}/#{user._id}"
 					else
-						url = "/subscription/group/user/#{user._id}"
+						return
 					queuedHttp({
 						method: "DELETE",
 						url: url
@@ -53,7 +56,7 @@ define [
 		$scope.updateSelectedUsers = () ->
 			$scope.selectedUsers = $scope.users.filter (user) -> user.selected
 
-	App.controller "SubscriptionGroupMemberListItemController", ($scope) ->
+	App.controller "UserMembershipListItemController", ($scope) ->
 		$scope.$watch "user.selected", (value) ->
 			if value?
 				$scope.updateSelectedUsers()

+ 8 - 0
services/web/public/stylesheets/app/homepage.less

@@ -94,6 +94,14 @@
 			border-radius: 9999px;
 		}
 	}
+	.hp-register-password-error {
+		margin-bottom: 9px;
+	}
+	.register-banner__password-error {
+		padding: 5px 9px;
+		border: none;
+		border-radius: @btn-border-radius-base;
+	}
 	.screenshot {
 		height: 600px;
 		margin: auto;

+ 35 - 28
services/web/public/stylesheets/app/templates-v2.less

@@ -16,10 +16,14 @@
 }
 
 .cta-links {
-	margin-bottom: @margin-md;
-
 	.cta-link.btn {
-		margin-right: @margin-sm;
+		margin: 0 @margin-sm @margin-sm 0;
+	}
+}
+
+.popular-tags {
+	.template-thumbnail {
+		margin: 0 0 1em 0!important;
 	}
 }
 
@@ -28,25 +32,24 @@
 }
 
 .templates-container {
-	column-count: 3;
-	column-gap: 1em;
+	column-count: 2;
+	column-gap: 2em;
 }
 
 .template-thumbnail {
-	&.template-thumbnail__container {
-		display: inline-block;
-		margin: 0 0 1em;
-		width: 100%;
-	}
+	display: inline-block;
+	margin: 0 0 2em;
+	width: 100%;
 
 	.thumbnail {
 		box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-		margin: 5% 0;
+		margin: 0 0 @margin-sm 0;
 		padding:0px;
 		display: flex;
 		justify-content: center;
 		align-items: center;
 		overflow: hidden;
+		width: 100%;
 
 		h3 {
 			color:@link-color;
@@ -67,33 +70,37 @@
 
 	.caption__description {
 		font-style: italic;
-		padding: 5px 0;
+		padding: 0 0 5px 0;
 		.text-overflow();
 	}
 
 	.caption__title {
 		display: inline-block;
-		max-width: 100%;
+		width: 100%;
+		text-align: center;
 		.text-overflow();
 	}
+}
 
-	/* Media Queries */
-	@media (max-width: @screen-md-min) {
-		.thumbnail {
-			margin: 5% auto;
-		}
+.template-large-pdf-preview {
+	border: solid 1px @gray-lightest;
+	margin-top: @margin-lg;
+}
 
-		.caption .description {
-			padding: 5px 50px;
-		}
+/* Media Queries */ 
+@media (min-width: @screen-sm-min) {
+	.templates-container {
+		column-count: 3;
+		column-gap: 3em;
 	}
 }
-    .section-tags {
-		margin-bottom: @margin-xl;
-		margin-top: @margin-md;
+@media (min-width: @screen-md-min) {
+	.template-large-pdf-preview {
+		margin-top: 0;
 	}
+}
 
-	.template-large-pdf-preview {
-		border: solid 1px @gray-lightest;
-		margin-bottom: 30px;
-	}
+.section-tags {
+	margin-bottom: @margin-xl;
+	margin-top: @margin-md;
+}

+ 2 - 0
services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee

@@ -30,6 +30,8 @@ describe "AuthenticationController", ->
 				revokeAllUserSessions: sinon.stub().callsArgWith(1, null)
 			"../../infrastructure/Modules": @Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, [])}}
 			"../SudoMode/SudoModeHandler": @SudoModeHandler = {activateSudoMode: sinon.stub().callsArgWith(1, null)}
+			"../Notifications/NotificationsBuilder": @NotificationsBuilder =
+				ipMatcherAffiliation: sinon.stub()
 		@user =
 			_id: ObjectId()
 			email: @email = "USER@example.com"

+ 44 - 0
services/web/test/unit/coffee/Authentication/AuthenticationManagerTests.coffee

@@ -94,6 +94,50 @@ describe "AuthenticationManager", ->
 			it "should not return a user", ->
 				@callback.calledWith(null, null).should.equal true
 
+	describe "validateEmail", ->
+		describe "valid", ->
+			it "should return null", ->
+				result = @AuthenticationManager.validateEmail 'foo@example.com'
+				expect(result).to.equal null
+
+		describe "invalid", ->
+			it "should return validation error object for no email", ->
+				result = @AuthenticationManager.validateEmail ''
+				expect(result).to.not.equal null
+				expect(result.message).to.equal 'email not valid'
+
+			it "should return validation error object for invalid", ->
+				result = @AuthenticationManager.validateEmail 'notanemail'
+				expect(result).to.not.equal null
+				expect(result.message).to.equal 'email not valid'
+
+	describe "validatePassword", ->
+		it "should return null if valid", ->
+			result = @AuthenticationManager.validatePassword 'banana'
+			expect(result).to.equal null
+
+		describe "invalid", ->
+			beforeEach ->
+				@settings.passwordStrengthOptions =
+					length:
+						max:10
+						min:6
+
+			it "should return validation error object if not set", ->
+				result = @AuthenticationManager.validatePassword()
+				expect(result).to.not.equal null
+				expect(result.message).to.equal 'password not set'
+
+			it "should return validation error object if too short", ->
+				result = @AuthenticationManager.validatePassword 'dsd'
+				expect(result).to.not.equal null
+				expect(result.message).to.equal 'password is too short'
+
+			it "should return validation error object if too long", ->
+				result = @AuthenticationManager.validatePassword 'dsdsadsadsadsadsadkjsadjsadjsadljs'
+				expect(result).to.not.equal null
+				expect(result.message).to.equal 'password is too long'
+
 	describe "setUserPassword", ->
 		beforeEach ->
 			@user_id = ObjectId()

+ 0 - 1
services/web/test/unit/coffee/Compile/CompileControllerTests.coffee

@@ -194,7 +194,6 @@ describe "CompileController", ->
 					.should.equal true
 
 			it "should set the content-disposition header with a safe version of the project name", ->
-				console.log @res.setContentDisposition.args[0]
 				@res.setContentDisposition
 					.calledWith('', filename: "test_nam_.pdf")
 					.should.equal true

+ 2 - 2
services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee

@@ -301,7 +301,7 @@ describe 'ExportsHandler', ->
 				@callback.calledWith(null, { body: @body })
 				.should.equal true
 
-	describe 'fetchZip', ->
+	describe 'fetchDownload', ->
 		beforeEach (done) ->
 			@settings.apis =
 				v1:
@@ -316,7 +316,7 @@ describe 'ExportsHandler', ->
 		describe "when all goes well", ->
 			beforeEach (done) ->
 				@stubRequest.get = @stubGet
-				@ExportsHandler.fetchZip @export_id, (error, body) =>
+				@ExportsHandler.fetchDownload @export_id, 'zip', (error, body) =>
 					@callback(error, body)
 					done()
 

+ 29 - 0
services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee

@@ -0,0 +1,29 @@
+SandboxedModule = require('sandboxed-module')
+should = require('chai').should()
+sinon = require('sinon')
+assertCalledWith = sinon.assert.calledWith
+assertNotCalled = sinon.assert.notCalled
+modulePath = "../../../../app/js/Features/Institutions/InstitutionsLocator"
+assert = require("chai").assert
+ObjectId = require('mongoose').Types.ObjectId
+
+describe 'InstitutionsLocator', ->
+	beforeEach ->
+		@user =
+			_id: "5208dd34438842e2db333333"
+		@institution =
+			v1Id: 123
+			managersIds: [ObjectId(), ObjectId()]
+		@Institution =
+			findOne: sinon.stub().yields(null, @institution)
+		@InstitutionsLocator = SandboxedModule.require modulePath, requires:
+			'../../models/Institution': Institution: @Institution
+			"logger-sharelatex": log:->
+
+	describe "finding managed institution", ->
+
+		it "should query the database", (done) ->
+			@InstitutionsLocator.findManagedInstitution @user._id, (err, institution)=>
+				assertCalledWith(@Institution.findOne, { managerIds: @user._id })
+				institution.should.equal @institution
+				done()

+ 400 - 198
services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee

@@ -289,71 +289,101 @@ describe 'ProjectEntityUpdateHandler', ->
 				.should.equal true
 
 	describe 'addDoc', ->
-		beforeEach ->
-			@path = "/path/to/doc"
+		describe 'adding a doc', ->
+			beforeEach ->
+				@path = "/path/to/doc"
 
-			@newDoc = _id: doc_id
-			@ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory =
-				withoutLock: sinon.stub().yields(null, @newDoc, folder_id, @path, @project)
-			@ProjectEntityUpdateHandler.addDoc project_id, folder_id, @docName, @docLines, userId, @callback
+				@newDoc = _id: doc_id
+				@ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory =
+					withoutLock: sinon.stub().yields(null, @newDoc, folder_id, @path, @project)
+				@ProjectEntityUpdateHandler.addDoc project_id, folder_id, @docName, @docLines, userId, @callback
 
-		it "creates the doc without history", () ->
-			@ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory.withoutLock
-				.calledWith(project_id, folder_id, @docName, @docLines, userId)
-				.should.equal true
+			it "creates the doc without history", () ->
+				@ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory.withoutLock
+					.calledWith(project_id, folder_id, @docName, @docLines, userId)
+					.should.equal true
 
-		it "sends the change in project structure to the doc updater", () ->
-			newDocs = [
-				doc: @newDoc
-				path: @path
-				docLines: @docLines.join('\n')
-			]
-			@DocumentUpdaterHandler.updateProjectStructure
-				.calledWith(project_id, projectHistoryId, userId, {newDocs})
-				.should.equal true
+			it "sends the change in project structure to the doc updater", () ->
+				newDocs = [
+					doc: @newDoc
+					path: @path
+					docLines: @docLines.join('\n')
+				]
+				@DocumentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, projectHistoryId, userId, {newDocs})
+					.should.equal true
+
+		describe 'adding a doc with an invalid name', ->
+			beforeEach ->
+				@path = "/path/to/doc"
+
+				@newDoc = _id: doc_id
+				@ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory =
+					withoutLock: sinon.stub().yields(null, @newDoc, folder_id, @path, @project)
+				@ProjectEntityUpdateHandler.addDoc project_id, folder_id, "*" + @docName, @docLines, userId, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
 
 	describe 'addFile', ->
-		beforeEach ->
-			@path = "/path/to/file"
+		describe 'adding a file', ->
+			beforeEach ->
+				@path = "/path/to/file"
 
-			@newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData}
-			@TpdsUpdateSender.addFile = sinon.stub().yields()
-			@ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
-			@ProjectEntityUpdateHandler.addFile project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback
+				@newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData}
+				@TpdsUpdateSender.addFile = sinon.stub().yields()
+				@ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+				@ProjectEntityUpdateHandler.addFile project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback
 
-		it "updates the file in the filestore", () ->
-			@FileStoreHandler.uploadFileFromDisk
-				.calledWith(project_id, file_id, @fileSystemPath)
-				.should.equal true
+			it "updates the file in the filestore", () ->
+				@FileStoreHandler.uploadFileFromDisk
+					.calledWith(project_id, file_id, @fileSystemPath)
+					.should.equal true
 
-		it "updates the file in mongo", () ->
-			fileMatcher = sinon.match (file) =>
-				file.name == @fileName
+			it "updates the file in mongo", () ->
+				fileMatcher = sinon.match (file) =>
+					file.name == @fileName
 
-			@ProjectEntityMongoUpdateHandler.addFile
-				.calledWithMatch(project_id, folder_id, fileMatcher)
-				.should.equal true
+				@ProjectEntityMongoUpdateHandler.addFile
+					.calledWithMatch(project_id, folder_id, fileMatcher)
+					.should.equal true
 
-		it "notifies the tpds", () ->
-			@TpdsUpdateSender.addFile
-				.calledWith({
-					project_id: project_id
-					project_name: @project.name
-					file_id: file_id
-					rev: 0
+			it "notifies the tpds", () ->
+				@TpdsUpdateSender.addFile
+					.calledWith({
+						project_id: project_id
+						project_name: @project.name
+						file_id: file_id
+						rev: 0
+						path: @path
+					})
+					.should.equal true
+
+			it "sends the change in project structure to the doc updater", () ->
+				newFiles = [
+					file: @newFile
 					path: @path
-				})
-				.should.equal true
+					url: @fileUrl
+				]
+				@DocumentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, projectHistoryId, userId, {newFiles})
+					.should.equal true
 
-		it "sends the change in project structure to the doc updater", () ->
-			newFiles = [
-				file: @newFile
-				path: @path
-				url: @fileUrl
-			]
-			@DocumentUpdaterHandler.updateProjectStructure
-				.calledWith(project_id, projectHistoryId, userId, {newFiles})
-				.should.equal true
+		describe 'adding a file with an invalid name', ->
+			beforeEach ->
+				@path = "/path/to/file"
+
+				@newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData}
+				@TpdsUpdateSender.addFile = sinon.stub().yields()
+				@ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+				@ProjectEntityUpdateHandler.addFile project_id, folder_id, "*" + @fileName, @fileSystemPath, @linkedFileData, userId, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
 
 	describe 'replaceFile', ->
 		beforeEach ->
@@ -404,83 +434,116 @@ describe 'ProjectEntityUpdateHandler', ->
 				.should.equal true
 
 	describe 'addDocWithoutUpdatingHistory', ->
-		beforeEach ->
-			@path = "/path/to/doc"
+		describe 'adding a doc', ->
+			beforeEach ->
+				@path = "/path/to/doc"
 
-			@project = _id: project_id, name: 'some project'
+				@project = _id: project_id, name: 'some project'
 
-			@TpdsUpdateSender.addDoc = sinon.stub().yields()
-			@DocstoreManager.updateDoc = sinon.stub().yields(null, false, @rev = 5)
-			@ProjectEntityMongoUpdateHandler.addDoc = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
-			@ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory project_id, folder_id, @docName, @docLines, userId, @callback
+				@TpdsUpdateSender.addDoc = sinon.stub().yields()
+				@DocstoreManager.updateDoc = sinon.stub().yields(null, false, @rev = 5)
+				@ProjectEntityMongoUpdateHandler.addDoc = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+				@ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory project_id, folder_id, @docName, @docLines, userId, @callback
 
-		it "updates the doc in the docstore", () ->
-			@DocstoreManager.updateDoc
-				.calledWith(project_id, doc_id, @docLines, 0, {})
-				.should.equal true
+			it "updates the doc in the docstore", () ->
+				@DocstoreManager.updateDoc
+					.calledWith(project_id, doc_id, @docLines, 0, {})
+					.should.equal true
 
-		it "updates the doc in mongo", () ->
-			docMatcher = sinon.match (doc) =>
-				doc.name == @docName
+			it "updates the doc in mongo", () ->
+				docMatcher = sinon.match (doc) =>
+					doc.name == @docName
 
-			@ProjectEntityMongoUpdateHandler.addDoc
-				.calledWithMatch(project_id, folder_id, docMatcher)
-				.should.equal true
+				@ProjectEntityMongoUpdateHandler.addDoc
+					.calledWithMatch(project_id, folder_id, docMatcher)
+					.should.equal true
 
-		it "notifies the tpds", () ->
-			@TpdsUpdateSender.addDoc
-				.calledWith({
-					project_id: project_id
-					project_name: @project.name
-					doc_id: doc_id
-					rev: 0
-					path: @path
-				})
-				.should.equal true
+			it "notifies the tpds", () ->
+				@TpdsUpdateSender.addDoc
+					.calledWith({
+						project_id: project_id
+						project_name: @project.name
+						doc_id: doc_id
+						rev: 0
+						path: @path
+					})
+					.should.equal true
 
-		it "should not should send the change in project structure to the doc updater", () ->
-			@DocumentUpdaterHandler.updateProjectStructure
-				.called
-				.should.equal false
+			it "should not should send the change in project structure to the doc updater", () ->
+				@DocumentUpdaterHandler.updateProjectStructure
+					.called
+					.should.equal false
+
+		describe 'adding a doc with an invalid name', ->
+			beforeEach ->
+				@path = "/path/to/doc"
+
+				@project = _id: project_id, name: 'some project'
+
+				@TpdsUpdateSender.addDoc = sinon.stub().yields()
+				@DocstoreManager.updateDoc = sinon.stub().yields(null, false, @rev = 5)
+				@ProjectEntityMongoUpdateHandler.addDoc = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+				@ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory project_id, folder_id, "*" + @docName, @docLines, userId, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
 
 	describe 'addFileWithoutUpdatingHistory', ->
-		beforeEach ->
-			@path = "/path/to/file"
+		describe 'adding a file', ->
+			beforeEach ->
+				@path = "/path/to/file"
 
-			@project = _id: project_id, name: 'some project'
+				@project = _id: project_id, name: 'some project'
 
-			@TpdsUpdateSender.addFile = sinon.stub().yields()
-			@ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
-			@ProjectEntityUpdateHandler.addFileWithoutUpdatingHistory project_id, folder_id, @fileName, @fileSystemPath, userId, @callback
+				@TpdsUpdateSender.addFile = sinon.stub().yields()
+				@ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+				@ProjectEntityUpdateHandler.addFileWithoutUpdatingHistory project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback
 
-		it "updates the file in the filestore", () ->
-			@FileStoreHandler.uploadFileFromDisk
-				.calledWith(project_id, file_id, @fileSystemPath)
-				.should.equal true
+			it "updates the file in the filestore", () ->
+				@FileStoreHandler.uploadFileFromDisk
+					.calledWith(project_id, file_id, @fileSystemPath)
+					.should.equal true
 
-		it "updates the file in mongo", () ->
-			fileMatcher = sinon.match (file) =>
-				file.name == @fileName
+			it "updates the file in mongo", () ->
+				fileMatcher = sinon.match (file) =>
+					file.name == @fileName
 
-			@ProjectEntityMongoUpdateHandler.addFile
-				.calledWithMatch(project_id, folder_id, fileMatcher)
-				.should.equal true
+				@ProjectEntityMongoUpdateHandler.addFile
+					.calledWithMatch(project_id, folder_id, fileMatcher)
+					.should.equal true
 
-		it "notifies the tpds", () ->
-			@TpdsUpdateSender.addFile
-				.calledWith({
-					project_id: project_id
-					project_name: @project.name
-					file_id: file_id
-					rev: 0
-					path: @path
-				})
-				.should.equal true
+			it "notifies the tpds", () ->
+				@TpdsUpdateSender.addFile
+					.calledWith({
+						project_id: project_id
+						project_name: @project.name
+						file_id: file_id
+						rev: 0
+						path: @path
+					})
+					.should.equal true
 
-		it "should not should send the change in project structure to the doc updater", () ->
-			@DocumentUpdaterHandler.updateProjectStructure
-				.called
-				.should.equal false
+			it "should not should send the change in project structure to the doc updater", () ->
+				@DocumentUpdaterHandler.updateProjectStructure
+					.called
+					.should.equal false
+
+		describe 'adding a file with an invalid name', ->
+			beforeEach ->
+				@path = "/path/to/file"
+
+				@project = _id: project_id, name: 'some project'
+
+				@TpdsUpdateSender.addFile = sinon.stub().yields()
+				@ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+				@ProjectEntityUpdateHandler.addFileWithoutUpdatingHistory project_id, folder_id, "*" + @fileName, @fileSystemPath, @linkedFileData, userId, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
 
 	describe 'upsertDoc', ->
 		describe 'upserting into an invalid folder', ->
@@ -543,6 +606,20 @@ describe 'ProjectEntityUpdateHandler', ->
 			it 'returns the doc', ->
 				@callback.calledWith(null, @newDoc, true)
 
+		describe 'upserting a new doc with an invalid name', ->
+			beforeEach ->
+				@folder = _id: folder_id, docs: []
+				@newDoc = _id: doc_id
+				@ProjectLocator.findElement = sinon.stub().yields(null, @folder)
+				@ProjectEntityUpdateHandler.addDoc = withoutLock: sinon.stub().yields(null, @newDoc)
+
+				@ProjectEntityUpdateHandler.upsertDoc project_id, folder_id, "*" + @docName, @docLines, @source, userId, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
+
 	describe 'upsertFile', ->
 		describe 'upserting into an invalid folder', ->
 			beforeEach ->
@@ -593,63 +670,155 @@ describe 'ProjectEntityUpdateHandler', ->
 			it 'returns the file', ->
 				@callback.calledWith(null, @newFile, true)
 
+		describe 'upserting a new file with an invalid name', ->
+			beforeEach ->
+				@folder = _id: folder_id, fileRefs: []
+				@newFile = _id: file_id
+				@ProjectLocator.findElement = sinon.stub().yields(null, @folder)
+				@ProjectEntityUpdateHandler.addFile = mainTask: sinon.stub().yields(null, @newFile)
+
+				@ProjectEntityUpdateHandler.upsertFile project_id, folder_id, '*' + @fileName, @fileSystemPath, @linkedFileData, userId, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
+
 	describe 'upsertDocWithPath', ->
-		beforeEach ->
-			@path = "/folder/doc.tex"
-			@newFolders = [ 'mock-a', 'mock-b' ]
-			@folder = _id: folder_id
-			@doc = _id: doc_id
-			@isNewDoc = true
-			@ProjectEntityUpdateHandler.mkdirp =
-				withoutLock: sinon.stub().yields(null, @newFolders, @folder)
-			@ProjectEntityUpdateHandler.upsertDoc =
-				withoutLock: sinon.stub().yields(null, @doc, @isNewDoc)
+		describe 'upserting a doc', ->
+			beforeEach ->
+				@path = "/folder/doc.tex"
+				@newFolders = [ 'mock-a', 'mock-b' ]
+				@folder = _id: folder_id
+				@doc = _id: doc_id
+				@isNewDoc = true
+				@ProjectEntityUpdateHandler.mkdirp =
+					withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+				@ProjectEntityUpdateHandler.upsertDoc =
+					withoutLock: sinon.stub().yields(null, @doc, @isNewDoc)
 
-			@ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback
+				@ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback
 
-		it 'creates any necessary folders', ->
-			@ProjectEntityUpdateHandler.mkdirp.withoutLock
-				.calledWith(project_id, '/folder')
-				.should.equal true
+			it 'creates any necessary folders', ->
+				@ProjectEntityUpdateHandler.mkdirp.withoutLock
+					.calledWith(project_id, '/folder')
+					.should.equal true
 
-		it 'upserts the doc', ->
-			@ProjectEntityUpdateHandler.upsertDoc.withoutLock
-				.calledWith(project_id, @folder._id, 'doc.tex', @docLines, @source, userId)
-				.should.equal true
+			it 'upserts the doc', ->
+				@ProjectEntityUpdateHandler.upsertDoc.withoutLock
+					.calledWith(project_id, @folder._id, 'doc.tex', @docLines, @source, userId)
+					.should.equal true
 
-		it 'calls the callback', ->
-			@callback
-				.calledWith(null, @doc, @isNewDoc, @newFolders, @folder)
-				.should.equal true
+			it 'calls the callback', ->
+				@callback
+					.calledWith(null, @doc, @isNewDoc, @newFolders, @folder)
+					.should.equal true
+
+		describe 'upserting a doc with an invalid path', ->
+			beforeEach ->
+				@path = "/*folder/doc.tex"
+				@newFolders = [ 'mock-a', 'mock-b' ]
+				@folder = _id: folder_id
+				@doc = _id: doc_id
+				@isNewDoc = true
+				@ProjectEntityUpdateHandler.mkdirp =
+					withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+				@ProjectEntityUpdateHandler.upsertDoc =
+					withoutLock: sinon.stub().yields(null, @doc, @isNewDoc)
+
+				@ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
+
+		describe 'upserting a doc with an invalid name', ->
+			beforeEach ->
+				@path = "/folder/*doc.tex"
+				@newFolders = [ 'mock-a', 'mock-b' ]
+				@folder = _id: folder_id
+				@doc = _id: doc_id
+				@isNewDoc = true
+				@ProjectEntityUpdateHandler.mkdirp =
+					withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+				@ProjectEntityUpdateHandler.upsertDoc =
+					withoutLock: sinon.stub().yields(null, @doc, @isNewDoc)
+
+				@ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
 
 	describe 'upsertFileWithPath', ->
-		beforeEach ->
-			@path = "/folder/file.png"
-			@newFolders = [ 'mock-a', 'mock-b' ]
-			@folder = _id: folder_id
-			@file = _id: file_id
-			@isNewFile = true
-			@ProjectEntityUpdateHandler.mkdirp =
-				withoutLock: sinon.stub().yields(null, @newFolders, @folder)
-			@ProjectEntityUpdateHandler.upsertFile =
-				mainTask: sinon.stub().yields(null, @file, @isNewFile)
-
-			@ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback
-
-		it 'creates any necessary folders', ->
-			@ProjectEntityUpdateHandler.mkdirp.withoutLock
-				.calledWith(project_id, '/folder')
-				.should.equal true
+		describe 'upserting a file', ->
+			beforeEach ->
+				@path = "/folder/file.png"
+				@newFolders = [ 'mock-a', 'mock-b' ]
+				@folder = _id: folder_id
+				@file = _id: file_id
+				@isNewFile = true
+				@ProjectEntityUpdateHandler.mkdirp =
+					withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+				@ProjectEntityUpdateHandler.upsertFile =
+					mainTask: sinon.stub().yields(null, @file, @isNewFile)
+
+				@ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback
+
+			it 'creates any necessary folders', ->
+				@ProjectEntityUpdateHandler.mkdirp.withoutLock
+					.calledWith(project_id, '/folder')
+					.should.equal true
 
-		it 'upserts the file', ->
-			@ProjectEntityUpdateHandler.upsertFile.mainTask
-				.calledWith(project_id, @folder._id, 'file.png', @fileSystemPath, @linkedFileData, userId)
-				.should.equal true
+			it 'upserts the file', ->
+				@ProjectEntityUpdateHandler.upsertFile.mainTask
+					.calledWith(project_id, @folder._id, 'file.png', @fileSystemPath, @linkedFileData, userId)
+					.should.equal true
 
-		it 'calls the callback', ->
-			@callback
-				.calledWith(null, @file, @isNewFile, undefined, @newFolders, @folder)
-				.should.equal true
+			it 'calls the callback', ->
+				@callback
+					.calledWith(null, @file, @isNewFile, undefined, @newFolders, @folder)
+					.should.equal true
+
+		describe 'upserting a file with an invalid path', ->
+			beforeEach ->
+				@path = "/*folder/file.png"
+				@newFolders = [ 'mock-a', 'mock-b' ]
+				@folder = _id: folder_id
+				@file = _id: file_id
+				@isNewFile = true
+				@ProjectEntityUpdateHandler.mkdirp =
+					withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+				@ProjectEntityUpdateHandler.upsertFile =
+					mainTask: sinon.stub().yields(null, @file, @isNewFile)
+
+				@ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
+
+		describe 'upserting a file with an invalid name', ->
+			beforeEach ->
+				@path = "/folder/*file.png"
+				@newFolders = [ 'mock-a', 'mock-b' ]
+				@folder = _id: folder_id
+				@file = _id: file_id
+				@isNewFile = true
+				@ProjectEntityUpdateHandler.mkdirp =
+					withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+				@ProjectEntityUpdateHandler.upsertFile =
+					mainTask: sinon.stub().yields(null, @file, @isNewFile)
+
+				@ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
 
 	describe 'deleteEntity', ->
 		beforeEach ->
@@ -721,16 +890,29 @@ describe 'ProjectEntityUpdateHandler', ->
 				.should.equal true
 
 	describe 'addFolder', ->
-		beforeEach ->
-			@parentFolder_id = '123asdf'
-			@folderName = 'new-folder'
-			@ProjectEntityMongoUpdateHandler.addFolder = sinon.stub().yields()
-			@ProjectEntityUpdateHandler.addFolder project_id, @parentFolder_id, @folderName, @callback
+		describe 'adding a folder', ->
+			beforeEach ->
+				@parentFolder_id = '123asdf'
+				@folderName = 'new-folder'
+				@ProjectEntityMongoUpdateHandler.addFolder = sinon.stub().yields()
+				@ProjectEntityUpdateHandler.addFolder project_id, @parentFolder_id, @folderName, @callback
+
+			it 'calls ProjectEntityMongoUpdateHandler', ->
+				@ProjectEntityMongoUpdateHandler.addFolder
+					.calledWith(project_id, @parentFolder_id, @folderName)
+					.should.equal true
 
-		it 'calls ProjectEntityMongoUpdateHandler', ->
-			@ProjectEntityMongoUpdateHandler.addFolder
-				.calledWith(project_id, @parentFolder_id, @folderName)
-				.should.equal true
+		describe 'adding a folder with an invalid name', ->
+			beforeEach ->
+				@parentFolder_id = '123asdf'
+				@folderName = '*new-folder'
+				@ProjectEntityMongoUpdateHandler.addFolder = sinon.stub().yields()
+				@ProjectEntityUpdateHandler.addFolder project_id, @parentFolder_id, @folderName, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
 
 	describe 'moveEntity', ->
 		beforeEach ->
@@ -763,35 +945,57 @@ describe 'ProjectEntityUpdateHandler', ->
 				.should.equal true
 
 	describe "renameEntity", ->
-		beforeEach ->
-			@project_name = 'project name'
-			@startPath = '/folder/a.tex'
-			@endPath = '/folder/b.tex'
-			@rev = 2
-			@changes = newDocs: ['old-doc'], newFiles: ['old-file']
-			@newDocName = 'b.tex'
-			@ProjectEntityMongoUpdateHandler.renameEntity = sinon.stub().yields(
-				null, @project, @startPath, @endPath, @rev, @changes
-			)
-			@TpdsUpdateSender.moveEntity = sinon.stub()
-			@DocumentUpdaterHandler.updateProjectStructure = sinon.stub()
+		describe 'renaming an entity', ->
+			beforeEach ->
+				@project_name = 'project name'
+				@startPath = '/folder/a.tex'
+				@endPath = '/folder/b.tex'
+				@rev = 2
+				@changes = newDocs: ['old-doc'], newFiles: ['old-file']
+				@newDocName = 'b.tex'
+				@ProjectEntityMongoUpdateHandler.renameEntity = sinon.stub().yields(
+					null, @project, @startPath, @endPath, @rev, @changes
+				)
+				@TpdsUpdateSender.moveEntity = sinon.stub()
+				@DocumentUpdaterHandler.updateProjectStructure = sinon.stub()
 
-			@ProjectEntityUpdateHandler.renameEntity project_id, doc_id, 'doc', @newDocName, userId, @callback
+				@ProjectEntityUpdateHandler.renameEntity project_id, doc_id, 'doc', @newDocName, userId, @callback
 
-		it 'moves the entity in mongo', ->
-			@ProjectEntityMongoUpdateHandler.renameEntity
-				.calledWith(project_id, doc_id, 'doc', @newDocName)
-				.should.equal true
+			it 'moves the entity in mongo', ->
+				@ProjectEntityMongoUpdateHandler.renameEntity
+					.calledWith(project_id, doc_id, 'doc', @newDocName)
+					.should.equal true
 
-		it 'notifies tpds', ->
-			@TpdsUpdateSender.moveEntity
-				.calledWith({project_id, @project_name, @startPath, @endPath, @rev})
-				.should.equal true
+			it 'notifies tpds', ->
+				@TpdsUpdateSender.moveEntity
+					.calledWith({project_id, @project_name, @startPath, @endPath, @rev})
+					.should.equal true
 
-		it 'sends the changes in project structure to the doc updater',  ->
-			@DocumentUpdaterHandler.updateProjectStructure
-				.calledWith(project_id, projectHistoryId, userId, @changes, @callback)
-				.should.equal true
+			it 'sends the changes in project structure to the doc updater',  ->
+				@DocumentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, projectHistoryId, userId, @changes, @callback)
+					.should.equal true
+
+		describe 'renaming an entity to an invalid name', ->
+			beforeEach ->
+				@project_name = 'project name'
+				@startPath = '/folder/a.tex'
+				@endPath = '/folder/b.tex'
+				@rev = 2
+				@changes = newDocs: ['old-doc'], newFiles: ['old-file']
+				@newDocName = '*b.tex'
+				@ProjectEntityMongoUpdateHandler.renameEntity = sinon.stub().yields(
+					null, @project, @startPath, @endPath, @rev, @changes
+				)
+				@TpdsUpdateSender.moveEntity = sinon.stub()
+				@DocumentUpdaterHandler.updateProjectStructure = sinon.stub()
+
+				@ProjectEntityUpdateHandler.renameEntity project_id, doc_id, 'doc', @newDocName, userId, @callback
+
+			it 'returns an error', ->
+				errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+				@callback.calledWithMatch(errorMatcher)
+					.should.equal true
 
 	describe "resyncProjectHistory", ->
 		describe "a deleted project", ->
@@ -998,5 +1202,3 @@ describe 'ProjectEntityUpdateHandler', ->
 
 			it "should call the callback", ->
 				@callback.called.should.equal true
-
-	

+ 56 - 3
services/web/test/unit/coffee/Project/SafePathTests.coffee

@@ -83,6 +83,59 @@ describe 'SafePath', ->
 			result = @SafePath.isCleanFilename 'foo\\bar'
 			result.should.equal false
 
+	describe 'isCleanPath', ->
+		it 'should accept a valid filename "main.tex"', ->
+			result = @SafePath.isCleanPath 'main.tex'
+			result.should.equal true
+
+		it 'should accept a valid path "foo/main.tex"', ->
+			result = @SafePath.isCleanPath 'foo/main.tex'
+			result.should.equal true
+
+		it 'should accept empty path elements', ->
+			result = @SafePath.isCleanPath 'foo//main.tex'
+			result.should.equal true
+
+		it 'should not accept an empty filename', ->
+			result = @SafePath.isCleanPath 'foo/bar/'
+			result.should.equal false
+
+		it 'should accept a path that starts with a slash', ->
+			result = @SafePath.isCleanPath '/etc/passwd'
+			result.should.equal true
+
+		it 'should not accept a path that has an asterisk as the 0th element', ->
+			result = @SafePath.isCleanPath '*/foo/bar'
+			result.should.equal false
+
+		it 'should not accept a path that has an asterisk as a middle element', ->
+			result = @SafePath.isCleanPath 'foo/*/bar'
+			result.should.equal false
+
+		it 'should not accept a path that has an asterisk as the filename', ->
+			result = @SafePath.isCleanPath 'foo/bar/*'
+			result.should.equal false
+
+		it 'should not accept a path that contains an asterisk in the 0th element', ->
+			result = @SafePath.isCleanPath 'f*o/bar/baz'
+			result.should.equal false
+
+		it 'should not accept a path that contains an asterisk in a middle element', ->
+			result = @SafePath.isCleanPath 'foo/b*r/baz'
+			result.should.equal false
+
+		it 'should not accept a path that contains an asterisk in the filename', ->
+			result = @SafePath.isCleanPath 'foo/bar/b*z'
+			result.should.equal false
+
+		it 'should not accept multiple problematic elements', ->
+			result = @SafePath.isCleanPath 'f*o/b*r/b*z'
+			result.should.equal false
+
+		it 'should not accept a problematic path with an empty element', ->
+			result = @SafePath.isCleanPath 'foo//*/bar'
+			result.should.equal false
+
 	describe 'isAllowedLength', ->
 		it 'should accept a valid path "main.tex"', ->
 			result = @SafePath.isAllowedLength 'main.tex'
@@ -96,7 +149,7 @@ describe 'SafePath', ->
 		it 'should not accept an empty path', ->
 			result = @SafePath.isAllowedLength ''
 			result.should.equal false
-	
+
 	describe 'clean', ->
 		it 'should not modify a valid filename', ->
 			result = @SafePath.clean 'main.tex'
@@ -105,7 +158,7 @@ describe 'SafePath', ->
 		it 'should replace invalid characters with _', ->
 			result = @SafePath.clean 'foo/bar*/main.tex'
 			result.should.equal 'foo_bar__main.tex'
-			
+
 		it 'should replace "." with "_"', ->
 			result = @SafePath.clean '.'
 			result.should.equal '_'
@@ -133,7 +186,7 @@ describe 'SafePath', ->
 		it 'should prefix javascript property names with @', ->
 			result = @SafePath.clean 'prototype'
 			result.should.equal '@prototype'
-		
+
 		it 'should prefix javascript property names in the prototype with @', ->
 			result = @SafePath.clean 'hasOwnProperty'
 			result.should.equal '@hasOwnProperty'

+ 0 - 19
services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee

@@ -81,25 +81,6 @@ describe "SubscriptionGroupController", ->
 					done()
 			@Controller.removeUserFromGroup @req, res
 
-	describe "renderSubscriptionGroupAdminPage", ->
-		it "should redirect you if you don't have a group account", (done)->
-			@subscription.groupPlan = false
-
-			res =
-				redirect : (path)=>
-					path.should.equal("/user/subscription")
-					done()
-			@Controller.renderSubscriptionGroupAdminPage @req, res
-
-		it "should redirect you don't have a subscription", (done)->
-			@SubscriptionLocator.getUsersSubscription = sinon.stub().callsArgWith(1)
-
-			res =
-				redirect : (path)=>
-					path.should.equal("/user/subscription")
-					done()
-			@Controller.renderSubscriptionGroupAdminPage @req, res
-
 	describe "exportGroupCsv", ->
 
 		beforeEach ->

+ 0 - 46
services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee

@@ -157,52 +157,6 @@ describe "SubscriptionGroupHandler", ->
 				{ $pull: { member_ids: @oldId } }
 			).should.equal true
 
-	describe "getPopulatedListOfMembers", ->
-		beforeEach ->
-			@subscription = {}
-			@SubscriptionLocator.getSubscription.callsArgWith(1, null, @subscription)
-			@UserGetter.getUser.callsArgWith(1, null, {_id:"31232"})
-
-		it "should locate the subscription", (done)->
-			@UserGetter.getUser.callsArgWith(1, null, {_id:"31232"})
-			@Handler.getPopulatedListOfMembers @subscriptionId, (err, users)=>
-				@SubscriptionLocator.getSubscription.calledWith(@subscriptionId).should.equal true
-				done()
-
-		it "should get the users by id", (done)->
-			@UserGetter.getUser.callsArgWith(1, null, {_id:"31232"})
-			@subscription.member_ids = ["1234", "342432", "312312"]
-			@Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=>
-				@UserGetter.getUser.calledWith(@subscription.member_ids[0]).should.equal true
-				@UserGetter.getUser.calledWith(@subscription.member_ids[1]).should.equal true
-				@UserGetter.getUser.calledWith(@subscription.member_ids[2]).should.equal true
-				users.length.should.equal @subscription.member_ids.length
-				done()
-
-		it "should just return the id if the user can not be found as they may have deleted their account", (done)->
-			@UserGetter.getUser.callsArgWith(1)
-			@subscription.member_ids = ["1234", "342432", "312312"]
-			@Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=>
-				assert.deepEqual users[0], {_id:@subscription.member_ids[0]}
-				assert.deepEqual users[1], {_id:@subscription.member_ids[1]}
-				assert.deepEqual users[2], {_id:@subscription.member_ids[2]}
-				done()
-
-		it "should return any invited users", (done) ->
-			@subscription.invited_emails = [ "jo@example.com" ]
-
-			@subscription.teamInvites = [
-				{ email: "charlie@example.com" }
-			]
-
-			@Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=>
-				users[0].email.should.equal "jo@example.com"
-				users[0].invite.should.equal true
-				users[1].email.should.equal "charlie@example.com"
-				users[1].invite.should.equal true
-				users.length.should.equal @subscription.teamInvites.length + @subscription.invited_emails.length
-				done()
-
 	describe "isUserPartOfGroup", ->
 		beforeEach ->
 			@subscription_id = "123ed13123"

+ 9 - 0
services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee

@@ -17,6 +17,7 @@ describe "SubscriptionUpdater", ->
 			_id: @adminuser_id = "5208dd34438843e2db000007"
 		@otherUserId = "5208dd34438842e2db000005"
 		@allUserIds = ["13213", "dsadas", "djsaiud89"]
+		@userStub = _id: 'mock-user-stub-id', email: 'mock-stub-email@baz.com'
 		@subscription = subscription =
 			_id: "111111111111111111111111"
 			admin_id: @adminUser._id
@@ -67,6 +68,7 @@ describe "SubscriptionUpdater", ->
 			getUsers: (memberIds, projection, callback) ->
 				users = memberIds.map (id) -> { _id: id }
 				callback(null, users)
+			getUserOrUserStubById: sinon.stub()
 
 		@ReferalFeatures = getBonusFeatures: sinon.stub().callsArgWith(1)
 		@Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, null)}}
@@ -190,6 +192,7 @@ describe "SubscriptionUpdater", ->
 	describe "removeUserFromGroup", ->
 		beforeEach ->
 			@FeaturesUpdater.refreshFeatures = sinon.stub().callsArgWith(1)
+			@UserGetter.getUserOrUserStubById.yields(null, {}, false)
 
 		it "should pull the users id from the group", (done)->
 			@SubscriptionUpdater.removeUserFromGroup @subscription._id, @otherUserId, =>
@@ -205,6 +208,12 @@ describe "SubscriptionUpdater", ->
 				@FeaturesUpdater.refreshFeatures.calledWith(@otherUserId).should.equal true
 				done()
 
+		it "should not update features for user stubs", (done)->
+			@UserGetter.getUserOrUserStubById.yields(null, {}, true)
+			@SubscriptionUpdater.removeUserFromGroup @subscription._id, @userStub._id, =>
+				@FeaturesUpdater.refreshFeatures.called.should.equal false
+				done()
+
 	describe "deleteSubscription", ->
 		beforeEach (done) ->
 			@subscription_id = ObjectId().toString()

+ 1 - 1
services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee

@@ -59,7 +59,7 @@ describe 'TemplatesController', ->
 			"uuid":v4:=>@uuid
 			"request": @request
 			"fs":@fs
-			"../../../../app/js/models/Project": {Project: @Project}
+			"../../../js/models/Project": {Project: @Project}
 		@zipUrl = "%2Ftemplates%2F52fb86a81ae1e566597a25f6%2Fv%2F4%2Fzip&templateName=Moderncv%20Banking&compiler=pdflatex"
 		@templateName = "project name here"
 		@user_id = "1234"

+ 16 - 17
services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee

@@ -19,6 +19,8 @@ describe "UserRegistrationHandler", ->
 		@UserCreator = 
 			createNewUser:sinon.stub().callsArgWith(1, null, @user)
 		@AuthenticationManager =
+			validateEmail: sinon.stub().returns(null)
+			validatePassword: sinon.stub().returns(null)
 			setUserPassword: sinon.stub().callsArgWith(2)
 		@NewsLetterManager =
 			subscribe: sinon.stub().callsArgWith(1)
@@ -44,28 +46,25 @@ describe "UserRegistrationHandler", ->
 
 
 	describe 'validate Register Request', ->
-
-
-		it 'allow working account through', ->
+		it 'allows passing validation through', ->
 			result = @handler._registrationRequestIsValid @passingRequest
 			result.should.equal true
-		
-		it 'not allow not valid email through ', ()->
-			@passingRequest.email = "notemail"
-			result = @handler._registrationRequestIsValid @passingRequest
-			result.should.equal false
 
-		it 'not allow no email through ', ->
-			@passingRequest.email = ""
-			result = @handler._registrationRequestIsValid @passingRequest
-			result.should.equal false
-		
-		it 'not allow no password through ', ()->
-			@passingRequest.password= ""
-			result = @handler._registrationRequestIsValid @passingRequest
-			result.should.equal false
+		describe 'failing email validation', ->
+			beforeEach ->
+				@AuthenticationManager.validateEmail.returns({ message: 'email not set' })
 
+			it 'does not allow through', ->
+				result = @handler._registrationRequestIsValid @passingRequest
+				result.should.equal false
+
+		describe 'failing password validation', ->
+			beforeEach ->
+				@AuthenticationManager.validatePassword.returns({ message: 'password is too short' })
 
+			it 'does not allow through', ->
+				result = @handler._registrationRequestIsValid @passingRequest
+				result.should.equal false
 
 	describe "registerNewUser", ->
 

+ 95 - 0
services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee

@@ -0,0 +1,95 @@
+sinon = require('sinon')
+assertCalledWith = sinon.assert.calledWith
+assertNotCalled = sinon.assert.notCalled
+chai = require('chai')
+should = chai.should()
+assert = chai.assert
+expect = require('chai').expect
+modulePath = "../../../../app/js/Features/UserMembership/UserMembershipController.js"
+SandboxedModule = require('sandboxed-module')
+MockRequest = require "../helpers/MockRequest"
+MockResponse = require "../helpers/MockResponse"
+
+describe "UserMembershipController", ->
+	beforeEach ->
+		@req = new MockRequest()
+		@user = _id: 'mock-user-id'
+		@newUser = _id: 'mock-new-user-id', email: 'new-user-email@foo.bar'
+		@subscription = { _id: 'mock-subscription-id'}
+		@users = [{ _id: 'mock-member-id-1' }, { _id: 'mock-member-id-2' }]
+
+		@AuthenticationController =
+			getLoggedInUserId: sinon.stub().returns(@user._id)
+		@UserMembershipHandler =
+			getEntity: sinon.stub().yields(null, @subscription)
+			getUsers: sinon.stub().yields(null, @users)
+			addUser: sinon.stub().yields(null, @newUser)
+			removeUser: sinon.stub().yields(null)
+		@UserMembershipController = SandboxedModule.require modulePath, requires:
+			'../Authentication/AuthenticationController': @AuthenticationController
+			'./UserMembershipHandler': @UserMembershipHandler
+			"logger-sharelatex":
+				log: -> 
+				err: ->
+
+	describe 'index', ->
+		it 'get entity', (done) ->
+			@UserMembershipController.index 'group', @req, render: () =>
+				sinon.assert.calledWith(@UserMembershipHandler.getEntity, 'group', @user._id)
+				done()
+
+		it 'get users', (done) ->
+			@UserMembershipController.index 'group', @req, render: () =>
+				sinon.assert.calledWith(@UserMembershipHandler.getUsers, 'group', @subscription)
+				done()
+
+		it 'render group view', (done) ->
+			@UserMembershipController.index 'group', @req, render: (viewPath, viewParams) =>
+				expect(viewPath).to.equal 'user_membership/index'
+				expect(viewParams.entity).to.deep.equal @subscription
+				expect(viewParams.users).to.deep.equal @users
+				expect(viewParams.translations.title).to.equal 'group_account'
+				expect(viewParams.paths.addMember).to.equal '/subscription/invites'
+				done()
+
+		it 'render group managers view', (done) ->
+			@UserMembershipController.index 'groupManagers', @req, render: (viewPath, viewParams) =>
+				expect(viewPath).to.equal 'user_membership/index'
+				expect(viewParams.translations.title).to.equal 'group_managers'
+				expect(viewParams.paths.exportMembers).to.be.undefined
+				done()
+
+		it 'render institution view', (done) ->
+			@UserMembershipController.index 'institution', @req, render: (viewPath, viewParams) =>
+				expect(viewPath).to.equal 'user_membership/index'
+				expect(viewParams.translations.title).to.equal 'institution_managers'
+				expect(viewParams.paths.exportMembers).to.be.undefined
+				done()
+
+	describe 'add', ->
+		beforeEach ->
+			@req.body.email = @newUser.email
+
+		it 'get entity', (done) ->
+			@UserMembershipController.add 'groupManagers', @req, json: () =>
+				sinon.assert.calledWith(@UserMembershipHandler.getEntity, 'groupManagers', @user._id)
+				done()
+
+		it 'add user', (done) ->
+			@UserMembershipController.add 'groupManagers', @req, json: () =>
+				sinon.assert.calledWith(@UserMembershipHandler.addUser, 'groupManagers', @subscription, @newUser.email)
+				done()
+
+		it 'return user object', (done) ->
+			@UserMembershipController.add 'groupManagers', @req, json: (payload) =>
+				payload.user.should.equal @newUser
+				done()
+
+	describe 'remove', ->
+		beforeEach ->
+			@req.params.userId = @newUser._id
+
+		it 'remove user', (done) ->
+			@UserMembershipController.remove 'groupManagers', @req, send: () =>
+				sinon.assert.calledWith(@UserMembershipHandler.removeUser, 'groupManagers', @subscription, @newUser._id)
+				done()

+ 172 - 0
services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee

@@ -0,0 +1,172 @@
+chai = require('chai')
+should = chai.should()
+expect = require('chai').expect
+sinon = require('sinon')
+assertCalledWith = sinon.assert.calledWith
+assertNotCalled = sinon.assert.notCalled
+ObjectId = require("../../../../app/js/infrastructure/mongojs").ObjectId
+modulePath = "../../../../app/js/Features/UserMembership/UserMembershipHandler"
+SandboxedModule = require("sandboxed-module")
+Errors = require("../../../../app/js/Features/Errors/Errors")
+
+describe 'UserMembershipHandler', ->
+	beforeEach ->
+		@user = _id: 'mock-user-id'
+		@newUser = _id: 'mock-new-user-id', email: 'new-user-email@foo.bar'
+		@subscription =
+			_id: 'mock-subscription-id'
+			groupPlan: true
+			membersLimit: 10
+			member_ids: [ObjectId(), ObjectId()]
+			manager_ids: [ObjectId()]
+			invited_emails: ['mock-email-1@foo.com']
+			teamInvites: [{ email: 'mock-email-1@bar.com' }]
+			update: sinon.stub().yields(null)
+		@institution =
+			_id: 'mock-institution-id'
+			v1Id: 123
+			managerIds: [ObjectId(), ObjectId(), ObjectId()]
+			update: sinon.stub().yields(null)
+
+		@SubscriptionLocator =
+			findManagedSubscription: sinon.stub().yields(null, @subscription)
+		@InstitutionsLocator =
+			findManagedInstitution: sinon.stub().yields(null, @institution)
+		@UserMembershipViewModel =
+			buildAsync: sinon.stub().yields(null, { _id: 'mock-member-id'})
+			build: sinon.stub().returns(@newUser)
+		@UserGetter =
+			getUserByAnyEmail: sinon.stub().yields(null, @newUser)
+		@UserMembershipHandler = SandboxedModule.require modulePath, requires:
+			'../Subscription/SubscriptionLocator': @SubscriptionLocator
+			'../Institutions/InstitutionsLocator': @InstitutionsLocator
+			'./UserMembershipViewModel': @UserMembershipViewModel
+			'../User/UserGetter': @UserGetter
+			'../Errors/Errors': Errors
+			'logger-sharelatex':
+				log: -> 
+				err: ->
+
+	describe 'getEntty', ->
+		it 'validate type', (done) ->
+			@UserMembershipHandler.getEntity 'foo', null, (error) ->
+				should.exist(error)
+				expect(error.message).to.match /No such entity/
+				done()
+
+		describe 'group subscriptions', ->
+			it 'get subscription', (done) ->
+				@UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) =>
+					should.not.exist(error)
+					assertCalledWith(@SubscriptionLocator.findManagedSubscription, @user._id)
+					expect(subscription).to.equal @subscription
+					expect(subscription.membersLimit).to.equal 10
+					done()
+
+			it 'check subscription is a group', (done) ->
+				@SubscriptionLocator.findManagedSubscription.yields(null, { groupPlan: false })
+				@UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) ->
+					should.exist(error)
+					done()
+
+			it 'handle error', (done) ->
+				@SubscriptionLocator.findManagedSubscription.yields(new Error('some error'))
+				@UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) =>
+					should.exist(error)
+					done()
+
+		describe 'group managers', ->
+			it 'has no members limit', (done) ->
+				@UserMembershipHandler.getEntity 'groupManagers', @user._id, (error, subscription) =>
+					should.not.exist(error)
+					assertCalledWith(@SubscriptionLocator.findManagedSubscription, @user._id)
+					expect(subscription.membersLimit).to.equal null
+					done()
+
+		describe 'institutions', ->
+			it 'get institution', (done) ->
+				@UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) =>
+					should.not.exist(error)
+					assertCalledWith(@InstitutionsLocator.findManagedInstitution, @user._id)
+					expect(institution).to.equal @institution
+					done()
+
+			it 'handle institution not found', (done) ->
+				@InstitutionsLocator.findManagedInstitution.yields(null, null)
+				@UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) =>
+					should.exist(error)
+					expect(error).to.be.an.instanceof(Errors.NotFoundError)
+					done()
+
+			it 'handle errors', (done) ->
+				@InstitutionsLocator.findManagedInstitution.yields(new Error('nope'))
+				@UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) =>
+					should.exist(error)
+					expect(error).to.not.be.an.instanceof(Errors.NotFoundError)
+					done()
+
+	describe 'getUsers', ->
+		describe 'group', ->
+			it 'build view model for all users', (done) ->
+				@UserMembershipHandler.getUsers 'group', @subscription, (error, users) =>
+					expectedCallcount =
+						@subscription.member_ids.length +
+						@subscription.invited_emails.length +
+						@subscription.teamInvites.length
+					expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount
+					done()
+
+		describe 'group mamagers', ->
+			it 'build view model for all managers', (done) ->
+				@UserMembershipHandler.getUsers 'groupManagers', @subscription, (error, users) =>
+					expectedCallcount = @subscription.manager_ids.length
+					expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount
+					done()
+
+		describe 'institution', ->
+			it 'build view model for all managers', (done) ->
+				@UserMembershipHandler.getUsers 'institution', @institution, (error, users) =>
+					expectedCallcount = @institution.managerIds.length
+					expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount
+					done()
+
+	describe 'addUser', ->
+		beforeEach ->
+			@email = @newUser.email
+
+		describe 'group', ->
+			it 'fails', (done) ->
+				@UserMembershipHandler.addUser 'group', @subscription, @email, (error) =>
+					expect(error).to.exist
+					done()
+
+		describe 'institution', ->
+			it 'get user', (done) ->
+				@UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) =>
+					assertCalledWith(@UserGetter.getUserByAnyEmail, @email)
+					done()
+
+			it 'handle user not found', (done) ->
+				@UserGetter.getUserByAnyEmail.yields(null, null)
+				@UserMembershipHandler.addUser 'institution', @institution, @email, (error) =>
+					expect(error).to.exist
+					expect(error).to.be.an.instanceof(Errors.NotFoundError)
+					done()
+
+			it 'add user to institution', (done) ->
+				@UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) =>
+					assertCalledWith(@institution.update, { $addToSet: managerIds: @newUser._id })
+					done()
+
+			it 'return user view', (done) ->
+				@UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) =>
+					user.should.equal @newUser
+					done()
+
+	describe 'removeUser', ->
+		describe 'institution', ->
+			it 'remove user from institution', (done) ->
+				@UserMembershipHandler.removeUser 'institution', @institution, @newUser._id, (error, user) =>
+					lastCall = @institution.update.lastCall
+					assertCalledWith(@institution.update, { $pull: managerIds: @newUser._id })
+					done()

+ 83 - 0
services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee

@@ -0,0 +1,83 @@
+chai = require('chai')
+should = chai.should()
+expect = require('chai').expect
+sinon = require('sinon')
+assertCalledWith = sinon.assert.calledWith
+assertNotCalled = sinon.assert.notCalled
+mongojs = require('mongojs')
+ObjectId = mongojs.ObjectId
+modulePath = "../../../../app/js/Features/UserMembership/UserMembershipViewModel"
+SandboxedModule = require("sandboxed-module")
+
+describe 'UserMembershipViewModel', ->
+	beforeEach ->
+		@UserGetter =
+			getUserOrUserStubById: sinon.stub()
+		@UserMembershipViewModel = SandboxedModule.require modulePath, requires:
+			'mongojs': mongojs
+			'../User/UserGetter': @UserGetter
+		@email = 'mock-email@bar.com'
+		@user = _id: 'mock-user-id', email: 'mock-email@baz.com', first_name: 'Name'
+		@userStub = _id: 'mock-user-stub-id', email: 'mock-stub-email@baz.com'
+
+	describe 'build', ->
+		it 'build email', ->
+			viewModel = @UserMembershipViewModel.build(@email)
+			expect(viewModel).to.deep.equal
+				email: @email
+				invite: true
+				first_name: null
+				last_name: null
+				_id: null
+
+		it 'build user', ->
+			viewModel = @UserMembershipViewModel.build(@user)
+			expect(viewModel._id).to.equal @user._id
+			expect(viewModel.email).to.equal @user.email
+			expect(viewModel.invite).to.equal false
+
+	describe 'build async', ->
+		beforeEach ->
+			@UserMembershipViewModel.build = sinon.stub()
+
+		it 'build email', (done) ->
+			@UserMembershipViewModel.buildAsync @email, (error, viewModel) =>
+				assertCalledWith(@UserMembershipViewModel.build, @email)
+				done()
+
+		it 'build user', (done) ->
+			@UserMembershipViewModel.buildAsync @user, (error, viewModel) =>
+				assertCalledWith(@UserMembershipViewModel.build, @user)
+				done()
+
+		it 'build user id', (done) ->
+			@UserGetter.getUserOrUserStubById.yields(null, @user, false)
+			@UserMembershipViewModel.buildAsync ObjectId(), (error, viewModel) =>
+				should.not.exist(error)
+				assertNotCalled(@UserMembershipViewModel.build)
+				expect(viewModel._id).to.equal @user._id
+				expect(viewModel.email).to.equal @user.email
+				expect(viewModel.first_name).to.equal @user.first_name
+				expect(viewModel.invite).to.equal false
+				should.exist(viewModel.email)
+				done()
+
+		it 'build user stub id', (done) ->
+			@UserGetter.getUserOrUserStubById.yields(null, @userStub, true)
+			@UserMembershipViewModel.buildAsync ObjectId(), (error, viewModel) =>
+				should.not.exist(error)
+				assertNotCalled(@UserMembershipViewModel.build)
+				expect(viewModel._id).to.equal @userStub._id
+				expect(viewModel.email).to.equal @userStub.email
+				expect(viewModel.invite).to.equal true
+				done()
+
+		it 'build user id with error', (done) ->
+			@UserGetter.getUserOrUserStubById.yields(new Error('nope'))
+			userId = ObjectId()
+			@UserMembershipViewModel.buildAsync userId, (error, viewModel) =>
+				should.not.exist(error)
+				assertNotCalled(@UserMembershipViewModel.build)
+				expect(viewModel._id).to.equal userId.toString()
+				should.not.exist(viewModel.email)
+				done()