Quellcode durchsuchen

Merge branch 'emailverification'

Henry Oswald vor 11 Jahren
Ursprung
Commit
e4011b9ba1
26 geänderte Dateien mit 522 neuen und 85 gelöschten Zeilen
  1. 1 0
      services/web/.gitignore
  2. 2 2
      services/web/Gruntfile.coffee
  3. 22 0
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  4. 3 3
      services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
  5. 4 4
      services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee
  6. 6 2
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  7. 0 24
      services/web/app/coffee/Features/Subscription/SubscriptionDomainAllocator.coffee
  8. 56 0
      services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee
  9. 53 0
      services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
  10. 31 2
      services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
  11. 7 1
      services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee
  12. 4 0
      services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
  13. 3 3
      services/web/app/coffee/Features/User/UserController.coffee
  14. 0 1
      services/web/app/coffee/models/User.coffee
  15. 35 0
      services/web/app/views/subscriptions/group/invite.jade
  16. 25 0
      services/web/app/views/subscriptions/group/successful_join.jade
  17. 4 0
      services/web/config/settings.defaults.coffee
  18. 1 0
      services/web/public/coffee/main.coffee
  19. 15 0
      services/web/public/coffee/main/subscription/group-subscription-invite-controller.coffee
  20. 8 8
      services/web/test/UnitTests/coffee/PasswordReset/PasswordResetHandlerTests.coffee
  21. 12 12
      services/web/test/UnitTests/coffee/Security/OneTimeTokenHandlerTests.coffee
  22. 28 1
      services/web/test/UnitTests/coffee/Subscription/SubscriptionControllerTests.coffee
  23. 11 13
      services/web/test/UnitTests/coffee/Subscription/SubscriptionDomainHandlerTests.coffee
  24. 109 1
      services/web/test/UnitTests/coffee/Subscription/SubscriptionGroupControllerTests.coffee
  25. 75 1
      services/web/test/UnitTests/coffee/Subscription/SubscriptionGroupHandlerTests.coffee
  26. 7 7
      services/web/test/UnitTests/coffee/User/UserControllerTests.coffee

+ 1 - 0
services/web/.gitignore

@@ -44,6 +44,7 @@ cookies.txt
 requestQueueWorker.js
 TpdsWorker.js
 BackgroundJobsWorker.js
+UserAndProjectPopulator.coffee
 
 public/js/history/versiondetail.js
 !public/js/libs/

+ 2 - 2
services/web/Gruntfile.coffee

@@ -299,7 +299,7 @@ module.exports = (grunt) ->
 
 		settings = require "settings-sharelatex"
 		UserRegistrationHandler = require "./app/js/Features/User/UserRegistrationHandler"
