소스 검색

Merge branch 'master' into ho-clsi-domain

Henry Oswald 8 년 전
부모
커밋
02110064f7
50개의 변경된 파일과 997개의 추가작업 그리고 574개의 파일을 삭제
  1. 1 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
  2. 1 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
  3. 14 13
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  4. 1 1
      services/web/app/coffee/Features/Errors/Errors.coffee
  5. 47 0
      services/web/app/coffee/Features/Subscription/DomainLicenceController.coffee
  6. 10 7
      services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
  7. 1 1
      services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee
  8. 0 70
      services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
  9. 10 49
      services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
  10. 15 8
      services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
  11. 4 19
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  12. 58 0
      services/web/app/coffee/Features/Subscription/TeamInvitesController.coffee
  13. 161 0
      services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee
  14. 2 2
      services/web/app/coffee/Features/User/UserController.coffee
  15. 4 0
      services/web/app/coffee/Features/User/UserCreator.coffee
  16. 8 1
      services/web/app/coffee/Features/User/UserGetter.coffee
  17. 18 16
      services/web/app/coffee/Features/User/UserHandler.coffee
  18. 1 1
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  19. 1 10
      services/web/app/coffee/Features/User/UserUpdater.coffee
  20. 2 0
      services/web/app/coffee/models/Subscription.coffee
  21. 15 0
      services/web/app/coffee/models/TeamInvite.coffee
  22. 4 0
      services/web/app/coffee/models/User.coffee
  23. 2 2
      services/web/app/views/layout.pug
  24. 35 24
      services/web/app/views/project/list/notifications.pug
  25. 39 0
      services/web/app/views/subscriptions/domain/join.pug
  26. 0 54
      services/web/app/views/subscriptions/group/invite.pug
  27. 0 25
      services/web/app/views/subscriptions/group/successful_join.pug
  28. 3 3
      services/web/app/views/subscriptions/group_admin.pug
  29. 40 0
      services/web/app/views/subscriptions/team/invite.pug
  30. 2 3
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
  31. 43 4
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/EnvironmentManager.coffee
  32. 2 1
      services/web/public/coffee/main.coffee
  33. 4 4
      services/web/public/coffee/main/group-members.coffee
  34. 8 8
      services/web/public/coffee/main/subscription/domain-subscription-join-controller.coffee
  35. 35 0
      services/web/public/coffee/main/subscription/team-invite-controller.coffee
  36. 7 3
      services/web/public/stylesheets/app/subscription.less
  37. 14 0
      services/web/test/acceptance/coffee/RegistrationTests.coffee
  38. 22 5
      services/web/test/acceptance/coffee/helpers/User.coffee
  39. 6 6
      services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee
  40. 9 9
      services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee
  41. 4 2
      services/web/test/unit/coffee/Subscription/LimitationsManagerTests.coffee
  42. 0 104
      services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee
  43. 19 73
      services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee
  44. 267 0
      services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee
  45. 3 3
      services/web/test/unit/coffee/User/UserControllerTests.coffee
  46. 8 0
      services/web/test/unit/coffee/User/UserCreatorTests.coffee
  47. 17 0
      services/web/test/unit/coffee/User/UserGetterTests.coffee
  48. 23 23
      services/web/test/unit/coffee/User/UserHandlerTests.coffee
  49. 4 4
      services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
  50. 3 14
      services/web/test/unit/coffee/User/UserUpdaterTests.coffee

+ 1 - 1
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee

@@ -27,7 +27,7 @@ module.exports = CollaboratorsInviteController =
 	_checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) ->
 		if Settings.restrictInvitesToExistingAccounts == true
 			logger.log {email}, "checking if user exists with this email"
-			UserGetter.getUserByMainEmail email, {_id: 1}, (err, user) ->
+			UserGetter.getUserByAnyEmail email, {_id: 1}, (err, user) ->
 				return callback(err) if err?
 				userExists = user? and user?._id?
 				callback(null, userExists)

+ 1 - 1
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee

@@ -32,7 +32,7 @@ module.exports = CollaboratorsInviteHandler =
 
 	_trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) ->
 		email = invite.email
-		UserGetter.getUserByMainEmail email, {_id: 1}, (err, existingUser) ->
+		UserGetter.getUserByAnyEmail email, {_id: 1}, (err, existingUser) ->
 			if err?
 				logger.err {projectId, email}, "error checking if user exists"
 				return callback(err)

+ 14 - 13
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -79,7 +79,7 @@ Thank you
 
 #{settings.appName} - <%= siteUrl %>
 """
-	compiledTemplate: (opts) -> 
+	compiledTemplate: (opts) ->
 		SingleCTAEmailBody({
 			title: "Password Reset"
 			greeting: "Hi,"
@@ -104,7 +104,7 @@ Thank you
 
 #{settings.appName} - <%= siteUrl %>
 """
-	compiledTemplate: (opts) -> 
+	compiledTemplate: (opts) ->
 		SingleCTAEmailBody({
 			title: "#{ opts.project.name } &ndash; shared by #{ opts.owner.email }"
 			greeting: "Hi,"
@@ -112,37 +112,38 @@ Thank you
 			secondaryMessage: null
 			ctaText: "View project"
 			ctaURL: opts.inviteUrl
-			gmailGoToAction: 
+			gmailGoToAction:
 				target: opts.inviteUrl
 				name: "View project"
 				description: "Join #{ opts.project.name } at ShareLaTeX"
 		})
 
-templates.completeJoinGroupAccount =
-	subject: _.template "Verify Email to join <%= group_name %> group"
+
+templates.verifyEmailToJoinTeam =
+	subject: _.template "<%= inviterName %> has invited you to join a team on #{settings.appName}"
 	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	plainTextTemplate: _.template """
-Hi, please verify your email to join the <%= group_name %> and get your free premium account
 
-Click this link to verify now: <%= completeJoinUrl %>
+Please click the button below to join the team and enjoy the benefits of an upgraded  <%= appName %> account.
+
+<%= acceptInviteUrl %>
 
 Thank You
 
 #{settings.appName} - <%= siteUrl %>
 """
-	compiledTemplate: (opts) -> 
+	compiledTemplate: (opts) ->
 		SingleCTAEmailBody({
-			title: "Verify Email to join #{ opts.group_name } group"
+			title: "#{opts.inviterName} has invited you to join a team on #{settings.appName}"
 			greeting: "Hi,"
-			message: "please verify your email to join the #{ opts.group_name } group and get your free premium account."
+			message: "Please click the button below to join the team and enjoy the benefits of an upgraded  #{ opts.appName } account."
 			secondaryMessage: null
 			ctaText: "Verify now"
-			ctaURL: opts.completeJoinUrl
+			ctaURL: opts.acceptInviteUrl
 			gmailGoToAction: null
 		})
 
-
 templates.testEmail =
 	subject: _.template "A Test Email from ShareLaTeX"
 	layout: BaseWithHeaderEmailLayout
