소스 검색

Merge branch 'master' into pr-subscription-improvements

Shane Kilkelly 10 년 전
부모
커밋
4a6df04c21
38개의 변경된 파일과 2922개의 추가작업 그리고 188개의 파일을 삭제
  1. 16 5
      services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee
  2. 25 2
      services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee
  3. 24 18
      services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
  4. 123 0
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
  5. 142 0
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
  6. 62 0
      services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee
  7. 8 5
      services/web/app/coffee/Features/Editor/EditorHttpController.coffee
  8. 21 6
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  9. 11 0
      services/web/app/coffee/Features/Helpers/EmailHelper.coffee
  10. 19 4
      services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee
  11. 26 11
      services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee
  12. 22 11
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  13. 8 6
      services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
  14. 29 10
      services/web/app/coffee/models/ProjectInvite.coffee
  15. 21 3
      services/web/app/views/project/editor/share.jade
  16. 18 0
      services/web/app/views/project/invite/not-valid.jade
  17. 31 0
      services/web/app/views/project/invite/show.jade
  18. 6 4
      services/web/app/views/project/list/notifications.jade
  19. 7 3
      services/web/app/views/user/register.jade
  20. 25 8
      services/web/public/coffee/ide/share/controllers/ShareController.coffee
  21. 63 20
      services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee
  22. 2 1
      services/web/public/coffee/ide/share/index.coffee
  23. 35 0
      services/web/public/coffee/ide/share/services/projectInvites.coffee
  24. 9 9
      services/web/public/coffee/ide/share/services/projectMembers.coffee
  25. 2 2
      services/web/public/stylesheets/app/editor/share.less
  26. 18 0
      services/web/public/stylesheets/app/invite.less
  27. 17 3
      services/web/public/stylesheets/app/project-list.less
  28. 1 1
      services/web/public/stylesheets/style.less
  29. 47 9
      services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee
  30. 87 35
      services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee
  31. 574 0
      services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee
  32. 723 0
      services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee
  33. 12 1
      services/web/test/UnitTests/coffee/Editor/EditorHttpControllerTests.coffee
  34. 51 2
      services/web/test/UnitTests/coffee/Notifications/NotificationsHandlerTests.coffee
  35. 48 3
      services/web/test/UnitTests/coffee/Project/ProjectEditorHandlerTests.coffee
  36. 38 3
      services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee
  37. 529 0
      services/web/test/acceptance/coffee/ProjectInviteTests.coffee
  38. 22 3
      services/web/test/acceptance/coffee/helpers/User.coffee

+ 16 - 5
services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee

@@ -4,7 +4,9 @@ ProjectEditorHandler = require "../Project/ProjectEditorHandler"
 EditorRealTimeController = require "../Editor/EditorRealTimeController"
 LimitationsManager = require "../Subscription/LimitationsManager"
 UserGetter = require "../User/UserGetter"
-mimelib = require("mimelib")
+EmailHelper = require "../Helpers/EmailHelper"
+logger = require 'logger-sharelatex'
+
 
 module.exports = CollaboratorsController =
 	addUserToProject: (req, res, next) ->
@@ -16,11 +18,11 @@ module.exports = CollaboratorsController =
 				return res.json { user: false }
 			else
 				{email, privileges} = req.body
-				
-				email = mimelib.parseAddresses(email or "")[0]?.address?.toLowerCase()
+
+				email = EmailHelper.parseEmail(email)
 				if !email? or email == ""
 					return res.status(400).send("invalid email address")
-					
+
 				adding_user_id = req.session?.user?._id
 				CollaboratorsHandler.addEmailToProject project_id, adding_user_id, email, privileges, (error, user_id) =>
 					return next(error) if error?
@@ -35,8 +37,9 @@ module.exports = CollaboratorsController =
 		user_id    = req.params.user_id
 		CollaboratorsController._removeUserIdFromProject project_id, user_id, (error) ->
 			return next(error) if error?
+			EditorRealTimeController.emitToRoom project_id, 'project:membership:changed', {members: true}
 			res.sendStatus 204
-	
+
 	removeSelfFromProject: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
 		user_id    = req.session?.user?._id
@@ -50,3 +53,11 @@ module.exports = CollaboratorsController =
 			EditorRealTimeController.emitToRoom(project_id, 'userRemovedFromProject', user_id)
 			callback()
 
+	getAllMembers: (req, res, next) ->
+		projectId = req.params.Project_id
+		logger.log {projectId}, "getting all active members for project"
+		CollaboratorsHandler.getAllMembers projectId, (err, members) ->
+			if err?
+				logger.err {projectId}, "error getting members for project"
+				return next(err)
+			res.json({members: members})

+ 25 - 2
services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee

@@ -2,7 +2,15 @@ Project = require("../../models/Project").Project
 EmailHandler = require("../Email/EmailHandler")
 Settings = require "settings-sharelatex"
 
-module.exports =
+
+module.exports = CollaboratorsEmailHandler =
+
+	_buildInviteUrl: (project, invite) ->
+		"#{Settings.siteUrl}/project/#{project._id}/invite/token/#{invite.token}?" + [
+			"project_name=#{encodeURIComponent(project.name)}"
+			"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
+		].join("&")
+
 	notifyUserOfProjectShare: (project_id, email, callback)->
 		Project
 			.findOne(_id: project_id )
@@ -22,4 +30,19 @@ module.exports =
 								"rs=ci" # referral source = collaborator invite
 							].join("&")
 					owner: project.owner_ref
-				EmailHandler.sendEmail "projectSharedWithYou", emailOptions, callback
+				EmailHandler.sendEmail "projectSharedWithYou", emailOptions, callback
+
+	notifyUserOfProjectInvite: (project_id, email, invite, callback)->
+		Project
+			.findOne(_id: project_id )
+			.select("name owner_ref")
+			.populate('owner_ref')
+			.exec (err, project)->
+				emailOptions =
+					to: email
+					replyTo: project.owner_ref.email
+					project:
+						name: project.name
+					inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite)
+					owner: project.owner_ref
+				EmailHandler.sendEmail "projectInvite", emailOptions, callback

+ 24 - 18
services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee

@@ -1,6 +1,5 @@
 UserCreator = require('../User/UserCreator')
 Project = require("../../models/Project").Project
-mimelib = require("mimelib")
 logger = require('logger-sharelatex')
 UserGetter = require "../User/UserGetter"
 ContactManager = require "../Contacts/ContactManager"
@@ -8,8 +7,12 @@ CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler"
 async = require "async"
 PrivilegeLevels = require "../Authorization/PrivilegeLevels"
 Errors = require "../Errors/Errors"
+EmailHelper = require "../Helpers/EmailHelper"
+ProjectEditorHandler = require "../Project/ProjectEditorHandler"
+
 
 module.exports = CollaboratorsHandler =
+
 	getMemberIdsWithPrivilegeLevels: (project_id, callback = (error, members) ->) ->
 		Project.findOne { _id: project_id }, { owner_ref: 1, collaberator_refs: 1, readOnly_refs: 1 }, (error, project) ->
 			return callback(error) if error?
@@ -21,12 +24,12 @@ module.exports = CollaboratorsHandler =
 			for member_id in project.collaberator_refs or []
 				members.push { id: member_id.toString(), privilegeLevel: PrivilegeLevels.READ_AND_WRITE }
 			return callback null, members
-	
+
 	getMemberIds: (project_id, callback = (error, member_ids) ->) ->
 		CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members) ->
 			return callback(error) if error?
 			return callback null, members.map (m) -> m.id
-	
+
 	getMembersWithPrivilegeLevels: (project_id, callback = (error, members) ->) ->
 		CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) ->
 			return callback(error) if error?
@@ -41,7 +44,7 @@ module.exports = CollaboratorsHandler =
 				(error) ->
 					return callback(error) if error?
 					callback null, result
-	
+
 	getMemberIdPrivilegeLevel: (user_id, project_id, callback = (error, privilegeLevel) ->) ->
 		# In future if the schema changes and getting all member ids is more expensive (multiple documents)
 		# then optimise this.
@@ -51,12 +54,12 @@ module.exports = CollaboratorsHandler =
 				if member.id == user_id?.toString()
 					return callback null, member.privilegeLevel
 			return callback null, PrivilegeLevels.NONE
-	
+
 	getMemberCount: (project_id, callback = (error, count) ->) ->
 		CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members) ->
 			return callback(error) if error?
 			return callback null, (members or []).length
-		
+
 	getCollaboratorCount: (project_id, callback = (error, count) ->) ->
 		CollaboratorsHandler.getMemberCount project_id, (error, count) ->
 			return callback(error) if error?
@@ -69,14 +72,14 @@ module.exports = CollaboratorsHandler =
 				if member.id.toString() == user_id.toString()
 					return callback null, true, member.privilegeLevel
 			return callback null, false, null
-			
+
 	getProjectsUserIsCollaboratorOf: (user_id, fields, callback = (error, readAndWriteProjects, readOnlyProjects) ->) ->
 		Project.find {collaberator_refs:user_id}, fields, (err, readAndWriteProjects)=>
 			return callback(err) if err?
 			Project.find {readOnly_refs:user_id}, fields, (err, readOnlyProjects)=>
 				return callback(err) if err?
 				callback(null, readAndWriteProjects, readOnlyProjects)
-		
+
 	removeUserFromProject: (project_id, user_id, callback = (error) ->)->
 		logger.log user_id: user_id, project_id: project_id, "removing user"
 		conditions = _id:project_id
@@ -86,7 +89,7 @@ module.exports = CollaboratorsHandler =
 			if err?
 				logger.error err: err, "problem removing user from project collaberators"
 			callback(err)
-	
+
 	removeUserFromAllProjets: (user_id, callback = (error) ->) ->
 		CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, { _id: 1 }, (error, readAndWriteProjects = [], readOnlyProjects = []) ->
 			return callback(error) if error?
@@ -98,10 +101,9 @@ module.exports = CollaboratorsHandler =
 						return cb() if !project?
 						CollaboratorsHandler.removeUserFromProject project._id, user_id, cb
 			async.series jobs, callback
-	
+
 	addEmailToProject: (project_id, adding_user_id, unparsed_email, privilegeLevel, callback = (error, user) ->) ->
-		emails = mimelib.parseAddresses(unparsed_email)
-		email = emails[0]?.address?.toLowerCase()
+		email = EmailHelper.parseEmail(unparsed_email)
 		if !email? or email == ""
 			return callback(new Error("no valid email provided: '#{unparsed_email}'"))
 		UserCreator.getUserOrCreateHoldingAccount email, (error, user) ->
@@ -118,7 +120,7 @@ module.exports = CollaboratorsHandler =
 			existing_users = existing_users.map (u) -> u.toString()
 			if existing_users.indexOf(user_id.toString()) > -1
 				return callback null # User already in Project
-				
+
 			if privilegeLevel == PrivilegeLevels.READ_AND_WRITE
 				level = {"collaberator_refs":user_id}
 				logger.log {privileges: "readAndWrite", user_id, project_id}, "adding user"
@@ -128,11 +130,6 @@ module.exports = CollaboratorsHandler =
 			else
 				return callback(new Error("unknown privilegeLevel: #{privilegeLevel}"))
 
-			# Do these in the background
-			UserGetter.getUser user_id, {email: 1}, (error, user) ->
-				if error?
-					logger.error {err: error, project_id, user_id}, "error getting user while adding to project"
-				CollaboratorsEmailHandler.notifyUserOfProjectShare project_id, user.email
 			ContactManager.addContact adding_user_id, user_id
 
 			Project.update { _id: project_id }, { $addToSet: level }, (error) ->
@@ -143,3 +140,12 @@ module.exports = CollaboratorsHandler =
 					if error?
 						logger.error {err: error, project_id, user_id}, "error flushing to TPDS after adding collaborator"
 				callback()
+
+	getAllMembers: (projectId, callback=(err, members)->) ->
+		logger.log {projectId}, "fetching all members"
+		CollaboratorsHandler.getMembersWithPrivilegeLevels projectId, (error, rawMembers) ->
+			if error?
+				logger.err {projectId, error}, "error getting members for project"
+				return callback(error)
+			{owner, members} = ProjectEditorHandler.buildOwnerAndMembersViews(rawMembers)
+			callback(null, members)

+ 123 - 0
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee

@@ -0,0 +1,123 @@
+ProjectGetter = require "../Project/ProjectGetter"
+LimitationsManager = require "../Subscription/LimitationsManager"
+UserGetter = require "../User/UserGetter"
+CollaboratorsHandler = require('./CollaboratorsHandler')
+CollaboratorsInviteHandler = require('./CollaboratorsInviteHandler')
+logger = require('logger-sharelatex')
+EmailHelper = require "../Helpers/EmailHelper"
+EditorRealTimeController = require("../Editor/EditorRealTimeController")
+NotificationsBuilder = require("../Notifications/NotificationsBuilder")
+
+
+module.exports = CollaboratorsInviteController =
+
+	getAllInvites: (req, res, next) ->
+		projectId = req.params.Project_id
+		logger.log {projectId}, "getting all active invites for project"
+		CollaboratorsInviteHandler.getAllInvites projectId, (err, invites) ->
+			if err?
+				logger.err {projectId}, "error getting invites for project"
+				return next(err)
+			res.json({invites: invites})
+
+	inviteToProject: (req, res, next) ->
+		projectId = req.params.Project_id
+		email = req.body.email
+		sendingUser = req.session.user
+		sendingUserId = sendingUser._id
+		logger.log {projectId, email, sendingUserId}, "inviting to project"
+		LimitationsManager.canAddXCollaborators projectId, 1, (error, allowed) =>
+			return next(error) if error?
+			if !allowed
+				logger.log {projectId, email, sendingUserId}, "not allowed to invite more users to project"
+				return res.json {invite: null}
+			{email, privileges} = req.body
+			email = EmailHelper.parseEmail(email)
+			if !email? or email == ""
+				logger.log {projectId, email, sendingUserId}, "invalid email address"
+				return res.sendStatus(400)
+			CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) ->
+				if err?
+					logger.err {projectId, email, sendingUserId}, "error creating project invite"
+					return next(err)
+				logger.log {projectId, email, sendingUserId}, "invite created"
+				EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true})
+				return res.json {invite: invite}
+
+	revokeInvite: (req, res, next) ->
+		projectId = req.params.Project_id
+		inviteId = req.params.invite_id
+		logger.log {projectId, inviteId}, "revoking invite"
+		CollaboratorsInviteHandler.revokeInvite projectId, inviteId, (err) ->
+			if err?
+				logger.err {projectId, inviteId}, "error revoking invite"
+				return next(err)
+			EditorRealTimeController.emitToRoom projectId, 'project:membership:changed', {invites: true}
+			res.sendStatus(201)
+
+	resendInvite: (req, res, next) ->
+		projectId = req.params.Project_id
+		inviteId = req.params.invite_id
+		sendingUser = req.session.user
+		logger.log {projectId, inviteId}, "resending invite"
+		CollaboratorsInviteHandler.resendInvite projectId, sendingUser, inviteId, (err) ->
+			if err?
+				logger.err {projectId, inviteId}, "error resending invite"
+				return next(err)
+			res.sendStatus(201)
+
+	viewInvite: (req, res, next) ->
+		projectId = req.params.Project_id
+		token = req.params.token
+		currentUser = req.session.user
+		_renderInvalidPage = () ->
+			logger.log {projectId, token}, "invite not valid, rendering not-valid page"
+			res.render "project/invite/not-valid", {title: "Invalid Invite"}
+		# check if the user is already a member of the project
+		CollaboratorsHandler.isUserMemberOfProject currentUser._id, projectId, (err, isMember, _privilegeLevel) ->
+			if err?
+				logger.err {err, projectId}, "error checking if user is member of project"
+				return next(err)
+			if isMember
+				logger.log {projectId, userId: currentUser._id}, "user is already a member of this project, redirecting"
+				return res.redirect "/project/#{projectId}"
+			# get the invite
+			CollaboratorsInviteHandler.getInviteByToken projectId, token, (err, invite) ->
+				if err?
+					logger.err {projectId, token}, "error getting invite by token"
+					return next(err)
+				# check if invite is gone, or otherwise non-existent
+				if !invite?
+					logger.log {projectId, token}, "no invite found for this token"
+					return _renderInvalidPage()
+				# check the user who sent the invite exists
+				UserGetter.getUser {_id: invite.sendingUserId}, {email: 1, first_name: 1, last_name: 1}, (err, owner) ->
+					if err?
+						logger.err {err, projectId}, "error getting project owner"
+						return next(err)
+					if !owner?
+						logger.log {projectId}, "no project owner found"
+						return _renderInvalidPage()
+					# fetch the project name
+					ProjectGetter.getProject projectId, {}, (err, project) ->
+						if err?
+							logger.err {err, projectId}, "error getting project"
+							return next(err)
+						if !project?
+							logger.log {projectId}, "no project found"
+							return _renderInvalidPage()
+						# finally render the invite
+						res.render "project/invite/show", {invite, project, owner, title: "Project Invite"}
+
+	acceptInvite: (req, res, next) ->
+		projectId = req.params.Project_id
+		inviteId = req.params.invite_id
+		{token} = req.body
+		currentUser = req.session.user
+		logger.log {projectId, inviteId, userId: currentUser._id}, "accepting invite"
+		CollaboratorsInviteHandler.acceptInvite projectId, inviteId, token, currentUser, (err) ->
+			if err?
+				logger.err {projectId, inviteId}, "error accepting invite by token"
+				return next(err)
+			EditorRealTimeController.emitToRoom projectId, 'project:membership:changed', {invites: true, members: true}
+			res.redirect "/project/#{projectId}"

+ 142 - 0
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee

