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

Merge pull request #602 from sharelatex/ta-add-multi-emails

Add Multiple Emails
Timothée Alby 8 лет назад
Родитель
Сommit
e64a704341

+ 18 - 0
services/web/app/coffee/Features/User/UserGetter.coffee

@@ -21,6 +21,10 @@ module.exports = UserGetter =
 
 
 		db.users.findOne query, projection, callback
 		db.users.findOne query, projection, callback
 
 
+	getUserEmail: (userId, callback = (error, email) ->) ->
+		@getUser userId, { email: 1 }, (error, user) ->
+			callback(error, user?.email)
+
 	getUserByMainEmail: (email, projection, callback = (error, user) ->) ->
 	getUserByMainEmail: (email, projection, callback = (error, user) ->) ->
 		email = email.trim()
 		email = email.trim()
 		if arguments.length == 2
 		if arguments.length == 2
@@ -28,6 +32,18 @@ module.exports = UserGetter =
 			projection = {}
 			projection = {}
 		db.users.findOne email: email, projection, callback
 		db.users.findOne email: email, projection, callback
 
 
+	getUserByAnyEmail: (email, projection, callback = (error, user) ->) ->
+		email = email.trim()
+		if arguments.length == 2
+			callback = projection
+			projection = {}
+		db.users.findOne 'emails.email': email, projection, (error, user) =>
+			return callback(error, user) if error? or user?
+
+			# While multiple emails are being rolled out, check for the main email as
+			# well
+			@getUserByMainEmail email, projection, callback
+
 	getUsers: (user_ids, projection, callback = (error, users) ->) ->
 	getUsers: (user_ids, projection, callback = (error, users) ->) ->
 		try
 		try
 			user_ids = user_ids.map (u) -> ObjectId(u.toString())
 			user_ids = user_ids.map (u) -> ObjectId(u.toString())
@@ -48,7 +64,9 @@ module.exports = UserGetter =
 
 
 [
 [
 	'getUser',
 	'getUser',
+	'getUserEmail',
 	'getUserByMainEmail',
 	'getUserByMainEmail',
+	'getUserByAnyEmail',
 	'getUsers',
 	'getUsers',
 	'getUserOrUserStubById'
 	'getUserOrUserStubById'
 ].map (method) ->
 ].map (method) ->

+ 80 - 13
services/web/app/coffee/Features/User/UserUpdater.coffee

@@ -2,6 +2,7 @@ logger = require("logger-sharelatex")
 mongojs = require("../../infrastructure/mongojs")
 mongojs = require("../../infrastructure/mongojs")
 metrics = require("metrics-sharelatex")
 metrics = require("metrics-sharelatex")
 db = mongojs.db
 db = mongojs.db
+async = require("async")
 ObjectId = mongojs.ObjectId
 ObjectId = mongojs.ObjectId
 UserGetter = require("./UserGetter")
 UserGetter = require("./UserGetter")
 
 
@@ -11,23 +12,89 @@ module.exports = UserUpdater =
 			query = _id: ObjectId(query)
 			query = _id: ObjectId(query)
 		else if query instanceof ObjectId
 		else if query instanceof ObjectId
 			query = _id: query
 			query = _id: query
+		else if typeof query._id == "string"
+			query._id = ObjectId(query._id)
 
 
 		db.users.update query, update, callback
 		db.users.update query, update, callback
 
 
 
 