@@ -154,7 +155,7 @@ This is a test email sent from ShareLaTeX.
 
 #{settings.appName} - <%= siteUrl %>
 """
-	compiledTemplate: (opts) -> 
+	compiledTemplate: (opts) ->
 		SingleCTAEmailBody({
 			title: "A Test Email from ShareLaTeX"
 			greeting: "Hi,"

+ 1 - 1
services/web/app/coffee/Features/Errors/Errors.coffee

@@ -56,7 +56,7 @@ V1HistoryNotSyncedError.prototype.__proto___ = Error.prototype
 
 ProjectHistoryDisabledError = (message) ->
 	error = new Error(message)
-	error.name = "ProjectHistoryDisabledError "
+	error.name = "ProjectHistoryDisabledError"
 	error.__proto__ = ProjectHistoryDisabledError.prototype
 	return error
 ProjectHistoryDisabledError.prototype.__proto___ = Error.prototype

+ 47 - 0
services/web/app/coffee/Features/Subscription/DomainLicenceController.coffee

@@ -0,0 +1,47 @@
+SubscriptionGroupHandler = require("./SubscriptionGroupHandler")
+logger = require("logger-sharelatex")
+SubscriptionLocator = require("./SubscriptionLocator")
+ErrorsController = require("../Errors/ErrorController")
+SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
+AuthenticationController = require('../Authentication/AuthenticationController')
+TeamInvitesHandler = require('./TeamInvitesHandler')
+
+async = require("async")
+
+module.exports =
+	join: (req, res)->
+		user = AuthenticationController.getSessionUser(req)
+		licence = SubscriptionDomainHandler.getLicenceUserCanJoin(user)
+
+		if !licence?
+			return ErrorsController.notFound(req, res)
+
+		jobs =
+			partOfGroup: (cb)->
+				SubscriptionGroupHandler.isUserPartOfGroup user.id, licence.group_subscription_id, cb
+			subscription: (cb)->
+				SubscriptionLocator.getUsersSubscription user.id, cb
+
+		async.series jobs, (err, results)->
+			{ partOfGroup, subscription } = results
+			if partOfGroup
+				return res.redirect("/user/subscription/custom_account")
+			else
+				res.render "subscriptions/domain/join",
+					title: "Group Invitation"
+					group_subscription_id: licence.group_subscription_id
+					licenceName: licence.name
+					has_personal_subscription: subscription?
+
+	createInvite: (req, res, next)->
+		user = AuthenticationController.getSessionUser(req)
+		licence = SubscriptionDomainHandler.getLicenceUserCanJoin(user)
+
+		if !licence?
+			return ErrorsController.notFound(req, res)
+
+		TeamInvitesHandler.createDomainInvite user, licence, (err) ->
+			if err?
+				next(err)
+			else
+				res.sendStatus 200

+ 10 - 7
services/web/app/coffee/Features/Subscription/LimitationsManager.coffee

@@ -6,7 +6,7 @@ Settings = require("settings-sharelatex")
 CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
 CollaboratorsInvitesHandler = require("../Collaborators/CollaboratorsInviteHandler")
 
-module.exports =
+module.exports = LimitationsManager =
 	allowedNumberOfCollaboratorsInProject: (project_id, callback) ->
 		ProjectGetter.getProject project_id, owner_ref: true, (error, project) =>
 			return callback(error) if error?
@@ -19,7 +19,6 @@ module.exports =
 				callback null, user.features.collaborators
 			else
 				callback null, Settings.defaultPlanCode.collaborators
-		
 
 	canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) ->
 		@allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) =>
@@ -56,6 +55,13 @@ module.exports =
 			return callback(err) if err?
 			callback err, subscriptions.length > 0, subscriptions
 
+	teamHasReachedMemberLimit: (subscription) ->
+		currentTotal = (subscription.member_ids or []).length +
+			(subscription.teamInvites or []).length +
+			(subscription.invited_emails or []).length
+
+		return currentTotal >= subscription.membersLimit
+
 	hasGroupMembersLimitReached: (user_id, callback = (err, limitReached, subscription)->)->
 		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
 			if err?
@@ -64,9 +70,6 @@ module.exports =
 			if !subscription?
 				logger.err user_id:user_id, "no subscription found for user"
 				return callback("no subscription found")
-			currentTotal = (subscription.member_ids or []).length + (subscription.invited_emails or []).length
-			limitReached = currentTotal >= subscription.membersLimit
-			logger.log user_id:user_id, limitReached:limitReached, currentTotal: currentTotal, membersLimit: subscription.membersLimit, "checking if subscription members limit has been reached"
-			callback(err, limitReached, subscription)
 
-getOwnerIdOfProject = (project_id, callback)->
+			limitReached = LimitationsManager.teamHasReachedMemberLimit(subscription)
+			callback(err, limitReached, subscription)

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

@@ -15,7 +15,7 @@ module.exports = SubscriptionDomainHandler =
 	getDomainLicencePage: (user)->
 		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
 		if licence?.verifyEmail
-			return "/user/subscription/#{licence.subscription_id}/group/invited"
+			return "/user/subscription/domain/join"
 		else
 			return undefined
 

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

@@ -33,14 +33,6 @@ module.exports =
 				return res.sendStatus 500
 			res.send()
 
-	removeEmailInviteFromGroup: (req, res)->
-		adminUserId = AuthenticationController.getLoggedInUserId(req)
-		email = req.params.email
-		logger.log {adminUserId, email}, "removing email invite from group subscription"
-		SubscriptionGroupHandler.removeEmailInviteFromGroup adminUserId, email, (err)->
-			return next(error) if error?
-			res.send()
-
 	removeSelfFromGroup: (req, res)->
 		adminUserId = req.query.admin_user_id
 		userToRemove_id = AuthenticationController.getLoggedInUserId(req)
@@ -62,68 +54,6 @@ module.exports =
 					users: users
 					subscription: subscription
 
-	renderGroupInvitePage: (req, res)->
-		group_subscription_id = req.params.subscription_id
-		user_id = AuthenticationController.getLoggedInUserId(req)
-		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(group_subscription_id)
-		if !licence?
-			return ErrorsController.notFound(req, res)
-		jobs =
-			partOfGroup: (cb)->
-				SubscriptionGroupHandler.isUserPartOfGroup user_id, licence.group_subscription_id, cb
-			subscription: (cb)->
-				SubscriptionLocator.getUsersSubscription user_id, cb
-		async.series jobs, (err, results)->
-			{partOfGroup, subscription} = results
-			if partOfGroup
-				return res.redirect("/user/subscription/custom_account")
-			else
-				res.render "subscriptions/group/invite",
-					title: "Group Invitation"
-					group_subscription_id:group_subscription_id
-					licenceName:licence.name
-					has_personal_subscription: subscription?
-
-	beginJoinGroup: (req, res)->
-		subscription_id = req.params.subscription_id
-		currentUser = AuthenticationController.getSessionUser(req)
-		if !currentUser?
-			logger.err {subscription_id}, "error getting current user"
-			return res.sendStatus 500
-		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)
-		if !licence?
-			return ErrorsController.notFound(req, res)
-		SubscriptionGroupHandler.sendVerificationEmail subscription_id, licence.name, currentUser.email, (err)->
-			if err?
-				res.sendStatus 500
-			else
-				res.sendStatus 200
-
-	completeJoin: (req, res)->
-		currentUser = AuthenticationController.getSessionUser(req)
-		subscription_id = req.params.subscription_id
-		if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
-			return ErrorsController.notFound(req, res)
-		email = currentUser?.email
-		logger.log subscription_id:subscription_id, user_id:currentUser?._id, email:email, "starting the completion of joining group"
-		SubscriptionGroupHandler.processGroupVerification email, subscription_id, req.query?.token, (err)->
-			if err? and err == "token_not_found"
-				return res.redirect "/user/subscription/#{subscription_id}/group/invited?expired=true"
-			else if err?
-				return res.sendStatus 500
-			else
-				logger.log subscription_id:subscription_id, email:email, "user successful completed join of group subscription"
-				return res.redirect "/user/subscription/#{subscription_id}/group/successful-join"
-
-	renderSuccessfulJoinPage: (req, res)->
-		subscription_id = req.params.subscription_id
-		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)
-		if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
-			return ErrorsController.notFound(req, res)
-		res.render "subscriptions/group/successful_join",
-			title: "Sucessfully joined group"
-			licenceName:licence.name
-
 	exportGroupCsv: (req, res)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log user_id: user_id, "exporting group csv"

+ 10 - 49
services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee

@@ -7,6 +7,7 @@ Subscription = require("../../models/Subscription").Subscription
 LimitationsManager = require("./LimitationsManager")
 logger = require("logger-sharelatex")
 OneTimeTokenHandler = require("../Security/OneTimeTokenHandler")
+TeamInvitesHandler = require("./TeamInvitesHandler")
 EmailHandler = require("../Email/EmailHandler")
 settings = require("settings-sharelatex")
 NotificationsBuilder = require("../Notifications/NotificationsBuilder")
@@ -22,7 +23,7 @@ module.exports = SubscriptionGroupHandler =
 			if limitReached
 				logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group"
 				return callback(limitReached:limitReached)
-			UserGetter.getUserByMainEmail newEmail, (err, user)->
+			UserGetter.getUserByAnyEmail newEmail, (err, user)->
 				return callback(err) if err?
 				if user?
 					SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)->
@@ -33,7 +34,7 @@ module.exports = SubscriptionGroupHandler =
 						userViewModel = buildUserViewModel(user)
 						callback(err, userViewModel)
 				else
-					SubscriptionUpdater.addEmailInviteToGroup adminUserId, newEmail, (err) ->
+					TeamInvitesHandler.createInvite adminUserId, newEmail, (err) ->
 						return callback(err) if err?
 						userViewModel = buildEmailInviteViewModel(newEmail)
 						callback(err, userViewModel)
@@ -41,10 +42,6 @@ module.exports = SubscriptionGroupHandler =
 	removeUserFromGroup: (adminUser_id, userToRemove_id, callback)->
 		SubscriptionUpdater.removeUserFromGroup adminUser_id, userToRemove_id, callback
 
-	removeEmailInviteFromGroup: (adminUser_id, email, callback) ->
-		SubscriptionUpdater.removeEmailInviteFromGroup adminUser_id, email, callback
-
-
 	replaceUserReferencesInGroups: (oldId, newId, callback) ->
 		Subscription.update {admin_id: oldId}, {admin_id: newId}, (error) ->
 			callback(error) if error?
@@ -62,8 +59,13 @@ module.exports = SubscriptionGroupHandler =
 	getPopulatedListOfMembers: (adminUser_id, callback)->
 		SubscriptionLocator.getUsersSubscription adminUser_id, (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)->
@@ -85,58 +87,17 @@ module.exports = SubscriptionGroupHandler =
 			logger.log user_id:user_id, subscription_id:subscription_id, partOfGroup:partOfGroup, "checking if user is part of a group"
 			callback(err, partOfGroup)
 
-
-	sendVerificationEmail: (subscription_id, licenceName, email, callback)->
-		ONE_DAY_IN_S = 1000 * 60 * 60 * 24
-		OneTimeTokenHandler.getNewToken subscription_id, {expiresIn:ONE_DAY_IN_S}, (err, token)->
-			opts =
-				to : email
-				group_name: licenceName
-				completeJoinUrl: "#{settings.siteUrl}/user/subscription/#{subscription_id}/group/complete-join?token=#{token}"
-			EmailHandler.sendEmail "completeJoinGroupAccount", opts, callback
-
-	processGroupVerification: (userEmail, subscription_id, token, callback)->
-		logger.log userEmail:userEmail, subscription_id:subscription_id, "processing group verification for user"
-		OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, token_subscription_id)->
-			if err?  or subscription_id != token_subscription_id
-				logger.err userEmail:userEmail, token:token, "token value not found for processing group verification"
-				return callback("token_not_found")
-			SubscriptionLocator.getSubscription subscription_id, (err, subscription)->
-				if err?
-					logger.err err:err, subscription:subscription, userEmail:userEmail, subscription_id:subscription_id, "error getting subscription"
-					return callback(err)
-				if !subscription?
-					logger.warn subscription_id:subscription_id, userEmail:userEmail, "no subscription found"
-					return callback()
-				SubscriptionGroupHandler.addUserToGroup subscription?.admin_id, userEmail, callback
-
-	convertEmailInvitesToMemberships: (email, user_id, callback = (err) ->) ->
-		SubscriptionLocator.getGroupsWithEmailInvite email, (err, groups = []) ->
-			return callback(err) if err?
-			logger.log {email, user_id, groups}, "found groups to convert from email invite to member"
-			jobs = []
-			for group in groups
-				do (group) ->
-					jobs.push (cb) ->
-						SubscriptionUpdater.removeEmailInviteFromGroup group.admin_id, email, (err) ->
-							return cb(err) if err?
-							SubscriptionUpdater.addUserToGroup group.admin_id, user_id, (err) ->
-								return cb(err) if err?
-								logger.log {group_id: group._id, user_id, email}, "converted email invite to group membership"
-								return cb()
-			async.series jobs, callback
-
 buildUserViewModel = (user)->
 	u =
 		email: user.email
 		first_name: user.first_name
 		last_name: user.last_name
-		holdingAccount: user.holdingAccount
+		invite: user.holdingAccount
 		_id: user._id
 	return u
 
 buildEmailInviteViewModel = (email) ->
 	return {
 		email: email
-		holdingAccount: true
+		invite: true
 	}

+ 15 - 8
services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee

@@ -1,6 +1,8 @@
 AuthenticationController = require('../Authentication/AuthenticationController')
 SubscriptionController = require('./SubscriptionController')
 SubscriptionGroupController = require './SubscriptionGroupController'
+DomainLicenceController = require './DomainLicenceController'
+TeamInvitesController = require './TeamInvitesController'
 Settings = require "settings-sharelatex"
 
 module.exports =
@@ -13,8 +15,7 @@ module.exports =
 
 		webRouter.get  '/user/subscription/custom_account', AuthenticationController.requireLogin(), SubscriptionController.userCustomSubscriptionPage
 
-		
-		webRouter.get  '/user/subscription/new',        AuthenticationController.requireLogin(), SubscriptionController.paymentPage 
+		webRouter.get  '/user/subscription/new',        AuthenticationController.requireLogin(), SubscriptionController.paymentPage
 
 		webRouter.get  '/user/subscription/thank-you', AuthenticationController.requireLogin(), SubscriptionController.successful_subscription
 
@@ -23,14 +24,21 @@ module.exports =
 		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
-		webRouter.delete '/subscription/group/email/:email', AuthenticationController.requireLogin(), SubscriptionGroupController.removeEmailInviteFromGroup
 		webRouter.delete '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.removeSelfFromGroup
 
+		# Team invites
+		webRouter.post '/subscription/invites',  AuthenticationController.requireLogin(),
+			TeamInvitesController.createInvite
+		webRouter.get '/subscription/invites/:token/',  AuthenticationController.requireLogin(),
+			TeamInvitesController.viewInvite
+		webRouter.put '/subscription/invites/:token/',  AuthenticationController.requireLogin(),
+			TeamInvitesController.acceptInvite
+		webRouter.delete '/subscription/invites/:email/',  AuthenticationController.requireLogin(),
+			TeamInvitesController.revokeInvite
 
-		webRouter.get '/user/subscription/:subscription_id/group/invited', AuthenticationController.requireLogin(), SubscriptionGroupController.renderGroupInvitePage
-		webRouter.post '/user/subscription/:subscription_id/group/begin-join', AuthenticationController.requireLogin(), SubscriptionGroupController.beginJoinGroup
-		webRouter.get '/user/subscription/:subscription_id/group/complete-join', AuthenticationController.requireLogin(), SubscriptionGroupController.completeJoin
-		webRouter.get '/user/subscription/:subscription_id/group/successful-join', AuthenticationController.requireLogin(), SubscriptionGroupController.renderSuccessfulJoinPage
+		# Routes to join a domain licence team
+		webRouter.get '/user/subscription/domain/join', AuthenticationController.requireLogin(), DomainLicenceController.join
+		webRouter.post '/user/subscription/domain/join', AuthenticationController.requireLogin(), DomainLicenceController.createInvite
 
 		#recurly callback
 		publicApiRouter.post '/user/subscription/callback',   SubscriptionController.recurlyNotificationParser, SubscriptionController.recurlyCallback
@@ -48,4 +56,3 @@ module.exports =
 
 		# Currently used in acceptance tests only, as a way to trigger the syncing logic
 		publicApiRouter.post "/user/:user_id/features/sync", AuthenticationController.httpAuth, SubscriptionController.refreshUserFeatures
-

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

@@ -26,28 +26,20 @@ module.exports = SubscriptionUpdater =
 
 	addUserToGroup: (adminUser_id, user_id, callback)->
 		logger.log adminUser_id:adminUser_id, user_id:user_id, "adding user into mongo subscription"
-		searchOps = 
+		searchOps =
 			admin_id: adminUser_id
-		insertOperation = 
+		insertOperation =
 			"$addToSet": {member_ids:user_id}
 		Subscription.findAndModify searchOps, insertOperation, (err, subscription)->
 			if err?
 				logger.err err:err, searchOps:searchOps, insertOperation:insertOperation, "error findy and modify add user to group"
 				return callback(err)
 			FeaturesUpdater.refreshFeatures user_id, callback
-	
-	addEmailInviteToGroup: (adminUser_id, email, callback) ->
-		logger.log {adminUser_id, email}, "adding email into mongo subscription"
-		searchOps = 
-			admin_id: adminUser_id
-		insertOperation =
-			"$addToSet": {invited_emails: email}
-		Subscription.findAndModify searchOps, insertOperation, callback
 
 	removeUserFromGroup: (adminUser_id, user_id, callback)->
-		searchOps = 
+		searchOps =
 			admin_id: adminUser_id
-		removeOperation = 
+		removeOperation =
 			"$pull": {member_ids:user_id}
 		Subscription.update searchOps, removeOperation, (err)->
 			if err?
@@ -55,13 +47,6 @@ module.exports = SubscriptionUpdater =
 				return callback(err)
 			FeaturesUpdater.refreshFeatures user_id, callback
 
-	removeEmailInviteFromGroup: (adminUser_id, email, callback)->
-		Subscription.update {
-			admin_id: adminUser_id
-		}, "$pull": {
-			invited_emails: email
-		}, callback
-
 	deleteSubscription: (subscription_id, callback = (error) ->) ->
 		SubscriptionLocator.getSubscription subscription_id, (err, subscription) ->
 			return callback(err) if err?

+ 58 - 0
services/web/app/coffee/Features/Subscription/TeamInvitesController.coffee

@@ -0,0 +1,58 @@
+settings = require "settings-sharelatex"
+logger = require("logger-sharelatex")
+TeamInvitesHandler = require('./TeamInvitesHandler')
+AuthenticationController = require("../Authentication/AuthenticationController")
+SubscriptionLocator = require("./SubscriptionLocator")
+ErrorController = require("../Errors/ErrorController")
+EmailHelper = require("../Helpers/EmailHelper")
+
+module.exports =
+	createInvite: (req, res, next) ->
+		teamManagerId = AuthenticationController.getLoggedInUserId(req)
+		email = EmailHelper.parseEmail(req.body.email)
+		if !email?
+			return res.sendStatus(400)
+
+		TeamInvitesHandler.createInvite teamManagerId, email, (err, invite) ->
+			return next(err) if err?
+			inviteView = { user:
+				{ email: invite.email, sentAt: invite.sentAt, invite: true }
+			}
+			res.json inviteView
+
+	viewInvite: (req, res, next) ->
+		token = req.params.token
+		userId = AuthenticationController.getLoggedInUserId(req)
+
+		TeamInvitesHandler.getInvite token, (err, invite, teamSubscription) ->
+			return next(err) if err?
+
+			if !invite
+				return ErrorController.notFound(req, res, next)
+
+			SubscriptionLocator.getUsersSubscription userId, (err, personalSubscription) ->
+				return next(err) if err?
+
+				res.render "subscriptions/team/invite",
+					inviterName: invite.inviterName
+					inviteToken: invite.token
+					hasPersonalSubscription: personalSubscription?
+					appName: settings.appName
+
+	acceptInvite: (req, res, next) ->
+		token = req.params.token
+		userId = AuthenticationController.getLoggedInUserId(req)
+
+		TeamInvitesHandler.acceptInvite token, userId, (err, results) ->
+			return next(err) if err?
+			res.sendStatus 204
+
+	revokeInvite: (req, res) ->
+		email = EmailHelper.parseEmail(req.params.email)
+		teamManagerId = AuthenticationController.getLoggedInUserId(req)
+		if !email?
+			return res.sendStatus(400)
+
+		TeamInvitesHandler.revokeInvite teamManagerId, email, (err, results) ->
+			return next(err) if err?
+			res.sendStatus 204

+ 161 - 0
services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee

@@ -0,0 +1,161 @@
+logger = require("logger-sharelatex")
+crypto = require("crypto")
+async = require("async")
+
+settings = require("settings-sharelatex")
+ObjectId = require("mongojs").ObjectId
+
+TeamInvite = require("../../models/TeamInvite").TeamInvite
+Subscription = require("../../models/Subscription").Subscription
+
+UserGetter = require("../User/UserGetter")
+SubscriptionLocator = require("./SubscriptionLocator")
+SubscriptionUpdater = require("./SubscriptionUpdater")
+LimitationsManager = require("./LimitationsManager")
+
+EmailHandler = require("../Email/EmailHandler")
+EmailHelper = require("../Helpers/EmailHelper")
+
+Errors = require "../Errors/Errors"
+
+module.exports = TeamInvitesHandler =
+	getInvite: (token, callback) ->
+		Subscription.findOne 'teamInvites.token': token, (err, subscription) ->
+			return callback(err) if err?
+			return callback(new Errors.NotFoundError('team not found')) unless subscription?
+
+			invite = subscription.teamInvites.find (i) -> i.token == token
+			return callback(null, invite, subscription)
+
+	createInvite: (teamManagerId, email, callback) ->
+		email = EmailHelper.parseEmail(email)
+		return callback(new Error('invalid email')) if !email?
+		logger.log {teamManagerId, email}, "Creating manager team invite"
+		UserGetter.getUser teamManagerId, (error, teamManager) ->
+			return callback(error) if error?
+
+			SubscriptionLocator.getUsersSubscription teamManagerId, (error, subscription) ->
+				return callback(error) if error?
+
+				if teamManager.first_name and teamManager.last_name
+					inviterName = "#{teamManager.first_name} #{teamManager.last_name} (#{teamManager.email})"
+				else
+					inviterName = teamManager.email
+
+				removeLegacyInvite subscription.id, email, (error) ->
+					return callback(error) if error?
+					createInvite(subscription, email, inviterName, callback)
+
+	createDomainInvite: (user, licence, callback) ->
+		email = EmailHelper.parseEmail(user.email)
+		return callback(new Error('invalid email')) if !email?
+		logger.log {licence, email: email}, "Creating domain team invite"
+		inviterName = licence.name.replace(/\s+licence$/i, licence.name)
+
+		SubscriptionLocator.getSubscription licence.subscription_id, (error, subscription) ->
+			return callback(error) if error?
+			createInvite(subscription, email, inviterName, callback)
+
+	acceptInvite: (token, userId, callback) ->
+		logger.log {userId}, "Accepting invite"
+		TeamInvitesHandler.getInvite token, (err, invite, subscription) ->
+			return callback(err) if err?
+			return callback(new Errors.NotFoundError('invite not found')) unless invite?
+
+			SubscriptionUpdater.addUserToGroup subscription.admin_id, userId, (err) ->
+				return callback(err) if err?
+
+				removeInviteFromTeam(subscription.id, invite.email, callback)
+
+	revokeInvite: (teamManagerId, email, callback) ->
+		email = EmailHelper.parseEmail(email)
+		return callback(new Error('invalid email')) if !email?
+		logger.log {teamManagerId, email}, "Revoking invite"
+		SubscriptionLocator.getUsersSubscription teamManagerId, (err, teamSubscription) ->
+			return callback(err) if err?
+
+			removeInviteFromTeam(teamSubscription.id, email, callback)
+
+	# Legacy method to allow a user to receive a confirmation email if their
+	# email is in Subscription.invited_emails when they join. We'll remove this
+	# after a short while.
+	createTeamInvitesForLegacyInvitedEmail: (email, callback) ->
+		SubscriptionLocator.getGroupsWithEmailInvite email, (err, teams) ->
+			return callback(err) if err?
+
+			async.map teams,
+				(team, cb) -> TeamInvitesHandler.createInvite(team.admin_id, email, cb)
+			, callback
+
+createInvite = (subscription, email, inviterName, callback) ->
+	logger.log {subscriptionId: subscription.id, email, inviterName}, "Creating invite"
+	checkIfInviteIsPossible subscription, email, (error, possible, reason) ->
+		return callback(error) if error?
+		return callback(reason) unless possible
+
+
+		invite = subscription.teamInvites.find (invite) -> invite.email == email
+
+		if !invite?
+			invite = {
+				email: email
+				inviterName: inviterName
+				token: crypto.randomBytes(32).toString("hex")
+				sentAt: new Date()
+			}
+			subscription.teamInvites.push(invite)
+		else
+			invite.sentAt = new Date()
+
+		subscription.save (error) ->
+			return callback(error) if error?
+
+			opts =
+				to: email
+				inviterName: inviterName
+				acceptInviteUrl: "#{settings.siteUrl}/subscription/invites/#{invite.token}/"
+				appName: settings.appName
+			EmailHandler.sendEmail "verifyEmailToJoinTeam", opts, (error) ->
+				return callback(error, invite)
+
+removeInviteFromTeam = (subscriptionId, email, callback) ->
+	searchConditions = { _id: new ObjectId(subscriptionId.toString()) }
+	removeInvite = { $pull: { teamInvites: { email: email } } }
+	logger.log {subscriptionId, email, searchConditions, removeInvite}, 'removeInviteFromTeam'
+
+	async.series [
+		(cb) -> Subscription.update(searchConditions, removeInvite, cb),
+		(cb) -> removeLegacyInvite(subscriptionId, email, cb),
+	], callback
+
+removeLegacyInvite = (subscriptionId, email, callback) ->
+	Subscription.update({
+		_id: new ObjectId(subscriptionId.toString())
+	}, {
+		$pull: {
+			invited_emails: email
+		}
+	}, callback)
+
+checkIfInviteIsPossible = (subscription, email, callback = (error, possible, reason) -> ) ->
+	unless subscription.groupPlan
+		logger.log {subscriptionId: subscription.id},
+			"can not add members to a subscription that is not in a group plan"
+		return callback(null, false, wrongPlan: true)
+
+	if LimitationsManager.teamHasReachedMemberLimit(subscription)
+		logger.log {subscriptionId: subscription.id}, "team has reached member limit"
+		return callback(null, false, limitReached: true)
+
+	UserGetter.getUserByAnyEmail email, (error, existingUser) ->
+		return callback(error) if error?
+		return callback(null, true) unless existingUser?
+
+		existingMember = subscription.member_ids.find (memberId) ->
+			memberId.toString() == existingUser._id.toString()
+
+		if existingMember
+			logger.log {subscriptionId: subscription.id, email}, "user already in team"
+			return callback(null, false, alreadyInTeam: true)
+		else
+			return callback(null, true)

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

@@ -110,9 +110,9 @@ module.exports = UserController =
 								logger.err err:err, user_id:user_id, "error getting user for email update"
 								return res.send 500
 							AuthenticationController.setInSessionUser(req, {email: user.email, first_name: user.first_name, last_name: user.last_name})
-							UserHandler.populateGroupLicenceInvite user, (err)-> #need to refresh this in the background
+							UserHandler.populateTeamInvites user, (err)-> #need to refresh this in the background
 								if err?
-									logger.err err:err, "error populateGroupLicenceInvite"
+									logger.err err:err, "error populateTeamInvites"
 								res.sendStatus(200)
 
 	logout : (req, res)->

+ 4 - 0
services/web/app/coffee/Features/User/UserCreator.coffee

@@ -18,6 +18,10 @@ module.exports = UserCreator =
 			
 		user.ace.syntaxValidation = true
 		user.featureSwitches?.pdfng = true
+		user.emails = [
+			email: user.email
+			createdAt: new Date()
+		]
 
 		user.save (err)->
 			callback(err, user)

+ 8 - 1
services/web/app/coffee/Features/User/UserGetter.coffee

@@ -64,12 +64,19 @@ module.exports = UserGetter =
 			return callback(null, user) if user?
 			db.userstubs.findOne query, projection, callback
 
+	# check for duplicate email address. This is also enforced at the DB level
+	ensureUniqueEmailAddress: (newEmail, callback) ->
+		@getUserByAnyEmail newEmail, (error, user) ->
+			return callback(message: 'alread_exists') if user?
+			callback(error)
+
 [
 	'getUser',
 	'getUserEmail',
 	'getUserByMainEmail',
 	'getUserByAnyEmail',
 	'getUsers',
-	'getUserOrUserStubById'
+	'getUserOrUserStubById',
+	'ensureUniqueEmailAddress',
 ].map (method) ->
 	metrics.timeAsyncMethod UserGetter, method, 'mongo.UserGetter', logger

+ 18 - 16
services/web/app/coffee/Features/User/UserHandler.coffee

@@ -1,29 +1,31 @@
 SubscriptionDomainHandler = require("../Subscription/SubscriptionDomainHandler")
 NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 SubscriptionGroupHandler = require("../Subscription/SubscriptionGroupHandler")
+TeamInvitesHandler = require("../Subscription/TeamInvitesHandler")
 logger = require("logger-sharelatex")
 
 
 module.exports = UserHandler =
 
-	populateGroupLicenceInvite: (user, callback = ->)->
-		SubscriptionGroupHandler.convertEmailInvitesToMemberships user.email, user._id, (err) ->
+	populateTeamInvites: (user, callback) ->
+		UserHandler.notifyDomainLicence user, (err) ->
 			return callback(err) if err?
+			TeamInvitesHandler.createTeamInvitesForLegacyInvitedEmail(user.email, callback)
 
-			logger.log user_id:user._id, "populating any potential group licence invites"
-			licence = SubscriptionDomainHandler.getLicenceUserCanJoin user
-			if !licence?
-				return callback()
+	notifyDomainLicence: (user, callback = ->)->
+		logger.log user_id:user._id, "notiying user about a potential domain licence"
+		licence = SubscriptionDomainHandler.getLicenceUserCanJoin user
+		if !licence?
+			return callback()
 
-			SubscriptionGroupHandler.isUserPartOfGroup user._id, licence.subscription_id, (err, alreadyPartOfGroup)->
-				if err?
-					return callback(err)
-				else if alreadyPartOfGroup
-					logger.log user_id:user._id, "user already part of group, not creating notifcation for them"
-					return callback()
-				else
-					NotificationsBuilder.groupPlan(user, licence).create(callback)
+		SubscriptionGroupHandler.isUserPartOfGroup user._id, licence.subscription_id, (err, alreadyPartOfGroup)->
+			if err?
+				return callback(err)
+			else if alreadyPartOfGroup
+				logger.log user_id:user._id, "user already part of team, not creating notifcation for them"
+				return callback()
+			else
+				NotificationsBuilder.groupPlan(user, licence).create(callback)
 
 	setupLoginData: (user, callback = ->)->
-		@populateGroupLicenceInvite user, callback
-
+		@populateTeamInvites user, callback

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

@@ -48,7 +48,7 @@ module.exports = UserRegistrationHandler =
 		if !requestIsValid
 			return callback(new Error("request is not valid"))
 		userDetails.email = userDetails.email?.trim()?.toLowerCase()
-		UserGetter.getUserByMainEmail userDetails.email, (err, user) =>
+		UserGetter.getUserByAnyEmail userDetails.email, (err, user) =>
 			if err?
 				return callback err
 			if user?.holdingAccount == false

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

@@ -43,7 +43,7 @@ module.exports = UserUpdater =
 	# Add a new email address for the user. Email cannot be already used by this
 	# or any other user
 	addEmailAddress: (userId, newEmail, callback) ->
-		@_ensureUniqueEmailAddress newEmail, (error) =>
+		UserGetter.ensureUniqueEmailAddress newEmail, (error) =>
 			return callback(error) if error?
 
 			update = $push: emails: email: newEmail, createdAt: new Date()
@@ -81,20 +81,11 @@ module.exports = UserUpdater =
 				return callback(new Error('Default email does not belong to user'))
 			callback()
 
-
-	# check for duplicate email address. This is also enforced at the DB level
-	_ensureUniqueEmailAddress: (newEmail, callback) ->
-		UserGetter.getUserByAnyEmail newEmail, (error, user) ->
-			return callback(message: 'alread_exists') if user?
-			callback()
-
-
 [
 	'updateUser'
 	'changeEmailAddress'
 	'setDefaultEmailAddress'
 	'addEmailAddress'
 	'removeEmailAddress'
-	'_ensureUniqueEmailAddress'
 ].map (method) ->
 	metrics.timeAsyncMethod(UserUpdater, method, 'mongo.UserUpdater', logger)

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

@@ -1,5 +1,6 @@
 mongoose = require 'mongoose'
 Settings = require 'settings-sharelatex'
+TeamInviteSchema = require('./TeamInvite').TeamInviteSchema
 
 Schema = mongoose.Schema
 ObjectId = Schema.ObjectId
@@ -8,6 +9,7 @@ SubscriptionSchema = new Schema
 	admin_id     :   {type:ObjectId, ref:'User', index: {unique: true, dropDups: true}}
 	member_ids   :   [ type:ObjectId, ref:'User' ]
 	invited_emails:  [ String ]
+	teamInvites  :   [ TeamInviteSchema ]
 	recurlySubscription_id : String
 	planCode 	 : {type: String}
 	groupPlan	 : {type: Boolean, default: false}

+ 15 - 0
services/web/app/coffee/models/TeamInvite.coffee

@@ -0,0 +1,15 @@
+mongoose = require 'mongoose'
+Settings = require 'settings-sharelatex'
+
+Schema = mongoose.Schema
+ObjectId = Schema.ObjectId
+
+TeamInviteSchema = new Schema
+	email          :     { type: String, required: true }
+	token          :     { type: String }
+	inviterName    :     { type: String }
+	sentAt         :     { type: Date }
+
+mongoose.model 'TeamInvite', TeamInviteSchema
+exports.TeamInvite = mongoose.model 'TeamInvite'
+exports.TeamInviteSchema = TeamInviteSchema

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

@@ -8,6 +8,10 @@ ObjectId = Schema.ObjectId
 
 UserSchema = new Schema
 	email             : {type : String, default : ''}
+	emails: [{
+		email: { type : String, default : '' },
+		createdAt: { type : Date, default: () -> new Date() }
+	}],
 	first_name        : {type : String, default : ''}
 	last_name         : {type : String, default : ''}
 	role  	          : {type : String, default : ''}

+ 2 - 2
services/web/app/views/layout.pug

@@ -54,7 +54,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 			meta(itemprop="image", name="image", content=metaImage.fields.file.url)
 
 		//-	Meta Tags: Twitter
-		-if (typeof(settings.social.twitter.handle) != "undefined")
+		-if (settings.social && settings.social.twitter && settings.social.twitter.handle)
 			meta(name="twitter:site", content=settings.social.twitter.handle)
 			meta(name="twitter:card", content=metaTwitterCard ? metaTwitterCard : 'summary')
 		-if (typeof(metaTwitterDescription) != "undefined")
@@ -65,7 +65,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 			meta(itemprop="image", name="image", content=metaImage.fields.file.url)
 
 		//-	Meta Tags: Open Graph
-		-if (typeof(settings.social.facebook.appId) != "undefined")
+		-if (settings.social && settings.social.facebook && settings.social.facebook.appId)
 			meta(name="fb:app_id", content=settings.social.facebook.appId)
 		-if (typeof(metaOpenGraphDescription) != "undefined")
 				meta(itemprop="og:description", content=metaOpenGraphDescription)

+ 35 - 24
services/web/app/views/project/list/notifications.pug

@@ -26,27 +26,38 @@ span(ng-controller="NotificationsController").userNotifications
 		)
 			.row(ng-hide="notification.hide")
 				.col-xs-12
-					.alert.alert-info(ng-if="notification.templateKey == 'notification_project_invite'", ng-controller="ProjectInviteNotificationController")
-						div.notification_inner
-							.notification_body(ng-show="!notification.accepted")
-								| !{translate("notification_project_invite_message")}
-								a.pull-right.btn.btn-sm.btn-info(href, ng-click="accept()", ng-disabled="notification.inflight")
-									span(ng-show="!notification.inflight") #{translate("join_project")}
-									span(ng-show="notification.inflight")
-										i.fa.fa-fw.fa-spinner.fa-spin
-										| &nbsp;
-										| #{translate("joining")}...
-							.notification_body(ng-show="notification.accepted")
-								| !{translate("notification_project_invite_accepted_message")}
-								a.pull-right.btn.btn-sm.btn-info(href="/project/{{ notification.messageOpts.projectId }}") #{translate("open_project")}
-							span().notification_close
-								button(ng-click="dismiss(notification)").close.pull-right
-									span(aria-hidden="true") &times;
-									span.sr-only #{translate("close")}
-					.alert.alert-info(ng-if="notification.templateKey != 'notification_project_invite'")
-						div.notification_inner
-							span(ng-bind-html="notification.html").notification_body
-							span().notification_close
-								button(ng-click="dismiss(notification)").close.pull-right
-									span(aria-hidden="true") &times;
-									span.sr-only #{translate("close")}
+					div(ng-switch="notification.templateKey")
+						.alert.alert-info(ng-switch-when="notification_project_invite", ng-controller="ProjectInviteNotificationController")
+							div.notification_inner
+								.notification_body(ng-show="!notification.accepted")
+									| !{translate("notification_project_invite_message")}
+									a.pull-right.btn.btn-sm.btn-info(href, ng-click="accept()", ng-disabled="notification.inflight")
+										span(ng-show="!notification.inflight") #{translate("join_project")}
+										span(ng-show="notification.inflight")
+											i.fa.fa-fw.fa-spinner.fa-spin
+											| &nbsp;
+											| #{translate("joining")}...
+								.notification_body(ng-show="notification.accepted")
+									| !{translate("notification_project_invite_accepted_message")}
+									a.pull-right.btn.btn-sm.btn-info(href="/project/{{ notification.messageOpts.projectId }}") #{translate("open_project")}
+								span().notification_close
+									button(ng-click="dismiss(notification)").close.pull-right
+										span(aria-hidden="true") &times;
+										span.sr-only #{translate("close")}
+						.alert.alert-info(ng-switch-when="notification_group_invite")
+							div.notification_inner
+								.notification_body
+									| #{translate("invited_to_join_team")}: {{ notification.messageOpts.groupName }}
+									a.pull-right.btn.btn-sm.btn-info(href="/user/subscription/domain/join")
+										| #{translate("join_team")}
+								span().notification_close
+									button(ng-click="dismiss(notification)").close.pull-right
+										span(aria-hidden="true") &times;
+										span.sr-only #{translate("close")}
+						.alert.alert-info(ng-switch-default)
+							div.notification_inner
+								span(ng-bind-html="notification.html").notification_body
+								span().notification_close
+									button(ng-click="dismiss(notification)").close.pull-right
+										span(aria-hidden="true") &times;
+										span.sr-only #{translate("close")}

+ 39 - 0
services/web/app/views/subscriptions/domain/join.pug

@@ -0,0 +1,39 @@
+extends ../../layout
+
+block scripts
+	script(type='text/javascript').
+		window.group_subscription_id = '#{group_subscription_id}'
+		window.has_personal_subscription = #{has_personal_subscription}
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-8.col-md-offset-2
+					-if (query.expired)
+						.alert.alert-warning #{translate("email_link_expired")}
+
+			.row.row-spaced
+				.col-md-8.col-md-offset-2.text-center(ng-cloak)
+					.card(ng-controller="DomainSubscriptionJoinController")
+						.page-header
+							h1.text-centered #{translate("you_are_invited_to_group", {groupName:licenceName})}
+
+						div(ng-show="view =='personalSubscription'")
+							p #{translate("cancel_personal_subscription_first")}
+							p
+								a.btn.btn.btn-default(ng-click="keepPersonalSubscription()", ng-disabled="inflight") #{translate("not_now")}
+								| &nbsp;
+								a.btn.btn.btn-primary(ng-click="cancelSubscription()", ng-disabled="inflight") #{translate("cancel_your_subscription")}
+
+						div(ng-show="view =='domainSubscriptionJoin'")
+							p #{translate("group_provides_you_with_premium_account", {groupName:licenceName})}
+							p
+								a.btn.btn-default(href="/project") #{translate("not_now")}
+								| &nbsp;
+								a.btn.btn.btn-primary(ng-click="joinGroup()", ng-disabled="inflight") #{translate("verify_email_address")}  
+
+						div(ng-show="view =='requestSent'")
+							p #{translate("check_email_to_complete_the_upgrade")}
+							p
+								a.btn.btn.btn-primary(href="/project") #{translate("done")}

+ 0 - 54
services/web/app/views/subscriptions/group/invite.pug

@@ -1,54 +0,0 @@
-extends ../../layout
-
-block scripts
-	script(type='text/javascript').
-		window.group_subscription_id = '#{group_subscription_id}'
-		window.has_personal_subscription = #{has_personal_subscription}
-
-block content
-	.content.content-alt
-		.container
-			.row
-				.col-md-8.col-md-offset-2
-					-if (query.expired)
-						.alert.alert-warning #{translate("email_link_expired")}
-
-			.row
-				div &nbsp;
-			.row
-				.col-md-8.col-md-offset-2(ng-cloak)
-					.card(ng-controller="GroupSubscriptionInviteController")
-						.page-header
-							h1.text-centered #{translate("you_are_invited_to_group", {groupName:licenceName})}
-
-						div(ng-show="view =='personalSubscription'").row.text-centered
-							div #{translate("cancel_personal_subscription_first")}
-							.row
-								.col-md-12 &nbsp;
-							.row
-								.col-md-12
-									a.btn.btn.btn-default(ng-click="keepPersonalSubscription()", ng-disabled="inflight") #{translate("not_now")}
-									span &nbsp;
-									a.btn.btn.btn-primary(ng-click="cancelSubscription()", ng-disabled="inflight") #{translate("cancel_your_subscription")}
-
-						div(ng-show="view =='groupSubscriptionInvite'").row.text-centered
-							.row
-								.col-md-12 #{translate("group_provides_you_with_premium_account", {groupName:licenceName})}
-							.row
-								.col-md-12 &nbsp;
-							.row 
-								.col-md-12
-									.text-center
-										a.btn.btn-default(href="/project") #{translate("not_now")}
-										span &nbsp;
-										a.btn.btn.btn-primary(ng-click="joinGroup()", ng-disabled="inflight") #{translate("verify_email_address")}  
-
-
-						span(ng-show="view =='requestSent'").row.text-centered.text-center
-							.row
-								.col-md-12 #{translate("check_email_to_complete_the_upgrade")}
-							.row
-								.col-md-12 &nbsp;
-							.row
-								.col-md-12
-									a.btn.btn.btn-primary(href="/project") #{translate("done")}

+ 0 - 25
services/web/app/views/subscriptions/group/successful_join.pug

@@ -1,25 +0,0 @@
-extends ../../layout
-
-block scripts
-	script(type='text/javascript').
-		window.subscription_id = '#{subscription_id}'
-
-block content
-	.content.content-alt
-		.container
-			.row
-				.col-md-8.col-md-offset-2(ng-cloak)
-					.card
-						.page-header.row.text-centered
-							h1 #{translate("you_have_joined", {groupName:licenceName})}
-						div(ng-show="!requestSent").row.text-centered
-							.row
-								.span-md-12 #{translate("claim_premium_account", {groupName:licenceName})}
-									div 
-							.row
-								.col-md-12 &nbsp;
-							.row
-								.span-md-12
-									a.btn.btn-success(href="/project") #{translate("done")} 
-
-

+ 3 - 3
services/web/app/views/subscriptions/group_admin.pug

@@ -32,7 +32,7 @@ block content
 										.col-md-5
 											span.header #{translate("name")}
 										.col-md-2
-											span.header #{translate("registered")}
+											span.header #{translate("accepted_invite")}
 								li.container-fluid(
 									ng-repeat="user in users | orderBy:'email':true",
 									ng-controller="SubscriptionGroupMemberListItemController"
@@ -49,8 +49,8 @@ block content
 											span.name {{ user.first_name }} {{ user.last_name }}
 										.col-md-2
 											span.registered
-												i.fa.fa-check.text-success(ng-show="!user.holdingAccount")
-												i.fa.fa-times(ng-show="user.holdingAccount")
+												i.fa.fa-check.text-success(ng-show="!user.invite")
+												i.fa.fa-times(ng-show="user.invite")
 								li(
 									ng-if="users.length == 0",
 									ng-cloak

+ 40 - 0
services/web/app/views/subscriptions/team/invite.pug

@@ -0,0 +1,40 @@
+extends ../../layout
+
+block scripts
+	script(type='text/javascript').
+		window.teamId = '#{teamId}'
+		window.hasPersonalSubscription = #{hasPersonalSubscription}
+		window.inviteToken = '#{inviteToken}'
+
+block content
+	.content.content-alt.team-invite
+		.container
+			.row
+				.col-md-8.col-md-offset-2
+					-if (query.expired)
+						.alert.alert-warning #{translate("email_link_expired")}
+
+			.row.row-spaced
+				.col-md-8.col-md-offset-2.text-center(ng-cloak)
+					.card(ng-controller="TeamInviteController")
+						.page-header
+							h1.text-centered #{translate("invited_to_group", {inviterName: inviterName, appName: appName})}
+
+						div(ng-show="view =='personalSubscription'")
+							p #{translate("cancel_personal_subscription_first")}
+							p
+								a.btn.btn.btn-default(ng-click="keepPersonalSubscription()", ng-disabled="inflight") #{translate("not_now")}
+								| &nbsp;
+								a.btn.btn.btn-primary(ng-click="cancelPersonalSubscription()", ng-disabled="inflight") #{translate("cancel_your_subscription")}
+
+						div(ng-show="view =='teamInvite'")
+							p #{translate("join_team_explanation", {appName: appName})}
+							p
+								a.btn.btn-default(href="/project") #{translate("not_now")}
+								| &nbsp;
+								a.btn.btn.btn-primary(ng-click="joinTeam()", ng-disabled="inflight") #{translate("accept_invitation")} 
+
+						div(ng-show="view =='inviteAccepted'")
+							p #{translate("joined_team", {inviterName: inviterName})}
+							p
+								a.btn.btn.btn-primary(href="/project") #{translate("done")}

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

@@ -34,8 +34,7 @@ define [
 				enableLiveAutocompletion: false
 			})
 
-			commandCompleter = new CommandManager(@metadataManager)
-
+			CommandCompleter = new CommandManager(@metadataManager)
 			SnippetCompleter = new EnvironmentManager()
 			PackageCompleter = new PackageManager(@metadataManager, Helpers)
 
@@ -153,7 +152,7 @@ define [
 								callback null, result
 
 			@editor.completers = [
-				commandCompleter
+				CommandCompleter
 				SnippetCompleter
 				PackageCompleter
 				ReferencesCompleter

+ 43 - 4
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/EnvironmentManager.coffee

@@ -23,7 +23,7 @@ define () ->
 		"thebibliography"
 	]
 
-	environmentNames = snippetNames.concat environments
+	environmentNames = snippetNames.concat(environments)
 
 	staticSnippets = for env in environments
 		{
@@ -111,7 +111,19 @@ define () ->
 			\\end{frame}
 		"""
 		meta: "env"