@@ -0,0 +1,142 @@
+ProjectInvite = require("../../models/ProjectInvite").ProjectInvite
+logger = require('logger-sharelatex')
+CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler"
+CollaboratorsHandler = require "./CollaboratorsHandler"
+UserGetter = require "../User/UserGetter"
+ProjectGetter = require "../Project/ProjectGetter"
+Async = require "async"
+PrivilegeLevels = require "../Authorization/PrivilegeLevels"
+Errors = require "../Errors/Errors"
+Crypto = require 'crypto'
+NotificationsBuilder = require("../Notifications/NotificationsBuilder")
+
+
+module.exports = CollaboratorsInviteHandler =
+
+	getAllInvites: (projectId, callback=(err, invites)->) ->
+		logger.log {projectId}, "fetching invites for project"
+		ProjectInvite.find {projectId: projectId}, (err, invites) ->
+			if err?
+				logger.err {err, projectId}, "error getting invites from mongo"
+				return callback(err)
+			logger.log {projectId, count: invites.length}, "found invites for project"
+			callback(null, invites)
+
+	getInviteCount: (projectId, callback=(err, count)->) ->
+		logger.log {projectId}, "counting invites for project"
+		ProjectInvite.count {projectId: projectId}, (err, count) ->
+			if err?
+				logger.err {err, projectId}, "error getting invites from mongo"
+				return callback(err)
+			callback(null, count)
+
+	_trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) ->
+		email = invite.email
+		UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) ->
+			if err?
+				logger.err {projectId, email}, "error checking if user exists"
+				return callback(err)
+			if !existingUser?
+				logger.log {projectId, email}, "no existing user found, returning"
+				return callback(null)
+			ProjectGetter.getProject projectId, {_id: 1, name: 1}, (err, project) ->
+				if err?
+					logger.err {projectId, email}, "error getting project"
+					return callback(err)
+				if !project?
+					logger.log {projectId}, "no project found while sending notification, returning"
+					return callback(null)
+				NotificationsBuilder.projectInvite(invite, project, sendingUser, existingUser).create(callback)
+
+	_tryCancelInviteNotification: (inviteId, callback=()->) ->
+			NotificationsBuilder.projectInvite({_id: inviteId}, null, null, null).read(callback)
+
+	_sendMessages: (projectId, sendingUser, invite, callback=(err)->) ->
+		logger.log {projectId, inviteId: invite._id}, "sending notification and email for invite"
+		CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, (err)->
+			return callback(err) if err?
+			CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)->
+				return callback(err) if err?
+				callback()
+
+	inviteToProject: (projectId, sendingUser, email, privileges, callback=(err,invite)->) ->
+		logger.log {projectId, sendingUserId: sendingUser._id, email, privileges}, "adding invite"
+		Crypto.randomBytes 24, (err, buffer) ->
+			if err?
+				logger.err {err, projectId, sendingUserId: sendingUser._id, email}, "error generating random token"
+				return callback(err)
+			token = buffer.toString('hex')
+			invite = new ProjectInvite {
+				email: email
+				token: token
+				sendingUserId: sendingUser._id
+				projectId: projectId
+				privileges: privileges
+			}
+			invite.save (err, invite) ->
+				if err?
+					logger.err {err, projectId, sendingUserId: sendingUser._id, email}, "error saving token"
+					return callback(err)
+				CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
+					if err?
+						logger.err {projectId, email}, "error sending messages for invite"
+					callback(err, invite)
+
+	revokeInvite: (projectId, inviteId, callback=(err)->) ->
+		logger.log {projectId, inviteId}, "removing invite"
+		ProjectInvite.remove {projectId: projectId, _id: inviteId}, (err) ->
+			if err?
+				logger.err {err, projectId, inviteId}, "error removing invite"
+				return callback(err)
+			CollaboratorsInviteHandler._tryCancelInviteNotification(inviteId, ()->)
+			callback(null)
+
+	resendInvite: (projectId, sendingUser, inviteId, callback=(err)->) ->
+		logger.log {projectId, inviteId}, "resending invite email"
+		ProjectInvite.findOne {_id: inviteId, projectId: projectId}, (err, invite) ->
+			if err?
+				logger.err {err, projectId, inviteId}, "error finding invite"
+				return callback(err)
+			if !invite?
+				logger.err {err, projectId, inviteId}, "no invite found, nothing to resend"
+				return callback(null)
+			CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
+				if err?
+					logger.err {projectid, inviteId}, "error resending invite messages"
+					return callback(err)
+				callback(null)
+
+	getInviteByToken: (projectId, tokenString, callback=(err,invite)->) ->
+		logger.log {projectId, tokenString}, "fetching invite by token"
+		ProjectInvite.findOne {projectId: projectId, token: tokenString}, (err, invite) ->
+			if err?
+				logger.err {err, projectId}, "error fetching invite"
+				return callback(err)
+			if !invite?
+				logger.err {err, projectId, token: tokenString}, "no invite found"
+				return callback(null, null)
+			callback(null, invite)
+
+	acceptInvite: (projectId, inviteId, tokenString, user, callback=(err)->) ->
+		logger.log {projectId, inviteId, userId: user._id}, "accepting invite"
+		CollaboratorsInviteHandler.getInviteByToken projectId, tokenString, (err, invite) ->
+			if err?
+				logger.err {err, projectId, inviteId}, "error finding invite"
+				return callback(err)
+			if !invite
+				err = new Errors.NotFoundError("no matching invite found")
+				logger.log {err, projectId, inviteId, tokenString}, "no matching invite found"
+				return callback(err)
+			inviteId = invite._id
+			CollaboratorsHandler.addUserIdToProject projectId, invite.sendingUserId, user._id, invite.privileges, (err) ->
+				if err?
+					logger.err {err, projectId, inviteId, userId: user._id}, "error adding user to project"
+					return callback(err)
+				# Remove invite
+				logger.log {projectId, inviteId}, "removing invite"
+				ProjectInvite.remove {_id: inviteId}, (err) ->
+					if err?
+						logger.err {err, projectId, inviteId}, "error removing invite"
+						return callback(err)
+					CollaboratorsInviteHandler._tryCancelInviteNotification inviteId, ()->
+					callback()

+ 62 - 0
services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee

@@ -1,6 +1,8 @@
 CollaboratorsController = require('./CollaboratorsController')
 AuthenticationController = require('../Authentication/AuthenticationController')
 AuthorizationMiddlewear = require('../Authorization/AuthorizationMiddlewear')
+CollaboratorsInviteController = require('./CollaboratorsInviteController')
+RateLimiterMiddlewear = require('../Security/RateLimiterMiddlewear')
 
 module.exports =
 	apply: (webRouter, apiRouter) ->
@@ -8,3 +10,63 @@ module.exports =
 
 		webRouter.post   '/project/:Project_id/users', AuthorizationMiddlewear.ensureUserCanAdminProject, CollaboratorsController.addUserToProject
 		webRouter.delete '/project/:Project_id/users/:user_id', AuthorizationMiddlewear.ensureUserCanAdminProject, CollaboratorsController.removeUserFromProject
+
+		webRouter.get(
+			'/project/:Project_id/members',
+			AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanAdminProject,
+			CollaboratorsController.getAllMembers
+		)
+
+		# invites
+		webRouter.post(
+			'/project/:Project_id/invite',
+			RateLimiterMiddlewear.rateLimit({
+				endpointName: "invite-to-project"
+				params: ["Project_id"]
+				maxRequests: 200
+				timeInterval: 60 * 10
+			}),
+			AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanAdminProject,
+			CollaboratorsInviteController.inviteToProject
+		)
+
+		webRouter.get(
+			'/project/:Project_id/invites',
+			AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanAdminProject,
+			CollaboratorsInviteController.getAllInvites
+		)
+
+		webRouter.delete(
+			'/project/:Project_id/invite/:invite_id',
+			AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanAdminProject,
+			CollaboratorsInviteController.revokeInvite
+		)
+
+		webRouter.post(
+			'/project/:Project_id/invite/:invite_id/resend',
+			RateLimiterMiddlewear.rateLimit({
+				endpointName: "resend-invite"
+				params: ["Project_id"]
+				maxRequests: 200
+				timeInterval: 60 * 10
+			}),
+			AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanAdminProject,
+			CollaboratorsInviteController.resendInvite
+		)
+
+		webRouter.get(
+			'/project/:Project_id/invite/token/:token',
+			AuthenticationController.requireLogin(),
+			CollaboratorsInviteController.viewInvite
+		)
+
+		webRouter.post(
+			'/project/:Project_id/invite/:invite_id/accept',
+			AuthenticationController.requireLogin(),
+			CollaboratorsInviteController.acceptInvite
+		)

+ 8 - 5
services/web/app/coffee/Features/Editor/EditorHttpController.coffee

@@ -9,6 +9,7 @@ AuthorizationManager = require("../Authorization/AuthorizationManager")
 ProjectEditorHandler = require('../Project/ProjectEditorHandler')
 Metrics = require('../../infrastructure/Metrics')
 CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
+CollaboratorsInviteHandler = require("../Collaborators/CollaboratorsInviteHandler")
 PrivilegeLevels = require "../Authorization/PrivilegeLevels"
 
 module.exports = EditorHttpController =
@@ -30,6 +31,7 @@ module.exports = EditorHttpController =
 				ProjectDeleter.unmarkAsDeletedByExternalSource project_id
 
 	_buildJoinProjectView: (project_id, user_id, callback = (error, project, privilegeLevel) ->) ->
+		logger.log {project_id, user_id}, "building the joinProject view"
 		ProjectGetter.getProjectWithoutDocLines project_id, (error, project) ->
 			return callback(error) if error?
 			return callback(new Error("not found")) if !project?
@@ -40,10 +42,13 @@ module.exports = EditorHttpController =
 					AuthorizationManager.getPrivilegeLevelForProject user_id, project_id, (error, privilegeLevel) ->
 						return callback(error) if error?
 						if !privilegeLevel? or privilegeLevel == PrivilegeLevels.NONE
-							callback null, null, false
-						else
+							logger.log {project_id, user_id, privilegeLevel}, "not an acceptable privilege level, returning null"
+							return callback null, null, false
+						CollaboratorsInviteHandler.getAllInvites project_id, (error, invites) ->
+							return callback(error) if error?
+							logger.log {project_id, user_id, memberCount: members.length, inviteCount: invites.length, privilegeLevel}, "returning project model view"
 							callback(null,
-								ProjectEditorHandler.buildProjectModelView(project, members),
+								ProjectEditorHandler.buildProjectModelView(project, members, invites),
 								privilegeLevel
 							)
 
@@ -135,5 +140,3 @@ module.exports = EditorHttpController =
 		EditorController.deleteEntity project_id, entity_id, entity_type, "editor", (error) ->
 			return next(error) if error?
 			res.sendStatus 204
-
-

+ 21 - 6
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -7,7 +7,7 @@ settings = require("settings-sharelatex")
 
 templates = {}
 
-templates.registered =	
+templates.registered =
 	subject:  _.template "Activate your #{settings.appName} Account"
 	layout: PersonalEmailLayout
 	type: "notification"
@@ -19,7 +19,7 @@ templates.registered =
 <p>If you have any questions or problems, please contact <a href="mailto:#{settings.adminEmail}">#{settings.adminEmail}</a>.</p>
 """
 
-templates.canceledSubscription = 
+templates.canceledSubscription =
 	subject:  _.template "ShareLaTeX thoughts"
 	layout: PersonalEmailLayout
 	type:"lifecycle"
@@ -36,7 +36,7 @@ ShareLaTeX Co-founder
 </p>
 '''
 
-templates.passwordResetRequested =	
+templates.passwordResetRequested =
 	subject:  _.template "Password Reset - #{settings.appName}"
 	layout: NotificationEmailLayout
 	type:"notification"
@@ -66,7 +66,7 @@ If you didn't request a password reset, let us know.
 <p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
 """
 
-templates.projectSharedWithYou = 
+templates.projectSharedWithYou =
 	subject: _.template "<%= owner.email %> wants to share <%= project.name %> with you"
 	layout: NotificationEmailLayout
 	type:"notification"