-	changeEmailAddress: (user_id, newEmail, callback)->
-		self = @
-		logger.log user_id:user_id, newEmail:newEmail, "updaing email address of user"
-		UserGetter.getUserByMainEmail newEmail, (error, user) ->
-			if user?
-				return callback({message:"alread_exists"})
-			self.updateUser user_id.toString(), {
-				$set: { "email": newEmail},
-			}, (err) ->
-				if err?
-					logger.err err:err, "problem updating users email"
-					return callback(err)
+	#
+	# DEPRECATED
+	#
+	# Change the user's main email address by adding a new email, switching the
+	# default email and removing the old email.  Prefer manipulating multiple
+	# emails and the default rather than calling this method directly
+	#
+	changeEmailAddress: (userId, newEmail, callback)->
+		logger.log userId: userId, newEmail: newEmail, "updaing email address of user"
+
+		oldEmail = null
+		async.series [
+			(cb) ->
+				UserGetter.getUserEmail userId, (error, email) ->
+					oldEmail = email
+					cb(error)
+			(cb) -> UserUpdater.addEmailAddress userId, newEmail, cb
+			(cb) -> UserUpdater.setDefaultEmailAddress userId, newEmail, cb
+			(cb) -> UserUpdater.removeEmailAddress userId, oldEmail, cb
+		], callback
+
+
+	# Add a new email address for the user. Email cannot be already used by this
+	# or any other user
+	addEmailAddress: (userId, newEmail, callback) ->
+		@_ensureUniqueEmailAddress newEmail, (error) =>
+			return callback(error) if error?
+
+			update = $push: emails: email: newEmail, createdAt: new Date()
+			@updateUser userId, update, (error) ->
+				if error?
+					logger.err error: error, 'problem updating users emails'
+					return callback(error)
 				callback()
 				callback()
 
 
 
 
-metrics.timeAsyncMethod UserUpdater, 'updateUser', 'mongo.UserUpdater', logger
+	# remove one of the user's email addresses. The email cannot be the user's
+	# default email address
+	removeEmailAddress: (userId, email, callback) ->
+		query = _id: userId, email: $ne: email
+		update = $pull: emails: email: email
+		@updateUser query, update, (error, res) ->
+			if error?
+				logger.err error:error, 'problem removing users email'
+				return callback(error)
+			if res.nMatched == 0
+				return callback(new Error('Cannot remove default email'))
+			callback()
+
+
+	# set the default email address by setting the `email` attribute. The email
+	# must be one of the user's multiple emails (`emails` attribute)
+	setDefaultEmailAddress: (userId, email, callback) ->
+		query = _id: userId, 'emails.email': email
+		update = $set: email: email
+		@updateUser query, update, (error, res) ->
+			if error?
+				logger.err error:error, 'problem setting default emails'
+				return callback(error)
+			if res.nMatched == 0
+				return callback(new Error('Default email does not belong to user'))
+			callback()
+
+
+	# check for duplicate email address. This is also enforced at the DB level
+	_ensureUniqueEmailAddress: (newEmail, callback) ->
+		UserGetter.getUserByAnyEmail newEmail, (error, user) ->
+			return callback(message: 'alread_exists') if user?
+			callback()
+
+
+[
+	'updateUser'
+	'changeEmailAddress'
+	'setDefaultEmailAddress'
+	'addEmailAddress'
+	'removeEmailAddress'
+	'_ensureUniqueEmailAddress'
+].map (method) ->
+	metrics.timeAsyncMethod(UserUpdater, method, 'mongo.UserUpdater', logger)

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

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

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

@@ -146,6 +146,15 @@ describe "LoginViaRegistration", ->
 
 
 	describe "[Security] Trying to register/login as another user", ->
 	describe "[Security] Trying to register/login as another user", ->
 
 
+		it 'should not allow sign in with secondary email', (done) ->
+			secondaryEmail = "acceptance-test-secondary@example.com"
+			@user1.addEmail secondaryEmail, (err) =>
+				@user1.loginWith secondaryEmail, (err) =>
+					expect(err?).to.equal false
+					@user1.isLoggedIn (err, isLoggedIn) ->
+						expect(isLoggedIn).to.equal false
+						done()
+
 		it 'should have user1 login', (done) ->
 		it 'should have user1 login', (done) ->
 			@user1.login (err) ->
 			@user1.login (err) ->
 				expect(err?).to.equal false
 				expect(err?).to.equal false

+ 28 - 0
services/web/test/acceptance/coffee/SettingsTests.coffee