-	}, {
+	}]
+
+	documentSnippet = {
+		caption: "\\begin{document}..."
+		snippet: """
+			\\begin{document}
+			$1
+			\\end{document}
+		"""
+		meta: "env"
+	}
+
+	bibliographySnippet = {
 		caption: "\\begin{thebibliography}..."
 		snippet: """
 			\\begin{thebibliography}{$1}
@@ -120,7 +132,8 @@ define () ->
 			\\end{thebibliography}
 		"""
 		meta: "env"
-	}]
+	}
+	staticSnippets.push(documentSnippet)
 
 	parseCustomEnvironments = (text) ->
 		re = /^\\newenvironment{(\w+)}.*$/gm
@@ -138,18 +151,44 @@ define () ->
 		result = []
 		iterations = 0
 		while match = re.exec(text)
-			if match[1] not in environmentNames
+			if match[1] not in environmentNames and match[1] != "document"
 				result.push {name: match[1], whitespace: match[2]}
 				iterations += 1
 				if iterations >= 1000
 					return result
 		return result
 
+	hasDocumentEnvironment = (text) ->
+		re = /^\\begin{document}/m
+		envs = []
+		iterations = 0
+		return re.exec(text) != null
+
+	hasBibliographyEnvironment = (text) ->
+		re = /^\\begin{thebibliography}/m
+		envs = []
+		iterations = 0
+		return re.exec(text) != null
+
 	class EnvironmentManager
 		getCompletions: (editor, session, pos, prefix, callback) ->
 			docText = session.getValue()
 			customEnvironments = parseCustomEnvironments(docText)
 			beginCommands = parseBeginCommands(docText)
