소스 검색

Merge branch 'master' of https://github.com/sharelatex/web-sharelatex

Henry Oswald 10 년 전
부모
커밋
763f16f43c

+ 6 - 3
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -12,9 +12,12 @@ basicAuth = require('basic-auth-connect')
 
 module.exports = AuthenticationController =
 	login: (req, res, next = (error) ->) ->
-		email = req.body?.email?.toLowerCase()
-		password = req.body?.password
-		redir = Url.parse(req.body?.redir or "/project").path
+		AuthenticationController.doLogin req.body, req, res, next
+	
+	doLogin: (options, req, res, next) ->
+		email = options.email?.toLowerCase()
+		password = options.password
+		redir = Url.parse(options.redir or "/project").path
 		LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
 			if !isAllowed
 				logger.log email:email, "too many login requests"

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

@@ -15,8 +15,6 @@ templates.registered =
 
 <p><a href="<%= setNewPasswordUrl %>">Click here to set your password and log in.</a></p>
 
-<p>Once you have reset your password you can <a href="#{settings.siteUrl}/login">log in here</a>.</p>
-
 <p>If you have any questions or problems, please contact <a href="mailto:#{settings.adminEmail}">#{settings.adminEmail}</a>.</p>
 """
 

+ 11 - 4
services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee

@@ -1,5 +1,7 @@
 PasswordResetHandler = require("./PasswordResetHandler")
 RateLimiter = require("../../infrastructure/RateLimiter")
+AuthenticationController = require("../Authentication/AuthenticationController")
+UserGetter = require("../User/UserGetter")
 logger = require "logger-sharelatex"
 
 module.exports =
@@ -37,14 +39,19 @@ module.exports =
 			title:"set_password"
 			passwordResetToken: req.session.resetToken
 
-	setNewUserPassword: (req, res)->
+	setNewUserPassword: (req, res, next)->
 		{passwordResetToken, password} = req.body
 		if !password? or password.length == 0 or !passwordResetToken? or passwordResetToken.length == 0
 			return res.sendStatus 400
 		delete req.session.resetToken
-		PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found) ->
+		PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found, user_id) ->
 			return next(err) if err?
 			if found
-				res.sendStatus 200
+				if req.body.login_after
+					UserGetter.getUser user_id, {email: 1}, (err, user) ->
+						return next(err) if err?
+						AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
+				else
+					res.sendStatus 200
 			else
-				res.send 404, {message: req.i18n.translate("password_reset_token_expired")}
+				res.sendStatus 404

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

@@ -23,11 +23,11 @@ module.exports =
 					return callback(error) if error?
 					callback null, true
 
-	setNewUserPassword: (token, password, callback = (error, found) ->)->
+	setNewUserPassword: (token, password, callback = (error, found, user_id) ->)->
 		OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, user_id)->
 			if err then return callback(err)
 			if !user_id?
-				return callback null, false
+				return callback null, false, null
 			AuthenticationManager.setUserPassword user_id, password, (err) ->
 				if err then return callback(err)
-				callback null, true
+				callback null, true, user_id

+ 6 - 29
services/web/app/coffee/Features/User/UserController.coffee

@@ -8,10 +8,7 @@ metrics = require("../../infrastructure/Metrics")
 Url = require("url")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 UserUpdater = require("./UserUpdater")
-EmailHandler = require("../Email/EmailHandler")
-OneTimeTokenHandler = require "../Security/OneTimeTokenHandler"
 settings = require "settings-sharelatex"
-crypto = require "crypto"
 
 module.exports =
 
@@ -85,32 +82,12 @@ module.exports =
 		if !email? or email == ""
 			res.sendStatus 422 # Unprocessable Entity
 			return
-		logger.log {email}, "registering new user"
-		UserRegistrationHandler.registerNewUser {
-			email: email
-			password: crypto.randomBytes(32).toString("hex")
-		}, (err, user)->
-			if err? and err?.message != "EmailAlreadyRegistered"
-				return next(err)
-			
-			if err?.message == "EmailAlreadyRegistered"
-				logger.log {email}, "user already exists, resending welcome email"
-
-			ONE_WEEK = 7 * 24 * 60 * 60 # seconds
-			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)}"
-
-				EmailHandler.sendEmail "registered", {
-					to: user.email
-					setNewPasswordUrl: setNewPasswordUrl
-				}, () ->
-					
-				res.json {
-					email: user.email
-					setNewPasswordUrl: setNewPasswordUrl
-				}
+		UserRegistrationHandler.registerNewUserAndSendActivationEmail email, (error, user, setNewPasswordUrl) ->
+			return next(error) if error?
+			res.json {
+				email: user.email
+				setNewPasswordUrl: setNewPasswordUrl
+			}
 
 	changePassword : (req, res, next = (error) ->)->
 		metrics.inc "user.password-change"

+ 25 - 1
services/web/app/coffee/Features/User/UserPagesController.coffee

@@ -1,4 +1,6 @@
 UserLocator = require("./UserLocator")
+UserGetter = require("./UserGetter")
+ErrorController = require("../Errors/ErrorController")
 logger = require("logger-sharelatex")
 Settings = require("settings-sharelatex")
 fs = require('fs')
@@ -20,11 +22,33 @@ module.exports =
 			sharedProjectData: sharedProjectData
 			newTemplateData: newTemplateData
 			new_email:req.query.new_email || ""
+	
+	activateAccountPage: (req, res) ->
+		# An 'activation' is actually just a password reset on an account that
+		# was set with a random password originally.
+		if !req.query?.user_id? or !req.query?.token?
+			return ErrorController.notFound(req, res)
+			
+		UserGetter.getUser req.query.user_id, {email: 1, loginCount: 1}, (error, user) ->
+			return next(error) if error?
+			if !user
+				return ErrorController.notFound(req, res)
+			if user.loginCount > 0
+				# Already seen this user, so account must be activate
+				# This lets users keep clicking the 'activate' link in their email
+				# as a way to log in which, if I know our users, they will.
+				res.redirect "/login?email=#{encodeURIComponent(user.email)}"
+			else
+				res.render 'user/activate',
+					title: 'activate_account'
+					email: user.email,
+					token: req.query.token
 
 	loginPage : (req, res)->
 		res.render 'user/login',
 			title: 'login',
-			redir: req.query.redir
+			redir: req.query.redir,
+			email: req.query.email
 
 	settingsPage : (req, res, next)->
 		logger.log user: req.session.user, "loading settings page"

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

@@ -5,8 +5,12 @@ AuthenticationManager = require("../Authentication/AuthenticationManager")
 NewsLetterManager = require("../Newsletter/NewsletterManager")
 async = require("async")
 logger = require("logger-sharelatex")
+crypto = require("crypto")
+EmailHandler = require("../Email/EmailHandler")
+OneTimeTokenHandler = require "../Security/OneTimeTokenHandler"
+settings = require "settings-sharelatex"
 
-module.exports =
+module.exports = UserRegistrationHandler =
 	validateEmail : (email) ->
 		re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\ ".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA -Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
 		return re.test(email)
@@ -58,6 +62,31 @@ module.exports =
 				], (err)->
 					logger.log user: user, "registered"
 					callback(err, user)
+	
+	registerNewUserAndSendActivationEmail: (email, callback = (error, user, setNewPasswordUrl) ->) ->
+		logger.log {email}, "registering new user"
+		UserRegistrationHandler.registerNewUser {
+			email: email
+			password: crypto.randomBytes(32).toString("hex")
+		}, (err, user)->
+			if err? and err?.message != "EmailAlreadyRegistered"
+				return next(err)
+			
+			if err?.message == "EmailAlreadyRegistered"
+				logger.log {email}, "user already exists, resending welcome email"
+
+			ONE_WEEK = 7 * 24 * 60 * 60 # seconds
+			OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
+				return next(err) if err?
+				
+				setNewPasswordUrl = "#{settings.siteUrl}/user/activate?token=#{token}&user_id=#{user._id}"
+
+				EmailHandler.sendEmail "registered", {
+					to: user.email
+					setNewPasswordUrl: setNewPasswordUrl
+				}, () ->
+				
+				callback null, user, setNewPasswordUrl
 
 
 

+ 2 - 0
services/web/app/coffee/router.coffee

@@ -77,6 +77,8 @@ module.exports = class Router
 		webRouter.get '/blog', BlogController.getIndexPage
 		webRouter.get '/blog/*', BlogController.getPage
 		
+		webRouter.get '/user/activate', UserPagesController.activateAccountPage
+		
 		webRouter.get  '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage
 		webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
 		webRouter.post '/user/password/update', AuthenticationController.requireLogin(), UserController.changePassword

+ 1 - 1
services/web/app/views/sentry.jade

@@ -57,7 +57,7 @@
 			],
 			shouldSendCallback: function(data) {
 				// only send a fraction of errors
-				var sampleRate = 0.05;
+				var sampleRate = 0.01;
 				return (Math.random() <= sampleRate);
 			},
 			dataCallback: function(data) {

+ 64 - 0
services/web/app/views/user/activate.jade

@@ -0,0 +1,64 @@
+extends ../layout
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4
+					.alert.alert-success #{translate("nearly_activated")}
+			.row
+				.col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4
+					.card
+						.page-header
+							h1 #{translate("please_set_a_password")}
+						form(
+							async-form="activate",
+							name="activationForm",
+							action="/user/password/set",
+							method="POST",
+							ng-cloak
+						)
+							input(name='_csrf', type='hidden', value=csrfToken)
+							input(
+								type="hidden",
+								name="passwordResetToken",
+								value=token
+							)
+							input(name='login_after', type='hidden', value="true")
+							.alert.alert-danger(ng-show="activationForm.response.error")
+								| #{translate("activation_token_expired")}
+							
+							.form-group
+								label(for='email') #{translate("email")}
+								input.form-control(
+									type='email',
+									name='email',
+									placeholder="email@example.com"
+									required,
+									ng-model="email",
+									ng-init="email = #{JSON.stringify(email)}",
+									ng-model-options="{ updateOn: 'blur' }",
+									disabled
+								)
+							.form-group
+								label(for='password') #{translate("password")}
+								input.form-control#passwordField(
+									type='password',
+									name='password',
+									placeholder="********",
+									required,
+									ng-model="password",
+									complex-password,
+									focus="true"
+								)
+								span.small.text-primary(ng-show="activationForm.password.$error.complexPassword", ng-bind-html="complexPasswordErrorMessage")
+							.actions
+								button.btn-primary.btn(
+									type='submit'
+									ng-disabled="activationForm.inflight || activationForm.password.$error.required|| activationForm.password.$error.complexPassword"
+								)
+									span(ng-show="!activationForm.inflight") #{translate("activate")}
+									span(ng-show="activationForm.inflight") #{translate("activating")}...
+
+	script(type='text/javascript').
+		window.passwordStrengthOptions = !{JSON.stringify(settings.passwordStrengthOptions || {})}

+ 1 - 0
services/web/app/views/user/login.jade

@@ -20,6 +20,7 @@ block content
 									placeholder='email@example.com',
 									ng-model="email",
 									ng-model-options="{ updateOn: 'blur' }",
+									ng-init="email = #{JSON.stringify(email)}",
 									focus="true"
 								)
 								span.small.text-primary(ng-show="loginForm.email.$invalid && loginForm.email.$dirty")

+ 5 - 4
services/web/app/views/user/setPassword.jade

@@ -16,10 +16,11 @@ block content
 							ng-cloak
 						)
 							input(type="hidden", name="_csrf", value=csrfToken)
-							form-messages(for="passwordResetForm")
-								.alert.alert-success(ng-show="passwordResetForm.response.success")
-									| #{translate("password_has_been_reset")}. 
-									a(href='/login') #{translate("login_here")}
+							.alert.alert-success(ng-show="passwordResetForm.response.success")
+								| #{translate("password_has_been_reset")}. 
+								a(href='/login') #{translate("login_here")}
+							.alert.alert-danger(ng-show="passwordResetForm.response.error")
+								| #{translate("password_reset_token_expired")}
 
 							.form-group
 								input.form-control#passwordField(

+ 25 - 21
services/web/public/js/libs/latex-log-parser.js

@@ -3,10 +3,11 @@ define(function() {
     var LOG_WRAP_LIMIT = 79;
     var LATEX_WARNING_REGEX = /^LaTeX Warning: (.*)$/;
     var HBOX_WARNING_REGEX = /^(Over|Under)full \\(v|h)box/;
-    var BIBER_WARNING_REGEX = /^Package biblatex Warning: (.*)$/;
-    var NATBIB_WARNING_REGEX = /^Package natbib Warning: (.*)$/;
+    var PACKAGE_WARNING_REGEX = /^(Package \b.+\b Warning:.*)$/; 
     // This is used to parse the line number from common latex warnings
     var LINES_REGEX = /lines? ([0-9]+)/;
+    // This is used to parse the package name from the package warnings
+    var PACKAGE_REGEX = /^Package (\b.+\b) Warning/;
 
     var LogText = function(text) {
         this.text = text.replace(/(\r\n)|\r/g, "\n");
@@ -101,10 +102,8 @@ define(function() {
                         this.parseSingleWarningLine(LATEX_WARNING_REGEX);
                     } else if (this.currentLineIsHboxWarning()) {
                         this.parseHboxLine();
-                    } else if (this.currentLineIsBiberWarning()) {
-                        this.parseBiberWarningLine();
-                    } else if (this.currentLineIsNatbibWarning()) {
-                        this.parseSingleWarningLine(NATBIB_WARNING_REGEX);
+                    } else if (this.currentLineIsPackageWarning()) {
+                        this.parseMultipleWarningLine();
                     } else {
                         this.parseParensForFilenames();
                     }
@@ -140,12 +139,8 @@ define(function() {
             return !!(this.currentLine.match(LATEX_WARNING_REGEX));
         };
 
-        this.currentLineIsBiberWarning = function () {
-            return !!(this.currentLine.match(BIBER_WARNING_REGEX));
-        };
-
-        this.currentLineIsNatbibWarning = function () {
-            return !!(this.currentLine.match(NATBIB_WARNING_REGEX));
+        this.currentLineIsPackageWarning = function () {
+            return !!(this.currentLine.match(PACKAGE_WARNING_REGEX));
         };
 
         this.currentLineIsHboxWarning = function() {
@@ -169,22 +164,31 @@ define(function() {
             });
         };
 
-        this.parseBiberWarningLine = function() {
-            // Biber warnings are multiple lines, let's parse the first line
-            var warningMatch = this.currentLine.match(BIBER_WARNING_REGEX);
+        this.parseMultipleWarningLine = function() {
+            // Some package warnings are multiple lines, let's parse the first line
+            var warningMatch = this.currentLine.match(PACKAGE_WARNING_REGEX);
             if (!warningMatch) return;  // Something strange happened, return early
 
-            // Now loop over the other output and just grab the message part
-            // Each line is prefiex with: (biblatex)
             var warning_lines = [warningMatch[1]];
-            while (((this.currentLine = this.log.nextLine()) !== false) &&
-                (warningMatch = this.currentLine.match(/^\(biblatex\)[\s]+(.*)$/))) {
-                warning_lines.push(warningMatch[1])
+            var lineMatch = this.currentLine.match(LINES_REGEX);
+            var line = lineMatch ? parseInt(lineMatch[1], 10) : null;
+            var packageMatch = this.currentLine.match(PACKAGE_REGEX);
+            var packageName = packageMatch[1];
+
+            // Regex to get rid of the unnecesary (packagename) prefix in most multi-line warnings
+            var prefixRegex = new RegExp("(?:\\(" + packageName + "\\))*[\\s]*(.*)", "i");
+
+            // After every warning message there's a blank line, let's use it
+            while (!!(this.currentLine = this.log.nextLine())) {
+                lineMatch = this.currentLine.match(LINES_REGEX);
+                line = lineMatch ? parseInt(lineMatch[1], 10) : line;
+                warningMatch = this.currentLine.match(prefixRegex)
+                warning_lines.push(warningMatch[1]);
             }
 
             var raw_message = warning_lines.join(' ');
             this.data.push({
-                line    : null,  // Unfortunately, biber doesn't return a line number
+                line    : line,
                 file    : this.currentFilePath,
                 level   : "warning",
                 message : raw_message,

+ 1 - 1
services/web/test/UnitTests/coffee/Authentication/AuthenticationManagerTests.coffee

@@ -71,7 +71,7 @@ describe "AuthenticationManager", ->
 			@bcrypt.genSalt = sinon.stub().callsArgWith(1, null, @salt)
 			@bcrypt.hash = sinon.stub().callsArgWith(2, null, @hashedPassword)
 			@db.users.update = sinon.stub().callsArg(2)
-			@AuthenticationManager.setUserPassword(@user_id, @unencryptedPassword, @callback)
+			@AuthenticationManager.setUserPassword(@user_id, @password, @callback)
 
 		it "should update the user's password in the database", ->
 			@db.users.update

+ 17 - 1
services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee

@@ -1,4 +1,5 @@
 should = require('chai').should()
+expect = require("chai").expect
 SandboxedModule = require('sandboxed-module')
 assert = require('assert')
 path = require('path')
@@ -21,6 +22,8 @@ describe "PasswordResetController", ->
 			"./PasswordResetHandler":@PasswordResetHandler
 			"logger-sharelatex": log:->
 			"../../infrastructure/RateLimiter":@RateLimiter
+			"../Authentication/AuthenticationController": @AuthenticationController = {}
+			"../User/UserGetter": @UserGetter = {}
 
 		@email = "bob@bob.com "
 		@token = "my security token that was emailed to me"
@@ -101,7 +104,7 @@ describe "PasswordResetController", ->
 
 		it "should send 404 if the token didn't work", (done)->
 			@PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, false)
-			@res.send = (code)=>
+			@res.sendStatus = (code)=>
 				code.should.equal 404
 				done()
 			@PasswordResetController.setNewUserPassword @req, @res
@@ -131,6 +134,19 @@ describe "PasswordResetController", ->
 				@req.session.should.not.have.property 'resetToken'
 				done()
 			@PasswordResetController.setNewUserPassword @req, @res
+		
+		it "should login user if login_after is set", (done) ->
+			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, { email: "joe@example.com" })
+			@PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true, @user_id = "user-id-123")
+			@req.body.login_after = "true"
+			@AuthenticationController.doLogin = (options, req, res, next)=>
+				@UserGetter.getUser.calledWith(@user_id).should.equal true
+				expect(options).to.deep.equal {
+					email: "joe@example.com",
+					password: @password
+				}
+				done()
+			@PasswordResetController.setNewUserPassword @req, @res
 
 	describe "renderSetPasswordForm", ->
 

+ 2 - 1
services/web/test/UnitTests/coffee/PasswordReset/PasswordResetHandlerTests.coffee

@@ -80,8 +80,9 @@ describe "PasswordResetHandler", ->
 		it "should set the user password", (done)->
 			@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1, null, @user_id)
 			@AuthenticationManager.setUserPassword.callsArgWith(2)
-			@PasswordResetHandler.setNewUserPassword @token, @password, (err, found) =>
+			@PasswordResetHandler.setNewUserPassword @token, @password, (err, found, user_id) =>
 				found.should.equal true
+				user_id.should.equal @user_id
 				@AuthenticationManager.setUserPassword.calledWith(@user_id, @password).should.equal true
 				done()			
 

+ 16 - 51
services/web/test/UnitTests/coffee/User/UserControllerTests.coffee

@@ -40,10 +40,6 @@ describe "UserController", ->
 			autoAllocate:sinon.stub()
 		@UserUpdater =
 			changeEmailAddress:sinon.stub()
-		@EmailHandler =
-			sendEmail:sinon.stub().callsArgWith(2)
-		@OneTimeTokenHandler =
-			getNewToken: sinon.stub()
 		@settings =
 			siteUrl: "sharelatex.example.com"
 		@UserController = SandboxedModule.require modulePath, requires:
@@ -57,9 +53,6 @@ describe "UserController", ->
 			"../Authentication/AuthenticationManager": @AuthenticationManager
 			"../Referal/ReferalAllocator":@ReferalAllocator
 			"../Subscription/SubscriptionDomainHandler":@SubscriptionDomainHandler
-			"../Email/EmailHandler": @EmailHandler
-			"../Security/OneTimeTokenHandler": @OneTimeTokenHandler
-			"crypto": @crypto = {}
 			"settings-sharelatex": @settings
 			"logger-sharelatex": {log:->}
 
@@ -175,51 +168,23 @@ describe "UserController", ->
 
 	describe "register", ->
 		beforeEach ->
-			@req.body.email = @user.email = "email@example.com"
-			@crypto.randomBytes = sinon.stub().returns({toString: () => @password = "mock-password"})
-			@OneTimeTokenHandler.getNewToken.callsArgWith(2, null, @token = "mock-token")
+			@UserRegistrationHandler.registerNewUserAndSendActivationEmail = sinon.stub().callsArgWith(1, null, @user, @url = "mock/url")
+			@req.body.email = @user.email = @email = "email@example.com"
+			@UserController.register @req, @res
 		
-		describe "with a new user", ->
-			beforeEach ->
-				@UserRegistrationHandler.registerNewUser.callsArgWith(1, null, @user)
-				@UserController.register @req, @res
-			
-			it "should ask the UserRegistrationHandler to register user", ->
-				@UserRegistrationHandler.registerNewUser
-					.calledWith({
-						email: @req.body.email
-						password: @password
-					}).should.equal true
-					
-			it "should generate a new password reset token", ->
-				@OneTimeTokenHandler.getNewToken
-					.calledWith(@user_id, expiresIn: 7 * 24 * 60 * 60)
-					.should.equal true
-
-			it "should send a registered email", ->
-				@EmailHandler.sendEmail
-					.calledWith("registered", {
-						to: @user.email
-						setNewPasswordUrl: "#{@settings.siteUrl}/user/password/set?passwordResetToken=#{@token}&email=#{encodeURIComponent(@user.email)}"
-					})
-					.should.equal true
-			
-			it "should return the user", ->
-				@res.json
-					.calledWith({
-						email: @user.email
-						setNewPasswordUrl: "#{@settings.siteUrl}/user/password/set?passwordResetToken=#{@token}&email=#{encodeURIComponent(@user.email)}"
-					})
-					.should.equal true
-
-		describe "with a user that already exists", ->
-			beforeEach ->
-				@UserRegistrationHandler.registerNewUser.callsArgWith(1, new Error("EmailAlreadyRegistered"), @user)
-				@UserController.register @req, @res
-				
-			it "should still generate a new password token and email", ->
-				@OneTimeTokenHandler.getNewToken.called.should.equal true
-				@EmailHandler.sendEmail.called.should.equal true
+		it "should register the user and send them an email", ->
+			@UserRegistrationHandler.registerNewUserAndSendActivationEmail
+				.calledWith(@email)
+				.should.equal true
+		
+		it "should return the user and activation url", ->
+			console.log @res.json.args
+			@res.json
+				.calledWith({
+					email: @email,
+					setNewPasswordUrl: @url
+				})
+				.should.equal true
 
 	describe "changePassword", ->
 

+ 49 - 2
services/web/test/UnitTests/coffee/User/UserPagesControllerTests.coffee

@@ -12,18 +12,25 @@ describe "UserPagesController", ->
 
 		@settings = {}
 		@user = 
-			_id:"kwjewkl"
+			_id: @user_id = "kwjewkl"
 			features:{}
+			email: "joe@example.com"
 
 		@UserLocator =
 			findById: sinon.stub().callsArgWith(1, null, @user)
+		@UserGetter =
+			getUser: sinon.stub().callsArgWith(2, null, @user)
 		@dropboxStatus = {}
 		@DropboxHandler =
 			getUserRegistrationStatus : sinon.stub().callsArgWith(1, null, @dropboxStatus)
+		@ErrorController =
+			notFound: sinon.stub()
 		@UserPagesController = SandboxedModule.require modulePath, requires:
 			"settings-sharelatex":@settings
 			"logger-sharelatex": log:->
 			"./UserLocator": @UserLocator
+			"./UserGetter": @UserGetter
+			"../Errors/ErrorController": @ErrorController
 			'../Dropbox/DropboxHandler': @DropboxHandler
 		@req = 
 			query:{}
@@ -103,4 +110,44 @@ describe "UserPagesController", ->
 			@res.render = (page, opts)=>
 				opts.user.should.equal @user
 				done()
-			@UserPagesController.settingsPage @req, @res
+			@UserPagesController.settingsPage @req, @res
+	
+	describe "activateAccountPage", ->
+		beforeEach ->
+			@req.query.user_id = @user_id
+			@req.query.token = @token = "mock-token-123"
+		
+		it "should 404 without a user_id", (done) ->
+			delete @req.query.user_id
+			@ErrorController.notFound = () ->
+				done()
+			@UserPagesController.activateAccountPage @req, @res
+		
+		it "should 404 without a token", (done) ->
+			delete @req.query.token
+			@ErrorController.notFound = () ->
+				done()
+			@UserPagesController.activateAccountPage @req, @res
+		
+		it "should 404 without a valid user_id", (done) ->
+			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
+			@ErrorController.notFound = () ->
+				done()
+			@UserPagesController.activateAccountPage @req, @res
+
+		it "should redirect activated users to login", (done) ->
+			@user.loginCount = 1
+			@res.redirect = (url) =>
+				@UserGetter.getUser.calledWith(@user_id).should.equal true
+				url.should.equal "/login?email=#{encodeURIComponent(@user.email)}"
+				done()
+			@UserPagesController.activateAccountPage @req, @res
+			
+		it "render the activation page if the user has not logged in before", (done) ->
+			@user.loginCount = 0
+			@res.render = (page, opts) =>
+				page.should.equal "user/activate"
+				opts.email.should.equal @user.email
+				opts.token.should.equal @token
+				done()
+			@UserPagesController.activateAccountPage @req, @res

+ 56 - 2
services/web/test/UnitTests/coffee/User/UserRegistrationHandlerTests.coffee

@@ -10,7 +10,7 @@ describe "UserRegistrationHandler", ->
 
 	beforeEach ->
 		@user =
-			_id: "31j2lk21kjl"
+			_id: @user_id = "31j2lk21kjl"
 		@User = 
 			findOne:sinon.stub()
 			update: sinon.stub().callsArgWith(2)
@@ -20,12 +20,20 @@ describe "UserRegistrationHandler", ->
 			setUserPassword: sinon.stub().callsArgWith(2)
 		@NewsLetterManager =
 			subscribe: sinon.stub().callsArgWith(1)
+		@EmailHandler =
+			sendEmail:sinon.stub().callsArgWith(2)
+		@OneTimeTokenHandler =
+			getNewToken: sinon.stub()
 		@handler = SandboxedModule.require modulePath, requires:
 			"../../models/User": {User:@User}
 			"./UserCreator": @UserCreator
 			"../Authentication/AuthenticationManager":@AuthenticationManager
 			"../Newsletter/NewsletterManager":@NewsLetterManager
 			"logger-sharelatex": @logger = { log: sinon.stub() }
+			"crypto": @crypto = {}
+			"../Email/EmailHandler": @EmailHandler
+			"../Security/OneTimeTokenHandler": @OneTimeTokenHandler
+			"settings-sharelatex": @settings = {siteUrl: "http://sl.example.com"}
 
 		@passingRequest = {email:"something@email.com", password:"123"}
 
@@ -128,4 +136,50 @@ describe "UserRegistrationHandler", ->
 		it "should call the ReferalAllocator", (done)->
 			done()
 
-
+	describe "registerNewUserAndSendActivationEmail", ->
+		beforeEach ->
+			@email = "email@example.com"
+			@crypto.randomBytes = sinon.stub().returns({toString: () => @password = "mock-password"})
+			@OneTimeTokenHandler.getNewToken.callsArgWith(2, null, @token = "mock-token")
+			@handler.registerNewUser = sinon.stub()
+			@callback = sinon.stub()
+		
+		describe "with a new user", ->
+			beforeEach ->
+				@handler.registerNewUser.callsArgWith(1, null, @user)
+				@handler.registerNewUserAndSendActivationEmail @email, @callback
+			
+			it "should ask the UserRegistrationHandler to register user", ->
+				@handler.registerNewUser
+					.calledWith({
+						email: @email
+						password: @password
+					}).should.equal true
+					
+			it "should generate a new password reset token", ->
+				
+				@OneTimeTokenHandler.getNewToken
+					.calledWith(@user_id, expiresIn: 7 * 24 * 60 * 60)
+					.should.equal true
+
+			it "should send a registered email", ->
+				@EmailHandler.sendEmail
+					.calledWith("registered", {
+						to: @user.email
+						setNewPasswordUrl: "#{@settings.siteUrl}/user/activate?token=#{@token}&user_id=#{@user_id}"
+					})
+					.should.equal true
+			
+			it "should return the user", ->
+				@callback
+					.calledWith(null, @user, "#{@settings.siteUrl}/user/activate?token=#{@token}&user_id=#{@user_id}")
+					.should.equal true
+
+		describe "with a user that already exists", ->
+			beforeEach ->
+				@handler.registerNewUser.callsArgWith(1, new Error("EmailAlreadyRegistered"), @user)
+				@handler.registerNewUserAndSendActivationEmail @email, @callback
+				
+			it "should still generate a new password token and email", ->
+				@OneTimeTokenHandler.getNewToken.called.should.equal true
+				@EmailHandler.sendEmail.called.should.equal true