-		PasswordResetTokenHandler = require "./app/js/Features/PasswordReset/PasswordResetTokenHandler"
+		OneTimeTokenHandler = require "./app/js/Features/Security/OneTimeTokenHandler"
 		UserRegistrationHandler.registerNewUser {
 			email: email
 			password: require("crypto").randomBytes(32).toString("hex")
@@ -310,7 +310,7 @@ module.exports = (grunt) ->
 			user.save (error) ->
 				throw error if error?
 				ONE_WEEK = 7 * 24 * 60 * 60 # seconds
-				PasswordResetTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
+				OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
 					return next(err) if err?
 					
 					console.log ""

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

@@ -88,6 +88,28 @@ templates.projectSharedWithYou =
 <p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
 """
 
+
+templates.completeJoinGroupAccount = 
+	subject: _.template "Verify Email to join <%= group_name %> group"
+	layout: NotificationEmailLayout
+	type:"notification"
+	compiledTemplate: _.template """
+<p>Hi, please verify your email to join the <%= group_name %> and get your free premium account</p>
+<center>
+	<div style="width:200px;background-color:#a93629;border:1px solid #e24b3b;border-radius:3px;padding:15px; margin:24px;">
+		<div style="padding-right:10px;padding-left:10px">
+			<a href="<%= completeJoinUrl %>" 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">
+		  			Verify now
+				</span>
+			</a>
+		</div>
+	</div>
+</center>
+<p> Thank you</p>
+<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
+"""
+
 module.exports =
 	templates: templates
 

+ 3 - 3
services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee

@@ -1,7 +1,7 @@
 settings = require("settings-sharelatex")
 async = require("async")
 UserGetter = require("../User/UserGetter")
-PasswordResetTokenHandler = require("./PasswordResetTokenHandler")
+OneTimeTokenHandler = require("../Security/OneTimeTokenHandler")
 EmailHandler = require("../Email/EmailHandler")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 logger = require("logger-sharelatex")
@@ -14,7 +14,7 @@ module.exports =
 			if !user? or user.holdingAccount
 				logger.err email:email, "user could not be found for password reset"
 				return callback(null, false)
-			PasswordResetTokenHandler.getNewToken user._id, (err, token)->
+			OneTimeTokenHandler.getNewToken user._id, (err, token)->
 				if err then return callback(err)
 				emailOptions =
 					to : email
@@ -24,7 +24,7 @@ module.exports =
 					callback null, true
 
 	setNewUserPassword: (token, password, callback = (error, found) ->)->
-		PasswordResetTokenHandler.getUserIdFromTokenAndExpire token, (err, user_id)->
+		OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, user_id)->
 			if err then return callback(err)
 			if !user_id?
 				return callback null, false

+ 4 - 4
services/web/app/coffee/Features/PasswordReset/PasswordResetTokenHandler.coffee → services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee

@@ -10,21 +10,21 @@ buildKey = (token)-> return "password_token:#{token}"
 
 module.exports =
 
-	getNewToken: (user_id, options = {}, callback)->
+	getNewToken: (value, options = {}, callback)->
 		# options is optional
 		if typeof options == "function"
 			callback = options
 			options = {}
 		expiresIn = options.expiresIn or ONE_HOUR_IN_S
-		logger.log user_id:user_id, "generating token for password reset"
+		logger.log value:value, "generating token for password reset"
 		token = crypto.randomBytes(32).toString("hex")
 		multi = rclient.multi()
-		multi.set buildKey(token), user_id
+		multi.set buildKey(token), value
 		multi.expire buildKey(token), expiresIn
 		multi.exec (err)->
 			callback(err, token)
 
-	getUserIdFromTokenAndExpire: (token, callback)->
+	getValueFromTokenAndExpire: (token, callback)->
 		logger.log token:token, "getting user id from password token"
 		multi = rclient.multi()
 		multi.get buildKey(token)

+ 6 - 2
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -8,7 +8,7 @@ RecurlyWrapper = require './RecurlyWrapper'
 Settings   = require 'settings-sharelatex'
 logger     = require('logger-sharelatex')
 GeoIpLookup = require("../../infrastructure/GeoIpLookup")
-
+SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 
 module.exports = SubscriptionController =
 
@@ -84,9 +84,13 @@ module.exports = SubscriptionController =
 		SecurityManager.getCurrentUser req, (error, user) =>
 			return next(error) if error?
 			LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
+				groupLicenceInviteUrl = SubscriptionDomainHandler.getDomainLicencePage(user)
 				if subscription?.customAccount
 					logger.log user: user, "redirecting to plans"
-					res.redirect "/user/subscription/custom_account"				
+					res.redirect "/user/subscription/custom_account"
+				else if groupLicenceInviteUrl? and !hasSubOrIsGroupMember
+					logger.log user:user, "redirecting to group subscription invite page"		
+					res.redirect groupLicenceInviteUrl
 				else if !hasSubOrIsGroupMember
 					logger.log user: user, "redirecting to plans"
 					res.redirect "/user/subscription/plans"

+ 0 - 24
services/web/app/coffee/Features/Subscription/SubscriptionDomainAllocator.coffee

@@ -1,24 +0,0 @@
-async = require("async")
-_ = require("underscore")
-settings = require("settings-sharelatex")
-SubscriptionGroupHandler = require("./SubscriptionGroupHandler")
-_s = require("underscore.string")
-
-module.exports = SubscriptionDomainAllocator =
-
-	autoAllocate: (user, callback = ->)->
-		licence = SubscriptionDomainAllocator._findDomainLicence(user.email)
-		if licence?
-			SubscriptionGroupHandler.addUserToGroup licence.adminUser_id, user.email, callback
-		else
-			callback()
-
-
-	_findDomainLicence: (email)->
-		licence = _.find settings.domainLicences, (licence)->
-			_.find licence.domains, (domain)->
-				_s.endsWith email, domain
-
-		return licence
-
-

+ 56 - 0
services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee

@@ -0,0 +1,56 @@
+async = require("async")
+_ = require("underscore")
+settings = require("settings-sharelatex")
+SubscriptionGroupHandler = require("./SubscriptionGroupHandler")
+_s = require("underscore.string")
+
+module.exports = SubscriptionDomainHandler =
+
+
+	getLicenceUserCanJoin: (user, callback)->
+		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
+		if licence?
+			callback null, licence
+		else
+			callback()
+
+	attemptToJoinGroup: (user, callback)->
+		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
+		if licence? and user.emailVerified
+			SubscriptionGroupHandler.addUserToGroup licence.adminUser_id, user.email, callback
+		else
+			callback "user not verified"
+
+	rejectInvitationToGroup: (user, subscription, callback)->
+		removeUserFromGroup(subscription.admin_id, user._id, callback)
+
+
+	getDomainLicencePage: (user)->
+		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
+		if licence?.verifyEmail
+			return "/user/subscription/#{licence.subscription_id}/group/invited"
+		else
+			return undefined
+
+
+	autoAllocate: (user, callback = ->)->
+		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
+		#
+		if licence?
+			SubscriptionGroupHandler.addUserToGroup licence.adminUser_id, user.email, callback
+		else
+			callback()
+
+
+	_findDomainLicence: (email)->
+		licence = _.find settings.domainLicences, (licence)->
+			_.find licence.domains, (domain)->
+				_s.endsWith email, domain
+
+		return licence
+
+	findDomainLicenceBySubscriptionId: (subscription_id)->
+		licence = _.find settings.domainLicences, (licence)->
+			licence?.subscription_id == subscription_id
+		return licence
+

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

@@ -2,6 +2,12 @@ SubscriptionGroupHandler = require("./SubscriptionGroupHandler")
 logger = require("logger-sharelatex")
 SubscriptionLocator = require("./SubscriptionLocator")
 
+ErrorsController = require("../Errors/ErrorController")
+settings = require("settings-sharelatex")
+
+SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
+_ = require("underscore")
+
 module.exports =
 
 	addUserToGroup: (req, res)->
@@ -32,3 +38,50 @@ module.exports =
 					title: 'group_admin'
 					users: users
 					subscription: subscription
+
+	renderGroupInvitePage: (req, res)->
+		subscription_id = req.params.subscription_id
+		user_id = req.session.user._id
+		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)
+		if !licence?
+			return ErrorsController.notFound(req, res)
+		SubscriptionGroupHandler.isUserPartOfGroup user_id, licence.subscription_id, (err, partOfGroup)->
+			if partOfGroup
+				return res.redirect("/user/subscription/custom_account")
+			else
+				res.render "subscriptions/group/invite",
+					title: "Group Invitation"
+					subscription_id:subscription_id
+					licenceName:licence.name
+
+	beginJoinGroup: (req, res)->
+		subscription_id = req.params.subscription_id
+		user_id = req.session.user._id
+		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)
+		if !licence?
+			return ErrorsController.notFound(req, res)
+		SubscriptionGroupHandler.sendVerificationEmail subscription_id, licence.name, req.session.user.email, (err)->
+			if err?
+				res.send 500
+			else
+				res.send 200
+
+	completeJoin: (req, res)->
+		subscription_id = req.params.subscription_id
+		if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
+			return ErrorsController.notFound(req, res)
+		SubscriptionGroupHandler.processGroupVerification req.session.user.email, subscription_id, req.query.token, (err)->
+			if err?
+				res.send 500
+			else
+				res.redirect "/user/subscription/#{subscription_id}/group/successful-join"
+
+	renderSuccessfulJoinPage: (req, res)->
+		subscription_id = req.params.subscription_id
+		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)
+		if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
+			return ErrorsController.notFound(req, res)
+		res.render "subscriptions/group/successful_join",
+			title: "Sucessfully joined group"
+			licenceName:licence.name	
+

+ 31 - 2
services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee

@@ -5,9 +5,12 @@ SubscriptionUpdater = require("./SubscriptionUpdater")
 SubscriptionLocator = require("./SubscriptionLocator")
 UserLocator = require("../User/UserLocator")
 LimitationsManager = require("./LimitationsManager")
+logger = require("logger-sharelatex")
+OneTimeTokenHandler = require("../Security/OneTimeTokenHandler")
+EmailHandler = require("../Email/EmailHandler")
+settings = require("settings-sharelatex")
 
-
-module.exports = 
+module.exports = SubscriptionGroupHandler =
 
 	addUserToGroup: (adminUser_id, newEmail, callback)->
 		UserCreator.getUserOrCreateHoldingAccount newEmail, (err, user)->
@@ -37,6 +40,32 @@ module.exports =
 			async.series jobs, (err)->
 				callback(err, users)
 
+	isUserPartOfGroup: (user_id, subscription_id, callback=(err, partOfGroup)->)->
+		SubscriptionLocator.getSubscriptionByMemberIdAndId user_id, subscription_id, (err, subscription)->
+			if subscription?
+				partOfGroup = true
+			else
+				partOfGroup = false
+			logger.log user_id:user_id, subscription_id:subscription_id, partOfGroup:partOfGroup, "checking if user is part of a group"
+			callback(err, partOfGroup)
+
+
+	sendVerificationEmail: (subscription_id, licenceName, email, callback)->
+		OneTimeTokenHandler.getNewToken subscription_id, (err, token)->
+			opts =
+				to : email
+				group_name: licenceName
+				completeJoinUrl: "#{settings.siteUrl}/user/subscription/#{subscription_id}/group/complete-join?token=#{token}"
+			EmailHandler.sendEmail "completeJoinGroupAccount", opts, callback
+
+	processGroupVerification: (userEmail, subscription_id, token, callback)->
+		OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, token_subscription_id)->
+			if err?  or subscription_id != token_subscription_id
+				logger.err userEmail:userEmail, token:token, "token value not found for processing group verification"
+				return callback("token not found")
+			SubscriptionLocator.getSubscription subscription_id, (err, subscription)->
+				SubscriptionGroupHandler.addUserToGroup subscription.admin_id, userEmail, callback
+
 
 buildUserViewModel = (user)->
 	u = 

+ 7 - 1
services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee

@@ -14,4 +14,10 @@ module.exports =
 
 	getMemberSubscriptions: (user_id, callback) ->
 		logger.log user_id: user_id, "getting users group subscriptions"
-		Subscription.find(member_ids: user_id).populate("admin_id").exec callback
+		Subscription.find(member_ids: user_id).populate("admin_id").exec callback
+
+	getSubscription: (subscription_id, callback)->
+		Subscription.findOne _id:subscription_id, callback
+
+	getSubscriptionByMemberIdAndId: (user_id, subscription_id, callback)->
+		Subscription.findOne member_ids: user_id, _id:subscription_id, callback

+ 4 - 0
services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee

@@ -24,6 +24,10 @@ module.exports =
 		app.post '/subscription/group/user', AuthenticationController.requireLogin(),  SubscriptionGroupController.addUserToGroup
 		app.del '/subscription/group/user/:user_id', AuthenticationController.requireLogin(), SubscriptionGroupController.removeUserFromGroup
 
+		app.get '/user/subscription/:subscription_id/group/invited', AuthenticationController.requireLogin(), SubscriptionGroupController.renderGroupInvitePage
+		app.post '/user/subscription/:subscription_id/group/begin-join', AuthenticationController.requireLogin(), SubscriptionGroupController.beginJoinGroup
+		app.get '/user/subscription/:subscription_id/group/complete-join', AuthenticationController.requireLogin(), SubscriptionGroupController.completeJoin
+		app.get '/user/subscription/:subscription_id/group/successful-join', AuthenticationController.requireLogin(), SubscriptionGroupController.renderSuccessfulJoinPage
 
 		#recurly callback
 		app.post '/user/subscription/callback',   SubscriptionController.recurlyNotificationParser, SubscriptionController.recurlyCallback

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

@@ -8,9 +8,9 @@ metrics = require("../../infrastructure/Metrics")
 Url = require("url")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 UserUpdater = require("./UserUpdater")
-SubscriptionDomainAllocator = require("../Subscription/SubscriptionDomainAllocator")
+SubscriptionDomainHandler = require("../Subscription/SubscriptionDomainHandler")
 EmailHandler = require("../Email/EmailHandler")
-PasswordResetTokenHandler = require "../PasswordReset/PasswordResetTokenHandler"
+OneTimeTokenHandler = require "../Security/OneTimeTokenHandler"
 settings = require "settings-sharelatex"
 crypto = require "crypto"
 
@@ -98,7 +98,7 @@ module.exports =
 				logger.log {email}, "user already exists, resending welcome email"
 
 			ONE_WEEK = 7 * 24 * 60 * 60 # seconds
-			PasswordResetTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
+			OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
 				return next(err) if err?
 				
 				setNewPasswordUrl = "#{settings.siteUrl}/user/password/set?passwordResetToken=#{token}&email=#{encodeURIComponent(email)}"

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

@@ -53,7 +53,6 @@ UserSchema = new Schema
 					# has this set to true, despite never having had a free trial
 					hadFreeTrial: {type: Boolean, default: false}
 
-
 UserSchema.statics.getAllIds = (callback)->
 	this.find {}, ["first_name"], callback
 

+ 35 - 0
services/web/app/views/subscriptions/group/invite.jade

@@ -0,0 +1,35 @@
+extends ../../layout
+
+block scripts
+	script(type='text/javascript').
+		window.subscription_id = '#{subscription_id}'
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-8.col-md-offset-2(ng-cloak)
+					.card(ng-controller="GroupSubscriptionInviteController")
+						.page-header
+							h1.text-centered #{translate("you_are_invited_to_group", {groupName:licenceName})}
+						div(ng-show="!requestSent").row.text-centered
+							.row
+								.col-md-12 #{translate("group_provides_you_with_premium_account", {groupName:licenceName})}
+							.row
+								.col-md-12 &nbsp;
+							.row 
+								.col-md-12
+									.text-center
+										a.btn.btn-default(href="/project") #{translate("not_now")}
+										&nbsp;
+										a.btn.btn.btn-primary(ng-click="joinGroup()") #{translate("verify_email_address")}  
+
+
+						span(ng-show="requestSent").row.text-centered.text-center
+							.row
+								.col-md-12 #{translate("check_email_to_complete_the_upgrade")}
+							.row
+								.col-md-12 &nbsp;
+							.row
+								.col-md-12
+									a.btn.btn.btn-primary(href="/project") #{translate("done")}

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

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

+ 4 - 0
services/web/config/settings.defaults.coffee

@@ -349,6 +349,10 @@ module.exports =
 	
 	reloadModuleViewsOnEachRequest: true
 
+	domainLicences: [
+		
+	]
+
 	# ShareLaTeX Server Pro options (https://www.sharelatex.com/university/onsite.html)
 	# ----------
 

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

@@ -15,6 +15,7 @@ define [
 	"main/new-subscription"
 	"main/annual-upgrade"
 	"main/register-users"
+	"main/subscription/group-subscription-invite-controller"
 	"analytics/AbTestingManager"
 	"directives/asyncForm"
 	"directives/stopPropagation"

+ 15 - 0
services/web/public/coffee/main/subscription/group-subscription-invite-controller.coffee

@@ -0,0 +1,15 @@
+define [
+	"base"
+], (App) ->
+	App.controller "GroupSubscriptionInviteController", ($scope, $http) ->
+
+		$scope.requestSent = false
+
+		$scope.joinGroup = ->
+			$scope.requestSent = true
+			request = $http.post "/user/subscription/#{subscription_id}/group/begin-join", {_csrf:window.csrfToken}
+			request.success (data, status)->
+				if status != 200 # assume request worked
+					$scope.requestSent = false
+			request.error (data, status)->
+				console.log "the request failed"

+ 8 - 8
services/web/test/UnitTests/coffee/PasswordReset/PasswordResetHandlerTests.coffee

@@ -12,9 +12,9 @@ describe "PasswordResetHandler", ->
 
 		@settings = 
 			siteUrl: "www.sharelatex.com"
-		@PasswordResetTokenHandler =
+		@OneTimeTokenHandler =
 			getNewToken:sinon.stub()
-			getUserIdFromTokenAndExpire:sinon.stub()
+			getValueFromTokenAndExpire:sinon.stub()
 		@UserGetter =
 			getUser:sinon.stub()
 		@EmailHandler = 
@@ -23,7 +23,7 @@ describe "PasswordResetHandler", ->
 			setUserPassword:sinon.stub()
 		@PasswordResetHandler = SandboxedModule.require modulePath, requires:
 			"../User/UserGetter": @UserGetter
-			"./PasswordResetTokenHandler": @PasswordResetTokenHandler
+			"../Security/OneTimeTokenHandler": @OneTimeTokenHandler
 			"../Email/EmailHandler":@EmailHandler
 			"../Authentication/AuthenticationManager":@AuthenticationManager
 			"settings-sharelatex": @settings
@@ -41,7 +41,7 @@ describe "PasswordResetHandler", ->
 
 		it "should check the user exists", (done)->
 			@UserGetter.getUser.callsArgWith(1)
-			@PasswordResetTokenHandler.getNewToken.callsArgWith(1)
+			@OneTimeTokenHandler.getNewToken.callsArgWith(1)
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
 				exists.should.equal false
 				done()
@@ -50,7 +50,7 @@ describe "PasswordResetHandler", ->
 		it "should send the email with the token", (done)->
 
 			@UserGetter.getUser.callsArgWith(1, null, @user)
-			@PasswordResetTokenHandler.getNewToken.callsArgWith(1, null, @token)
+			@OneTimeTokenHandler.getNewToken.callsArgWith(1, null, @token)
 			@EmailHandler.sendEmail.callsArgWith(2)
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
 				@EmailHandler.sendEmail.called.should.equal true
@@ -63,7 +63,7 @@ describe "PasswordResetHandler", ->
 		it "should return exists = false for a holdingAccount", (done) ->
 			@user.holdingAccount = true
 			@UserGetter.getUser.callsArgWith(1, null, @user)
-			@PasswordResetTokenHandler.getNewToken.callsArgWith(1)
+			@OneTimeTokenHandler.getNewToken.callsArgWith(1)
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
 				exists.should.equal false
 				done()
@@ -71,14 +71,14 @@ describe "PasswordResetHandler", ->
 	describe "setNewUserPassword", ->
 
 		it "should return false if no user id can be found", (done)->
-			@PasswordResetTokenHandler.getUserIdFromTokenAndExpire.callsArgWith(1)
+			@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1)
 			@PasswordResetHandler.setNewUserPassword @token, @password, (err, found) =>
 				found.should.equal false
 				@AuthenticationManager.setUserPassword.called.should.equal false
 				done()		
 
 		it "should set the user password", (done)->
-			@PasswordResetTokenHandler.getUserIdFromTokenAndExpire.callsArgWith(1, null, @user_id)
+			@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1, null, @user_id)
 			@AuthenticationManager.setUserPassword.callsArgWith(2)
 			@PasswordResetHandler.setNewUserPassword @token, @password, (err, found) =>
 				found.should.equal true

+ 12 - 12
services/web/test/UnitTests/coffee/PasswordReset/PasswordResetTokenHandlerTests.coffee → services/web/test/UnitTests/coffee/Security/OneTimeTokenHandlerTests.coffee

@@ -3,13 +3,13 @@ SandboxedModule = require('sandboxed-module')
 assert = require('assert')
 path = require('path')
 sinon = require('sinon')
-modulePath = path.join __dirname, "../../../../app/js/Features/PasswordReset/PasswordResetTokenHandler"
+modulePath = path.join __dirname, "../../../../app/js/Features/Security/OneTimeTokenHandler"
 expect = require("chai").expect
 
-describe "PasswordResetTokenHandler", ->
+describe "OneTimeTokenHandler", ->
 
 	beforeEach ->
-		@user_id = "user id here"
+		@value = "user id here"
 		@stubbedToken = require("crypto").randomBytes(32)
 
 		@settings = 
@@ -22,7 +22,7 @@ describe "PasswordResetTokenHandler", ->
 			expire:sinon.stub()
 			exec:sinon.stub()
 		self = @
-		@PasswordResetTokenHandler = SandboxedModule.require modulePath, requires:
+		@OneTimeTokenHandler = SandboxedModule.require modulePath, requires:
 			"redis-sharelatex" :
 				createClient: =>
 					auth:->
@@ -37,30 +37,30 @@ describe "PasswordResetTokenHandler", ->
 
 		it "should set a new token into redis with a ttl", (done)->
 			@redisMulti.exec.callsArgWith(0) 
-			@PasswordResetTokenHandler.getNewToken @user_id, (err, token) =>
-				@redisMulti.set.calledWith("password_token:#{@stubbedToken.toString("hex")}", @user_id).should.equal true
+			@OneTimeTokenHandler.getNewToken @value, (err, token) =>
+				@redisMulti.set.calledWith("password_token:#{@stubbedToken.toString("hex")}", @value).should.equal true
 				@redisMulti.expire.calledWith("password_token:#{@stubbedToken.toString("hex")}", 60 * 60).should.equal true
 				done()
 
 		it "should return if there was an error", (done)->
 			@redisMulti.exec.callsArgWith(0, "error")
-			@PasswordResetTokenHandler.getNewToken @user_id, (err, token)=>
+			@OneTimeTokenHandler.getNewToken @value, (err, token)=>
 				err.should.exist
 				done()
 
 		it "should allow the expiry time to be overridden", (done) ->
 			@redisMulti.exec.callsArgWith(0) 
 			@ttl = 42
-			@PasswordResetTokenHandler.getNewToken @user_id, {expiresIn: @ttl}, (err, token) =>
+			@OneTimeTokenHandler.getNewToken @value, {expiresIn: @ttl}, (err, token) =>
 				@redisMulti.expire.calledWith("password_token:#{@stubbedToken.toString("hex")}", @ttl).should.equal true
 				done()
 
-	describe "getUserIdFromTokenAndExpire", ->
+	describe "getValueFromTokenAndExpire", ->
 
 		it "should get and delete the token", (done)->
-			@redisMulti.exec.callsArgWith(0, null, [@user_id]) 
-			@PasswordResetTokenHandler.getUserIdFromTokenAndExpire @stubbedToken, (err, user_id)=>
-				user_id.should.equal @user_id
+			@redisMulti.exec.callsArgWith(0, null, [@value]) 
+			@OneTimeTokenHandler.getValueFromTokenAndExpire @stubbedToken, (err, value)=>
+				value.should.equal @value
 				@redisMulti.get.calledWith("password_token:#{@stubbedToken}").should.equal true
 				@redisMulti.del.calledWith("password_token:#{@stubbedToken}").should.equal true
 				done()

+ 28 - 1
services/web/test/UnitTests/coffee/Subscription/SubscriptionControllerTests.coffee

@@ -58,7 +58,8 @@ describe "SubscriptionController sanboxed", ->
 			gaExperiments:{}
 		@GeoIpLookup =
 			getCurrencyCode:sinon.stub()
-
+		@SubscriptionDomainHandler = 
+			getDomainLicencePage:sinon.stub()	
 		@SubscriptionController = SandboxedModule.require modulePath, requires:
 			'../../managers/SecurityManager': @SecurityManager
 			'./SubscriptionHandler': @SubscriptionHandler
@@ -69,6 +70,7 @@ describe "SubscriptionController sanboxed", ->
 			'./RecurlyWrapper': @RecurlyWrapper
 			"logger-sharelatex": log:->
 			"settings-sharelatex": @settings
+			"./SubscriptionDomainHandler":@SubscriptionDomainHandler
 
 
 		@res = new MockResponse()
@@ -206,6 +208,31 @@ describe "SubscriptionController sanboxed", ->
 				@res.redirected.should.equal true
 				@res.redirectedTo.should.equal "/user/subscription/plans"
 
+		describe "with a potential domain licence", ->
+			beforeEach () ->
+				@groupUrl = "/go/over-here"
+				@SubscriptionDomainHandler.getDomainLicencePage.returns(@groupUrl)
+
+			describe "without an existing subscription", ->
+				beforeEach (done)->
+					@res.callback = done
+					@LimitationsManager.userHasSubscriptionOrIsGroupMember.callsArgWith(1, null, false)
+					@SubscriptionController.userSubscriptionPage @req, @res
+
+				it "should redirect to the group invite url", ->
+					@res.redirected.should.equal true
+					@res.redirectedTo.should.equal @groupUrl
+
+			describe "with an existing subscription", ->
+				beforeEach (done)->
+					@res.callback = done
+					@LimitationsManager.userHasSubscriptionOrIsGroupMember.callsArgWith(1, null, true)
+					@SubscriptionController.userSubscriptionPage @req, @res
+
+
+				it "should render the dashboard", ->
+					@res.renderedTemplate.should.equal "subscriptions/dashboard"
+				
 		describe "with a user with a paid subscription", ->
 			beforeEach (done) ->
 				@res.callback = done

+ 11 - 13
services/web/test/UnitTests/coffee/Subscription/SubscriptionDomainAllocatorTests.coffee → services/web/test/UnitTests/coffee/Subscription/SubscriptionDomainHandlerTests.coffee

@@ -3,10 +3,10 @@ SandboxedModule = require('sandboxed-module')
 assert = require('assert')
 path = require('path')
 sinon = require('sinon')
-modulePath = path.join __dirname, "../../../../app/js/Features/Subscription/SubscriptionDomainAllocator"
+modulePath = path.join __dirname, "../../../../app/js/Features/Subscription/SubscriptionDomainHandler"
 expect = require("chai").expect
 
-describe "SubscriptionDomainAllocator", ->
+describe "SubscriptionDomainHandler", ->
 
 	beforeEach ->
 
@@ -18,43 +18,41 @@ describe "SubscriptionDomainAllocator", ->
 			]
 		@SubscriptionGroupHandler = 
 			addUserToGroup: sinon.stub().callsArg(2)
-		@SubscriptionDomainAllocator = SandboxedModule.require modulePath, requires:
+		@SubscriptionDomainHandler = SandboxedModule.require modulePath, requires:
 			"settings-sharelatex":@settings
 			"logger-sharelatex": log:->
 			"./SubscriptionGroupHandler": @SubscriptionGroupHandler
 
-
-
 	describe "_findDomainLicence", ->
 
 		it "should find the domain", (done)->
-			licence = @SubscriptionDomainAllocator._findDomainLicence "bob@uni.edu"
+			licence = @SubscriptionDomainHandler._findDomainLicence "bob@uni.edu"
 			licence.adminUser_id.should.equal @adminUser_id
 			done()
 
 		it "should find one of the other emails in the domain list", (done)->
-			licence = @SubscriptionDomainAllocator._findDomainLicence "sally@student.uni.edu"
+			licence = @SubscriptionDomainHandler._findDomainLicence "sally@student.uni.edu"
 			licence.adminUser_id.should.equal @adminUser_id
 			done()
 
 		it "should return undefined if no licence matches", (done)->
-			licence = @SubscriptionDomainAllocator._findDomainLicence "bob@other.edu"
+			licence = @SubscriptionDomainHandler._findDomainLicence "bob@other.edu"
 			expect(licence).to.not.exist
 			done(licence)
 
 	describe "autoAllocate", ->
 		beforeEach ->
 			@email = "bob@somewhere.com"
-			@SubscriptionDomainAllocator._findDomainLicence = sinon.stub()
+			@SubscriptionDomainHandler._findDomainLicence = sinon.stub()
 
 		it "should call the SubscriptionGroupHandler if there is licence", (done)->
-			@SubscriptionDomainAllocator._findDomainLicence.returns(@settings.domainLicences[1])
-			@SubscriptionDomainAllocator.autoAllocate {email:@email}, (err)=>
+			@SubscriptionDomainHandler._findDomainLicence.returns(@settings.domainLicences[1])
+			@SubscriptionDomainHandler.autoAllocate {email:@email}, (err)=>
 				@SubscriptionGroupHandler.addUserToGroup.calledWith(@adminUser_id, @email).should.equal true
 				done()
 
 		it "should not call the SubscriptionGroupHandler if there is no licence", (done)->
-			@SubscriptionDomainAllocator._findDomainLicence.returns()
-			@SubscriptionDomainAllocator.autoAllocate {email:@email}, (err)=>
+			@SubscriptionDomainHandler._findDomainLicence.returns()
+			@SubscriptionDomainHandler.autoAllocate {email:@email}, (err)=>
 				@SubscriptionGroupHandler.addUserToGroup.called.should.equal false
 				done()

+ 109 - 1
services/web/test/UnitTests/coffee/Subscription/SubscriptionGroupControllerTests.coffee

@@ -13,16 +13,42 @@ describe "Subscription Group Controller", ->
 		@GroupHandler = 
 			addUserToGroup: sinon.stub().callsArgWith(2, null, @user)
 			removeUserFromGroup: sinon.stub().callsArgWith(2)
+			isUserPartOfGroup: sinon.stub()
+			sendVerificationEmail:sinon.stub()
+			processGroupVerification:sinon.stub()
+
 		@SubscriptionLocator = getUsersSubscription: sinon.stub().callsArgWith(1, null, @subscription)
 
+		@SubscriptionDomainHandler = 
+			findDomainLicenceBySubscriptionId:sinon.stub()
+
+		@OneTimeTokenHandler =
+			getValueFromTokenAndExpire:sinon.stub()
+
+
+		@ErrorsController = 
+			notFound:sinon.stub()
+
 		@Controller = SandboxedModule.require modulePath, requires:
 			"./SubscriptionGroupHandler":@GroupHandler
 			"logger-sharelatex": log:->
 			"./SubscriptionLocator": @SubscriptionLocator
+			"./SubscriptionDomainHandler":@SubscriptionDomainHandler
+			"../Errors/ErrorController":@ErrorsController
+
 		@adminUserId = "123jlkj"
+		@subscription_id = "123434325412"
+		@user_email = "bob@gmail.com"
 		@req =
 			session:
-				user: _id: @adminUserId
+				user: 
+					_id: @adminUserId
+					email:@user_email
+			params:
+				subscription_id:@subscription_id
+			query:{}
+
+		@token = "super-secret-token"
 
 
 	describe "addUserToGroup", ->
@@ -59,3 +85,85 @@ describe "Subscription Group Controller", ->
 					path.should.equal("/")
 					done()
 			@Controller.renderSubscriptionGroupAdminPage @req, res
+
+
+	describe "renderGroupInvitePage", ->
+		describe "with a valid licence", ->
+			beforeEach ->
+				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns({subscription_id:@subscription_id, adminUser_id:@adminUserId})
+
+			it "should render subscriptions/group/invite if not part of group", (done)->
+				@GroupHandler.isUserPartOfGroup.callsArgWith(2, null, false)
+				res =
+					render : (pageName)=>
+						pageName.should.equal "subscriptions/group/invite"
+						done()
+				@Controller.renderGroupInvitePage @req, res
+
+			it "should redirect to custom page if is already part of group", (done)->
+				@GroupHandler.isUserPartOfGroup.callsArgWith(2, null, true)
+				res =
+					redirect : (location)=>
+						location.should.equal "/user/subscription/custom_account"
+						done()
+				@Controller.renderGroupInvitePage @req, res
+
+		describe "without a valid licence", ->
+			beforeEach ->
+				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns(undefined)
+
+			it "should send a 500", (done)->
+				@Controller.renderGroupInvitePage @req, {}
+				@ErrorsController.notFound.called.should.equal true
+				done()
+
+
+
+	describe "beginJoinGroup", ->
+		describe "with a valid licence", ->
+			beforeEach ->
+				@licenceName = "get amazing licence"
+				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns({name:@licenceName})
+				@GroupHandler.sendVerificationEmail.callsArgWith(3)
+
+			it "should ask the SubscriptionGroupHandler to send the verification email", (done)->
+				res =
+					send : (statusCode)=>
+						statusCode.should.equal 200
+						@GroupHandler.sendVerificationEmail.calledWith(@subscription_id, @licenceName, @user_email).should.equal true
+						done()
+				@Controller.beginJoinGroup @req, res
+
+		describe "without a valid licence", ->
+			beforeEach ->
+				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns(undefined)
+
+			it "should send a 500", (done)->
+				@Controller.beginJoinGroup @req, {}
+				@ErrorsController.notFound.called.should.equal true
+				done()
+
+
+	describe "completeJoin", ->
+		describe "with a valid licence", ->
+			beforeEach ->
+				@GroupHandler.processGroupVerification.callsArgWith(3)
+				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns({name:@licenceName})
+
+			it "should redirect to the success page upon processGroupVerification", (done)->
+				@req.query.token = @token
+				res =
+					redirect : (location)=>
+						@GroupHandler.processGroupVerification.calledWith(@user_email, @subscription_id, @token).should.equal true
+						location.should.equal "/user/subscription/#{@subscription_id}/group/successful-join"
+						done()
+				@Controller.completeJoin @req, res
+
+		describe "without a valid licence", ->
+			beforeEach ->
+				@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns(undefined)
+
+			it "should send a 500", (done)->
+				@Controller.completeJoin @req, {}
+				@ErrorsController.notFound.called.should.equal true
+				done()

+ 75 - 1
services/web/test/UnitTests/coffee/Subscription/SubscriptionGroupHandlerTests.coffee

@@ -10,10 +10,13 @@ describe "Subscription Group Handler", ->
 	beforeEach ->
 		@adminUser_id = "12321"
 		@newEmail = "bob@smith.com"
-		@user = {_id:"3121321", email:@newEmail}
+		@user_id = "3121321"
+		@user = {_id:@user_id, email:@newEmail}
 
 		@SubscriptionLocator = 
 			getUsersSubscription: sinon.stub()
+			getSubscriptionByMemberIdAndId: sinon.stub()
+			getSubscription: sinon.stub()
 
 		@UserCreator = 
 			getUserOrCreateHoldingAccount: sinon.stub().callsArgWith(1, null, @user)
@@ -28,6 +31,16 @@ describe "Subscription Group Handler", ->
 		@LimitationsManager =
 			hasGroupMembersLimitReached: sinon.stub()
 
+		@OneTimeTokenHandler =
+			getValueFromTokenAndExpire:sinon.stub()
+			getNewToken:sinon.stub()
+
+		@EmailHandler =
+			sendEmail:sinon.stub()
+
+		@settings = 
+			siteUrl:"http://www.sharelatex.com"
+
 		@Handler = SandboxedModule.require modulePath, requires:
 			"logger-sharelatex": log:->
 			"../User/UserCreator": @UserCreator
@@ -35,6 +48,13 @@ describe "Subscription Group Handler", ->
 			"./SubscriptionLocator": @SubscriptionLocator
 			"../User/UserLocator": @UserLocator
 			"./LimitationsManager": @LimitationsManager
+			"../Security/OneTimeTokenHandler":@OneTimeTokenHandler
+			"../Email/EmailHandler":@EmailHandler
+			"settings-sharelatex":@settings
+			"logger-sharelatex": 
+				err:->
+				log:->
+
 
 	describe "addUserToGroup", ->
 		it "should find or create the user", (done)->
@@ -100,3 +120,57 @@ describe "Subscription Group Handler", ->
 				assert.deepEqual users[2], {_id:@subscription.member_ids[2]}
 				done()
 
+	describe "isUserPartOfGroup", ->
+		beforeEach ->
+			@subscription_id = "123ed13123"
+
+		it "should return true when user is part of subscription", (done)->
+			@SubscriptionLocator.getSubscriptionByMemberIdAndId.callsArgWith(2, null, {_id:@subscription_id})
+			@Handler.isUserPartOfGroup @user_id, @subscription_id, (err, partOfGroup)->
+				partOfGroup.should.equal true
+				done()
+
+		it "should return false when no subscription is found", (done)->
+			@SubscriptionLocator.getSubscriptionByMemberIdAndId.callsArgWith(2, null)
+			@Handler.isUserPartOfGroup @user_id, @subscription_id, (err, partOfGroup)->
+				partOfGroup.should.equal false
+				done()
+
+
+	describe "sendVerificationEmail", ->
+		beforeEach ->
+			@token = "secret token"
+			@subscription_id = "123ed13123"
+			@licenceName = "great licnece"
+			@email = "bob@smith.com"
+			@OneTimeTokenHandler.getNewToken.callsArgWith(1, null, @token)
+			@EmailHandler.sendEmail.callsArgWith(2)
+
+		it "should put a one time token into the email", (done)->
+			@Handler.sendVerificationEmail @subscription_id, @licenceName, @email, (err)=>
+				emailOpts = @EmailHandler.sendEmail.args[0][1]
+				emailOpts.completeJoinUrl.should.equal "#{@settings.siteUrl}/user/subscription/#{@subscription_id}/group/complete-join?token=#{@token}"
+				emailOpts.to.should.equal @email
+				emailOpts.group_name.should.equal @licenceName
+				done()
+
+	describe "processGroupVerification", ->
+		beforeEach ->
+			@token = "31dDAd2Da"
+			@subscription_id = "31DSd1123D"
+			@admin_id = "eDSda1ew"
+			@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1, null, @subscription_id)
+			@SubscriptionLocator.getSubscription.callsArgWith(1, null, {admin_id:@admin_id})
+			@Handler.addUserToGroup = sinon.stub().callsArgWith(2)
+
+		it "should addUserToGroup", (done)->
+			@Handler.processGroupVerification @email, @subscription_id, @token, (err)=>
+				@Handler.addUserToGroup.calledWith(@admin_id, @email).should.equal true
+				done()
+
+
+
+
+
+
+

+ 7 - 7
services/web/test/UnitTests/coffee/User/UserControllerTests.coffee

@@ -36,13 +36,13 @@ describe "UserController", ->
 			setUserPassword: sinon.stub()
 		@ReferalAllocator =
 			allocate:sinon.stub()
-		@SubscriptionDomainAllocator = 
+		@SubscriptionDomainHandler = 
 			autoAllocate:sinon.stub()
 		@UserUpdater =
 			changeEmailAddress:sinon.stub()
 		@EmailHandler =
 			sendEmail:sinon.stub().callsArgWith(2)
-		@PasswordResetTokenHandler =
+		@OneTimeTokenHandler =
 			getNewToken: sinon.stub()
 		@settings =
 			siteUrl: "sharelatex.example.com"
@@ -56,9 +56,9 @@ describe "UserController", ->
 			"../Authentication/AuthenticationController": @AuthenticationController
 			"../Authentication/AuthenticationManager": @AuthenticationManager
 			"../Referal/ReferalAllocator":@ReferalAllocator
-			"../Subscription/SubscriptionDomainAllocator":@SubscriptionDomainAllocator
+			"../Subscription/SubscriptionDomainHandler":@SubscriptionDomainHandler
 			"../Email/EmailHandler": @EmailHandler
-			"../PasswordReset/PasswordResetTokenHandler": @PasswordResetTokenHandler
+			"../Security/OneTimeTokenHandler": @OneTimeTokenHandler
 			"crypto": @crypto = {}
 			"settings-sharelatex": @settings
 			"logger-sharelatex": {log:->}
@@ -177,7 +177,7 @@ describe "UserController", ->
 		beforeEach ->
 			@req.body.email = @user.email = "email@example.com"
 			@crypto.randomBytes = sinon.stub().returns({toString: () => @password = "mock-password"})
-			@PasswordResetTokenHandler.getNewToken.callsArgWith(2, null, @token = "mock-token")
+			@OneTimeTokenHandler.getNewToken.callsArgWith(2, null, @token = "mock-token")
 		
 		describe "with a new user", ->
 			beforeEach ->
@@ -192,7 +192,7 @@ describe "UserController", ->
 					}).should.equal true
 					
 			it "should generate a new password reset token", ->
-				@PasswordResetTokenHandler.getNewToken
+				@OneTimeTokenHandler.getNewToken
 					.calledWith(@user_id, expiresIn: 7 * 24 * 60 * 60)
 					.should.equal true
 
@@ -218,7 +218,7 @@ describe "UserController", ->
 				@UserController.register @req, @res
 				
 			it "should still generate a new password token and email", ->
-				@PasswordResetTokenHandler.getNewToken.called.should.equal true
+				@OneTimeTokenHandler.getNewToken.called.should.equal true
 				@EmailHandler.sendEmail.called.should.equal true
 
 	describe "changePassword", ->