+			if hasDocumentEnvironment(docText)
+				ind = staticSnippets.indexOf(documentSnippet)
+				if ind != -1
+					staticSnippets.splice(ind, 1)
+			else
+				staticSnippets.push documentSnippet
+
+			if hasBibliographyEnvironment(docText)
+				ind = staticSnippets.indexOf(bibliographySnippet)
+				if ind != -1
+					staticSnippets.splice(ind, 1)
+			else
+				staticSnippets.push bibliographySnippet
+
 			parsedItemsMap = {}
 			for environment in customEnvironments
 				parsedItemsMap[environment.name] = environment

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

@@ -16,7 +16,8 @@ define [
 	"main/annual-upgrade"
 	"main/announcements"
 	"main/register-users"
-	"main/subscription/group-subscription-invite-controller"
+	"main/subscription/domain-subscription-join-controller"
+	"main/subscription/team-invite-controller"
 	"main/contact-us"
 	"main/learn"
 	"analytics/AbTestingManager"

+ 4 - 4
services/web/public/coffee/main/group-members.coffee

@@ -22,7 +22,7 @@ define [
 			emails = parseEmails($scope.inputs.emails)
 			for email in emails
 				queuedHttp
-					.post("/subscription/group/user", {
+					.post("/subscription/invites", {
 						email: email,
 						_csrf: window.csrfToken
 					})
@@ -34,8 +34,8 @@ define [
 		$scope.removeMembers = () ->
 			for user in $scope.selectedUsers
 				do (user) ->
-					if user.holdingAccount and !user._id?
-						url = "/subscription/group/email/#{encodeURIComponent(user.email)}"
+					if user.invite and !user._id?
+						url = "/subscription/invites/#{encodeURIComponent(user.email)}"
 					else
 						url = "/subscription/group/user/#{user._id}"
 					queuedHttp({
@@ -56,4 +56,4 @@ define [
 	App.controller "SubscriptionGroupMemberListItemController", ($scope) ->
 		$scope.$watch "user.selected", (value) ->
 			if value?
-				$scope.updateSelectedUsers()
+				$scope.updateSelectedUsers()

+ 8 - 8
services/web/public/coffee/main/subscription/group-subscription-invite-controller.coffee → services/web/public/coffee/main/subscription/domain-subscription-join-controller.coffee

@@ -1,35 +1,35 @@
 define [
 	"base"
 ], (App) ->
-	App.controller "GroupSubscriptionInviteController", ($scope, $http) ->
+	App.controller "DomainSubscriptionJoinController", ($scope, $http) ->
 
 		$scope.inflight = false
 
 		if has_personal_subscription
 			$scope.view = "personalSubscription"
-		else 
-			$scope.view = "groupSubscriptionInvite"
+		else
+			$scope.view = "domainSubscriptionJoin"
 
 		$scope.keepPersonalSubscription = ->
-			$scope.view = "groupSubscriptionInvite"
+			$scope.view = "domainSubscriptionJoin"
 
 		$scope.cancelSubscription = ->
 			$scope.inflight = true
 			request = $http.post "/user/subscription/cancel", {_csrf:window.csrfToken}
 			request.then ()->
 				$scope.inflight = false
-				$scope.view = "groupSubscriptionInvite"
+				$scope.view = "domainSubscriptionJoin"
 			request.catch ()->
-				console.log "the request failed"					
+				console.log "the request failed"
 
 		$scope.joinGroup = ->
 			$scope.view = "requestSent"
 			$scope.inflight = true
-			request = $http.post "/user/subscription/#{group_subscription_id}/group/begin-join", {_csrf:window.csrfToken}
+			request = $http.post "/user/subscription/domain/join", {_csrf:window.csrfToken}
 			request.then (response)->
 				{ status } = response
 				$scope.inflight = false
 				if status != 200 # assume request worked
 					$scope.requestSent = false
 			request.catch ()->
-				console.log "the request failed"
+				console.log "the request failed"

+ 35 - 0
services/web/public/coffee/main/subscription/team-invite-controller.coffee

@@ -0,0 +1,35 @@
+define [
+	"base"
+], (App) ->
+	App.controller "TeamInviteController", ($scope, $http) ->
+
+		$scope.inflight = false
+
+		if hasPersonalSubscription
+			$scope.view = "personalSubscription"
+		else
+			$scope.view = "teamInvite"
+
+		$scope.keepPersonalSubscription = ->
+			$scope.view = "teamInvite"
+
+		$scope.cancelPersonalSubscription = ->
+			$scope.inflight = true
+			request = $http.post "/user/subscription/cancel", {_csrf:window.csrfToken}
+			request.then ()->
+				$scope.inflight = false
+				$scope.view = "teamInvite"
+			request.catch ()->
+				console.log "the request failed"
+
+		$scope.joinTeam = ->
+			$scope.inflight = true
+			request = $http.put "/subscription/invites/#{window.inviteToken}/", {_csrf:window.csrfToken}
+			request.then (response)->
+				{ status } = response
+				$scope.inflight = false
+				$scope.view = "inviteAccepted"
+				if status != 200 # assume request worked
+					$scope.requestSent = false
+			request.catch ()->
+				console.log "the request failed"

+ 7 - 3
services/web/public/stylesheets/app/subscription.less

@@ -24,7 +24,7 @@
 .input-feedback-message {
 	display: none;
 	font-size: 0.8em;
-	
+
 	.has-error & {
 		display: inline-block;
 	}
@@ -58,7 +58,7 @@
 
 		& + & {
 			border-left-width: 0;
-			border-radius: 0 @border-radius-large @border-radius-large 0; 
+			border-radius: 0 @border-radius-large @border-radius-large 0;
 		}
 
 		&-selected {
@@ -73,6 +73,10 @@
 	}
 }
 
+.team-invite .message {
+	margin: 3em 0;
+}
+
 .capitalised {
 	text-transform:capitalize;
-}
+}

+ 14 - 0
services/web/test/acceptance/coffee/RegistrationTests.coffee

@@ -128,6 +128,20 @@ describe "CSRF protection", ->
 						expect(response.statusCode).to.equal 403
 						done()
 
+describe "Register", ->
+	before ->
+		@user = new User()
+
+	it 'Set emails attribute', (done) ->
+		@user.register (error, user) =>
+			expect(error).to.not.exist
+			user.email.should.equal @user.email
+			user.emails.should.exist
+			user.emails.should.be.a 'array'
+			user.emails.length.should.equal 1
+			user.emails[0].email.should.equal @user.email
+			done()
+
 describe "LoginViaRegistration", ->
 
 	before (done) ->

+ 22 - 5
services/web/test/acceptance/coffee/helpers/User.coffee

@@ -22,9 +22,30 @@ class User
 			jar: @jar
 		})
 
+	setExtraAttributes: (user) ->
+		throw new Error("User does not exist") unless user?._id?
+		@id = user._id.toString()
+		@_id = user._id.toString()
+		@first_name = user.first_name
+		@referal_id = user.referal_id
+
 	get: (callback = (error, user)->) ->
 		db.users.findOne { _id: ObjectId(@_id) }, callback
 
+	register: (callback = (error, user) ->) ->
+		return callback(new Error('User already registered')) if @_id?
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.post {
+				url: '/register'
+				json: { @email, @password }
+			}, (error, response, body) =>
+				return callback(error) if error?
+				db.users.findOne { email: @email }, (error, user) =>
+					return callback(error) if error?
+					@setExtraAttributes user
+					callback(null, user)
+
 	login: (callback = (error) ->) ->
 		@loginWith(@email, callback)
 
@@ -47,11 +68,7 @@ class User
 				return callback(error) if error?
 				UserUpdater.updateUser user._id, $set: emails: @emails, (error) =>
 					return callback(error) if error?
-					@id = user?._id?.toString()
-					@_id = user?._id?.toString()
-					@first_name = user?.first_name
-					@referal_id = user?.referal_id
-
+					@setExtraAttributes user
 					callback(null, @password)
 
 	setFeatures: (features, callback = (error) ->) ->

+ 6 - 6
services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee

@@ -25,7 +25,7 @@ describe "CollaboratorsInviteController", ->
 
 		@LimitationsManager = {}
 		@UserGetter =
-			getUserByMainEmail: sinon.stub()
+			getUserByAnyEmail: sinon.stub()
 			getUser: sinon.stub()
 
 		@CollaboratorsInviteController = SandboxedModule.require modulePath, requires:
@@ -716,7 +716,7 @@ describe "CollaboratorsInviteController", ->
 
 				beforeEach ->
 					@user = {_id: ObjectId().toString()}
-					@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user)
+					@UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, @user)
 
 				it 'should callback with `true`', (done) ->
 					@call (err, shouldAllow) =>
@@ -728,7 +728,7 @@ describe "CollaboratorsInviteController", ->
 
 				beforeEach ->
 					@user = null
-					@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user)
+					@UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, @user)
 
 				it 'should callback with `false`', (done) ->
 					@call (err, shouldAllow) =>
@@ -738,15 +738,15 @@ describe "CollaboratorsInviteController", ->
 
 				it 'should have called getUser', (done) ->
 					@call (err, shouldAllow) =>
-						@UserGetter.getUserByMainEmail.callCount.should.equal 1
-						@UserGetter.getUserByMainEmail.calledWith(@email, {_id: 1}).should.equal true
+						@UserGetter.getUserByAnyEmail.callCount.should.equal 1
+						@UserGetter.getUserByAnyEmail.calledWith(@email, {_id: 1}).should.equal true
 						done()
 
 			describe 'when getUser produces an error', ->
 
 				beforeEach ->
 					@user = null
-					@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops'))
+					@UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, new Error('woops'))
 
 				it 'should callback with an error', (done) ->
 					@call (err, shouldAllow) =>