@@ -87,8 +87,24 @@ templates.projectSharedWithYou =
 <p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
 """
 
+templates.projectInvite =
+	subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
+	layout: NotificationEmailLayout
+	type:"notification"
+	compiledTemplate: _.template """
+<p>Hi, <%= owner.email %> wants to share <a href="<%= project.url %>">'<%= project.name %>'</a> with you</p>
+<center>
+			<a style="text-decoration: none; width: 200px; background-color: #a93629; border: 1px solid #e24b3b; border-radius: 3px; padding: 15px; margin: 24px; display: block;" href="<%= inviteUrl %>" style="text-decoration:none" target="_blank">
+				<span style= "font-size:16px;font-family:Helvetica,Arial;font-weight:400;color:#fff;white-space:nowrap;display:block; text-align:center">
+					View Project
+				</span>
+			</a>
+</center>
+<p> Thank you</p>
+<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
+"""
 
-templates.completeJoinGroupAccount = 
+templates.completeJoinGroupAccount =
 	subject: _.template "Verify Email to join <%= group_name %> group"
 	layout: NotificationEmailLayout
 	type:"notification"
@@ -123,4 +139,3 @@ module.exports =
 			html: template.layout(opts)
 			type:template.type
 		}
-

+ 11 - 0
services/web/app/coffee/Features/Helpers/EmailHelper.coffee

@@ -0,0 +1,11 @@
+mimelib = require("mimelib")
+
+
+module.exports = EmailHelper =
+
+	parseEmail: (email) ->
+			email = mimelib.parseAddresses(email or "")[0]?.address?.toLowerCase()
+			if !email? or email == ""
+				return null
+			else
+				return email

+ 19 - 4
services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee

@@ -1,16 +1,31 @@
 logger = require("logger-sharelatex")
 NotificationsHandler = require("./NotificationsHandler")
 
-module.exports = 
+module.exports =
+
+	# Note: notification keys should be url-safe
 
 	groupPlan: (user, licence)->
 		key : "join-sub-#{licence.subscription_id}"
 		create: (callback = ->)->
-			messageOpts = 
+			messageOpts =
 				groupName: licence.name
 				subscription_id: licence.subscription_id
-			logger.log user_id:user._id, key:@key, "creating notification key for user"
-			NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, callback
+			logger.log user_id:user._id, key:key, "creating notification key for user"
+			NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, null, false, callback
 
 		read: (callback = ->)->
 			NotificationsHandler.markAsReadWithKey user._id, @key, callback
+
+	projectInvite: (invite, project, sendingUser, user) ->
+		key: "project-invite-#{invite._id}"
+		create: (callback=()->) ->
+			messageOpts =
+				userName: sendingUser.first_name
+				projectName: project.name
+				projectId: project._id.toString()
+				token: invite.token
+			logger.log {user_id: user._id, project_id: project._id, invite_id: invite._id, key: @key}, "creating project invite notification for user"
+			NotificationsHandler.createNotification user._id, @key, "notification_project_invite", messageOpts, invite.expires, true, callback
+		read:  (callback=()->) ->
+			NotificationsHandler.markAsReadByKeyOnly @key, callback

+ 26 - 11
services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee

@@ -10,10 +10,10 @@ makeRequest = (opts, callback)->
 	else
 		request(opts, callback)
 
-module.exports = 
+module.exports =
 
 	getUserNotifications: (user_id, callback)->
-		opts = 
+		opts =
 			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
 			json: true
 			timeout: oneSecond
@@ -29,21 +29,26 @@ module.exports =
 					unreadNotifications = []
 				callback(null, unreadNotifications)
 
-	createNotification: (user_id, key, templateKey, messageOpts, callback)->
-		opts = 
-			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
-			timeout: oneSecond
-			method:"POST"
-			json: {
+	createNotification: (user_id, key, templateKey, messageOpts, expiryDateTime, forceCreate, callback)->
+		payload = {
 				key:key
 				messageOpts:messageOpts
 				templateKey:templateKey
-			}
+		}
+		if expiryDateTime?
+			payload.expires = expiryDateTime
+		if forceCreate
+			payload.forceCreate = true
+		opts =
+			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
+			timeout: oneSecond
+			method:"POST"
+			json: payload
 		logger.log opts:opts, "creating notification for user"
 		makeRequest opts, callback
 
 	markAsReadWithKey: (user_id, key, callback)->
-		opts = 
+		opts =
 			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
 			method: "DELETE"
 			timeout: oneSecond
@@ -52,7 +57,7 @@ module.exports =
 			}
 		logger.log user_id:user_id, key:key, "sending mark notification as read with key to notifications api"
 		makeRequest opts, callback
-	
+
 
 	markAsRead: (user_id, notification_id, callback)->
 		opts =
@@ -61,3 +66,13 @@ module.exports =
 			timeout:oneSecond
 		logger.log user_id:user_id, notification_id:notification_id, "sending mark notification as read to notifications api"
 		makeRequest opts, callback
+
+	# removes notification by key, without regard for user_id,
+	# should not be exposed to user via ui/router
+	markAsReadByKeyOnly: (key, callback)->
+		opts =
+			uri: "#{settings.apis.notifications?.url}/key/#{key}"
+			method: "DELETE"
+			timeout: oneSecond
+		logger.log {key:key}, "sending mark notification as read with key-only to notifications api"
+		makeRequest opts, callback

+ 22 - 11
services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee

@@ -1,7 +1,7 @@
 _ = require("underscore")
 
 module.exports = ProjectEditorHandler =
-	buildProjectModelView: (project, members) ->
+	buildProjectModelView: (project, members, invites) ->
 		result =
 			_id        : project._id
 			name       : project.name
@@ -15,17 +15,16 @@ module.exports = ProjectEditorHandler =
 			deletedByExternalDataSource : project.deletedByExternalDataSource || false
 			deletedDocs: project.deletedDocs
 			members:     []
-		
-		owner = null
-		for member in members
-			if member.privilegeLevel == "owner"
-				owner = member.user
-			else
-				result.members.push @buildUserModelView member.user, member.privilegeLevel
-		if owner?
-			result.owner = @buildUserModelView owner, "owner"
+			invites:     invites
+
+		if !result.invites?
+			result.invites = []
+
+		{owner, ownerFeatures, members} = @buildOwnerAndMembersViews(members)
+		result.owner = owner
+		result.members = members
 
-		result.features = _.defaults(owner?.features or {}, {
+		result.features = _.defaults(ownerFeatures or {}, {
 			collaborators: -1 # Infinite
 			versioning: false
 			dropbox:false
@@ -37,6 +36,18 @@ module.exports = ProjectEditorHandler =
 
 		return result
 
+	buildOwnerAndMembersViews: (members) ->
+		owner = null
+		ownerFeatures = null
+		filteredMembers = []
+		for member in members
+			if member.privilegeLevel == "owner"
+				ownerFeatures = member.user.features
+				owner = @buildUserModelView member.user, "owner"
+			else
+				filteredMembers.push @buildUserModelView member.user, member.privilegeLevel
+		{owner: owner, ownerFeatures: ownerFeatures, members: filteredMembers}
+
 	buildUserModelView: (user, privileges) ->
 		_id        : user._id
 		first_name : user.first_name

+ 8 - 6
services/web/app/coffee/Features/Subscription/LimitationsManager.coffee

@@ -4,6 +4,7 @@ User = require("../../models/User").User
 SubscriptionLocator = require("./SubscriptionLocator")
 Settings = require("settings-sharelatex")
 CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
+CollaboratorsInvitesHandler = require("../Collaborators/CollaboratorsInviteHandler")
 
 module.exports =
 
@@ -20,10 +21,12 @@ module.exports =
 			return callback(error) if error?
 			CollaboratorsHandler.getCollaboratorCount project_id, (error, current_number) =>
 				return callback(error) if error?
-				if current_number + x_collaborators <= allowed_number or allowed_number < 0
-					callback null, true
-				else
-					callback null, false
+				CollaboratorsInvitesHandler.getInviteCount project_id, (error, invite_count) =>
+					return callback(error) if error?
+					if current_number + invite_count + x_collaborators <= allowed_number or allowed_number < 0
+						callback null, true
+					else
+						callback null, false
 
 	userHasSubscriptionOrIsGroupMember: (user, callback = (err, hasSubscriptionOrIsMember)->) ->
 		@userHasSubscription user, (err, hasSubscription, subscription)=>
@@ -41,7 +44,7 @@ module.exports =
 			hasValidSubscription = subscription? and (subscription.recurlySubscription_id? or subscription?.customAccount == true)
 			logger.log user:user, hasValidSubscription:hasValidSubscription, subscription:subscription, "checking if user has subscription"
 			callback err, hasValidSubscription, subscription
-			
+
 	userIsMemberOfGroupSubscription: (user, callback = (error, isMember, subscriptions) ->) ->
 		logger.log user_id: user._id, "checking is user is member of subscription groups"
 		SubscriptionLocator.getMemberSubscriptions user._id, (err, subscriptions = []) ->
@@ -65,4 +68,3 @@ getOwnerOfProject = (project_id, callback)->
 		return callback(error) if error?
 		User.findById project.owner_ref, (error, owner) ->
 			callback(error, owner)
-

+ 29 - 10
services/web/app/coffee/models/ProjectInvite.coffee

@@ -1,23 +1,42 @@
 mongoose = require 'mongoose'
 Settings = require 'settings-sharelatex'
 
+
 Schema = mongoose.Schema
 ObjectId = Schema.ObjectId
 
-ProjectInviteSchema = new Schema
-	project_id:     ObjectId
-	from_user_id:   ObjectId
-	privilegeLevel: String
-	# For existing users
-	to_user_id:     ObjectId
-	# For non-existant users
-	hashed_token:   String
-	email:          String
+
+EXPIRY_IN_SECONDS = 60 * 60 * 24 * 30
+
+ExpiryDate = () ->
+	timestamp = new Date()
+	timestamp.setSeconds(timestamp.getSeconds() + EXPIRY_IN_SECONDS)
+	return timestamp
+
+
+
+ProjectInviteSchema = new Schema(
+	{
+		email:          String
+		token:          String
+		sendingUserId:  ObjectId
+		projectId:      ObjectId
+		privileges:     String
+		createdAt:      {type: Date, default: Date.now}
+		expires:        {type: Date, default: ExpiryDate, index: {expireAfterSeconds: 10}}
+	},
+	{
+		collection: 'projectInvites'
+	}
+)
+
 
 conn = mongoose.createConnection(Settings.mongo.url, server: poolSize: Settings.mongo.poolSize || 10)
 
+
 ProjectInvite = conn.model('ProjectInvite', ProjectInviteSchema)
 
 mongoose.model 'ProjectInvite', ProjectInviteSchema
 exports.ProjectInvite = ProjectInvite
-exports.ProjectInviteSchema = ProjectInviteSchema
+exports.ProjectInviteSchema = ProjectInviteSchema
+exports.EXPIRY_IN_SECONDS = EXPIRY_IN_SECONDS

+ 21 - 3
services/web/app/views/project/editor/share.jade

@@ -27,12 +27,12 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 					) #{translate("make_private")}
 			.row.project-member
 				.col-xs-8 {{ project.owner.email }}
-				.text-right(
+				.text-left(
 					ng-class="{'col-xs-3': project.members.length > 0, 'col-xs-4': project.members.length == 0}"
 				) #{translate("owner")}
 			.row.project-member(ng-repeat="member in project.members")
 				.col-xs-8 {{ member.email }}
-				.col-xs-3.text-right
+				.col-xs-3.text-left
 					span(ng-show="member.privileges == 'readAndWrite'") #{translate("can_edit")}
 					span(ng-show="member.privileges == 'readOnly'") #{translate("read_only")}
 				.col-xs-1
@@ -43,6 +43,23 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 						ng-click="removeMember(member)"
 					)
 						i.fa.fa-times
+			.row.project-invite(ng-repeat="invite in project.invites")
+				.col-xs-8 {{ invite.email }}&nbsp;
+					div.small
+						| #{translate("invite_not_accepted")}.&nbsp;
+						a(href="#", ng-click="resendInvite(invite, $event)") #{translate("resend")}
+				.col-xs-3.text-left
+					// todo: get invite privileges
+					span(ng-show="invite.privileges == 'readAndWrite'") #{translate("can_edit")}
+					span(ng-show="invite.privileges == 'readOnly'") #{translate("read_only")}
+				.col-xs-1
+					a(
+						href
+						tooltip="#{translate('revoke_invite')}"
+						tooltip-placement="bottom"
+						ng-click="revokeInvite(invite)"
+					)
+						i.fa.fa-times
 			.row.invite-controls
 				form(ng-show="canAddCollaborators")
 					.small #{translate("share_with_your_collabs")}
@@ -78,6 +95,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 							button.btn.btn-info(
 								type="submit"
 								ng-mousedown="addMembers()"
+								ng-keyup="$event.keyCode == 13 ? addMembers() : null"
 							) #{translate("share")}
 				div(ng-hide="canAddCollaborators")
 					p.text-center #{translate("need_to_upgrade_for_more_collabs")}. Also:
@@ -123,7 +141,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 		.modal-footer-left
 			i.fa.fa-refresh.fa-spin(ng-show="state.inflight")
 			span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")}
-		button.btn.btn-primary(
+		button.btn.btn-default(
 			ng-click="done()"
 		) #{translate("close")}
 

+ 18 - 0
services/web/app/views/project/invite/not-valid.jade

@@ -0,0 +1,18 @@
+extends ../../layout
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-8.col-md-offset-2
+					.card.project-invite-invalid
+						.page-header.text-centered
+							h1  #{translate("invite_not_valid")}
+						.row.text-center
+							.col-md-12
+								p
+									| #{translate("invite_not_valid_description")}.
+						.row.text-center.actions
+							.col-md-12
+								a.btn.btn-info(href="/project") #{translate("back_to_your_projects")}
+							

+ 31 - 0
services/web/app/views/project/invite/show.jade

@@ -0,0 +1,31 @@
+extends ../../layout
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-8.col-md-offset-2
+					.card.project-invite-accept
+						.page-header.text-centered
+							h1  #{translate("user_wants_you_to_see_project", {username:owner.first_name, projectname:""})}
+								em
+									span.project-name #{project.name} 
+						.row.text-center
+							.col-md-12
+								p
+									| #{translate("accepting_invite_as")}&nbsp;
+									em #{user.email}
+						.row
+							.col-md-12
+								form.form(
+									name="acceptForm",
+									method="POST",
+									action="/project/#{invite.projectId}/invite/#{invite._id}/accept"
+								)
+									input(name='_csrf', type='hidden', value=csrfToken)
+									input(name='token', type='hidden', value="#{invite.token}")
+									.form-group.text-center
+										button.btn.btn-lg.btn-primary(type="submit")
+											| #{translate("join_project")}
+									.form-group.text-center
+							

+ 6 - 4
services/web/app/views/project/list/notifications.jade

@@ -9,7 +9,9 @@ span(ng-controller="NotificationsController").userNotifications
 			.row(ng-hide="unreadNotification.hide")
 				.col-xs-12
 					.alert.alert-info
-						span(ng-bind-html="unreadNotification.html")
-						button(ng-click="dismiss(unreadNotification)").close.pull-right
-							span(aria-hidden="true") &times;
-							span.sr-only #{translate("close")}
+						div.notification_inner
+							span(ng-bind-html="unreadNotification.html").notification_body
+							span().notification_close
+								button(ng-click="dismiss(unreadNotification)").close.pull-right
+									span(aria-hidden="true") &times;
+									span.sr-only #{translate("close")}

+ 7 - 3
services/web/app/views/user/register.jade

@@ -6,9 +6,13 @@ block content
 			.row
 				.registration_message
 					if sharedProjectData.user_first_name !== undefined
-						h1  #{translate("user_wants_you_to_see_project", {username:sharedProjectData.user_first_name, projectname:sharedProjectData.project_name})}
-						div #{translate("join_sl_to_view_project")}.
-							a(href="/login")  #{translate("login_here")}
+						h1  #{translate("user_wants_you_to_see_project", {username:sharedProjectData.user_first_name, projectname:""})}
+							em #{sharedProjectData.project_name}
+						div
+							| #{translate("join_sl_to_view_project")}.
+						div
+							| #{translate("if_you_are_registered")},
+							a(href="/login?redir=#{getReqQueryParam('redir')}")  #{translate("login_here")}
 					else if newTemplateData.templateName !== undefined
 						h1 #{translate("register_to_edit_template", {templateName:newTemplateData.templateName})}
 

+ 25 - 8
services/web/public/coffee/ide/share/controllers/ShareController.coffee

@@ -1,13 +1,30 @@
 define [
 	"base"
 ], (App) ->
-	App.controller "ShareController", ["$scope", "$modal", "event_tracking", ($scope, $modal, event_tracking) ->
-		$scope.openShareProjectModal = () ->
-			event_tracking.sendMBOnce "ide-open-share-modal-once"
+	App.controller "ShareController", ["$scope", "$modal", "ide", "projectInvites", "projectMembers", "event_tracking",
+	($scope, $modal, ide, projectInvites, projectMembers, event_tracking) ->
+			$scope.openShareProjectModal = () ->
+				event_tracking.sendMBOnce "ide-open-share-modal-once"
 
-			$modal.open(
-				templateUrl: "shareProjectModalTemplate"
-				controller:  "ShareProjectModalController"
-				scope: $scope
-			)
+				$modal.open(
+					templateUrl: "shareProjectModalTemplate"
+					controller:  "ShareProjectModalController"
+					scope: $scope
+				)
+
+			ide.socket.on 'project:membership:changed', (data) =>
+				if data.members
+					projectMembers.getMembers()
+						.success (responseData) =>
+							if responseData.members
+								$scope.project.members = responseData.members
+						.error (responseDate) =>
+							console.error "Error fetching members for project"
+				if data.invites
+					projectInvites.getInvites()
+						.success (responseData) =>
+							if responseData.invites
+								$scope.project.invites = responseData.invites
+						.error (responseDate) =>
+							console.error "Error fetching invites for project"
 	]

+ 63 - 20
services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee

@@ -1,7 +1,7 @@
 define [
 	"base"
 ], (App) ->
-	App.controller "ShareProjectModalController", ($scope, $modalInstance, $timeout, projectMembers, $modal, $http) ->
+	App.controller "ShareProjectModalController", ($scope, $modalInstance, $timeout, projectMembers, projectInvites, $modal, $http) ->
 		$scope.inputs = {
 			privileges: "readAndWrite"
 			contacts: []
@@ -10,6 +10,7 @@ define [
 			error: null
 			inflight: false
 			startedFreeTrial: false
+			invites: []
 		}
 
 		$modalInstance.opened.then () ->
@@ -22,6 +23,8 @@ define [
 			allowedNoOfMembers = $scope.project.features.collaborators
 			$scope.canAddCollaborators = noOfMembers < allowedNoOfMembers or allowedNoOfMembers == INFINITE_COLLABORATORS
 
+		window._m = projectMembers
+
 		$scope.autocompleteContacts = []
 		do loadAutocompleteUsers = () ->
 			$http.get "/user/contacts"
@@ -38,9 +41,12 @@ define [
 						else
 							# Must be a group
 							contact.display = contact.name
-		
+
 		getCurrentMemberEmails = () ->
-			$scope.project.members.map (u) -> u.email
+			($scope.project.members || []).map (u) -> u.email
+
+		getCurrentInviteEmails = () ->
+			($scope.project.invites || []).map (u) -> u.email
 
 		$scope.filterAutocompleteUsers = ($query) ->
 			currentMemberEmails = getCurrentMemberEmails()
@@ -60,36 +66,47 @@ define [
 				$scope.inputs.contacts = []
 				$scope.state.error = null
 				$scope.state.inflight = true
-				
+
+				if !$scope.project.invites?
+					$scope.project.invites = []
+
 				currentMemberEmails = getCurrentMemberEmails()
+				currentInviteEmails = getCurrentInviteEmails()
 				do addNextMember = () ->
 					if members.length == 0 or !$scope.canAddCollaborators
 						$scope.state.inflight = false
 						$scope.$apply()
 						return
-					
+
 					member = members.shift()
 					if !member.type? and member.display in currentMemberEmails
 						# Skip this existing member
 						return addNextMember()
-					
-					if member.type == "user"
-						request = projectMembers.addMember(member.email, $scope.inputs.privileges)
+
+					# NOTE: groups aren't really a thing in ShareLaTeX, partially inherited from DJ
+					if member.display in currentInviteEmails and inviteId = _.find(($scope.project.invites || []), (invite) -> invite.email == member.display)?._id
+						request = projectInvites.resendInvite(inviteId)
+					else if member.type == "user"
+						request = projectInvites.sendInvite(member.email, $scope.inputs.privileges)
 					else if member.type == "group"
 						request = projectMembers.addGroup(member.id, $scope.inputs.privileges)
 					else # Not an auto-complete object, so email == display
-						request = projectMembers.addMember(member.display, $scope.inputs.privileges)
-					
+						request = projectInvites.sendInvite(member.display, $scope.inputs.privileges)
+
 					request
 						.success (data) ->
-							if data.users?
-								users = data.users
-							else if data.user?
-								users = [data.user]
+							if data.invite
+								invite = data.invite
+								$scope.project.invites.push invite
 							else
-								users = []
-								
-							$scope.project.members.push users...
+								if data.users?
+									users = data.users
+								else if data.user?
+									users = [data.user]
+								else
+									users = []
+								$scope.project.members.push users...
+
 							setTimeout () ->
 								# Give $scope a chance to update $scope.canAddCollaborators
 								# with new collaborator information.
@@ -98,8 +115,7 @@ define [
 						.error () ->
 							$scope.state.inflight = false
 							$scope.state.error = true
-			
-			
+
 			$timeout addMembers, 50 # Give email list a chance to update
 
 		$scope.removeMember = (member) ->
@@ -116,6 +132,33 @@ define [
 					$scope.state.inflight = false
 					$scope.state.error = "Sorry, something went wrong :("
 
+		$scope.revokeInvite = (invite) ->
+			$scope.state.error = null
+			$scope.state.inflight = true
+			projectInvites
+				.revokeInvite(invite._id)
+				.success () ->
+					$scope.state.inflight = false
+					index = $scope.project.invites.indexOf(invite)
+					return if index == -1
+					$scope.project.invites.splice(index, 1)
+				.error () ->
+					$scope.state.inflight = false
+					$scope.state.error = "Sorry, something went wrong :("
+
+		$scope.resendInvite = (invite, event) ->
+			$scope.state.error = null
+			$scope.state.inflight = true
+			projectInvites
+				.resendInvite(invite._id)
+				.success () ->
+					$scope.state.inflight = false
+					event.target.blur()
+				.error () ->
+					$scope.state.inflight = false
+					$scope.state.error = "Sorry, something went wrong resending the invite :("
+					event.target.blur()
+
 		$scope.openMakePublicModal = () ->
 			$modal.open {
 				templateUrl: "makePublicModalTemplate"
@@ -158,4 +201,4 @@ define [
 
 		$scope.cancel = () ->
 			$modalInstance.dismiss()
-	]
+	]

+ 2 - 1
services/web/public/coffee/ide/share/index.coffee

@@ -2,4 +2,5 @@ define [
 	"ide/share/controllers/ShareController"
 	"ide/share/controllers/ShareProjectModalController"
 	"ide/share/services/projectMembers"
-], () ->
+	"ide/share/services/projectInvites"
+], () ->

+ 35 - 0
services/web/public/coffee/ide/share/services/projectInvites.coffee

@@ -0,0 +1,35 @@
+define [
+	"base"
+], (App) ->
+	App.factory "projectInvites", ["ide", "$http", (ide, $http) ->
+		return {
+
+			sendInvite: (email, privileges) ->
+				$http.post("/project/#{ide.project_id}/invite", {
+					email: email
+					privileges: privileges
+					_csrf: window.csrfToken
+				})
+
+			revokeInvite: (inviteId) ->
+				$http({
+					url: "/project/#{ide.project_id}/invite/#{inviteId}"
+					method: "DELETE"
+					headers:
+						"X-Csrf-Token": window.csrfToken
+				})
+
+			resendInvite: (inviteId, privileges) ->
+				$http.post("/project/#{ide.project_id}/invite/#{inviteId}/resend", {
+					_csrf: window.csrfToken
+				})
+
+			getInvites: () ->
+				$http.get("/project/#{ide.project_id}/invites", {
+					json: true
+					headers:
+						"X-Csrf-Token": window.csrfToken
+				})
+
+		}
+	]

+ 9 - 9
services/web/public/coffee/ide/share/services/projectMembers.coffee

@@ -11,19 +11,19 @@ define [
 						"X-Csrf-Token": window.csrfToken
 				})
 
-			addMember: (email, privileges) ->
-				$http.post("/project/#{ide.project_id}/users", {
-					email: email
-					privileges: privileges
-					_csrf: window.csrfToken
-				})
-			
 			addGroup: (group_id, privileges) ->
 				$http.post("/project/#{ide.project_id}/group", {
 					group_id: group_id
 					privileges: privileges
 					_csrf: window.csrfToken
 				})
-				
+
+			getMembers: () ->
+				$http.get("/project/#{ide.project_id}/members", {
+					json: true
+					headers:
+						"X-Csrf-Token": window.csrfToken
+				})
+
 		}
-	]
+	]

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

@@ -6,7 +6,7 @@
 		font-size: 1rem;
 	}
 
-	.project-member, .public-access-level {
+	.project-member, .project-invite, .public-access-level {
 		padding: (@line-height-computed / 2) 0;
 		border-bottom: 1px solid @gray-lighter;
 		font-size: 14px;
@@ -19,7 +19,7 @@
 		padding-bottom: @line-height-computed;
 	}
 
-	.project-member {
+	.project-member, .project-invite {
 		&:hover {
 			background-color: @gray-lightest;
 		}

+ 18 - 0
services/web/public/stylesheets/app/invite.less

@@ -0,0 +1,18 @@
+.project-invite-accept {
+	.page-header {
+		.project-name {
+			white-space: pre;
+		}
+	}
+	form {
+		padding-top: 15px;
+	}
+	margin-bottom: 30px;
+}
+
+.project-invite-invalid {
+	.actions {
+		padding-top: 15px;
+	}
+	margin-bottom: 30px;
+}

+ 17 - 3
services/web/public/stylesheets/app/project-list.less

@@ -32,10 +32,24 @@
 	ul {
 		margin-bottom:0px;
 	}
-	.alert {
-		.box-shadow(2px 4px 6px rgba(0, 0, 0, 0.25));
+	.notification_entry {
+		.alert {
+			.box-shadow(2px 4px 6px rgba(0, 0, 0, 0.25));
+			.notification_inner {
+				display: table-row;
+				.notification_body {
+					display: table-cell;
+					width: 99%;
+					padding-right: 15px;
+					vertical-align: middle;
+				}
+				.notification_close {
+					display: table-cell;
+					vertical-align: middle;
+				}
+			}
+		}
 	}
-
 }
 
 ul.folders-menu {

+ 1 - 1
services/web/public/stylesheets/style.less

@@ -76,8 +76,8 @@
 @import "app/translations.less";
 @import "app/contact-us.less";
 @import "app/sprites.less";
+@import "app/invite.less";
 
 @import	"../js/libs/pdfListView/TextLayer.css";
 @import	"../js/libs/pdfListView/AnnotationsLayer.css";
 @import	"../js/libs/pdfListView/HighlightsLayer.css";
-

+ 47 - 9
services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee

@@ -18,6 +18,7 @@ describe "CollaboratorsController", ->
 			'../Subscription/LimitationsManager' : @LimitationsManager = {}
 			'../Project/ProjectEditorHandler' : @ProjectEditorHandler = {}
 			'../User/UserGetter': @UserGetter = {}
+			'logger-sharelatex': @logger = {err: sinon.stub(), erro: sinon.stub(), log: sinon.stub()}
 		@res = new MockResponse()
 		@req = new MockRequest()
 
@@ -73,10 +74,10 @@ describe "CollaboratorsController", ->
 			beforeEach ->
 				@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, false)
 				@CollaboratorsController.addUserToProject @req, @res, @next
-		
+
 			it "should not add the user to the project", ->
 				@CollaboratorsHandler.addEmailToProject.called.should.equal false
-		
+
 			it "should not emit a userAddedToProject event", ->
 				@EditorRealTimeController.emitToRoom.called.should.equal false
 
@@ -93,17 +94,17 @@ describe "CollaboratorsController", ->
 				@res.status = sinon.stub().returns @res
 				@res.send = sinon.stub()
 				@CollaboratorsController.addUserToProject @req, @res, @next
-		
+
 			it "should not add the user to the project", ->
 				@CollaboratorsHandler.addEmailToProject.called.should.equal false
-		
+
 			it "should not emit a userAddedToProject event", ->
 				@EditorRealTimeController.emitToRoom.called.should.equal false
 
 			it "should return a 400 response", ->
 				@res.status.calledWith(400).should.equal true
 				@res.send.calledWith("invalid email address").should.equal true
-			
+
 	describe "removeUserFromProject", ->
 		beforeEach ->
 			@req.params =
@@ -113,20 +114,23 @@ describe "CollaboratorsController", ->
 			@EditorRealTimeController.emitToRoom = sinon.stub()
 			@CollaboratorsHandler.removeUserFromProject = sinon.stub().callsArg(2)
 			@CollaboratorsController.removeUserFromProject @req, @res
-			
+
 		it "should from the user from the project", ->
 			@CollaboratorsHandler.removeUserFromProject
 				.calledWith(@project_id, @user_id)
 				.should.equal true
-		
+
 		it "should emit a userRemovedFromProject event to the proejct", ->
 			@EditorRealTimeController.emitToRoom
 				.calledWith(@project_id, 'userRemovedFromProject', @user_id)
 				.should.equal true
-				
+
 		it "should send the back a success response", ->
 			@res.sendStatus.calledWith(204).should.equal true
 
+		it 'should have called emitToRoom', ->
+			@EditorRealTimeController.emitToRoom.calledWith(@project_id, 'project:membership:changed').should.equal true
+
 	describe "removeSelfFromProject", ->
 		beforeEach ->
 			@req.session =
@@ -141,7 +145,7 @@ describe "CollaboratorsController", ->
 			@CollaboratorsHandler.removeUserFromProject
 				.calledWith(@project_id, @user_id)
 				.should.equal true
-	
+
 		it "should emit a userRemovedFromProject event to the proejct", ->
 			@EditorRealTimeController.emitToRoom
 				.calledWith(@project_id, 'userRemovedFromProject', @user_id)
@@ -150,3 +154,37 @@ describe "CollaboratorsController", ->
 		it "should return a success code", ->
 			@res.sendStatus.calledWith(204).should.equal true
 
+	describe 'getAllMembers', ->
+		beforeEach ->
+			@req.session =
+				user: _id: @user_id = "user-id-123"
+			@req.params = Project_id: @project_id
+			@res.json = sinon.stub()
+			@next = sinon.stub()
+			@members = [{a: 1}]
+			@CollaboratorsHandler.getAllMembers = sinon.stub().callsArgWith(1, null, @members)
+			@CollaboratorsController.getAllMembers(@req, @res, @next)
+
+		it 'should not produce an error', ->
+			@next.callCount.should.equal 0
+
+		it 'should produce a json response', ->
+			@res.json.callCount.should.equal 1
+			@res.json.calledWith({members: @members}).should.equal true
+
+		it 'should call CollaboratorsHandler.getAllMembers', ->
+			@CollaboratorsHandler.getAllMembers.callCount.should.equal 1
+
+		describe 'when CollaboratorsHandler.getAllMembers produces an error', ->
+			beforeEach ->
+				@res.json = sinon.stub()
+				@next = sinon.stub()
+				@CollaboratorsHandler.getAllMembers = sinon.stub().callsArgWith(1, new Error('woops'))
+				@CollaboratorsController.getAllMembers(@req, @res, @next)
+
+			it 'should produce an error', ->
+				@next.callCount.should.equal 1
+				@next.firstCall.args[0].should.be.instanceof Error
+
+			it 'should not produce a json response', ->
+				@res.json.callCount.should.equal 0

+ 87 - 35
services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee

@@ -18,13 +18,14 @@ describe "CollaboratorsHandler", ->
 			"../Project/ProjectEntityHandler": @ProjectEntityHandler = {}
 			"./CollaboratorsEmailHandler": @CollaboratorsEmailHandler = {}
 			"../Errors/Errors": Errors
+			"../Project/ProjectEditorHandler": @ProjectEditorHandler = {}
 
 		@project_id = "mock-project-id"
 		@user_id = "mock-user-id"
 		@adding_user_id = "adding-user-id"
 		@email = "joe@sharelatex.com"
 		@callback = sinon.stub()
-	
+
 	describe "getMemberIdsWithPrivilegeLevels", ->
 		describe "with project", ->
 			beforeEach ->
@@ -46,16 +47,16 @@ describe "CollaboratorsHandler", ->
 						{ id: "read-write-ref-2", privilegeLevel: "readAndWrite" }
 					])
 					.should.equal true
-		
+
 		describe "with a missing project", ->
 			beforeEach ->
 				@Project.findOne = sinon.stub().yields(null, null)
-			
+
 			it "should return a NotFoundError", (done) ->
 				@CollaboratorHandler.getMemberIdsWithPrivilegeLevels @project_id, (error) ->
 					error.should.be.instanceof Errors.NotFoundError
 					done()
-	
+
 	describe "getMemberIds", ->
 		beforeEach ->
 			@CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub()
@@ -68,7 +69,7 @@ describe "CollaboratorsHandler", ->
 			@callback
 				.calledWith(null, ["member-id-1", "member-id-2"])
 				.should.equal true
-	
+
 	describe "getMembersWithPrivilegeLevels", ->
 		beforeEach ->
 			@CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub()
@@ -86,7 +87,7 @@ describe "CollaboratorsHandler", ->
 			@UserGetter.getUser.withArgs("read-write-ref-2").yields(null, { _id: "read-write-ref-2" })
 			@UserGetter.getUser.withArgs("doesnt-exist").yields(null, null)
 			@CollaboratorHandler.getMembersWithPrivilegeLevels @project_id, @callback
-		
+
 		it "should return an array of members with their privilege levels", ->
 			@callback
 				.calledWith(null, [
@@ -96,7 +97,7 @@ describe "CollaboratorsHandler", ->
 					{ user: { _id: "read-write-ref-2" }, privilegeLevel: "readAndWrite" }
 				])
 				.should.equal true
-	
+
 	describe "getMemberIdPrivilegeLevel", ->
 		beforeEach ->
 			@CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub()
@@ -116,7 +117,7 @@ describe "CollaboratorsHandler", ->
 			@CollaboratorHandler.getMemberIdPrivilegeLevel "member-id-3", @project_id, (error, level) ->
 				expect(level).to.equal false
 				done()
-	
+
 	describe "isUserMemberOfProject", ->
 		beforeEach ->
 			@CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub()
@@ -128,7 +129,7 @@ describe "CollaboratorsHandler", ->
 					{ id: @user_id, privilegeLevel: "readAndWrite" }
 				])
 				@CollaboratorHandler.isUserMemberOfProject @user_id, @project_id, @callback
-			
+
 			it "should return true and the privilegeLevel", ->
 				@callback
 					.calledWith(null, true, "readAndWrite")
@@ -140,12 +141,12 @@ describe "CollaboratorsHandler", ->
 					{ id: "not-the-user", privilegeLevel: "readOnly" }
 				])
 				@CollaboratorHandler.isUserMemberOfProject @user_id, @project_id, @callback
-			
+
 			it "should return false", ->
 				@callback
 					.calledWith(null, false, null)
 					.should.equal true
-	
+
 	describe "getProjectsUserIsCollaboratorOf", ->
 		beforeEach ->
 			@fields = "mock fields"
@@ -153,7 +154,7 @@ describe "CollaboratorsHandler", ->
 			@Project.find.withArgs({collaberator_refs:@user_id}, @fields).yields(null, ["mock-read-write-project-1", "mock-read-write-project-2"])
 			@Project.find.withArgs({readOnly_refs:@user_id}, @fields).yields(null, ["mock-read-only-project-1", "mock-read-only-project-2"])
 			@CollaboratorHandler.getProjectsUserIsCollaboratorOf @user_id, @fields, @callback
-		
+
 		it "should call the callback with the projects", ->
 			@callback
 				.calledWith(null, ["mock-read-write-project-1", "mock-read-write-project-2"], ["mock-read-only-project-1", "mock-read-only-project-2"])
@@ -172,17 +173,15 @@ describe "CollaboratorsHandler", ->
 					"$pull":{collaberator_refs:@user_id, readOnly_refs:@user_id}
 				})
 				.should.equal true
-	
+
 	describe "addUserToProject", ->
 		beforeEach ->
 			@Project.update = sinon.stub().callsArg(2)
 			@Project.findOne = sinon.stub().callsArgWith(2, null, @project = {})
 			@ProjectEntityHandler.flushProjectToThirdPartyDataStore = sinon.stub().callsArg(1)
 			@CollaboratorHandler.addEmailToProject = sinon.stub().callsArgWith(4, null, @user_id)
-			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user = { _id: @user_id, email: @email })
-			@CollaboratorsEmailHandler.notifyUserOfProjectShare = sinon.stub()
 			@ContactManager.addContact = sinon.stub()
-			
+
 		describe "as readOnly", ->
 			beforeEach ->
 				@CollaboratorHandler.addUserIdToProject @project_id, @adding_user_id, @user_id, "readOnly", @callback
@@ -195,22 +194,17 @@ describe "CollaboratorsHandler", ->
 						"$addToSet":{ readOnly_refs: @user_id }
 					})
 					.should.equal true
-			
+
 			it "should flush the project to the TPDS", ->
 				@ProjectEntityHandler.flushProjectToThirdPartyDataStore
 					.calledWith(@project_id)
 					.should.equal true
 
-			it "should send an email to the shared-with user", ->
-				@CollaboratorsEmailHandler.notifyUserOfProjectShare
-					.calledWith(@project_id, @email)
-					.should.equal true
-			
 			it "should add the user as a contact for the adding user", ->
 				@ContactManager.addContact
 					.calledWith(@adding_user_id, @user_id)
 					.should.equal true
-					
+
 		describe "as readAndWrite", ->
 			beforeEach ->
 				@CollaboratorHandler.addUserIdToProject @project_id, @adding_user_id, @user_id, "readAndWrite", @callback
@@ -223,19 +217,19 @@ describe "CollaboratorsHandler", ->
 						"$addToSet":{ collaberator_refs: @user_id }
 					})
 					.should.equal true
-			
+
 			it "should flush the project to the TPDS", ->
 				@ProjectEntityHandler.flushProjectToThirdPartyDataStore
 					.calledWith(@project_id)
 					.should.equal true
-		
+
 		describe "with invalid privilegeLevel", ->
 			beforeEach ->
 				@CollaboratorHandler.addUserIdToProject @project_id, @adding_user_id, @user_id, "notValid", @callback
 
 			it "should call the callback with an error", ->
 				@callback.calledWith(new Error()).should.equal true
-		
+
 		describe "when user already exists as a collaborator", ->
 			beforeEach ->
 				@project.collaberator_refs = [@user_id]
@@ -243,7 +237,7 @@ describe "CollaboratorsHandler", ->
 
 			it "should not add the user again", ->
 				@Project.update.called.should.equal false
-	
+
 	describe "addEmailToProject", ->
 		beforeEach ->
 			@UserCreator.getUserOrCreateHoldingAccount = sinon.stub().callsArgWith(1, null, @user = {_id: @user_id})
@@ -252,27 +246,27 @@ describe "CollaboratorsHandler", ->
 		describe "with a valid email", ->
 			beforeEach ->
 				@CollaboratorHandler.addEmailToProject @project_id, @adding_user_id, (@email = "Joe@example.com"), (@privilegeLevel = "readAndWrite"), @callback
-			
+
 			it "should get the user with the lowercased email", ->
 				@UserCreator.getUserOrCreateHoldingAccount
 					.calledWith(@email.toLowerCase())
 					.should.equal true
-		
+
 			it "should add the user to the project by id", ->
 				@CollaboratorHandler.addUserIdToProject
 					.calledWith(@project_id, @adding_user_id, @user_id, @privilegeLevel)
 					.should.equal true
-			
+
 			it "should return the callback with the user_id", ->
 				@callback.calledWith(null, @user_id).should.equal true
-		
+
 		describe "with an invalid email", ->
 			beforeEach ->
 				@CollaboratorHandler.addEmailToProject @project_id, @adding_user_id, "not-and-email", (@privilegeLevel = "readAndWrite"), @callback
-		
+
 			it "should call the callback with an error", ->
 				@callback.calledWith(new Error()).should.equal true
-			
+
 			it "should not add any users to the proejct", ->
 				@CollaboratorHandler.addUserIdToProject.called.should.equal false
 
@@ -286,9 +280,67 @@ describe "CollaboratorsHandler", ->
 			)
 			@CollaboratorHandler.removeUserFromProject = sinon.stub().yields()
 			@CollaboratorHandler.removeUserFromAllProjets @user_id, done
-		
+
 		it "should remove the user from each project", ->
 			for project_id in ["read-and-write-0", "read-and-write-1", "read-only-0", "read-only-1"]
 				@CollaboratorHandler.removeUserFromProject
 					.calledWith(project_id, @user_id)
-					.should.equal true
+					.should.equal true
+
+	describe 'getAllMembers', ->
+
+		beforeEach ->
+			@owning_user = {_id: 'owner-id', email: 'owner@example.com', features: {a: 1}}
+			@readwrite_user = {_id: 'readwrite-id', email: 'readwrite@example.com'}
+			@members = [
+				{user: @owning_user,    privilegeLevel: "owner"},
+				{user: @readwrite_user, privilegeLevel: "readAndWrite"}
+			]
+			@CollaboratorHandler.getMembersWithPrivilegeLevels = sinon.stub().callsArgWith(1, null, @members)
+			@ProjectEditorHandler.buildOwnerAndMembersViews = sinon.stub().returns(@views = {
+				owner: @owning_user,
+				ownerFeatures: @owning_user.features,
+				members: [ {_id: @readwrite_user._id, email: @readwrite_user.email} ]
+			})
+			@callback = sinon.stub()
+			@CollaboratorHandler.getAllMembers @project_id, @callback
+
+		it 'should not produce an error', ->
+			@callback.callCount.should.equal 1
+			expect(@callback.firstCall.args[0]).to.equal null
+
+		it 'should produce a list of members', ->
+			@callback.callCount.should.equal 1
+			expect(@callback.firstCall.args[1]).to.deep.equal @views.members
+
+		it 'should call getMembersWithPrivileges', ->
+			@CollaboratorHandler.getMembersWithPrivilegeLevels.callCount.should.equal 1
+			@CollaboratorHandler.getMembersWithPrivilegeLevels.firstCall.args[0].should.equal @project_id
+
+		it 'should call ProjectEditorHandler.buildOwnerAndMembersViews', ->
+			@ProjectEditorHandler.buildOwnerAndMembersViews.callCount.should.equal 1
+			@ProjectEditorHandler.buildOwnerAndMembersViews.firstCall.args[0].should.equal @members
+
+		describe 'when getMembersWithPrivileges produces an error', ->
+
+			beforeEach ->
+				@CollaboratorHandler.getMembersWithPrivilegeLevels = sinon.stub().callsArgWith(1, new Error('woops'))
+				@ProjectEditorHandler.buildOwnerAndMembersViews = sinon.stub().returns(@views = {
+					owner: @owning_user,
+					ownerFeatures: @owning_user.features,
+					members: [ {_id: @readwrite_user._id, email: @readwrite_user.email} ]
+				})
+				@callback = sinon.stub()
+				@CollaboratorHandler.getAllMembers @project_id, @callback
+
+			it 'should produce an error', ->
+				@callback.callCount.should.equal 1
+				expect(@callback.firstCall.args[0]).to.not.equal null
+				expect(@callback.firstCall.args[0]).to.be.instanceof Error
+
+			it 'should call getMembersWithPrivileges', ->
+				@CollaboratorHandler.getMembersWithPrivilegeLevels.callCount.should.equal 1
+				@CollaboratorHandler.getMembersWithPrivilegeLevels.firstCall.args[0].should.equal @project_id
+
+			it 'should not call ProjectEditorHandler.buildOwnerAndMembersViews', ->
+				@ProjectEditorHandler.buildOwnerAndMembersViews.callCount.should.equal 0

+ 574 - 0
services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee

@@ -0,0 +1,574 @@
+sinon = require('sinon')
+chai = require('chai')
+should = chai.should()
+expect = chai.expect
+modulePath = "../../../../app/js/Features/Collaborators/CollaboratorsInviteController.js"
+SandboxedModule = require('sandboxed-module')
+events = require "events"
+MockRequest = require "../helpers/MockRequest"
+MockResponse = require "../helpers/MockResponse"
+ObjectId = require("mongojs").ObjectId
+
+describe "CollaboratorsInviteController", ->
+	beforeEach ->
+		@CollaboratorsInviteController = SandboxedModule.require modulePath, requires:
+			"../Project/ProjectGetter": @ProjectGetter = {}
+			'../Subscription/LimitationsManager' : @LimitationsManager = {}
+			'../User/UserGetter': @UserGetter = {getUser: sinon.stub()}
+			"./CollaboratorsHandler": @CollaboratorsHandler = {}
+			"./CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {}
+			'logger-sharelatex': @logger = {err: sinon.stub(), error: sinon.stub(), log: sinon.stub()}
+			"../Editor/EditorRealTimeController": @EditorRealTimeController = {emitToRoom: sinon.stub()}
+			"../Notifications/NotificationsBuilder": @NotificationsBuilder = {}
+		@res = new MockResponse()
+		@req = new MockRequest()
+
+		@project_id = "project-id-123"
+		@callback = sinon.stub()
+
+	describe 'getAllInvites', ->
+
+		beforeEach ->
+			@fakeInvites = [
+				{_id: ObjectId(), one: 1},
+				{_id: ObjectId(), two: 2}
+			]
+			@req.params =
+				Project_id: @project_id
+			@res.json = sinon.stub()
+			@next = sinon.stub()
+
+		describe 'when all goes well', ->
+
+			beforeEach ->
+				@CollaboratorsInviteHandler.getAllInvites = sinon.stub().callsArgWith(1, null, @fakeInvites)
+				@CollaboratorsInviteController.getAllInvites @req, @res, @next
+
+			it 'should not produce an error', ->
+				@next.callCount.should.equal 0
+
+			it 'should produce a list of invite objects', ->
+				@res.json.callCount.should.equal 1
+				@res.json.calledWith({invites: @fakeInvites}).should.equal true
+
+			it 'should have called CollaboratorsInviteHandler.getAllInvites', ->
+				@CollaboratorsInviteHandler.getAllInvites.callCount.should.equal 1
+				@CollaboratorsInviteHandler.getAllInvites.calledWith(@project_id).should.equal true
+
+		describe 'when CollaboratorsInviteHandler.getAllInvites produces an error', ->
+
+			beforeEach ->
+				@CollaboratorsInviteHandler.getAllInvites = sinon.stub().callsArgWith(1, new Error('woops'))
+				@CollaboratorsInviteController.getAllInvites @req, @res, @next
+
+			it 'should produce an error', ->
+				@next.callCount.should.equal 1
+				@next.firstCall.args[0].should.be.instanceof Error
+
+	describe 'inviteToProject', ->
+
+		beforeEach ->
+			@targetEmail = "user@example.com"
+			@req.params =
+				Project_id: @project_id
+			@current_user =
+				_id: @current_user_id = "current-user-id"
+			@req.session =
+				user: @current_user
+			@req.body =
+				email: @targetEmail
+				privileges: @privileges = "readAndWrite"
+			@res.json = sinon.stub()
+			@res.sendStatus = sinon.stub()
+			@invite = {
+				_id: ObjectId(),
+				token: "htnseuthaouse",
+				sendingUserId: @current_user_id,
+				projectId: @targetEmail,
+				targetEmail: 'user@example.com'
+				createdAt: new Date(),
+			}
+			@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true)
+			@CollaboratorsInviteHandler.inviteToProject = sinon.stub().callsArgWith(4, null, @invite)
+			@callback = sinon.stub()
+			@next = sinon.stub()
+
+		describe 'when all goes well', ->
+
+			beforeEach ->
+				@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true)
+				@CollaboratorsInviteController.inviteToProject @req, @res, @next
+
+			it 'should produce json response', ->
+				@res.json.callCount.should.equal 1
+				({invite: @invite}).should.deep.equal(@res.json.firstCall.args[0])
+
+			it 'should have called canAddXCollaborators', ->
+				@LimitationsManager.canAddXCollaborators.callCount.should.equal 1
+				@LimitationsManager.canAddXCollaborators.calledWith(@project_id).should.equal true
+
+			it 'should have called inviteToProject', ->
+				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 1
+				@CollaboratorsInviteHandler.inviteToProject.calledWith(@project_id,@current_user,@targetEmail,@privileges).should.equal true
+
+			it 'should have called emitToRoom', ->
+				@EditorRealTimeController.emitToRoom.callCount.should.equal 1
+				@EditorRealTimeController.emitToRoom.calledWith(@project_id, 'project:membership:changed').should.equal true
+
+		describe 'when the user is not allowed to add more collaborators', ->
+
+			beforeEach ->
+				@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, false)
+				@CollaboratorsInviteController.inviteToProject @req, @res, @next
+
+			it 'should produce json response without an invite', ->
+				@res.json.callCount.should.equal 1
+				({invite: null}).should.deep.equal(@res.json.firstCall.args[0])
+
+			it 'should not have called inviteToProject', ->
+				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0
+
+		describe 'when canAddXCollaborators produces an error', ->
+
+			beforeEach ->
+				@err = new Error('woops')
+				@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, @err)
+				@CollaboratorsInviteController.inviteToProject @req, @res, @next
+
+			it 'should call next with an error', ->
+				@next.callCount.should.equal 1
+				@next.calledWith(@err).should.equal true
+
+			it 'should not have called inviteToProject', ->
+				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0
+
+		describe 'when inviteToProject produces an error', ->
+
+			beforeEach ->
+				@err = new Error('woops')
+				@CollaboratorsInviteHandler.inviteToProject = sinon.stub().callsArgWith(4, @err)
+				@CollaboratorsInviteController.inviteToProject @req, @res, @next
+
+			it 'should call next with an error', ->
+				@next.callCount.should.equal 1
+				@next.calledWith(@err).should.equal true
+
+			it 'should have called canAddXCollaborators', ->
+				@LimitationsManager.canAddXCollaborators.callCount.should.equal 1
+				@LimitationsManager.canAddXCollaborators.calledWith(@project_id).should.equal true
+
+			it 'should have called inviteToProject', ->
+				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 1
+				@CollaboratorsInviteHandler.inviteToProject.calledWith(@project_id,@current_user,@targetEmail,@privileges).should.equal true
+
+	describe "viewInvite", ->
+
+		beforeEach ->
+			@token = "some-opaque-token"
+			@req.params =
+				Project_id: @project_id
+				token: @token
+			@req.session =
+				user: _id: @current_user_id = "current-user-id"
+			@res.render = sinon.stub()
+			@res.redirect = sinon.stub()
+			@res.sendStatus = sinon.stub()
+			@invite = {
+				_id: ObjectId(),
+				token: @token,
+				sendingUserId: ObjectId(),
+				projectId: @project_id,
+				targetEmail: 'user@example.com'
+				createdAt: new Date(),
+			}
+			@fakeProject =
+				_id: @project_id
+				name: "some project"
+				owner_ref: @invite.sendingUserId
+				collaberator_refs: []
+				readOnly_refs: []
+			@owner =
+				_id: @fakeProject.owner_ref
+				first_name: "John"
+				last_name: "Doe"
+				email: "john@example.com"
+
+			@CollaboratorsHandler.isUserMemberOfProject = sinon.stub().callsArgWith(2, null, false, null)
+			@CollaboratorsInviteHandler.getInviteByToken = sinon.stub().callsArgWith(2, null, @invite)
+			@ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, @fakeProject)
+			@UserGetter.getUser.callsArgWith(2, null, @owner)
+
+			@callback = sinon.stub()
+			@next = sinon.stub()
+
+		describe 'when the token is valid', ->
+
+			beforeEach ->
+				@CollaboratorsInviteController.viewInvite @req, @res, @next
+
+			it 'should render the view template', ->
+				@res.render.callCount.should.equal 1
+				@res.render.calledWith('project/invite/show').should.equal true
+
+			it 'should not call next', ->
+				@next.callCount.should.equal 0
+
+			it 'should call CollaboratorsHandler.isUserMemberOfProject', ->
+				@CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1
+				@CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true
+
+			it 'should call getInviteByToken', ->
+				@CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1
+				@CollaboratorsInviteHandler.getInviteByToken.calledWith(@fakeProject._id, @invite.token).should.equal true
+
+			it 'should call User.getUser', ->
+				@UserGetter.getUser.callCount.should.equal 1
+				@UserGetter.getUser.calledWith({_id: @fakeProject.owner_ref}).should.equal true
+
+			it 'should call ProjectGetter.getProject', ->
+				@ProjectGetter.getProject.callCount.should.equal 1
+				@ProjectGetter.getProject.calledWith(@project_id).should.equal true
+
+		describe 'when user is already a member of the project', ->
+
+			beforeEach ->
+				@CollaboratorsHandler.isUserMemberOfProject = sinon.stub().callsArgWith(2, null, true, null)
+				@CollaboratorsInviteController.viewInvite @req, @res, @next
+
+			it 'should redirect to the project page', ->
+				@res.redirect.callCount.should.equal 1
+				@res.redirect.calledWith("/project/#{@project_id}").should.equal true
+
+			it 'should not call next with an error', ->
+				@next.callCount.should.equal 0
+
+			it 'should call CollaboratorsHandler.isUserMemberOfProject', ->
+				@CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1
+				@CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true
+
+			it 'should not call getInviteByToken', ->
+				@CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 0
+
+			it 'should not call User.getUser', ->
+				@UserGetter.getUser.callCount.should.equal 0
+
+			it 'should not call ProjectGetter.getProject', ->
+				@ProjectGetter.getProject.callCount.should.equal 0
+
+		describe 'when isUserMemberOfProject produces an error', ->
+
+			beforeEach ->
+				@CollaboratorsHandler.isUserMemberOfProject = sinon.stub().callsArgWith(2, new Error('woops'))
+				@CollaboratorsInviteController.viewInvite @req, @res, @next
+
+			it 'should call next with an error', ->
+				@next.callCount.should.equal 1
+				expect(@next.firstCall.args[0]).to.be.instanceof Error
+
+			it 'should call CollaboratorsHandler.isUserMemberOfProject', ->
+				@CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1
+				@CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true
+
+			it 'should not call getInviteByToken', ->
+				@CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 0
+
+			it 'should not call User.getUser', ->
+				@UserGetter.getUser.callCount.should.equal 0
+
+			it 'should not call ProjectGetter.getProject', ->
+				@ProjectGetter.getProject.callCount.should.equal 0
+
+		describe 'when the getInviteByToken produces an error', ->
+
+			beforeEach ->
+				@err = new Error('woops')
+				@CollaboratorsInviteHandler.getInviteByToken.callsArgWith(2, @err)
+				@CollaboratorsInviteController.viewInvite @req, @res, @next
+
+			it 'should call next with the error', ->
+				@next.callCount.should.equal 1
+				@next.calledWith(@err).should.equal true
+
+			it 'should call CollaboratorsHandler.isUserMemberOfProject', ->
+				@CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1
+				@CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true
+
+			it 'should call getInviteByToken', ->
+				@CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1
+				@CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true
+
+			it 'should not call User.getUser', ->
+				@UserGetter.getUser.callCount.should.equal 0
+
+			it 'should not call ProjectGetter.getProject', ->
+				@ProjectGetter.getProject.callCount.should.equal 0
+
+		describe 'when the getInviteByToken does not produce an invite', ->
+
+			beforeEach ->
+				@CollaboratorsInviteHandler.getInviteByToken.callsArgWith(2, null, null)
+				@CollaboratorsInviteController.viewInvite @req, @res, @next
+
+			it 'should render the not-valid view template', ->
+				@res.render.callCount.should.equal 1
+				@res.render.calledWith('project/invite/not-valid').should.equal true
+
+			it 'should not call next', ->
+				@next.callCount.should.equal 0
+
+			it 'should call CollaboratorsHandler.isUserMemberOfProject', ->
+				@CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1
+				@CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true
+
+			it 'should call getInviteByToken', ->
+				@CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1
+				@CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true
+
+			it 'should not call User.getUser', ->
+				@UserGetter.getUser.callCount.should.equal 0
+
+			it 'should not call ProjectGetter.getProject', ->
+				@ProjectGetter.getProject.callCount.should.equal 0
+
+		describe 'when User.getUser produces an error', ->
+
+			beforeEach ->
+				@UserGetter.getUser.callsArgWith(2, new Error('woops'))
+				@CollaboratorsInviteController.viewInvite @req, @res, @next
+
+			it 'should produce an error', ->
+				@next.callCount.should.equal 1
+				expect(@next.firstCall.args[0]).to.be.instanceof Error
+
+			it 'should call CollaboratorsHandler.isUserMemberOfProject', ->
+				@CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1
+				@CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true
+
+			it 'should call getInviteByToken', ->
+				@CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1
+
+			it 'should call User.getUser', ->
+				@UserGetter.getUser.callCount.should.equal 1
+				@UserGetter.getUser.calledWith({_id: @fakeProject.owner_ref}).should.equal true
+
+			it 'should not call ProjectGetter.getProject', ->
+				@ProjectGetter.getProject.callCount.should.equal 0
+
+		describe 'when User.getUser does not find a user', ->
+
+			beforeEach ->
+				@UserGetter.getUser.callsArgWith(2, null, null)
+				@CollaboratorsInviteController.viewInvite @req, @res, @next
+
+			it 'should render the not-valid view template', ->
+				@res.render.callCount.should.equal 1
+				@res.render.calledWith('project/invite/not-valid').should.equal true
+
+			it 'should not call next', ->
+				@next.callCount.should.equal 0
+
+			it 'should call CollaboratorsHandler.isUserMemberOfProject', ->
+				@CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1
+				@CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true
+
+			it 'should call getInviteByToken', ->
+				@CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1
+
+			it 'should call User.getUser', ->
+				@UserGetter.getUser.callCount.should.equal 1
+				@UserGetter.getUser.calledWith({_id: @fakeProject.owner_ref}).should.equal true
+
+			it 'should not call ProjectGetter.getProject', ->
+				@ProjectGetter.getProject.callCount.should.equal 0
+
+		describe 'when getProject produces an error', ->
+
+			beforeEach ->
+				@ProjectGetter.getProject.callsArgWith(2, new Error('woops'))
+				@CollaboratorsInviteController.viewInvite @req, @res, @next
+
+			it 'should produce an error', ->
+				@next.callCount.should.equal 1
+				expect(@next.firstCall.args[0]).to.be.instanceof Error
+
+			it 'should call CollaboratorsHandler.isUserMemberOfProject', ->
+				@CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1
+				@CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true
+
+			it 'should call getInviteByToken', ->
+				@CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1
+
+			it 'should call User.getUser', ->
+				@UserGetter.getUser.callCount.should.equal 1
+				@UserGetter.getUser.calledWith({_id: @fakeProject.owner_ref}).should.equal true
+
+			it 'should call ProjectGetter.getProject', ->
+				@ProjectGetter.getProject.callCount.should.equal 1
+
+		describe 'when Project.getUser does not find a user', ->
+
+			beforeEach ->
+				@ProjectGetter.getProject.callsArgWith(2, null, null)
+				@CollaboratorsInviteController.viewInvite @req, @res, @next
+
+			it 'should render the not-valid view template', ->
+				@res.render.callCount.should.equal 1
+				@res.render.calledWith('project/invite/not-valid').should.equal true
+
+			it 'should not call next', ->
+				@next.callCount.should.equal 0
+
+			it 'should call CollaboratorsHandler.isUserMemberOfProject', ->
+				@CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1
+				@CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true
+
+			it 'should call getInviteByToken', ->
+				@CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1
+
+			it 'should call getUser', ->
+				@UserGetter.getUser.callCount.should.equal 1
+				@UserGetter.getUser.calledWith({_id: @fakeProject.owner_ref}).should.equal true
+
+			it 'should call ProjectGetter.getProject', ->
+				@ProjectGetter.getProject.callCount.should.equal 1
+
+	describe "resendInvite", ->
+
+		beforeEach ->
+			@req.params =
+				Project_id: @project_id
+				invite_id: @invite_id = "thuseoautoh"
+			@req.session =
+				user: _id: @current_user_id = "current-user-id"
+			@res.render = sinon.stub()
+			@res.sendStatus = sinon.stub()
+			@CollaboratorsInviteHandler.resendInvite = sinon.stub().callsArgWith(3, null)
+			@callback = sinon.stub()
+			@next = sinon.stub()
+
+		describe 'when resendInvite does not produce an error', ->
+
+			beforeEach ->
+				@CollaboratorsInviteController.resendInvite @req, @res, @next
+
+			it 'should produce a 201 response', ->
+				@res.sendStatus.callCount.should.equal 1
+				@res.sendStatus.calledWith(201).should.equal true
+
+			it 'should have called resendInvite', ->
+				@CollaboratorsInviteHandler.resendInvite.callCount.should.equal 1
+
+		describe 'when resendInvite produces an error', ->
+
+			beforeEach ->
+				@err = new Error('woops')
+				@CollaboratorsInviteHandler.resendInvite = sinon.stub().callsArgWith(3, @err)
+				@CollaboratorsInviteController.resendInvite @req, @res, @next
+
+			it 'should not produce a 201 response', ->
+				@res.sendStatus.callCount.should.equal 0
+
+			it 'should call next with the error', ->
+				@next.callCount.should.equal 1
+				@next.calledWith(@err).should.equal true
+
+			it 'should have called resendInvite', ->
+				@CollaboratorsInviteHandler.resendInvite.callCount.should.equal 1
+
+	describe "revokeInvite", ->
+
+		beforeEach ->
+			@req.params =
+				Project_id: @project_id
+				invite_id: @invite_id = "thuseoautoh"
+			@current_user =
+				_id: @current_user_id = "current-user-id"
+			@req.session =
+				user: @current_user
+			@res.render = sinon.stub()
+			@res.sendStatus = sinon.stub()
+			@CollaboratorsInviteHandler.revokeInvite = sinon.stub().callsArgWith(2, null)
+			@callback = sinon.stub()
+			@next = sinon.stub()
+
+		describe 'when revokeInvite does not produce an error', ->
+
+			beforeEach ->
+				@CollaboratorsInviteController.revokeInvite @req, @res, @next
+
+			it 'should produce a 201 response', ->
+				@res.sendStatus.callCount.should.equal 1
+				@res.sendStatus.calledWith(201).should.equal true
+
+			it 'should have called revokeInvite', ->
+				@CollaboratorsInviteHandler.revokeInvite.callCount.should.equal 1
+
+			it 'should have called emitToRoom', ->
+				@EditorRealTimeController.emitToRoom.callCount.should.equal 1
+				@EditorRealTimeController.emitToRoom.calledWith(@project_id, 'project:membership:changed').should.equal true
+
+		describe 'when revokeInvite produces an error', ->
+
+			beforeEach ->
+				@err = new Error('woops')
+				@CollaboratorsInviteHandler.revokeInvite = sinon.stub().callsArgWith(2, @err)
+				@CollaboratorsInviteController.revokeInvite @req, @res, @next
+
+			it 'should not produce a 201 response', ->
+				@res.sendStatus.callCount.should.equal 0
+
+			it 'should call next with the error', ->
+				@next.callCount.should.equal 1
+				@next.calledWith(@err).should.equal true
+
+			it 'should have called revokeInvite', ->
+				@CollaboratorsInviteHandler.revokeInvite.callCount.should.equal 1
+
+	describe "acceptInvite", ->
+
+		beforeEach ->
+			@req.params =
+				Project_id: @project_id
+				invite_id: @invite_id = "thuseoautoh"
+			@req.session =
+				user: _id: @current_user_id = "current-user-id"
+			@req.body =
+				token: "thsueothaueotauahsuet"
+			@res.render = sinon.stub()
+			@res.redirect = sinon.stub()
+			@CollaboratorsInviteHandler.acceptInvite = sinon.stub().callsArgWith(4, null)
+			@callback = sinon.stub()
+			@next = sinon.stub()
+
+		describe 'when acceptInvite does not produce an error', ->
+
+			beforeEach ->
+				@CollaboratorsInviteController.acceptInvite @req, @res, @next
+
+			it 'should redirect to project page', () ->
+				@res.redirect.callCount.should.equal 1
+				@res.redirect.calledWith("/project/#{@project_id}").should.equal true
+
+			it 'should have called acceptInvite', ->
+				@CollaboratorsInviteHandler.acceptInvite.callCount.should.equal 1
+
+			it 'should have called emitToRoom', ->
+				@EditorRealTimeController.emitToRoom.callCount.should.equal 1
+				@EditorRealTimeController.emitToRoom.calledWith(@project_id, 'project:membership:changed').should.equal true
+
+		describe 'when revokeInvite produces an error', ->
+
+			beforeEach ->
+				@err = new Error('woops')
+				@CollaboratorsInviteHandler.acceptInvite = sinon.stub().callsArgWith(4, @err)
+				@CollaboratorsInviteController.acceptInvite @req, @res, @next
+
+			it 'should not redirect to project page', ->
+				@res.redirect.callCount.should.equal 0
+
+			it 'should call next with the error', ->
+				@next.callCount.should.equal 1
+				@next.calledWith(@err).should.equal true
+
+			it 'should have called acceptInvite', ->
+				@CollaboratorsInviteHandler.acceptInvite.callCount.should.equal 1

+ 723 - 0
services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee

@@ -0,0 +1,723 @@
+sinon = require('sinon')
+chai = require('chai')
+should = chai.should()
+expect = chai.expect
+modulePath = "../../../../app/js/Features/Collaborators/CollaboratorsInviteHandler.js"
+SandboxedModule = require('sandboxed-module')
+events = require "events"
+ObjectId = require("mongojs").ObjectId
+Crypto = require('crypto')
+
+describe "CollaboratorsInviteHandler", ->
+	beforeEach ->
+		@ProjectInvite = class ProjectInvite
+			constructor: (options={}) ->
+				this._id = ObjectId()
+				for k,v of options
+					this[k] = v
+				this
+			save: sinon.stub()
+			@findOne: sinon.stub()
+			@find: sinon.stub()
+			@remove: sinon.stub()
+			@count: sinon.stub()
+		@Crypto = Crypto
+		@CollaboratorsInviteHandler = SandboxedModule.require modulePath, requires:
+			'settings-sharelatex': @settings = {}
+			'../../models/ProjectInvite': {ProjectInvite: @ProjectInvite}
+			'logger-sharelatex': @logger = {err: sinon.stub(), error: sinon.stub(), log: sinon.stub()}
+			'./CollaboratorsEmailHandler': @CollaboratorsEmailHandler = {}
+			"./CollaboratorsHandler": @CollaboratorsHandler = {addUserIdToProject: sinon.stub()}
+			'../User/UserGetter': @UserGetter = {getUser: sinon.stub()}
+			"../Project/ProjectGetter": @ProjectGetter = {}
+			"../Notifications/NotificationsBuilder": @NotificationsBuilder = {}
+			'crypto': @Crypto
+
+		@projectId = ObjectId()
+		@sendingUserId = ObjectId()
+		@sendingUser =
+			_id: @sendingUserId
+			name: "Bob"
+		@email = "user@example.com"
+		@userId = ObjectId()
+		@user =
+			_id: @userId
+			email: 'someone@example.com'
+		@inviteId = ObjectId()
+		@token = 'hnhteaosuhtaeosuahs'
+		@privileges = "readAndWrite"
+		@fakeInvite =
+			_id:            @inviteId
+			email:          @email
+			token:          @token
+			sendingUserId:  @sendingUserId
+			projectId:      @projectId
+			privileges:     @privileges
+			createdAt:      new Date()
+
+	describe 'getInviteCount', ->
+
+		beforeEach ->
+			@ProjectInvite.count.callsArgWith(1, null, 2)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler.getInviteCount @projectId, callback
+
+		it 'should not produce an error', (done) ->
+			@call (err, invites) =>
+				expect(err).to.not.be.instanceof Error
+				expect(err).to.be.oneOf [null, undefined]
+				done()
+
+		it 'should produce the count of documents', (done) ->
+			@call (err, count) =>
+				expect(count).to.equal 2
+				done()
+
+		describe 'when model.count produces an error', ->
+
+			beforeEach ->
+				@ProjectInvite.count.callsArgWith(1, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err, count) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+	describe 'getAllInvites', ->
+
+		beforeEach ->
+			@fakeInvites = [
+				{_id: ObjectId(), one: 1},
+				{_id: ObjectId(), two: 2}
+			]
+			@ProjectInvite.find.callsArgWith(1, null, @fakeInvites)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler.getAllInvites @projectId, callback
+
+		describe 'when all goes well', ->
+
+			beforeEach ->
+
+			it 'should not produce an error', (done) ->
+				@call (err, invites) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should produce a list of invite objects', (done) ->
+				@call (err, invites) =>
+					expect(invites).to.not.be.oneOf [null, undefined]
+					expect(invites).to.deep.equal @fakeInvites
+					done()
+
+			it 'should have called ProjectInvite.find', (done) ->
+				@call (err, invites) =>
+					@ProjectInvite.find.callCount.should.equal 1
+					@ProjectInvite.find.calledWith({projectId: @projectId}).should.equal true
+					done()
+
+		describe 'when ProjectInvite.find produces an error', ->
+
+			beforeEach ->
+				@ProjectInvite.find.callsArgWith(1, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err, invites) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+	describe 'inviteToProject', ->
+
+		beforeEach ->
+			@ProjectInvite::save = sinon.spy (cb) -> cb(null, this)
+			@randomBytesSpy = sinon.spy(@Crypto, 'randomBytes')
+			@CollaboratorsInviteHandler._sendMessages = sinon.stub().callsArgWith(3, null)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler.inviteToProject @projectId, @sendingUser, @email, @privileges, callback
+
+		afterEach ->
+			@randomBytesSpy.restore()
+
+		describe 'when all goes well', ->
+
+			beforeEach ->
+
+			it 'should not produce an error', (done) ->
+				@call (err, invite) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should produce the invite object', (done) ->
+				@call (err, invite) =>
+					expect(invite).to.not.equal null
+					expect(invite).to.not.equal undefined
+					expect(invite).to.be.instanceof Object
+					expect(invite).to.have.all.keys ['_id', 'email', 'token', 'sendingUserId', 'projectId', 'privileges']
+					done()
+
+			it 'should have generated a random token', (done) ->
+				@call (err, invite) =>
+					@randomBytesSpy.callCount.should.equal 1
+					done()
+
+			it 'should have called ProjectInvite.save', (done) ->
+				@call (err, invite) =>
+					@ProjectInvite::save.callCount.should.equal 1
+					done()
+
+			it 'should have called _sendMessages', (done) ->
+				@call (err, invite) =>
+					@CollaboratorsInviteHandler._sendMessages.callCount.should.equal 1
+					@CollaboratorsInviteHandler._sendMessages.calledWith(@projectId, @sendingUser).should.equal true
+					done()
+
+		describe 'when saving model produces an error', ->
+
+			beforeEach ->
+				@ProjectInvite::save = sinon.spy (cb) -> cb(new Error('woops'), this)
+
+			it 'should produce an error', (done) ->
+				@call (err, invite) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+	describe '_sendMessages', ->
+
+		beforeEach ->
+			@CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub().callsArgWith(3, null)
+			@CollaboratorsInviteHandler._trySendInviteNotification = sinon.stub().callsArgWith(3, null)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler._sendMessages @projectId, @sendingUser, @fakeInvite, callback
+
+		describe 'when all goes well', ->
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should call CollaboratorsEmailHandler.notifyUserOfProjectInvite', (done) ->
+				@call (err) =>
+					@CollaboratorsEmailHandler.notifyUserOfProjectInvite.callCount.should.equal 1
+					@CollaboratorsEmailHandler.notifyUserOfProjectInvite.calledWith(@projectId, @fakeInvite.email, @fakeInvite).should.equal true
+					done()
+
+			it 'should call _trySendInviteNotification', (done) ->
+				@call (err) =>
+					@CollaboratorsInviteHandler._trySendInviteNotification.callCount.should.equal 1
+					@CollaboratorsInviteHandler._trySendInviteNotification.calledWith(@projectId, @sendingUser, @fakeInvite).should.equal true
+					done()
+
+		describe 'when CollaboratorsEmailHandler.notifyUserOfProjectInvite produces an error', ->
+
+			beforeEach ->
+				@CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub().callsArgWith(3, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err, invite) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should not call _trySendInviteNotification', (done) ->
+				@call (err) =>
+					@CollaboratorsInviteHandler._trySendInviteNotification.callCount.should.equal 0
+					done()
+
+		describe 'when _trySendInviteNotification produces an error', ->
+
+			beforeEach ->
+				@CollaboratorsInviteHandler._trySendInviteNotification = sinon.stub().callsArgWith(3, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err, invite) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+	describe 'revokeInvite', ->
+
+		beforeEach ->
+			@ProjectInvite.remove.callsArgWith(1, null)
+			@CollaboratorsInviteHandler._tryCancelInviteNotification = sinon.stub().callsArgWith(1, null)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler.revokeInvite @projectId, @inviteId, callback
+
+		describe 'when all goes well', ->
+
+			beforeEach ->
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should call ProjectInvite.remove', (done) ->
+				@call (err) =>
+					@ProjectInvite.remove.callCount.should.equal 1
+					@ProjectInvite.remove.calledWith({projectId: @projectId, _id: @inviteId}).should.equal true
+					done()
+
+			it 'should call _tryCancelInviteNotification', (done) ->
+				@call (err) =>
+					@CollaboratorsInviteHandler._tryCancelInviteNotification.callCount.should.equal 1
+					@CollaboratorsInviteHandler._tryCancelInviteNotification.calledWith(@inviteId).should.equal true
+					done()
+
+		describe 'when remove produces an error', ->
+
+			beforeEach ->
+				@ProjectInvite.remove.callsArgWith(1, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+	describe 'resendInvite', ->
+
+		beforeEach ->
+			@ProjectInvite.findOne.callsArgWith(1, null, @fakeInvite)
+			@CollaboratorsInviteHandler._sendMessages = sinon.stub().callsArgWith(3, null)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler.resendInvite @projectId, @sendingUser, @inviteId, callback
+
+		describe 'when all goes well', ->
+
+			beforeEach ->
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should call ProjectInvite.findOne', (done) ->
+				@call (err, invite) =>
+					@ProjectInvite.findOne.callCount.should.equal 1
+					@ProjectInvite.findOne.calledWith({_id: @inviteId, projectId: @projectId}).should.equal true
+					done()
+
+			it 'should have called _sendMessages', (done) ->
+				@call (err, invite) =>
+					@CollaboratorsInviteHandler._sendMessages.callCount.should.equal 1
+					@CollaboratorsInviteHandler._sendMessages.calledWith(@projectId, @sendingUser, @fakeInvite).should.equal true
+					done()
+
+		describe 'when findOne produces an error', ->
+
+			beforeEach ->
+				@ProjectInvite.findOne.callsArgWith(1, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err, invite) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should not have called _sendMessages', (done) ->
+				@call (err, invite) =>
+					@CollaboratorsInviteHandler._sendMessages.callCount.should.equal 0
+					done()
+
+		describe 'when findOne does not find an invite', ->
+
+			beforeEach ->
+				@ProjectInvite.findOne.callsArgWith(1, null, null)
+
+			it 'should not produce an error', (done) ->
+				@call (err, invite) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should not have called _sendMessages', (done) ->
+				@call (err, invite) =>
+					@CollaboratorsInviteHandler._sendMessages.callCount.should.equal 0
+					done()
+
+	describe 'getInviteByToken', ->
+
+		beforeEach ->
+			@ProjectInvite.findOne.callsArgWith(1, null, @fakeInvite)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler.getInviteByToken @projectId, @token, callback
+
+		describe 'when all goes well', ->
+
+			beforeEach ->
+
+			it 'should not produce an error', (done) ->
+				@call (err, invite) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should produce the invite object', (done) ->
+				@call (err, invite) =>
+					expect(invite).to.deep.equal @fakeInvite
+					done()
+
+			it 'should call ProjectInvite.findOne', (done) ->
+				@call (err, invite) =>
+					@ProjectInvite.findOne.callCount.should.equal 1
+					@ProjectInvite.findOne.calledWith({projectId: @projectId, token: @token}).should.equal true
+					done()
+
+		describe 'when findOne produces an error', ->
+
+			beforeEach ->
+				@ProjectInvite.findOne.callsArgWith(1, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err, invite) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+		describe 'when findOne does not find an invite', ->
+
+			beforeEach ->
+				@ProjectInvite.findOne.callsArgWith(1, null, null)
+
+			it 'should not produce an error', (done) ->
+				@call (err, invite) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should not produce an invite object', (done) ->
+				@call (err, invite) =>
+					expect(invite).to.not.be.instanceof Error
+					expect(invite).to.be.oneOf [null, undefined]
+					done()
+
+	describe 'acceptInvite', ->
+
+		beforeEach ->
+			@fakeProject =
+				_id: @projectId
+				collaberator_refs: []
+				readOnly_refs: []
+			@CollaboratorsHandler.addUserIdToProject.callsArgWith(4, null)
+			@_getInviteByToken = sinon.stub(@CollaboratorsInviteHandler, 'getInviteByToken')
+			@_getInviteByToken.callsArgWith(2, null, @fakeInvite)
+			@CollaboratorsInviteHandler._tryCancelInviteNotification = sinon.stub().callsArgWith(1, null)
+			@ProjectInvite.remove.callsArgWith(1, null)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler.acceptInvite @projectId, @inviteId, @token, @user, callback
+
+		afterEach ->
+			@_getInviteByToken.restore()
+
+		describe 'when all goes well', ->
+
+			beforeEach ->
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should have called getInviteByToken', (done) ->
+				@call (err) =>
+					@_getInviteByToken.callCount.should.equal 1
+					@_getInviteByToken.calledWith(@projectId, @token).should.equal true
+					done()
+
+			it 'should have called CollaboratorsHandler.addUserIdToProject', (done) ->
+				@call (err) =>
+					@CollaboratorsHandler.addUserIdToProject.callCount.should.equal 1
+					@CollaboratorsHandler.addUserIdToProject.calledWith(@projectId, @sendingUserId, @userId, @fakeInvite.privileges).should.equal true
+					done()
+
+			it 'should have called ProjectInvite.remove', (done) ->
+				@call (err) =>
+					@ProjectInvite.remove.callCount.should.equal 1
+					@ProjectInvite.remove.calledWith({_id: @inviteId}).should.equal true
+					done()
+
+		describe 'when the invite is for readOnly access', ->
+
+			beforeEach ->
+				@fakeInvite.privileges = 'readOnly'
+				@_getInviteByToken.callsArgWith(2, null, @fakeInvite)
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should have called CollaboratorsHandler.addUserIdToProject', (done) ->
+				@call (err) =>
+					@CollaboratorsHandler.addUserIdToProject.callCount.should.equal 1
+					@CollaboratorsHandler.addUserIdToProject.calledWith(@projectId, @sendingUserId, @userId, @fakeInvite.privileges).should.equal true
+					done()
+
+		describe 'when getInviteByToken does not find an invite', ->
+
+			beforeEach ->
+				@_getInviteByToken.callsArgWith(2, null, null)
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					expect(err.name).to.equal "NotFoundError"
+					done()
+
+			it 'should have called getInviteByToken', (done) ->
+				@call (err) =>
+					@_getInviteByToken.callCount.should.equal 1
+					@_getInviteByToken.calledWith(@projectId, @token).should.equal true
+					done()
+
+			it 'should not have called CollaboratorsHandler.addUserIdToProject', (done) ->
+				@call (err) =>
+					@CollaboratorsHandler.addUserIdToProject.callCount.should.equal 0
+					done()
+
+			it 'should not have called ProjectInvite.remove', (done) ->
+				@call (err) =>
+					@ProjectInvite.remove.callCount.should.equal 0
+					done()
+
+		describe 'when getInviteByToken produces an error', ->
+
+			beforeEach ->
+				@_getInviteByToken.callsArgWith(2, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should have called getInviteByToken', (done) ->
+				@call (err) =>
+					@_getInviteByToken.callCount.should.equal 1
+					@_getInviteByToken.calledWith(@projectId, @token).should.equal true
+					done()
+
+			it 'should not have called CollaboratorsHandler.addUserIdToProject', (done) ->
+				@call (err) =>
+					@CollaboratorsHandler.addUserIdToProject.callCount.should.equal 0
+					done()
+
+			it 'should not have called ProjectInvite.remove', (done) ->
+				@call (err) =>
+					@ProjectInvite.remove.callCount.should.equal 0
+					done()
+
+		describe 'when addUserIdToProject produces an error', ->
+
+			beforeEach ->
+				@CollaboratorsHandler.addUserIdToProject.callsArgWith(4, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should have called getInviteByToken', (done) ->
+				@call (err) =>
+					@_getInviteByToken.callCount.should.equal 1
+					@_getInviteByToken.calledWith(@projectId, @token).should.equal true
+					done()
+
+			it 'should have called CollaboratorsHandler.addUserIdToProject', (done) ->
+				@call (err) =>
+					@CollaboratorsHandler.addUserIdToProject.callCount.should.equal 1
+					@CollaboratorsHandler.addUserIdToProject.calledWith(@projectId, @sendingUserId, @userId, @fakeInvite.privileges).should.equal true
+					done()
+
+			it 'should not have called ProjectInvite.remove', (done) ->
+				@call (err) =>
+					@ProjectInvite.remove.callCount.should.equal 0
+					done()
+
+		describe 'when ProjectInvite.remove produces an error', ->
+
+			beforeEach ->
+				@ProjectInvite.remove.callsArgWith(1, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should have called getInviteByToken', (done) ->
+				@call (err) =>
+					@_getInviteByToken.callCount.should.equal 1
+					@_getInviteByToken.calledWith(@projectId, @token).should.equal true
+					done()
+
+			it 'should have called CollaboratorsHandler.addUserIdToProject', (done) ->
+				@call (err) =>
+					@CollaboratorsHandler.addUserIdToProject.callCount.should.equal 1
+					@CollaboratorsHandler.addUserIdToProject.calledWith(@projectId, @sendingUserId, @userId, @fakeInvite.privileges).should.equal true
+					done()
+
+			it 'should have called ProjectInvite.remove', (done) ->
+				@call (err) =>
+					@ProjectInvite.remove.callCount.should.equal 1
+					done()
+
+	describe '_tryCancelInviteNotification', ->
+		beforeEach ->
+			@inviteId = ObjectId()
+			@currentUser = {_id: ObjectId()}
+			@notification = {read: sinon.stub().callsArgWith(0, null)}
+			@NotificationsBuilder.projectInvite = sinon.stub().returns(@notification)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler._tryCancelInviteNotification @inviteId, callback
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.be.oneOf [null, undefined]
+				done()
+
+		it 'should call notification.read', (done) ->
+			@call (err) =>
+				@notification.read.callCount.should.equal 1
+				done()
+
+		describe 'when notification.read produces an error', ->
+			beforeEach ->
+				@notification = {read: sinon.stub().callsArgWith(0, new Error('woops'))}
+				@NotificationsBuilder.projectInvite = sinon.stub().returns(@notification)
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+	describe "_trySendInviteNotification", ->
+
+		beforeEach ->
+			@invite =
+				_id: ObjectId(),
+				token: "some_token",
+				sendingUserId: ObjectId(),
+				projectId: @project_id,
+				targetEmail: 'user@example.com'
+				createdAt: new Date(),
+			@sendingUser =
+				_id: ObjectId()
+				first_name: "jim"
+			@existingUser = {_id: ObjectId()}
+			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @existingUser)
+			@fakeProject =
+				_id: @project_id
+				name: "some project"
+			@ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, @fakeProject)
+			@notification = {create: sinon.stub().callsArgWith(0, null)}
+			@NotificationsBuilder.projectInvite = sinon.stub().returns(@notification)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler._trySendInviteNotification @project_id, @sendingUser, @invite, callback
+
+		describe 'when the user exists', ->
+
+			beforeEach ->
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should call getUser', (done) ->
+				@call (err) =>
+					@UserGetter.getUser.callCount.should.equal 1
+					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					done()
+
+			it 'should call getProject', (done) ->
+				@call (err) =>
+					@ProjectGetter.getProject.callCount.should.equal 1
+					@ProjectGetter.getProject.calledWith(@project_id).should.equal true
+					done()
+
+			it 'should call NotificationsBuilder.projectInvite.create', (done) ->
+				@call (err) =>
+					@NotificationsBuilder.projectInvite.callCount.should.equal 1
+					@notification.create.callCount.should.equal 1
+					done()
+
+			describe 'when getProject produces an error', ->
+
+				beforeEach ->
+					@ProjectGetter.getProject.callsArgWith(2, new Error('woops'))
+
+				it 'should produce an error', (done) ->
+					@call (err) =>
+						expect(err).to.be.instanceof Error
+						done()
+
+				it 'should not call NotificationsBuilder.projectInvite.create', (done) ->
+					@call (err) =>
+						@NotificationsBuilder.projectInvite.callCount.should.equal 0
+						@notification.create.callCount.should.equal 0
+						done()
+
+			describe 'when projectInvite.create produces an error', ->
+
+				beforeEach ->
+					@notification.create.callsArgWith(0, new Error('woops'))
+
+				it 'should produce an error', (done) ->
+					@call (err) =>
+						expect(err).to.be.instanceof Error
+						done()
+
+		describe 'when the user does not exist', ->
+
+			beforeEach ->
+				@UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should call getUser', (done) ->
+				@call (err) =>
+					@UserGetter.getUser.callCount.should.equal 1
+					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					done()
+
+			it 'should not call getProject', (done) ->
+				@call (err) =>
+					@ProjectGetter.getProject.callCount.should.equal 0
+					done()
+
+			it 'should not call NotificationsBuilder.projectInvite.create', (done) ->
+				@call (err) =>
+					@NotificationsBuilder.projectInvite.callCount.should.equal 0
+					@notification.create.callCount.should.equal 0
+					done()
+
+		describe 'when the getUser produces an error', ->
+
+			beforeEach ->
+				@UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should call getUser', (done) ->
+				@call (err) =>
+					@UserGetter.getUser.callCount.should.equal 1
+					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					done()
+
+			it 'should not call getProject', (done) ->
+				@call (err) =>
+					@ProjectGetter.getProject.callCount.should.equal 0
+					done()
+
+			it 'should not call NotificationsBuilder.projectInvite.create', (done) ->
+				@call (err) =>
+					@NotificationsBuilder.projectInvite.callCount.should.equal 0
+					@notification.create.callCount.should.equal 0
+					done()

+ 12 - 1
services/web/test/UnitTests/coffee/Editor/EditorHttpControllerTests.coffee

@@ -17,7 +17,8 @@ describe "EditorHttpController", ->
 			"./EditorController": @EditorController = {}
 			'../../infrastructure/Metrics': @Metrics = {inc: sinon.stub()}
 			"../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {}
-			
+			"../Collaborators/CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {}
+
 		@project_id = "mock-project-id"
 		@doc_id = "mock-doc-id"
 		@user_id = "mock-user-id"
@@ -102,9 +103,14 @@ describe "EditorHttpController", ->
 				_id: @project_id
 				owner:{_id:"something"}
 				view: true
+			@invites = [
+				{_id: "invite_one", email: "user-one@example.com", privileges: "readOnly", projectId: @project._id}
+				{_id: "invite_two", email: "user-two@example.com", privileges: "readOnly", projectId: @project._id}
+			]
 			@ProjectEditorHandler.buildProjectModelView = sinon.stub().returns(@projectModelView)
 			@ProjectGetter.getProjectWithoutDocLines = sinon.stub().callsArgWith(1, null, @project)
 			@CollaboratorsHandler.getMembersWithPrivilegeLevels = sinon.stub().callsArgWith(1, null, @members)
+			@CollaboratorsInviteHandler.getAllInvites = sinon.stub().callsArgWith(1, null, @invites)
 			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user)
 				
 		describe "when authorized", ->
@@ -133,6 +139,11 @@ describe "EditorHttpController", ->
 					.calledWith(@user_id, @project_id)
 					.should.equal true
 
+			it 'should include the invites', ->
+				@CollaboratorsInviteHandler.getAllInvites
+					.calledWith(@project._id)
+					.should.equal true
+
 			it "should return the project model view, privilege level and protocol version", ->
 				@callback.calledWith(null, @projectModelView, "owner").should.equal true
 				

+ 51 - 2
services/web/test/UnitTests/coffee/Notifications/NotificationsHandlerTests.coffee

@@ -60,12 +60,61 @@ describe 'NotificationsHandler', ->
 			@key = "some key here"
 			@messageOpts = {value:12344}
 			@templateKey = "renderThisHtml"
+			@expiry = null
+			@forceCreate = false
 
 		it "should post the message over", (done)->
-			@handler.createNotification user_id, @key, @templateKey, @messageOpts, =>
+			@handler.createNotification user_id, @key, @templateKey, @messageOpts, @expiry, @forceCreate, =>
 				args = @request.args[0][0]
 				args.uri.should.equal "#{notificationUrl}/user/#{user_id}"
 				args.timeout.should.equal 1000
 				expectedJson = {key:@key, templateKey:@templateKey, messageOpts:@messageOpts}
 				assert.deepEqual(args.json, expectedJson)
-				done()
+				done()
+
+		describe 'when expiry date is supplied', ->
+			beforeEach ->
+				@key = "some key here"
+				@messageOpts = {value:12344}
+				@templateKey = "renderThisHtml"
+				@expiry = new Date()
+				@forceCreate = false
+
+			it 'should post the message over with expiry field', (done) ->
+				@handler.createNotification user_id, @key, @templateKey, @messageOpts, @expiry, @forceCreate, =>
+					args = @request.args[0][0]
+					args.uri.should.equal "#{notificationUrl}/user/#{user_id}"
+					args.timeout.should.equal 1000
+					expectedJson = {key:@key, templateKey:@templateKey, messageOpts:@messageOpts, expires: @expiry}
+					assert.deepEqual(args.json, expectedJson)
+					done()
+
+		describe 'when forceCreate is true', ->
+			beforeEach ->
+				@key = "some key here"
+				@messageOpts = {value:12344}
+				@templateKey = "renderThisHtml"
+				@expiry = null
+				@forceCreate = true
+
+			it 'should add a forceCreate=true flag to the payload', (done) ->
+				@handler.createNotification user_id, @key, @templateKey, @messageOpts, @expiry, @forceCreate, =>
+					args = @request.args[0][0]
+					args.uri.should.equal "#{notificationUrl}/user/#{user_id}"
+					args.timeout.should.equal 1000
+					expectedJson = {key:@key, templateKey:@templateKey, messageOpts:@messageOpts, forceCreate: @forceCreate}
+					assert.deepEqual(args.json, expectedJson)
+					done()
+
+	describe "markAsReadByKeyOnly", ->
+		beforeEach ->
+			@key = "some key here"
+
+		it 'should send a delete request when a delete has been received to mark a notification', (done)->
+			@handler.markAsReadByKeyOnly @key, =>
+				opts =
+					uri: "#{notificationUrl}/key/#{@key}"
+					timeout:1000
+					method: "DELETE"
+				@request.calledWith(opts).should.equal true
+				done()

+ 48 - 3
services/web/test/UnitTests/coffee/Project/ProjectEditorHandlerTests.coffee

@@ -1,4 +1,5 @@
 chai = require('chai')
+expect = chai.expect
 should = chai.should()
 
 modulePath = "../../../../app/js/Features/Project/ProjectEditorHandler"
@@ -67,12 +68,16 @@ describe "ProjectEditorHandler", ->
 			},
 			privilegeLevel: "readAndWrite"
 		}]
+		@invites = [
+			{_id: "invite_one", email: "user-one@example.com", privileges: "readOnly", projectId: @project._id}
+			{_id: "invite_two", email: "user-two@example.com", privileges: "readOnly", projectId: @project._id}
+		]
 		@handler = SandboxedModule.require modulePath
 
 	describe "buildProjectModelView", ->
 		describe "with owner and members included", ->
 			beforeEach ->
-				@result = @handler.buildProjectModelView @project, @members
+				@result = @handler.buildProjectModelView @project, @members, @invites
 
 			it "should include the id", ->
 				should.exist @result._id
@@ -101,7 +106,7 @@ describe "ProjectEditorHandler", ->
 			it "should include the deletedDocs", ->
 				should.exist @result.deletedDocs
 				@result.deletedDocs.should.equal @project.deletedDocs
-				
+
 			it "should gather readOnly_refs and collaberators_refs into a list of members", ->
 				findMember = (id) =>
 					for member in @result.members
@@ -144,6 +149,10 @@ describe "ProjectEditorHandler", ->
 				@result.rootFolder[0].folders[0].docs[0].name.should.equal "main.tex"
 				should.not.exist @result.rootFolder[0].folders[0].docs[0].lines
 
+			it 'should include invites', ->
+				should.exist @result.invites
+				@result.invites.should.deep.equal @invites
+
 		describe "deletedByExternalDataSource", ->
 
 			it "should set the deletedByExternalDataSource flag to false when it is not there", ->
@@ -168,10 +177,46 @@ describe "ProjectEditorHandler", ->
 					compileGroup:"priority"
 					compileTimeout: 96
 				@result = @handler.buildProjectModelView @project, @members
-			
+
 			it "should copy the owner features to the project", ->
 				@result.features.versioning.should.equal @owner.features.versioning
 				@result.features.collaborators.should.equal @owner.features.collaborators
 				@result.features.compileGroup.should.equal @owner.features.compileGroup
 				@result.features.compileTimeout.should.equal @owner.features.compileTimeout
 
+	describe 'buildOwnerAndMembersViews', ->
+		beforeEach ->
+			@owner.features =
+				versioning: true
+				collaborators: 3
+				compileGroup:"priority"
+				compileTimeout: 22
+			@result = @handler.buildOwnerAndMembersViews @members
+
+		it 'should produce an object with owner, ownerFeatures and members keys', ->
+			expect(@result).to.have.all.keys ['owner', 'ownerFeatures', 'members']
+
+		it 'should separate the owner from the members', ->
+			@result.members.length.should.equal(@members.length-1)
+			expect(@result.owner._id).to.equal @owner._id
+			expect(@result.owner.email).to.equal @owner.email
+			expect(@result.members.filter((m) => m._id == @owner._id).length).to.equal 0
+
+		it 'should extract the ownerFeatures from the owner object', ->
+			expect(@result.ownerFeatures).to.deep.equal @owner.features
+
+		describe 'when there is no owner', ->
+			beforeEach ->
+				# remove the owner from members list
+				@membersWithoutOwner = @members.filter((m) => m.user._id != @owner._id)
+				@result = @handler.buildOwnerAndMembersViews @membersWithoutOwner
+
+			it 'should produce an object with owner, ownerFeatures and members keys', ->
+				expect(@result).to.have.all.keys ['owner', 'ownerFeatures', 'members']
+
+			it 'should not separate out an owner', ->
+				@result.members.length.should.equal @membersWithoutOwner.length
+				expect(@result.owner).to.equal null
+
+			it 'should not extract the ownerFeatures from the owner object', ->
+				expect(@result.ownerFeatures).to.equal null

+ 38 - 3
services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee

@@ -30,6 +30,7 @@ describe "LimitationsManager", ->
 			'./SubscriptionLocator':@SubscriptionLocator
 			'settings-sharelatex' : @Settings = {}
 			"../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {}
+			"../Collaborators/CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {}
 			'logger-sharelatex':log:->
 
 	describe "allowedNumberOfCollaboratorsInProject", ->
@@ -56,6 +57,7 @@ describe "LimitationsManager", ->
 	describe "canAddXCollaborators", ->
 		beforeEach ->
 			@CollaboratorsHandler.getCollaboratorCount = (project_id, callback) => callback(null, @current_number)
+			@CollaboratorsInviteHandler.getInviteCount = (project_id, callback) => callback(null, @invite_count)
 			sinon.stub @LimitationsManager,
 					   "allowedNumberOfCollaboratorsInProject",
 					   (project_id, callback) => callback(null, @allowed_number)
@@ -65,6 +67,17 @@ describe "LimitationsManager", ->
 			beforeEach ->
 				@current_number = 1
 				@allowed_number = 2
+				@invite_count = 0
+				@LimitationsManager.canAddXCollaborators(@project_id, 1, @callback)
+
+			it "should return true", ->
+				@callback.calledWith(null, true).should.equal true
+
+		describe "when the project has fewer collaborators and invites than allowed", ->
+			beforeEach ->
+				@current_number = 1
+				@allowed_number = 4
+				@invite_count = 1
 				@LimitationsManager.canAddXCollaborators(@project_id, 1, @callback)
 
 			it "should return true", ->
@@ -74,6 +87,7 @@ describe "LimitationsManager", ->
 			beforeEach ->
 				@current_number = 1
 				@allowed_number = 2
+				@invite_count = 0
 				@LimitationsManager.canAddXCollaborators(@project_id, 2, @callback)
 
 			it "should return false", ->
@@ -83,6 +97,7 @@ describe "LimitationsManager", ->
 			beforeEach ->
 				@current_number = 3
 				@allowed_number = 2
+				@invite_count = 0
 				@LimitationsManager.canAddXCollaborators(@project_id, 1, @callback)
 
 			it "should return false", ->
@@ -92,11 +107,31 @@ describe "LimitationsManager", ->
 			beforeEach ->
 				@current_number = 100
 				@allowed_number = -1
+				@invite_count = 0
 				@LimitationsManager.canAddXCollaborators(@project_id, 1, @callback)
 
 			it "should return true", ->
 				@callback.calledWith(null, true).should.equal true
 
+		describe 'when the project has more invites than allowed', ->
+			beforeEach ->
+				@current_number = 0
+				@allowed_number = 2
+				@invite_count = 2
+				@LimitationsManager.canAddXCollaborators(@project_id, 1, @callback)
+
+			it "should return false", ->
+				@callback.calledWith(null, false).should.equal true
+
+		describe 'when the project has more invites and collaborators than allowed', ->
+			beforeEach ->
+				@current_number = 1
+				@allowed_number = 2
+				@invite_count = 1
+				@LimitationsManager.canAddXCollaborators(@project_id, 1, @callback)
+
+			it "should return false", ->
+				@callback.calledWith(null, false).should.equal true
 
 	describe "userHasSubscription", ->
 		beforeEach ->
@@ -193,7 +228,7 @@ describe "LimitationsManager", ->
 			@LimitationsManager.userHasSubscriptionOrIsGroupMember @user, (err, hasSubOrIsGroupMember)->
 				hasSubOrIsGroupMember.should.equal false
 				done()
-				
+
 	describe "hasGroupMembersLimitReached", ->
 
 		beforeEach ->
@@ -214,10 +249,10 @@ describe "LimitationsManager", ->
 			@LimitationsManager.hasGroupMembersLimitReached @user_id, (err, limitReached)->
 				limitReached.should.equal false
 				done()
-				
+
 		it "should return true if the limit has been exceded", (done)->
 			@subscription.membersLimit = 0
 			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription)
 			@LimitationsManager.hasGroupMembersLimitReached @user_id, (err, limitReached)->
 				limitReached.should.equal true
-				done()
+				done()

+ 529 - 0
services/web/test/acceptance/coffee/ProjectInviteTests.coffee

@@ -0,0 +1,529 @@
+expect = require("chai").expect
+Async = require("async")
+User = require "./helpers/User"
+request = require "./helpers/request"
+settings = require "settings-sharelatex"
+CollaboratorsEmailHandler = require "../../../app/js/Features/Collaborators/CollaboratorsEmailHandler"
+
+
+createInvite = (sendingUser, projectId, email, callback=(err, invite)->) ->
+	sendingUser.getCsrfToken (err) ->
+		return callback(err) if err
+		sendingUser.request.post {
+			uri: "/project/#{projectId}/invite",
+			json:
+				email: email
+				privileges: 'readAndWrite'
+		}, (err, response, body) ->
+			return callback(err) if err
+			callback(null, body.invite)
+
+createProject = (owner, projectName, callback=(err, projectId, project)->) ->
+	owner.createProject projectName, (err, projectId) ->
+		throw err if err
+		fakeProject = {
+			_id: projectId,
+			name: projectName,
+			owner_ref: owner
+		}
+		callback(err, projectId, fakeProject)
+
+createProjectAndInvite = (owner, projectName, email, callback=(err, project, invite)->) ->
+	createProject owner, projectName, (err, projectId, project) ->
+		return callback(err) if err
+		createInvite owner, projectId, email, (err, invite) ->
+			return callback(err) if err
+			link =  CollaboratorsEmailHandler._buildInviteUrl(project, invite)
+			callback(null, project, invite, link)
+
+revokeInvite = (sendingUser, projectId, inviteId, callback=(err)->) ->
+	sendingUser.getCsrfToken (err) ->
+		return callback(err) if err
+		sendingUser.request.delete {
+			uri: "/project/#{projectId}/invite/#{inviteId}",
+		}, (err, response, body) ->
+			return callback(err) if err
+			callback(null)
+
+
+# Actions
+tryFollowInviteLink = (user, link, callback=(err, response, body)->) ->
+	user.request.get {
+		uri: link
+		baseUrl: null
+	}, callback
+
+tryAcceptInvite = (user, invite, callback=(err, response, body)->) ->
+	user.request.post {
+		uri: "/project/#{invite.projectId}/invite/#{invite._id}/accept"
+		json:
+			token: invite.token
+	}, callback
+
+tryRegisterUser = (user, email, redir, callback=(err, response, body)->) ->
+	user.getCsrfToken (error) =>
+		return callback(error) if error?
+		user.request.post {
+			url: "/register"
+			json:
+				email: email
+				password: "some_weird_password"
+				redir: redir
+		}, callback
+
+tryFollowLoginLink = (user, loginLink, callback=(err, response, body)->) ->
+	user.getCsrfToken (error) =>
+		return callback(error) if error?
+		user.request.get loginLink, callback
+
+tryLoginUser = (user, redir, callback=(err, response, body)->) ->
+	user.getCsrfToken (error) =>
+		return callback(error) if error?
+		user.request.post {
+			url: "/login"
+			json:
+				email: user.email
+				password: user.password
+				redir: redir
+		}, callback
+
+tryGetInviteList = (user, projectId, callback=(err, response, body)->) ->
+	user.getCsrfToken (error) =>
+		return callback(error) if error?
+		user.request.get {
+			url: "/project/#{projectId}/invites"
+			json: true
+		}, callback
+
+tryJoinProject = (user, projectId, callback=(err, response, body)->) ->
+	user.getCsrfToken (error) =>
+		return callback(error) if error?
+		user.request.post {
+			url: "/project/#{projectId}/join"
+			qs: {user_id: user._id}
+			auth:
+				user: settings.apis.web.user
+				pass: settings.apis.web.pass
+				sendImmediately: true
+			json: true
+			jar: false
+		}, callback
+
+# Expectations
+expectProjectAccess = (user, projectId, callback=(err,result)->) ->
+	# should have access to project
+	user.openProject projectId, (err) =>
+		expect(err).to.be.oneOf [null, undefined]
+		callback()
+
+expectNoProjectAccess = (user, projectId, callback=(err,result)->) ->
+	# should not have access to project page
+	user.openProject projectId, (err) =>
+		expect(err).to.be.instanceof Error
+		callback()
+
+expectInvitePage = (user, link, callback=(err,result)->) ->
+	# view invite
+	tryFollowInviteLink user, link, (err, response, body) ->
+		expect(err).to.be.oneOf [null, undefined]
+		expect(response.statusCode).to.equal 200
+		expect(body).to.match new RegExp("<title>Project Invite - .*</title>")
+		callback()
+
+expectInvalidInvitePage = (user, link, callback=(err,result)->) ->
+	# view invalid invite
+	tryFollowInviteLink user, link, (err, response, body) ->
+		expect(err).to.be.oneOf [null, undefined]
+		expect(response.statusCode).to.equal 200
+		expect(body).to.match new RegExp("<title>Invalid Invite - .*</title>")
+		callback()
+
+expectInviteRedirectToRegister = (user, link, callback=(err,result)->) ->
+	# view invite, redirect to `/register`
+	tryFollowInviteLink user, link, (err, response, body) ->
+		expect(err).to.be.oneOf [null, undefined]
+		expect(response.statusCode).to.equal 302
+		expect(response.headers.location).to.match new RegExp("^/register\?.*redir=.*$")
+		# follow redirect to register page and extract the redirectUrl from form
+		user.request.get response.headers.location, (err, response, body) ->
+			redirectUrl = body.match(/input name="redir" type="hidden" value="([^"]*)"/m)?[1]
+			loginUrl = body.match(/href="([^"]*)">\s*Login here/m)?[1]
+			expect(redirectUrl).to.not.be.oneOf [null, undefined]
+			expect(loginUrl).to.not.be.oneOf [null, undefined]
+			callback(null, redirectUrl, loginUrl)
+
+expectLoginPage = (user, loginLink, callback=(err, result)->) ->
+	tryFollowLoginLink user, loginLink, (err, response, body) ->
+		expect(err).to.be.oneOf [null, undefined]
+		expect(response.statusCode).to.equal 200
+		expect(body).to.match new RegExp("<title>Login - .*</title>")
+		redirectUrl = body.match(/input name="redir" type="hidden" value="([^"]*)"/m)?[1]
+		callback(null, redirectUrl)
+
+expectLoginRedirectToInvite = (user, redir, link, callback=(err, result)->) ->
+	tryLoginUser user, redir, (err, response, body) ->
+		expect(err).to.be.oneOf [null, undefined]
+		expect(response.statusCode).to.equal 200
+		expect(link).to.match new RegExp("^.*#{body.redir}\?.*$")
+		callback(null, null)
+
+expectRegistrationRedirectToInvite = (user, email, redir, link, callback=(err, result)->) ->
+	tryRegisterUser user, email, redir, (err, response, body) ->
+		expect(err).to.be.oneOf [null, undefined]
+		expect(response.statusCode).to.equal 200
+		expect(link).to.match new RegExp("^.*#{body.redir}\?.*$")
+		callback(null, null)
+
+expectInviteRedirectToProject = (user, link, invite, callback=(err,result)->) ->
+	# view invite, redirect straight to project
+	tryFollowInviteLink user, link, (err, response, body) ->
+		expect(err).to.be.oneOf [null, undefined]
+		expect(response.statusCode).to.equal 302
+		expect(response.headers.location).to.equal "/project/#{invite.projectId}"
+		callback()
+
+expectAcceptInviteAndRedirect = (user, invite, callback=(err,result)->) ->
+	# should accept the invite and redirect to project
+	tryAcceptInvite user, invite, (err, response, body) =>
+		expect(err).to.be.oneOf [null, undefined]
+		expect(response.statusCode).to.equal 302
+		expect(response.headers.location).to.equal "/project/#{invite.projectId}"
+		callback()
+
+expectInviteListCount = (user, projectId, count, callback=(err)->) ->
+	tryGetInviteList user, projectId, (err, response, body) ->
+		expect(err).to.be.oneOf [null, undefined]
+		expect(response.statusCode).to.equal 200
+		expect(body).to.have.all.keys ['invites']
+		expect(body.invites.length).to.equal count
+		callback()
+
+expectInvitesInJoinProjectCount = (user, projectId, count, callback=(err,result)->) ->
+	tryJoinProject user, projectId, (err, response, body) ->
+		expect(err).to.be.oneOf [null, undefined]
+		expect(response.statusCode).to.equal 200
+		expect(body.project).to.contain.keys ['invites']
+		expect(body.project.invites.length).to.equal count
+		callback()
+
+
+
+describe "ProjectInviteTests", ->
+	before (done) ->
+		@sendingUser = new User()
+		@user = new User()
+		@site_admin = new User({email: "admin@example.com"})
+		@email = 'smoketestuser@example.com'
+		@projectName = 'sharing test'
+		Async.series [
+			(cb) => @user.login cb
+			(cb) => @user.logout cb
+			(cb) => @sendingUser.login cb
+		], done
+
+	describe 'creating invites', ->
+
+		beforeEach (done) ->
+			@projectName = "wat"
+			@projectId = null
+			@fakeProject = null
+			done()
+
+		afterEach ->
+
+		describe 'creating two invites', ->
+
+			beforeEach (done) ->
+				Async.series [
+					(cb) =>
+						createProject @sendingUser, @projectName, (err, projectId, project) =>
+							@projectId = projectId
+							@fakeProject = project
+							cb()
+				], done
+
+			afterEach (done) ->
+				Async.series [
+					(cb) => @sendingUser.deleteProject(@projectId, cb)
+					(cb) => @sendingUser.deleteProject(@projectId, cb)
+				], done
+
+			it 'should allow the project owner to create and remove invites', (done) ->
+				@invite = null
+				Async.series [
+					(cb) => expectProjectAccess @sendingUser, @projectId, cb
+					(cb) => expectInviteListCount @sendingUser, @projectId, 0, cb
+					# create invite, check invite list count
+					(cb) => createInvite @sendingUser, @projectId, @email, (err, invite) =>
+						return cb(err) if err
+						@invite = invite
+						cb()
+					(cb) => expectInviteListCount @sendingUser, @projectId, 1, cb
+					(cb) => revokeInvite @sendingUser, @projectId, @invite._id, cb
+					(cb) => expectInviteListCount @sendingUser, @projectId, 0, cb
+					# and a second time
+					(cb) => createInvite @sendingUser, @projectId, @email, (err, invite) =>
+						return cb(err) if err
+						@invite = invite
+						cb()
+					(cb) => expectInviteListCount @sendingUser, @projectId, 1, cb
+					# check the joinProject view
+					(cb) => expectInvitesInJoinProjectCount @sendingUser, @projectId, 1, cb
+					# revoke invite
+					(cb) => revokeInvite @sendingUser, @projectId, @invite._id, cb
+					(cb) => expectInviteListCount @sendingUser, @projectId, 0, cb
+					(cb) => expectInvitesInJoinProjectCount @sendingUser, @projectId, 0, cb
+				], done
+
+			it 'should allow the project owner to many invites at once', (done) ->
+				@inviteOne = null
+				@inviteTwo = null
+				Async.series [
+					(cb) => expectProjectAccess @sendingUser, @projectId, cb
+					(cb) => expectInviteListCount @sendingUser, @projectId, 0, cb
+					# create first invite
+					(cb) => createInvite @sendingUser, @projectId, @email, (err, invite) =>
+						return cb(err) if err
+						@inviteOne = invite
+						cb()
+					(cb) => expectInviteListCount @sendingUser, @projectId, 1, cb
+					# and a second
+					(cb) => createInvite @sendingUser, @projectId, @email, (err, invite) =>
+						return cb(err) if err
+						@inviteTwo = invite
+						cb()
+					# should have two
+					(cb) => expectInviteListCount @sendingUser, @projectId, 2, cb
+					(cb) => expectInvitesInJoinProjectCount @sendingUser, @projectId, 2, cb
+					# revoke first
+					(cb) => revokeInvite @sendingUser, @projectId, @inviteOne._id, cb
+					(cb) => expectInviteListCount @sendingUser, @projectId, 1, cb
+					# revoke second
+					(cb) => revokeInvite @sendingUser, @projectId, @inviteTwo._id, cb
+					(cb) => expectInviteListCount @sendingUser, @projectId, 0, cb
+				], done
+
+	describe 'clicking the invite link', ->
+
+		beforeEach (done) ->
+			@projectId = null
+			@fakeProject = null
+			done()
+
+
+		describe "user is logged in already", ->
+
+			beforeEach (done) ->
+				Async.series [
+					(cb) =>
+						createProjectAndInvite @sendingUser, @projectName, @email, (err, project, invite, link) =>
+							@projectId = project._id
+							@fakeProject = project
+							@invite = invite
+							@link = link
+							cb()
+					(cb) =>
+						@user.login (err) =>
+							if err
+								throw err
+							cb()
+				], done
+
+			afterEach (done) ->
+				Async.series [
+					(cb) => @sendingUser.deleteProject(@projectId, cb)
+					(cb) => @sendingUser.deleteProject(@projectId, cb)
+					(cb) => revokeInvite(@sendingUser, @projectId, @invite._id, cb)
+				], done
+
+			describe 'user is already a member of the project', ->
+
+				beforeEach (done) ->
+					Async.series [
+						(cb) => expectInvitePage @user, @link, cb
+						(cb) => expectAcceptInviteAndRedirect @user, @invite, cb
+						(cb) => expectProjectAccess @user, @invite.projectId, cb
+					], done
+
+				describe 'when user clicks on the invite a second time', ->
+
+					it 'should just redirect to the project page', (done) ->
+						Async.series [
+							(cb) => expectProjectAccess @user, @invite.projectId, cb
+							(cb) => expectInviteRedirectToProject @user, @link, @invite, cb
+							(cb) => expectProjectAccess @user, @invite.projectId, cb
+						], done
+
+					describe 'when the user recieves another invite to the same project', ->
+
+						it 'should redirect to the project page', (done) ->
+							Async.series [
+								(cb) =>
+									createInvite @sendingUser, @projectId, @email, (err, invite) =>
+										if err
+											throw err
+										@secondInvite = invite
+										@secondLink = CollaboratorsEmailHandler._buildInviteUrl(@fakeProject, invite)
+										cb()
+								(cb) => expectInviteRedirectToProject @user, @secondLink, @secondInvite, cb
+								(cb) => expectProjectAccess @user, @invite.projectId, cb
+								(cb) => revokeInvite @sendingUser, @projectId, @secondInvite._id, cb
+							], done
+
+
+			describe 'user is not a member of the project', ->
+
+				it 'should not grant access if the user does not accept the invite', (done) ->
+					Async.series(
+						[
+							(cb) => expectInvitePage @user, @link, cb
+							(cb) => expectNoProjectAccess @user, @invite.projectId, cb
+						], done
+					)
+
+				it 'should render the invalid-invite page if the token is invalid', (done) ->
+					Async.series(
+						[
+							(cb) =>
+								link = @link.replace(@invite.token, 'not_a_real_token')
+								expectInvalidInvitePage @user, link, cb
+							(cb) => expectNoProjectAccess @user, @invite.projectId, cb
+							(cb) => expectNoProjectAccess @user, @invite.projectId, cb
+						], done
+					)
+
+				it 'should allow the user to accept the invite and access the project', (done) ->
+					Async.series(
+						[
+							(cb) => expectInvitePage @user, @link, cb
+							(cb) => expectAcceptInviteAndRedirect @user, @invite, cb
+							(cb) => expectProjectAccess @user, @invite.projectId, cb
+						], done
+					)
+
+		describe 'user is not logged in initially', ->
+
+			before (done) ->
+				@user.logout done
+
+			beforeEach (done) ->
+				Async.series [
+					(cb) =>
+						createProjectAndInvite @sendingUser, @projectName, @email, (err, project, invite, link) =>
+							@projectId = project._id
+							@fakeProject = project
+							@invite = invite
+							@link = link
+							cb()
+				], done
+
+			afterEach (done) ->
+				Async.series [
+					(cb) => @sendingUser.deleteProject(@projectId, cb)
+					(cb) => @sendingUser.deleteProject(@projectId, cb)
+					(cb) => revokeInvite(@sendingUser, @projectId, @invite._id, cb)
+				], done
+
+			describe 'registration prompt workflow with valid token', ->
+
+				it 'should redirect to the register page', (done) ->
+					Async.series [
+						(cb) => expectInviteRedirectToRegister(@user, @link, cb)
+					], done
+
+				it 'should allow user to accept the invite if the user registers a new account', (done) ->
+					Async.series [
+						(cb) =>
+							expectInviteRedirectToRegister @user, @link, (err, redirectUrl) =>
+								@_redir = redirectUrl
+								cb()
+						(cb) => expectRegistrationRedirectToInvite @user, "some_email@example.com", @_redir, @link, cb
+						(cb) => expectInvitePage @user, @link, cb
+						(cb) => expectAcceptInviteAndRedirect @user, @invite, cb
+						(cb) => expectProjectAccess @user, @invite.projectId, cb
+					], done
+
+			describe 'registration prompt workflow with non-valid token', ->
+
+				before (done)->
+					@user.logout done
+
+				it 'should redirect to the register page', (done) ->
+					Async.series [
+						(cb) => expectInviteRedirectToRegister(@user, @link, cb)
+						(cb) => expectNoProjectAccess @user, @invite.projectId, cb
+					], done
+
+				it 'should display invalid-invite if the user registers a new account', (done) ->
+					badLink = @link.replace(@invite.token, 'not_a_real_token')
+					Async.series [
+						(cb) =>
+							expectInviteRedirectToRegister @user, badLink, (err, redirectUrl) =>
+								@_redir = redirectUrl
+								cb()
+						(cb) => expectRegistrationRedirectToInvite @user, "some_email@example.com", @_redir, badLink, cb
+						(cb) => expectInvalidInvitePage @user, badLink, cb
+						(cb) => expectNoProjectAccess @user, @invite.projectId, cb
+					], done
+
+			describe 'login workflow with valid token', ->
+
+				before (done)->
+					@user.logout done
+
+				it 'should redirect to the register page', (done) ->
+					Async.series [
+						(cb) => expectInviteRedirectToRegister(@user, @link, cb)
+						(cb) => expectNoProjectAccess @user, @invite.projectId, cb
+					], done
+
+				it 'should allow the user to login to view the invite', (done) ->
+					Async.series [
+						(cb) =>
+							expectInviteRedirectToRegister @user, @link, (err, redirectUrl, loginUrl) =>
+								@_redir = redirectUrl
+								@_loginLink = loginUrl
+								cb()
+						(cb) =>
+							expectLoginPage @user, @_loginLink, (err, redirectUrl) =>
+								expect(@_redir).to.equal redirectUrl
+								cb()
+						(cb) => expectLoginRedirectToInvite @user, @_redir, @link, cb
+						(cb) => expectInvitePage @user, @link, cb
+						(cb) => expectNoProjectAccess @user, @invite.projectId, cb
+					], done
+
+				it 'should allow user to accept the invite if the user registers a new account', (done) ->
+					Async.series [
+						(cb) => expectInvitePage @user, @link, cb
+						(cb) => expectAcceptInviteAndRedirect @user, @invite, cb
+						(cb) => expectProjectAccess @user, @invite.projectId, cb
+					], done
+
+			describe 'login workflow with non-valid token', ->
+
+				before (done)->
+					@user.logout done
+
+				it 'should redirect to the register page', (done) ->
+					Async.series [
+						(cb) => expectInviteRedirectToRegister(@user, @link, cb)
+						(cb) => expectNoProjectAccess @user, @invite.projectId, cb
+					], done
+
+				it 'should show the invalid-invite page once the user has logged in', (done) ->
+					badLink = @link.replace(@invite.token, 'not_a_real_token')
+					Async.series [
+						(cb) =>
+							expectInviteRedirectToRegister @user, badLink, (err, redirectUrl, loginUrl) =>
+								@_redir = redirectUrl
+								@_loginLink = loginUrl
+								cb()
+						(cb) =>
+							expectLoginPage @user, @_loginLink, (err, redirectUrl) =>
+								expect(@_redir).to.equal redirectUrl
+								cb()
+						(cb) => expectLoginRedirectToInvite @user, @_redir, badLink, cb
+						(cb) => expectInvalidInvitePage @user, badLink, cb
+						(cb) => expectNoProjectAccess @user, @invite.projectId, cb
+					], done

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

@@ -13,7 +13,7 @@ class User
 		@request = request.defaults({
 			jar: @jar
 		})
-	
+
 	login: (callback = (error) ->) ->
 		@getCsrfToken (error) =>
 			return callback(error) if error?
@@ -28,6 +28,8 @@ class User
 					return callback(error) if error?
 					@id = user?._id?.toString()
 					@_id = user?._id?.toString()
+					@first_name = user?.first_name
+					@referal_id = user?.referal_id
 					callback()
 
 	logout: (callback = (error) ->) ->
@@ -59,7 +61,24 @@ class User
 			if !body?.project_id?
 				console.error "SOMETHING WENT WRONG CREATING PROJECT", response.statusCode, response.headers["location"], body
 			callback(null, body.project_id)
-	
+
+	deleteProject: (project_id, callback=(error)) ->
+		@request.delete {
+			url: "/project/#{project_id}"
+		}, (error, response, body) ->
+			return callback(error) if error?
+			callback(null)
+
+	openProject: (project_id, callback=(error)) ->
+		@request.get {
+			url: "/project/#{project_id}"
+		}, (error, response, body) ->
+			return callback(error) if error?
+			if response.statusCode != 200
+				err = new Error("Non-success response when opening project: #{response.statusCode}")
+				return callback(err)
+			callback(null)
+
 	addUserToProject: (project_id, email, privileges, callback = (error, user) ->) ->
 		@request.post {
 			url: "/project/#{project_id}/users",
@@ -67,7 +86,7 @@ class User
 		}, (error, response, body) ->
 			return callback(error) if error?
 			callback(null, body.user)
-	
+
 	makePublic: (project_id, level, callback = (error) ->) ->
 		@request.post {
 			url: "/project/#{project_id}/settings/admin",