@@ -0,0 +1,28 @@
+should = require('chai').should()
+async = require("async")
+User = require "./helpers/User"
+
+describe 'SettingsPage', ->
+
+	before (done) ->
+		@user = new User()
+		async.series [
+			@user.ensureUserExists.bind(@user)
+			@user.login.bind(@user)
+			@user.activateSudoMode.bind(@user)
+		], done
+
+	it 'load settigns page', (done) ->
+		@user.getUserSettingsPage (err, statusCode) ->
+			statusCode.should.equal 200
+			done()
+
+	it 'update main email address', (done) ->
+		newEmail = 'foo@bar.com'
+		@user.updateSettings email: newEmail, (error) =>
+			should.not.exist error
+			@user.get (error, user) ->
+				user.email.should.equal newEmail
+				user.emails.length.should.equal 1
+				user.emails[0].email.should.equal newEmail
+				done()

+ 42 - 7
services/web/test/acceptance/coffee/helpers/User.coffee

@@ -3,13 +3,18 @@ _ = require("underscore")
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
 {db, ObjectId} = require("../../../../app/js/infrastructure/mongojs")
 {db, ObjectId} = require("../../../../app/js/infrastructure/mongojs")
 UserModel = require("../../../../app/js/models/User").User
 UserModel = require("../../../../app/js/models/User").User
+UserUpdater = require("../../../../app/js/Features/User/UserUpdater")
 AuthenticationManager = require("../../../../app/js/Features/Authentication/AuthenticationManager")
 AuthenticationManager = require("../../../../app/js/Features/Authentication/AuthenticationManager")
 
 
 count = 0
 count = 0
 
 
 class User
 class User
 	constructor: (options = {}) ->
 	constructor: (options = {}) ->
-		@email = "acceptance-test-#{count}@example.com"
+		@emails = [
+			email: "acceptance-test-#{count}@example.com"
+			createdAt: new Date()
+		]
+		@email = @emails[0].email
 		@password = "acceptance-test-#{count}-password"
 		@password = "acceptance-test-#{count}-password"
 		count++
 		count++
 		@jar = request.jar()
 		@jar = request.jar()
@@ -17,14 +22,20 @@ class User
 			jar: @jar
 			jar: @jar
 		})
 		})
 
 
+	get: (callback = (error, user)->) ->
+		db.users.findOne { _id: ObjectId(@_id) }, callback
+
 	login: (callback = (error) ->) ->
 	login: (callback = (error) ->) ->
+		@loginWith(@email, callback)
+
+	loginWith: (email, callback = (error) ->) ->
 		@ensureUserExists (error) =>
 		@ensureUserExists (error) =>
 			return callback(error) if error?
 			return callback(error) if error?
 			@getCsrfToken (error) =>
 			@getCsrfToken (error) =>
 				return callback(error) if error?
 				return callback(error) if error?
 				@request.post {
 				@request.post {
 					url: "/login"
 					url: "/login"
-					json: { @email, @password }
+					json: { email, @password }
 				}, callback
 				}, callback
 
 
 	ensureUserExists: (callback = (error) ->) ->
 	ensureUserExists: (callback = (error) ->) ->
@@ -34,11 +45,14 @@ class User
 			return callback(error) if error?
 			return callback(error) if error?
 			AuthenticationManager.setUserPassword user._id, @password, (error) =>
 			AuthenticationManager.setUserPassword user._id, @password, (error) =>
 				return callback(error) if error?
 				return callback(error) if error?
-				@id = user?._id?.toString()
-				@_id = user?._id?.toString()
-				@first_name = user?.first_name
-				@referal_id = user?.referal_id
-				callback(null, @password)
+				UserUpdater.updateUser user._id, $set: emails: @emails, (error) =>
+					return callback(error) if error?
+					@id = user?._id?.toString()
+					@_id = user?._id?.toString()
+					@first_name = user?.first_name
+					@referal_id = user?.referal_id
+
+					callback(null, @password)
 
 
 	setFeatures: (features, callback = (error) ->) ->
 	setFeatures: (features, callback = (error) ->) ->
 		update = {}
 		update = {}