+ 9 - 9
services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee

@@ -605,7 +605,7 @@ describe "CollaboratorsInviteHandler", ->
 				_id: ObjectId()
 				first_name: "jim"
 			@existingUser = {_id: ObjectId()}
-			@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @existingUser)
+			@UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, @existingUser)
 			@fakeProject =
 				_id: @project_id
 				name: "some project"
@@ -626,8 +626,8 @@ describe "CollaboratorsInviteHandler", ->
 
 			it 'should call getUser', (done) ->
 				@call (err) =>
-					@UserGetter.getUserByMainEmail.callCount.should.equal 1
-					@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
+					@UserGetter.getUserByAnyEmail.callCount.should.equal 1
+					@UserGetter.getUserByAnyEmail.calledWith(@invite.email).should.equal true
 					done()
 
 			it 'should call getProject', (done) ->
@@ -671,7 +671,7 @@ describe "CollaboratorsInviteHandler", ->
 		describe 'when the user does not exist', ->
 
 			beforeEach ->
-				@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, null)
+				@UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, null)
 
 			it 'should not produce an error', (done) ->
 				@call (err) =>
@@ -680,8 +680,8 @@ describe "CollaboratorsInviteHandler", ->
 
 			it 'should call getUser', (done) ->
 				@call (err) =>
