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

Merge pull request #619 from sharelatex/afc-email-tokens

Use emails with tokens for team invites
James Allen 8 лет назад
Родитель
Сommit
f7532e5854
31 измененных файлов с 806 добавлено и 483 удалено
  1. 14 13
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  2. 1 1
      services/web/app/coffee/Features/Errors/Errors.coffee
  3. 47 0
      services/web/app/coffee/Features/Subscription/DomainLicenceController.coffee
  4. 10 7
      services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
  5. 1 1
      services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee
  6. 0 70
      services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
  7. 9 48
      services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
  8. 15 8
      services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
  9. 4 19
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  10. 58 0
      services/web/app/coffee/Features/Subscription/TeamInvitesController.coffee
  11. 161 0
      services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee
  12. 2 2
      services/web/app/coffee/Features/User/UserController.coffee
  13. 18 16
      services/web/app/coffee/Features/User/UserHandler.coffee
  14. 2 0
      services/web/app/coffee/models/Subscription.coffee
  15. 15 0
      services/web/app/coffee/models/TeamInvite.coffee
  16. 39 0
      services/web/app/views/subscriptions/domain/join.pug
  17. 0 54
      services/web/app/views/subscriptions/group/invite.pug
  18. 0 25
      services/web/app/views/subscriptions/group/successful_join.pug
  19. 3 3
      services/web/app/views/subscriptions/group_admin.pug
  20. 40 0
      services/web/app/views/subscriptions/team/invite.pug
  21. 2 1
      services/web/public/coffee/main.coffee
  22. 4 4
      services/web/public/coffee/main/group-members.coffee
  23. 8 8
      services/web/public/coffee/main/subscription/domain-subscription-join-controller.coffee
  24. 35 0
      services/web/public/coffee/main/subscription/team-invite-controller.coffee
  25. 7 3
      services/web/public/stylesheets/app/subscription.less
  26. 4 2
      services/web/test/unit/coffee/Subscription/LimitationsManagerTests.coffee
  27. 0 104
      services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee
  28. 14 68
      services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee
  29. 267 0
      services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee
  30. 3 3
      services/web/test/unit/coffee/User/UserControllerTests.coffee
  31. 23 23
      services/web/test/unit/coffee/User/UserHandlerTests.coffee

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

+ 9 - 48
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")
@@ -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)->

+ 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

+ 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

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

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

+ 14 - 68
services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee

@@ -30,8 +30,9 @@ 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()
@@ -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
@@ -111,7 +113,7 @@ describe "SubscriptionGroupHandler", ->
 		it "should add an email invite if no user is found", (done) ->
 			@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
 

+ 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