@@ -62,6 +76,10 @@ class User
 					@_id = user?._id?.toString()
 					@_id = user?._id?.toString()
 					callback()
 					callback()
 
 
+	addEmail: (email, callback = (error) ->) ->
+		@emails.push(email: email, createdAt: new Date())
+		UserUpdater.addEmailAddress @id, email, callback
+
 	ensure_admin: (callback = (error) ->) ->
 	ensure_admin: (callback = (error) ->) ->
 		db.users.update {_id: ObjectId(@id)}, { $set: { isAdmin: true }}, callback
 		db.users.update {_id: ObjectId(@id)}, { $set: { isAdmin: true }}, callback
 
 
@@ -226,6 +244,23 @@ class User
 				return callback(error) if error?
 				return callback(error) if error?
 				callback(null, response.statusCode)
 				callback(null, response.statusCode)
 
 
+	activateSudoMode: (callback = (error)->) ->
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.post {
+				uri: '/confirm-password',
+				json:
+					password: @password
+			}, callback
+
+	updateSettings: (newSettings, callback = (error, response, body) ->) ->
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.post {
+				url: '/user/settings'
+				json: newSettings
+			}, callback
+
 	getProjectListPage: (callback=(error, statusCode)->) ->
 	getProjectListPage: (callback=(error, statusCode)->) ->
 		@getCsrfToken (error) =>
 		@getCsrfToken (error) =>
 			return callback(error) if error?
 			return callback(error) if error?

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

@@ -56,3 +56,21 @@ describe "UserGetter", ->
 				@findOne.called.should.equal true
 				@findOne.called.should.equal true
 				@findOne.calledWith(email: email).should.equal true
 				@findOne.calledWith(email: email).should.equal true
 				done()
 				done()
+
+	describe "getUserByAnyEmail", ->
+		it "query user for any email", (done)->
+			email = 'hello@world.com'
+			projection = emails: 1
+			@UserGetter.getUserByAnyEmail " #{email} ", projection, (error, user) =>
+				@findOne.calledWith('emails.email': email, projection).should.equal true
+				user.should.deep.equal @fakeUser
+				done()
+
+		it "checks main email as well", (done)->
+			@findOne.callsArgWith(2, null, null)
+			email = 'hello@world.com'
+			projection = emails: 1
+			@UserGetter.getUserByAnyEmail " #{email} ", projection, (error, user) =>
+				@findOne.calledTwice.should.equal true
+				@findOne.calledWith(email: email, projection).should.equal true
+				done()

+ 113 - 13
services/web/test/unit/coffee/User/UserUpdaterTests.coffee

@@ -15,35 +15,135 @@ describe "UserUpdater", ->
 			db:{}
 			db:{}
 			ObjectId:(id)-> return id
 			ObjectId:(id)-> return id
 		@UserGetter =
 		@UserGetter =
-			getUserByMainEmail: sinon.stub()
+			getUserEmail: sinon.stub()
+			getUserByAnyEmail: sinon.stub()
+		@logger = err: sinon.stub(), log: ->
 		@UserUpdater = SandboxedModule.require modulePath, requires:
 		@UserUpdater = SandboxedModule.require modulePath, requires:
 			"settings-sharelatex":@settings
 			"settings-sharelatex":@settings
-			"logger-sharelatex": log:->
+			"logger-sharelatex": @logger
 			"./UserGetter": @UserGetter
 			"./UserGetter": @UserGetter
 			"../../infrastructure/mongojs":@mongojs
 			"../../infrastructure/mongojs":@mongojs
 			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
 			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
 
 
 		@stubbedUser = 
 		@stubbedUser = 
+			_id: "3131231"
 			name:"bob"
 			name:"bob"
 			email:"hello@world.com"
 			email:"hello@world.com"
-		@user_id = "3131231"
 		@newEmail = "bob@bob.com"
 		@newEmail = "bob@bob.com"
 
 
-	describe "changeEmailAddress", ->
+	describe 'changeEmailAddress', ->
 		beforeEach ->
 		beforeEach ->