-					@UserGetter.getUserByMainEmail.callCount.should.equal 1
-					@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
+					@UserGetter.getUserByAnyEmail.callCount.should.equal 1
+					@UserGetter.getUserByAnyEmail.calledWith(@invite.email).should.equal true
 					done()
 
 			it 'should not call getProject', (done) ->
@@ -698,7 +698,7 @@ describe "CollaboratorsInviteHandler", ->
 		describe 'when the getUser produces an error', ->
 
 			beforeEach ->
-				@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops'))
+				@UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, new Error('woops'))
 
 			it 'should produce an error', (done) ->
 				@call (err) =>
@@ -707,8 +707,8 @@ describe "CollaboratorsInviteHandler", ->
 
 			it 'should call getUser', (done) ->
 				@call (err) =>
-					@UserGetter.getUserByMainEmail.callCount.should.equal 1
-					@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
+					@UserGetter.getUserByAnyEmail.callCount.should.equal 1
+					@UserGetter.getUserByAnyEmail.calledWith(@invite.email).should.equal true
 					done()
 
 			it 'should not call getProject', (done) ->

+ 4 - 2
services/web/test/unit/coffee/Subscription/LimitationsManagerTests.coffee

@@ -55,7 +55,7 @@ describe "LimitationsManager", ->
 
 			it "should return the number of collaborators the user is allowed", ->
 				@callback.calledWith(null, @user.features.collaborators).should.equal true
-	
+
 	describe "allowedNumberOfCollaboratorsForUser", ->
 		describe "when the user has no features", ->
 			beforeEach ->
@@ -286,7 +286,9 @@ describe "LimitationsManager", ->
 			@subscription =
 				membersLimit: 3
 				member_ids: ["", ""]
-				invited_emails: ["bob@example.com"]
+				teamInvites: [
+					{ email: "bob@example.com", sentAt: new Date(), token: "hey" }
+				]
 
 		it "should return true if the limit is hit (including members and invites)", (done)->
 			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription)

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

@@ -24,10 +24,7 @@ describe "SubscriptionGroupController", ->
 		@GroupHandler =
 			addUserToGroup: sinon.stub().callsArgWith(2, null, @user)
 			removeUserFromGroup: sinon.stub().callsArgWith(2)
-			removeEmailInviteFromGroup: sinon.stub().callsArgWith(2)
 			isUserPartOfGroup: sinon.stub()
-			sendVerificationEmail:sinon.stub()
-			processGroupVerification:sinon.stub()
 			getPopulatedListOfMembers: sinon.stub().callsArgWith(1, null, [@user])
 		@SubscriptionLocator = getUsersSubscription: sinon.stub().callsArgWith(1, null, @subscription)
 		@AuthenticationController =
@@ -80,16 +77,6 @@ describe "SubscriptionGroupController", ->
 					done()
 			@Controller.removeUserFromGroup @req, res
 
-	describe "removeEmailInviteFromGroup", ->
-		it "should use the admin id for the logged in user and take the email from the params", (done)->
-			email = "jo@example.com"
-			@req.params = email: email
-			res =
-				send : =>
-					@GroupHandler.removeEmailInviteFromGroup.calledWith(@adminUserId, email).should.equal true
-					done()
-			@Controller.removeEmailInviteFromGroup @req, res
-
 	describe "renderSubscriptionGroupAdminPage", ->
 		it "should redirect you if you don't have a group account", (done)->
 			@subscription.groupPlan = false
@@ -109,97 +96,6 @@ describe "SubscriptionGroupController", ->
 					done()
 			@Controller.renderSubscriptionGroupAdminPage @req, res
 
-	describe "renderGroupInvitePage", ->
-		describe "with a valid licence", ->
-			beforeEach ->
-				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns({subscription_id:@subscription_id, adminUser_id:@adminUserId})
-
-			it "should render subscriptions/group/invite if not part of group", (done)->
-				@GroupHandler.isUserPartOfGroup.callsArgWith(2, null, false)
-				res =
-					render : (pageName)=>
-						pageName.should.equal "subscriptions/group/invite"
-						done()
-				@Controller.renderGroupInvitePage @req, res
-
-			it "should redirect to custom page if is already part of group", (done)->
-				@GroupHandler.isUserPartOfGroup.callsArgWith(2, null, true)
-				res =
-					redirect : (location)=>
-						location.should.equal "/user/subscription/custom_account"
-						done()
-				@Controller.renderGroupInvitePage @req, res
-
-		describe "without a valid licence", ->
-			beforeEach ->
-				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns(undefined)
-
-			it "should send a 500", (done)->
-				@Controller.renderGroupInvitePage @req, {}
-				@ErrorsController.notFound.called.should.equal true
-				done()
-
-
-
-	describe "beginJoinGroup", ->
-		describe "with a valid licence", ->
-			beforeEach ->
-				@licenceName = "get amazing licence"
-				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns({name:@licenceName})
-				@GroupHandler.sendVerificationEmail.callsArgWith(3)
-
-			it "should ask the SubscriptionGroupHandler to send the verification email", (done)->
-				res =
-					sendStatus : (statusCode)=>
-						statusCode.should.equal 200
-						@GroupHandler.sendVerificationEmail.calledWith(@subscription_id, @licenceName, @user_email).should.equal true
-						done()
-				@Controller.beginJoinGroup @req, res
-
-		describe "without a valid licence", ->
-			beforeEach ->
-				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns(undefined)
-
-			it "should send a 500", (done)->
-				@Controller.beginJoinGroup @req, {}
-				@ErrorsController.notFound.called.should.equal true
-				done()
-
-
-	describe "completeJoin", ->
-		describe "with a valid licence", ->
-			beforeEach ->
-				@GroupHandler.processGroupVerification.callsArgWith(3)
-				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns({name:@licenceName})
-
-			it "should redirect to the success page upon processGroupVerification", (done)->
-				@req.query.token = @token
-				res =
-					redirect : (location)=>
-						@GroupHandler.processGroupVerification.calledWith(@user_email, @subscription_id, @token).should.equal true
-						location.should.equal "/user/subscription/#{@subscription_id}/group/successful-join"
-						done()
-				@Controller.completeJoin @req, res
-
-		describe "without a valid licence", ->
-
-			it "should send a 500", (done)->
-				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns(undefined)
-				@Controller.completeJoin @req, {}
-				@ErrorsController.notFound.called.should.equal true
-				done()
-
-			it "should redirect to the invited page with querystring if token was not found", (done)->
-				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns({name:@licenceName})
-				@req.query.token = @token
-				@GroupHandler.processGroupVerification.callsArgWith(3, "token_not_found")
-				res =
-					redirect : (location)=>
-						location.should.equal "/user/subscription/#{@subscription_id}/group/invited?expired=true"
-						done()
-				@Controller.completeJoin @req, res
-
-
 	describe "exportGroupCsv", ->
 
 		beforeEach ->

+ 19 - 73
services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee

@@ -30,12 +30,13 @@ describe "SubscriptionGroupHandler", ->
 		@SubscriptionUpdater =
 			addUserToGroup: sinon.stub().callsArgWith(2)
 			removeUserFromGroup: sinon.stub().callsArgWith(2)
-			addEmailInviteToGroup: sinon.stub().callsArgWith(2)
-			removeEmailInviteFromGroup: sinon.stub().callsArgWith(2)
+
+		@TeamInvitesHandler =
+			createInvite: sinon.stub().callsArgWith(2)
 
 		@UserGetter =
 			getUser: sinon.stub()
-			getUserByMainEmail: sinon.stub()
+			getUserByAnyEmail: sinon.stub()
 
 		@LimitationsManager =
 			hasGroupMembersLimitReached: sinon.stub()
@@ -61,6 +62,7 @@ describe "SubscriptionGroupHandler", ->
 			"logger-sharelatex": log:->
 			"../User/UserCreator": @UserCreator
 			"./SubscriptionUpdater": @SubscriptionUpdater
+			"./TeamInvitesHandler": @TeamInvitesHandler
 			"./SubscriptionLocator": @SubscriptionLocator
 			"../../models/Subscription": Subscription: @Subscription
 			"../User/UserGetter": @UserGetter
@@ -78,11 +80,11 @@ describe "SubscriptionGroupHandler", ->
 	describe "addUserToGroup", ->
 		beforeEach ->
 			@LimitationsManager.hasGroupMembersLimitReached.callsArgWith(1, null, false, @subscription)
-			@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
-
+			@UserGetter.getUserByAnyEmail.callsArgWith(1, null, @user)
+			
 		it "should find the user", (done)->
 			@Handler.addUserToGroup @adminUser_id, @newEmail, (err)=>
-				@UserGetter.getUserByMainEmail.calledWith(@newEmail).should.equal true
+				@UserGetter.getUserByAnyEmail.calledWith(@newEmail).should.equal true
 				done()
 
 		it "should add the user to the group", (done)->
@@ -109,9 +111,9 @@ describe "SubscriptionGroupHandler", ->
 				done()
 
 		it "should add an email invite if no user is found", (done) ->
-			@UserGetter.getUserByMainEmail.callsArgWith(1, null, null)
+			@UserGetter.getUserByAnyEmail.callsArgWith(1, null, null)
 			@Handler.addUserToGroup @adminUser_id, @newEmail, (err)=>
-				@SubscriptionUpdater.addEmailInviteToGroup.calledWith(@adminUser_id, @newEmail).should.equal true
+				@TeamInvitesHandler.createInvite.calledWith(@adminUser_id, @newEmail).should.equal true
 				done()
 
 	describe "removeUserFromGroup", ->
@@ -183,13 +185,18 @@ describe "SubscriptionGroupHandler", ->
 				done()
 
 		it "should return any invited users", (done) ->
-			@subscription.invited_emails = ["jo@example.com", "charlie@example.com"]
+			@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].holdingAccount.should.equal true
+				users[0].invite.should.equal true
 				users[1].email.should.equal "charlie@example.com"
-				users[1].holdingAccount.should.equal true
-				users.length.should.equal @subscription.invited_emails.length
+				users[1].invite.should.equal true
+				users.length.should.equal @subscription.teamInvites.length + @subscription.invited_emails.length
 				done()
 
 	describe "isUserPartOfGroup", ->
@@ -207,64 +214,3 @@ describe "SubscriptionGroupHandler", ->
 			@Handler.isUserPartOfGroup @user_id, @subscription_id, (err, partOfGroup)->
 				partOfGroup.should.equal false
 				done()
-
-
-	describe "sendVerificationEmail", ->
-		beforeEach ->
-			@token = "secret token"
-			@subscription_id = "123ed13123"
-			@licenceName = "great licnece"
-			@email = "bob@smith.com"
-			@OneTimeTokenHandler.getNewToken.callsArgWith(2, null, @token)
-			@EmailHandler.sendEmail.callsArgWith(2)
-
-		it "should put a one time token into the email", (done)->
-			@Handler.sendVerificationEmail @subscription_id, @licenceName, @email, (err)=>
-				emailOpts = @EmailHandler.sendEmail.args[0][1]
-				emailOpts.completeJoinUrl.should.equal "#{@settings.siteUrl}/user/subscription/#{@subscription_id}/group/complete-join?token=#{@token}"
-				emailOpts.to.should.equal @email
-				emailOpts.group_name.should.equal @licenceName
-				done()
-
-	describe "processGroupVerification", ->
-		beforeEach ->
-			@token = "31dDAd2Da"
-			@SubscriptionLocator.getSubscription.callsArgWith(1, null, @subscription)
-			@Handler.addUserToGroup = sinon.stub().callsArgWith(2)
-
-		it "should addUserToGroup", (done)->
-			@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1, null, @subscription_id)
-			@Handler.processGroupVerification @email, @subscription_id, @token, (err)=>
-				@Handler.addUserToGroup.calledWith(@adminUser_id, @email).should.equal true
-				done()
-
-		it "should return token_not_found error if it couldn't get the token", (done)->
-			@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1)
-			@Handler.processGroupVerification @email, @subscription_id, @token, (err)=>
-				err.should.equal "token_not_found"
-				done()
-
-	describe "convertEmailInvitesToMemberships", ->
-		beforeEach ->
-			@SubscriptionLocator.getGroupsWithEmailInvite = sinon.stub().yields(null, @groups = [{ admin_id: "group-1" }, { admin_id: "group-2" }])
-
-		it "should get groups with the email address invited to", (done) ->
-			@Handler.convertEmailInvitesToMemberships @email, @user_id, (err) =>
-				@SubscriptionLocator.getGroupsWithEmailInvite.calledWith(@email).should.equal true
-				done()
-
-		it "should remove the email from each group", (done) ->
-			@Handler.convertEmailInvitesToMemberships @email, @user_id, (err) =>
-				for group in @groups
-					@SubscriptionUpdater.removeEmailInviteFromGroup
-						.calledWith(group.admin_id, @email)
-						.should.equal true
-				done()
-
-		it "should add the user to each group", (done) ->
-			@Handler.convertEmailInvitesToMemberships @email, @user_id, (err) =>
-				for group in @groups
-					@SubscriptionUpdater.addUserToGroup
-						.calledWith(group.admin_id, @user_id)
-						.should.equal true
-				done()

+ 267 - 0
services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee

@@ -0,0 +1,267 @@
+SandboxedModule = require('sandboxed-module')
+should = require('chai').should()
+sinon = require 'sinon'
+expect = require("chai").expect
+querystring = require 'querystring'
+modulePath = "../../../../app/js/Features/Subscription/TeamInvitesHandler"
+
+ObjectId = require("mongojs").ObjectId
+Errors = require("../../../../app/js/Features/Errors/Errors")
+
+describe "TeamInvitesHandler", ->
+	beforeEach ->
+		@manager = {
+			id: "666666",
+			first_name: "Daenerys"
+			last_name: "Targaryen"
+			email: "daenerys@example.com"
+		}
+
+		@token = "aaaaaaaaaaaaaaaaaaaaaa"
+
+		@teamInvite = {
+			email: "jorah@example.com",
+			token: @token,
+		}
+
+		@subscription = {
+			id: "55153a8014829a865bbf700d",
+			admin_id: @manager.id,
+			groupPlan: true,
+			member_ids: [],
+			teamInvites: [ @teamInvite ],
+			save: sinon.stub().yields(null),
+		}
+
+		@SubscriptionLocator = {
+			getUsersSubscription: sinon.stub(),
+			getSubscription: sinon.stub().yields(null, @subscription)
+		}
+
+		@UserGetter = {
+			getUser: sinon.stub().yields(),
+			getUserByAnyEmail: sinon.stub().yields()
+		}
+
+		@SubscriptionUpdater = {
+			addUserToGroup: sinon.stub().yields()
+		}
+
+		@LimitationsManager = {
+			teamHasReachedMemberLimit: sinon.stub().returns(false)
+		}
+
+		@Subscription = {
+			findOne: sinon.stub().yields()
+			update: sinon.stub().yields()
+		}
+
+		@EmailHandler = {
+			sendEmail: sinon.stub().yields(null)
+		}
+
+		@newToken = "bbbbbbbbb"
+
+		@crypto = {
+			randomBytes: =>
+				toString: sinon.stub().returns(@newToken)
+		}
+
+		@UserGetter.getUser.withArgs(@manager.id).yields(null, @manager)
+		@UserGetter.getUserByAnyEmail.withArgs(@manager.email).yields(null, @manager)
+
+		@SubscriptionLocator.getUsersSubscription.yields(null, @subscription)
+		@Subscription.findOne.yields(null, @subscription)
+
+		@TeamInvitesHandler = SandboxedModule.require modulePath, requires:
+				"logger-sharelatex": { log: -> }
+				"crypto": @crypto
+				"settings-sharelatex": { siteUrl: "http://example.com" }
+				"../../models/TeamInvite": { TeamInvite: @TeamInvite = {} }
+				"../../models/Subscription": { Subscription: @Subscription }
+				"../User/UserGetter": @UserGetter
+				"./SubscriptionLocator": @SubscriptionLocator
+				"./SubscriptionUpdater": @SubscriptionUpdater
+				"./LimitationsManager": @LimitationsManager
+				"../Email/EmailHandler": @EmailHandler
+				"../Errors/Errors": Errors
+
+	describe "getInvite", ->
+		it "returns the invite if there's one", (done) ->
+			@TeamInvitesHandler.getInvite @token, (err, invite, subscription) =>
+				expect(err).to.eq(null)
+				expect(invite).to.deep.eq(@teamInvite)
+				expect(subscription).to.deep.eq(@subscription)
+				done()
+
+		it "returns teamNotFound if there's none", (done) ->
+			@Subscription.findOne = sinon.stub().yields(null, null)
+
+			@TeamInvitesHandler.getInvite @token, (err, invite, subscription) ->
+				expect(err).to.be.instanceof(Errors.NotFoundError)
+				done()
+
+	describe "createInvite", ->
+		it "adds the team invite to the subscription", (done) ->
+			@TeamInvitesHandler.createInvite @manager.id, "John.Snow@example.com", (err, invite) =>
+				expect(err).to.eq(null)
+				expect(invite.token).to.eq(@newToken)
+				expect(invite.email).to.eq("john.snow@example.com")
+				expect(invite.inviterName).to.eq("Daenerys Targaryen (daenerys@example.com)")
+				expect(@subscription.teamInvites).to.deep.include(invite)
+				done()
+
+		it "sends an email", (done) ->
+			@TeamInvitesHandler.createInvite @manager.id, "John.Snow@example.com", (err, invite) =>
+				@EmailHandler.sendEmail.calledWith("verifyEmailToJoinTeam",
+					sinon.match({
+						to: "john.snow@example.com",
+						inviterName: "Daenerys Targaryen (daenerys@example.com)",
+						acceptInviteUrl: "http://example.com/subscription/invites/#{@newToken}/"
+					})
+				).should.equal true
+				done()
+
+		it "refreshes the existing invite if the email has already been invited", (done) ->
+			originalInvite = Object.assign({}, @teamInvite)
+
+			@TeamInvitesHandler.createInvite @manager.id, originalInvite.email, (err, invite) =>
+				expect(err).to.eq(null)
+				expect(invite).to.exist
+
+				expect(@subscription.teamInvites.length).to.eq 1
+				expect(@subscription.teamInvites).to.deep.include invite
+
+				expect(invite.email).to.eq originalInvite.email
+
+				@subscription.save.calledOnce.should.eq true
+
+				done()
+
+		it "removes any legacy invite from the subscription", (done) ->
+			@TeamInvitesHandler.createInvite @manager.id, "John.Snow@example.com", (err, invite) =>
+				@Subscription.update.calledWith(
+					{ _id: new ObjectId("55153a8014829a865bbf700d") },
+					{ '$pull': { invited_emails: "john.snow@example.com" } }
+				).should.eq true
+				done()
+
+	describe "createDomainInvite", ->
+		beforeEach ->
+			@licence =
+				subscription_id: @subscription.id
+				name: "Team Daenerys"
+
+			@user =
+				email: "John.Snow@example.com"
+
+		it "adds the team invite to the subscription", (done) ->
+			@TeamInvitesHandler.createDomainInvite @user, @licence, (err, invite) =>
+				expect(err).to.eq(null)
+				expect(invite.token).to.eq(@newToken)
+				expect(invite.email).to.eq("john.snow@example.com")
+				expect(invite.inviterName).to.eq("Team Daenerys")
+				expect(@subscription.teamInvites).to.deep.include(invite)
+				done()
+
+		it "sends an email", (done) ->
+			@TeamInvitesHandler.createDomainInvite @user, @licence, (err, invite) =>
+				@EmailHandler.sendEmail.calledWith("verifyEmailToJoinTeam",
+					sinon.match({
+						to: "john.snow@example.com"
+						inviterName: "Team Daenerys"
+						acceptInviteUrl: "http://example.com/subscription/invites/#{@newToken}/"
+					})
+				).should.equal true
+				done()
+
+	describe "acceptInvite", ->
+		beforeEach ->
+			@user = {
+				id: "123456789",
+				first_name: "Tyrion",
+				last_name: "Lannister",
+				email: "tyrion@example.com"
+			}
+
+			@UserGetter.getUserByAnyEmail.withArgs(@user.email).yields(null, @user)
+
+			@subscription.teamInvites.push({
+				email: "john.snow@example.com",
+				token: "dddddddd",
+				inviterName: "Daenerys Targaryen (daenerys@example.com)"
+			})
+
+		it "adds the user to the team", (done) ->
+			@TeamInvitesHandler.acceptInvite "dddddddd", @user.id, =>
+				@SubscriptionUpdater.addUserToGroup.calledWith(@manager.id, @user.id).should.eq true
+				done()
+
+		it "removes the invite from the subscription", (done) ->
+			@TeamInvitesHandler.acceptInvite "dddddddd", @user.id, =>
+				@Subscription.update.calledWith(
+					{ _id: new ObjectId("55153a8014829a865bbf700d") },
+					{ '$pull': { teamInvites: { email: 'john.snow@example.com' } } }
+				).should.eq true
+				done()
+
+	describe "revokeInvite", ->
+		it "removes the team invite from the subscription", (done) ->
+			@TeamInvitesHandler.revokeInvite @manager.id, "jorah@example.com", =>
+				@Subscription.update.calledWith(
+					{ _id: new ObjectId("55153a8014829a865bbf700d") },
+					{ '$pull': { teamInvites: { email: "jorah@example.com" } } }
+				).should.eq true
+
+				@Subscription.update.calledWith(
+					{ _id: new ObjectId("55153a8014829a865bbf700d") },
+					{ '$pull': { invited_emails: "jorah@example.com" } }
+				).should.eq true
+				done()
+
+	describe "createTeamInvitesForLegacyInvitedEmail", (done) ->
+		beforeEach ->
+			@subscription.invited_emails = ["eddard@example.com", "robert@example.com"]
+			@TeamInvitesHandler.createInvite = sinon.stub().yields(null)
+			@SubscriptionLocator.getGroupsWithEmailInvite = sinon.stub().yields(null, [@subscription])
+
+		it "sends an invitation email to addresses in the legacy invited_emails field", (done) ->
+			@TeamInvitesHandler.createTeamInvitesForLegacyInvitedEmail "eddard@example.com", (err, invite) =>
+				expect(err).not.to.exist
+
+				@TeamInvitesHandler.createInvite.calledWith(
+					@subscription.admin_id,
+					"eddard@example.com"
+				).should.eq true
+
+				@TeamInvitesHandler.createInvite.callCount.should.eq 1
+
+				done()
+
+	describe "validation", ->
+		it "doesn't create an invite if the team limit has been reached", (done) ->
+			@LimitationsManager.teamHasReachedMemberLimit = sinon.stub().returns(true)
+			@TeamInvitesHandler.createInvite @manager.id, "John.Snow@example.com", (err, invite) =>
+				expect(err).to.deep.equal(limitReached: true)
+				done()
+
+		it "doesn't create an invite if the subscription is not in a group plan", (done) ->
+			@subscription.groupPlan = false
+			@TeamInvitesHandler.createInvite @manager.id, "John.Snow@example.com", (err, invite) =>
+				expect(err).to.deep.equal(wrongPlan: true)
+				done()
+
+		it "doesn't create an invite if the user is already part of the team", (done) ->
+			member = {
+				id: "1a2b",
+				_id: "1a2b",
+				email: "tyrion@example.com"
+			}
+
+			@subscription.member_ids = [member.id]
+			@UserGetter.getUserByAnyEmail.withArgs(member.email).yields(null, member)
+
+			@TeamInvitesHandler.createInvite @manager.id, "tyrion@example.com", (err, invite) =>
+				expect(err).to.deep.equal(alreadyInTeam: true)
+				expect(invite).not.to.exist
+				done()