-			@UserUpdater.updateUser = sinon.stub().callsArgWith(2)
+			@UserGetter.getUserEmail.callsArgWith(1, null, @stubbedUser.email)
+			@UserUpdater.addEmailAddress = sinon.stub().callsArgWith(2)
+			@UserUpdater.setDefaultEmailAddress = sinon.stub().callsArgWith(2)
+			@UserUpdater.removeEmailAddress = sinon.stub().callsArgWith(2)
 
 
-		it "should check if the new email already has an account", (done)->
-			@UserGetter.getUserByMainEmail.callsArgWith(1, null, @stubbedUser)
-			@UserUpdater.changeEmailAddress @user_id, @stubbedUser.email, (err)=>
-				@UserUpdater.updateUser.called.should.equal false
+		it 'change email', (done)->
+			@UserUpdater.changeEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				should.not.exist(err)
+				@UserUpdater.addEmailAddress.calledWith(
+					@stubbedUser._id, @newEmail
+				).should.equal true
+				@UserUpdater.setDefaultEmailAddress.calledWith(
+					@stubbedUser._id, @newEmail
+				).should.equal true
+				@UserUpdater.removeEmailAddress.calledWith(
+					@stubbedUser._id, @stubbedUser.email
+				).should.equal true
+				done()
+
+		it 'handle error', (done)->
+			@UserUpdater.removeEmailAddress.callsArgWith(2, new Error('nope'))
+			@UserUpdater.changeEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				should.exist(err)
+				done()
+
+	describe 'addEmailAddress', ->
+		beforeEach ->
+			@UserUpdater._ensureUniqueEmailAddress = sinon.stub().callsArgWith(1)
+
+		it 'add email', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null)
+
+			@UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				@UserUpdater._ensureUniqueEmailAddress.called.should.equal true
+				should.not.exist(err)
+				@UserUpdater.updateUser.calledWith(
+					@stubbedUser._id,
+					$push: { emails: { email: @newEmail, createdAt: sinon.match.date } }
+				).should.equal true
+				done()
+
+		it 'handle error', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope'))
+
+			@UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				@logger.err.called.should.equal true
 				should.exist(err)
 				should.exist(err)
 				done()
 				done()
 
 
+	describe 'removeEmailAddress', ->
+		it 'remove email', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 1)
 
 
-		it "should set the users password", (done)->
-			@UserGetter.getUserByMainEmail.callsArgWith(1, null)
-			@UserUpdater.changeEmailAddress @user_id, @newEmail, (err)=>
-				@UserUpdater.updateUser.calledWith(@user_id, $set: { "email": @newEmail}).should.equal true
+			@UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				should.not.exist(err)
+				@UserUpdater.updateUser.calledWith(
+					{ _id: @stubbedUser._id, email: { $ne: @newEmail } },
+					$pull: { emails: { email: @newEmail } }
+				).should.equal true
 				done()
 				done()
 
 
+		it 'handle error', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope'))
+
+			@UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				should.exist(err)
+				done()
+
+		it 'handle missed update', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 0)
+
+			@UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				should.exist(err)
+				done()
+
+	describe 'setDefaultEmailAddress', ->
+		it 'set default', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 1)
+
+			@UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				should.not.exist(err)
+				@UserUpdater.updateUser.calledWith(
+					{ _id: @stubbedUser._id, 'emails.email': @newEmail },
+					$set: { email: @newEmail }
+				).should.equal true
+				done()
+
+		it 'handle error', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope'))
+
+			@UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				should.exist(err)
+				done()
+
+		it 'handle missed update', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 0)
+
+			@UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				should.exist(err)
+				done()
+
+
+	describe '_ensureUniqueEmailAddress', ->
+		it 'should return error if existing user is found', (done)->
+			@UserGetter.getUserByAnyEmail.callsArgWith(1, null, @stubbedUser)
+			@UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=>
+				should.exist(err)
+				done()
+
+		it 'should return null if no user is found', (done)->
+			@UserGetter.getUserByAnyEmail.callsArgWith(1)
+			@UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=>
+				should.not.exist(err)
+				done()