+ 3 - 3
services/web/test/unit/coffee/User/UserControllerTests.coffee

@@ -55,7 +55,7 @@ describe "UserController", ->
 		@settings =
 			siteUrl: "sharelatex.example.com"
 		@UserHandler =
-			populateGroupLicenceInvite:sinon.stub().callsArgWith(1)
+			populateTeamInvites: sinon.stub().callsArgWith(1)
 		@UserSessionsManager =
 			trackSession: sinon.stub()
 			untrackSession: sinon.stub()
@@ -267,12 +267,12 @@ describe "UserController", ->
 				done()
 			@UserController.updateUserSettings @req, @res
 
-		it "should call populateGroupLicenceInvite", (done)->
+		it "should call populateTeamInvites", (done)->
 			@req.body.email = @newEmail.toUpperCase()
 			@UserUpdater.changeEmailAddress.callsArgWith(2)
 			@res.sendStatus = (code)=>
 				code.should.equal 200
-				@UserHandler.populateGroupLicenceInvite.calledWith(@user).should.equal true
+				@UserHandler.populateTeamInvites.calledWith(@user).should.equal true
 				done()
 			@UserController.updateUserSettings @req, @res
 

+ 8 - 0
services/web/test/unit/coffee/User/UserCreatorTests.coffee

@@ -70,3 +70,11 @@ describe "UserCreator", ->
 				assert.equal user.holdingAccount, true
 				assert.equal user.last_name, "lastNammmmeee"
 				done()
+
+		it "should set emails attribute", (done)->
+			@UserCreator.createNewUser email: @email, (err, user)=>
+				user.email.should.equal @email
+				user.emails.length.should.equal 1
+				user.emails[0].email.should.equal @email
+				user.emails[0].createdAt.should.be.a 'date'
+				done()

+ 17 - 0
services/web/test/unit/coffee/User/UserGetterTests.coffee

@@ -85,3 +85,20 @@ describe "UserGetter", ->
 				@findOne.calledTwice.should.equal true
 				@findOne.calledWith(email: email, projection).should.equal true
 				done()
+
+	describe 'ensureUniqueEmailAddress', ->
+		beforeEach ->
+			@UserGetter.getUserByAnyEmail = sinon.stub()
+
+		it 'should return error if existing user is found', (done)->
+			@UserGetter.getUserByAnyEmail.callsArgWith(1, null, @fakeUser)
+			@UserGetter.ensureUniqueEmailAddress @newEmail, (err)=>
+				should.exist(err)
+				err.message.should.equal 'alread_exists'
+				done()
+
+		it 'should return null if no user is found', (done)->
+			@UserGetter.getUserByAnyEmail.callsArgWith(1)
+			@UserGetter.ensureUniqueEmailAddress @newEmail, (err)=>
+				should.not.exist(err)
+				done()

+ 23 - 23
services/web/test/unit/coffee/User/UserHandlerTests.coffee

@@ -7,39 +7,49 @@ SandboxedModule = require('sandboxed-module')
 describe "UserHandler", ->
 
 	beforeEach ->
-		@user = 
+		@user =
 			_id:"12390i"
 			email: "bob@bob.com"
 			remove: sinon.stub().callsArgWith(0)
 
-		@licence = 
+		@licence =
 			subscription_id: 12323434
 		@SubscriptionDomainHandler =
 			getLicenceUserCanJoin: sinon.stub()
 
 		@SubscriptionGroupHandler =
 			isUserPartOfGroup:sinon.stub()
-			convertEmailInvitesToMemberships: sinon.stub().callsArgWith(2)
 		@createStub = sinon.stub().callsArgWith(0)
-		@NotificationsBuilder = 
+		@NotificationsBuilder =
 			groupPlan:sinon.stub().returns({create:@createStub})
 
+		@TeamInvitesHandler =
+			createTeamInvitesForLegacyInvitedEmail: sinon.stub().yields()
+
 		@UserHandler = SandboxedModule.require modulePath, requires:
 			"logger-sharelatex": @logger = { log: sinon.stub() }
 			"../Notifications/NotificationsBuilder":@NotificationsBuilder
 			"../Subscription/SubscriptionDomainHandler":@SubscriptionDomainHandler
 			"../Subscription/SubscriptionGroupHandler":@SubscriptionGroupHandler
+			"../Subscription/TeamInvitesHandler": @TeamInvitesHandler
+
+	describe "populateTeamInvites", ->
+		beforeEach (done)->
+			@UserHandler.notifyDomainLicence = sinon.stub().yields()
+			@UserHandler.populateTeamInvites @user, done
+
+		it "notifies the user about domain licences zzzzz", ->
+			@UserHandler.notifyDomainLicence.calledWith(@user).should.eq true
+
+		it "notifies the user about legacy team invites", ->
+			@TeamInvitesHandler.createTeamInvitesForLegacyInvitedEmail
+				.calledWith(@user.email).should.eq true
 
-	describe "populateGroupLicenceInvite", ->
+	describe "notifyDomainLicence", ->
 		describe "no licence", ->
 			beforeEach (done)->
 				@SubscriptionDomainHandler.getLicenceUserCanJoin.returns()
-				@UserHandler.populateGroupLicenceInvite @user, done
-			
-			it "should call convertEmailInvitesToMemberships", ->
-				@SubscriptionGroupHandler.convertEmailInvitesToMemberships
-					.calledWith(@user.email, @user._id)
-					.should.equal true
+				@UserHandler.populateTeamInvites @user, done
 
 			it "should not call NotificationsBuilder", (done)->
 				@NotificationsBuilder.groupPlan.called.should.equal false
@@ -53,28 +63,18 @@ describe "UserHandler", ->
 			beforeEach (done)->
 				@SubscriptionDomainHandler.getLicenceUserCanJoin.returns(@licence)
 				@SubscriptionGroupHandler.isUserPartOfGroup.callsArgWith(2, null, false)
-				@UserHandler.populateGroupLicenceInvite @user, done
+				@UserHandler.populateTeamInvites @user, done
 
 			it "should create notifcation", (done)->
 				@NotificationsBuilder.groupPlan.calledWith(@user, @licence).should.equal true
 				done()
-			
-			it "should call convertEmailInvitesToMemberships", ->
-				@SubscriptionGroupHandler.convertEmailInvitesToMemberships
-					.calledWith(@user.email, @user._id)
-					.should.equal true
 
 		describe "with matching licence user is already in", ->
 			beforeEach (done)->
 				@SubscriptionDomainHandler.getLicenceUserCanJoin.returns(@licence)
 				@SubscriptionGroupHandler.isUserPartOfGroup.callsArgWith(2, null, true)
-				@UserHandler.populateGroupLicenceInvite @user, done
+				@UserHandler.populateTeamInvites @user, done
 
 			it "should create notifcation", (done)->
 				@NotificationsBuilder.groupPlan.called.should.equal false
 				done()
-			
-			it "should call convertEmailInvitesToMemberships", ->
-				@SubscriptionGroupHandler.convertEmailInvitesToMemberships
-					.calledWith(@user.email, @user._id)
-					.should.equal true

+ 4 - 4
services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee

@@ -14,7 +14,7 @@ describe "UserRegistrationHandler", ->
 		@User = 
 			update: sinon.stub().callsArgWith(2)
 		@UserGetter =
-			getUserByMainEmail: sinon.stub()
+			getUserByAnyEmail: sinon.stub()
 		@UserCreator = 
 			createNewUser:sinon.stub().callsArgWith(1, null, @user)
 		@AuthenticationManager =
@@ -72,7 +72,7 @@ describe "UserRegistrationHandler", ->
 			beforeEach ->
 				@user.holdingAccount = true
 				@handler._registrationRequestIsValid = sinon.stub().returns true
-				@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
+				@UserGetter.getUserByAnyEmail.callsArgWith(1, null, @user)
 
 			it "should not create a new user if there is a holding account there", (done)->
 				@handler.registerNewUser @passingRequest, (err)=>
@@ -96,7 +96,7 @@ describe "UserRegistrationHandler", ->
 					done()
 
 			it "should return email registered in the error if there is a non holdingAccount there", (done)->
-				@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user = {holdingAccount:false})
+				@UserGetter.getUserByAnyEmail.callsArgWith(1, null, @user = {holdingAccount:false})
 				@handler.registerNewUser @passingRequest, (err, user)=>
 					err.should.deep.equal new Error("EmailAlreadyRegistered")
 					user.should.deep.equal @user
@@ -105,7 +105,7 @@ describe "UserRegistrationHandler", ->
 		describe "validRequest", ->
 			beforeEach ->
 				@handler._registrationRequestIsValid = sinon.stub().returns true
-				@UserGetter.getUserByMainEmail.callsArgWith 1
+				@UserGetter.getUserByAnyEmail.callsArgWith 1
 
 			it "should create a new user", (done)->
 				@handler.registerNewUser @passingRequest, (err)=>

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

@@ -17,6 +17,7 @@ describe "UserUpdater", ->
 		@UserGetter =
 			getUserEmail: sinon.stub()
 			getUserByAnyEmail: sinon.stub()
+			ensureUniqueEmailAddress: sinon.stub()
 		@logger = err: sinon.stub(), log: ->
 		@UserUpdater = SandboxedModule.require modulePath, requires:
 			"settings-sharelatex":@settings
@@ -60,13 +61,13 @@ describe "UserUpdater", ->
 
 	describe 'addEmailAddress', ->
 		beforeEach ->
-			@UserUpdater._ensureUniqueEmailAddress = sinon.stub().callsArgWith(1)
+			@UserGetter.ensureUniqueEmailAddress = sinon.stub().callsArgWith(1)
 
 		it 'add email', (done)->
 			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null)
 
 			@UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=>
-				@UserUpdater._ensureUniqueEmailAddress.called.should.equal true
+				@UserGetter.ensureUniqueEmailAddress.called.should.equal true
 				should.not.exist(err)
 				@UserUpdater.updateUser.calledWith(
 					@stubbedUser._id,
@@ -135,15 +136,3 @@ describe "UserUpdater", ->
 				done()
 
 
-	describe '_ensureUniqueEmailAddress', ->
-		it 'should return error if existing user is found', (done)->
-			@UserGetter.getUserByAnyEmail.callsArgWith(1, null, @stubbedUser)
-			@UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=>
-				should.exist(err)
-				done()
-
-		it 'should return null if no user is found', (done)->
-			@UserGetter.getUserByAnyEmail.callsArgWith(1)
-			@UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=>
-				should.not.exist(err)
-				done()