diff --git a/packages/rocketchat-api/server/v1/users.js b/packages/rocketchat-api/server/v1/users.js index ab92925e3c39..aecbd4598164 100644 --- a/packages/rocketchat-api/server/v1/users.js +++ b/packages/rocketchat-api/server/v1/users.js @@ -260,6 +260,33 @@ RocketChat.API.v1.addRoute('users.update', { authRequired: true }, { } }); +RocketChat.API.v1.addRoute('users.updateOwnBasicInfo', { authRequired: true }, { + post() { + check(this.bodyParams, { + data: Match.ObjectIncluding({ + email: Match.Maybe(String), + name: Match.Maybe(String), + username: Match.Maybe(String), + currentPassword: Match.Maybe(String), + newPassword: Match.Maybe(String) + }), + customFields: Match.Maybe(Object) + }); + + const userData = { + email: this.bodyParams.data.email, + realname: this.bodyParams.data.name, + username: this.bodyParams.data.username, + newPassword: this.bodyParams.data.newPassword, + typedPassword: this.bodyParams.data.currentPassword + }; + + Meteor.runAsUser(this.userId, () => Meteor.call('saveUserProfile', userData, this.bodyParams.customFields)); + + return RocketChat.API.v1.success({ user: RocketChat.models.Users.findOneById(this.userId, { fields: RocketChat.API.v1.defaultFieldsToExclude }) }); + } +}); + RocketChat.API.v1.addRoute('users.createToken', { authRequired: true }, { post() { const user = this.getUserFromParams(); @@ -267,7 +294,7 @@ RocketChat.API.v1.addRoute('users.createToken', { authRequired: true }, { Meteor.runAsUser(this.userId, () => { data = Meteor.call('createToken', user._id); }); - return data ? RocketChat.API.v1.success({data}) : RocketChat.API.v1.unauthorized(); + return data ? RocketChat.API.v1.success({ data }) : RocketChat.API.v1.unauthorized(); } }); diff --git a/packages/rocketchat-lib/server/functions/saveUser.js b/packages/rocketchat-lib/server/functions/saveUser.js index 9f45ed155c33..c4d1b4089b77 100644 --- a/packages/rocketchat-lib/server/functions/saveUser.js +++ b/packages/rocketchat-lib/server/functions/saveUser.js @@ -7,27 +7,45 @@ RocketChat.saveUser = function(userId, userData) { const existingRoles = _.pluck(RocketChat.authz.getRoles(), '_id'); if (userData._id && userId !== userData._id && !RocketChat.authz.hasPermission(userId, 'edit-other-user-info')) { - throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed', { method: 'insertOrUpdateUser', action: 'Editing_user' }); + throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed', { + method: 'insertOrUpdateUser', + action: 'Editing_user' + }); } if (!userData._id && !RocketChat.authz.hasPermission(userId, 'create-user')) { - throw new Meteor.Error('error-action-not-allowed', 'Adding user is not allowed', { method: 'insertOrUpdateUser', action: 'Adding_user' }); + throw new Meteor.Error('error-action-not-allowed', 'Adding user is not allowed', { + method: 'insertOrUpdateUser', + action: 'Adding_user' + }); } if (userData.roles && _.difference(userData.roles, existingRoles).length > 0) { - throw new Meteor.Error('error-action-not-allowed', 'The field Roles consist invalid role name', { method: 'insertOrUpdateUser', action: 'Assign_role' }); + throw new Meteor.Error('error-action-not-allowed', 'The field Roles consist invalid role name', { + method: 'insertOrUpdateUser', + action: 'Assign_role' + }); } if (userData.roles && _.indexOf(userData.roles, 'admin') >= 0 && !RocketChat.authz.hasPermission(userId, 'assign-admin-role')) { - throw new Meteor.Error('error-action-not-allowed', 'Assigning admin is not allowed', { method: 'insertOrUpdateUser', action: 'Assign_admin' }); + throw new Meteor.Error('error-action-not-allowed', 'Assigning admin is not allowed', { + method: 'insertOrUpdateUser', + action: 'Assign_admin' + }); } if (!userData._id && !s.trim(userData.name)) { - throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { method: 'insertOrUpdateUser', field: 'Name' }); + throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { + method: 'insertOrUpdateUser', + field: 'Name' + }); } if (!userData._id && !s.trim(userData.username)) { - throw new Meteor.Error('error-the-field-is-required', 'The field Username is required', { method: 'insertOrUpdateUser', field: 'Username' }); + throw new Meteor.Error('error-the-field-is-required', 'The field Username is required', { + method: 'insertOrUpdateUser', + field: 'Username' + }); } let nameValidation; @@ -39,20 +57,33 @@ RocketChat.saveUser = function(userId, userData) { } if (userData.username && !nameValidation.test(userData.username)) { - throw new Meteor.Error('error-input-is-not-a-valid-field', `${ _.escape(userData.username) } is not a valid username`, { method: 'insertOrUpdateUser', input: userData.username, field: 'Username' }); + throw new Meteor.Error('error-input-is-not-a-valid-field', `${ _.escape(userData.username) } is not a valid username`, { + method: 'insertOrUpdateUser', + input: userData.username, + field: 'Username' + }); } if (!userData._id && !userData.password) { - throw new Meteor.Error('error-the-field-is-required', 'The field Password is required', { method: 'insertOrUpdateUser', field: 'Password' }); + throw new Meteor.Error('error-the-field-is-required', 'The field Password is required', { + method: 'insertOrUpdateUser', + field: 'Password' + }); } if (!userData._id) { if (!RocketChat.checkUsernameAvailability(userData.username)) { - throw new Meteor.Error('error-field-unavailable', `${ _.escape(userData.username) } is already in use :(`, { method: 'insertOrUpdateUser', field: userData.username }); + throw new Meteor.Error('error-field-unavailable', `${ _.escape(userData.username) } is already in use :(`, { + method: 'insertOrUpdateUser', + field: userData.username + }); } if (userData.email && !RocketChat.checkEmailAvailability(userData.email)) { - throw new Meteor.Error('error-field-unavailable', `${ _.escape(userData.email) } is already in use :(`, { method: 'insertOrUpdateUser', field: userData.email }); + throw new Meteor.Error('error-field-unavailable', `${ _.escape(userData.email) } is already in use :(`, { + method: 'insertOrUpdateUser', + field: userData.email + }); } RocketChat.validateEmailDomain(userData.email); @@ -73,7 +104,7 @@ RocketChat.saveUser = function(userId, userData) { $set: { name: userData.name, roles: userData.roles || ['user'], - settings: userData.settings + settings: userData.settings || {} } }; @@ -81,8 +112,8 @@ RocketChat.saveUser = function(userId, userData) { updateUser.$set.requirePasswordChange = userData.requirePasswordChange; } - if (userData.verified) { - updateUser.$set['emails.0.verified'] = true; + if (typeof userData.verified === 'boolean') { + updateUser.$set['emails.0.verified'] = userData.verified; } Meteor.users.update({ _id }, updateUser); @@ -120,7 +151,10 @@ RocketChat.saveUser = function(userId, userData) { try { Email.send(email); } catch (error) { - throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${ error.message }`, { function: 'RocketChat.saveUser', message: error.message }); + throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${ error.message }`, { + function: 'RocketChat.saveUser', + message: error.message + }); } }); } @@ -128,7 +162,7 @@ RocketChat.saveUser = function(userId, userData) { userData._id = _id; if (RocketChat.settings.get('Accounts_SetDefaultAvatar') === true && userData.email) { - const gravatarUrl = Gravatar.imageUrl(userData.email, {default: '404', size: 200, secure: true}); + const gravatarUrl = Gravatar.imageUrl(userData.email, { default: '404', size: 200, secure: true }); try { RocketChat.setUserAvatar(userData, gravatarUrl, '', 'url'); @@ -149,7 +183,8 @@ RocketChat.saveUser = function(userId, userData) { } if (userData.email) { - RocketChat.setEmail(userData._id, userData.email); + const shouldSendVerificationEmailToUser = userData.verified !== true; + RocketChat.setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser); } if (userData.password && userData.password.trim() && RocketChat.authz.hasPermission(userId, 'edit-other-user-password')) { @@ -172,7 +207,9 @@ RocketChat.saveUser = function(userId, userData) { updateUser.$set.requirePasswordChange = userData.requirePasswordChange; } - updateUser.$set['emails.0.verified'] = !!userData.verified; + if (typeof userData.verified === 'boolean') { + updateUser.$set['emails.0.verified'] = userData.verified; + } Meteor.users.update({ _id: userData._id }, updateUser); diff --git a/packages/rocketchat-lib/server/functions/setEmail.js b/packages/rocketchat-lib/server/functions/setEmail.js index b63f39cb8323..9d1819cfd1f5 100644 --- a/packages/rocketchat-lib/server/functions/setEmail.js +++ b/packages/rocketchat-lib/server/functions/setEmail.js @@ -1,6 +1,6 @@ import s from 'underscore.string'; -RocketChat._setEmail = function(userId, email) { +RocketChat._setEmail = function(userId, email, shouldSendVerificationEmail = true) { email = s.trim(email); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: '_setEmail' }); @@ -27,6 +27,9 @@ RocketChat._setEmail = function(userId, email) { // Set new email RocketChat.models.Users.setEmail(user._id, email); user.email = email; + if (shouldSendVerificationEmail === true) { + Meteor.call('sendConfirmationEmail', user.email); + } return user; }; diff --git a/tests/end-to-end/api/01-users.js b/tests/end-to-end/api/01-users.js index cf8708eb8564..690b542d86a5 100644 --- a/tests/end-to-end/api/01-users.js +++ b/tests/end-to-end/api/01-users.js @@ -2,10 +2,21 @@ /* globals expect */ /* eslint no-unused-vars: 0 */ -import {getCredentials, api, login, request, credentials, apiEmail, apiUsername, targetUser, log} from '../../data/api-data.js'; -import {adminEmail, password, preferences} from '../../data/user.js'; -import {imgURL} from '../../data/interactions.js'; -import {customFieldText, clearCustomFields, setCustomFields} from '../../data/custom-fields.js'; +import crypto from 'crypto'; +import { + getCredentials, + api, + login, + request, + credentials, + apiEmail, + apiUsername, + targetUser, + log +} from '../../data/api-data.js'; +import { adminEmail, preferences, password } from '../../data/user.js'; +import { imgURL } from '../../data/interactions.js'; +import { customFieldText, clearCustomFields, setCustomFields } from '../../data/custom-fields.js'; describe('[Users]', function() { this.retries(0); @@ -46,7 +57,7 @@ describe('[Users]', function() { }); it('should create a new user with custom fields', (done) => { - setCustomFields({customFieldText}, (error) => { + setCustomFields({ customFieldText }, (error) => { if (error) { return done(error); } @@ -117,10 +128,12 @@ describe('[Users]', function() { } [ - {name: 'customFieldText', value: '', reason: 'is required and missing'}, - {name: 'customFieldText', value: '0', reason: 'length is less than minLength'}, - {name: 'customFieldText', value: '0123456789-0', reason: 'length is more than maxLength'} - ].forEach((field) => { failUserWithCustomField(field); }); + { name: 'customFieldText', value: '', reason: 'is required and missing' }, + { name: 'customFieldText', value: '0', reason: 'length is less than minLength' }, + { name: 'customFieldText', value: '0123456789-0', reason: 'length is more than maxLength' } + ].forEach((field) => { + failUserWithCustomField(field); + }); }); describe('[/users.info]', () => { @@ -210,12 +223,13 @@ describe('[Users]', function() { }); describe('[/users.update]', () => { + it('should update a user\'s info by userId', (done) => { request.post(api('users.update')) .set(credentials) .send({ userId: targetUser._id, - data :{ + data: { email: apiEmail, name: `edited${ apiUsername }`, username: `edited${ apiUsername }`, @@ -235,6 +249,172 @@ describe('[Users]', function() { }) .end(done); }); + + it('should update a user\'s email by userId', (done) => { + request.post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + email: `edited${ apiEmail }` + } + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.emails[0].address', `edited${ apiEmail }`); + expect(res.body).to.have.nested.property('user.emails[0].verified', false); + }) + .end(done); + }); + + it('should verify user\'s email by userId', (done) => { + request.post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + verified: true + } + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.emails[0].verified', true); + }) + .end(done); + }); + }); + + describe('[/users.updateOwnBasicInfo]', () => { + let user; + before((done) => { + const username = `user.test.${ Date.now() }`; + const email = `${ username }@rocket.chat`; + request.post(api('users.create')) + .set(credentials) + .send({ email, name: username, username, password}) + .end((err, res) => { + user = res.body.user; + done(); + }); + }); + + let userCredentials; + before((done) => { + request.post(api('login')) + .send({ + user: user.username, + password + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + userCredentials = {}; + userCredentials['X-Auth-Token'] = res.body.data.authToken; + userCredentials['X-User-Id'] = res.body.data.userId; + }) + .end(done); + }); + after(done => { + request.post(api('users.delete')).set(credentials).send({ + userId: user._id + }).end(done); + user = undefined; + }); + + const newPassword = `${ password }test`; + const editedUsername = `basicInfo.name${ +new Date() }`; + const editedName = `basic-info-test-name${ +new Date() }`; + const editedEmail = `test${ +new Date() }@mail.com`; + + it('should update the user own basic information', (done) => { + request.post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + name: editedName, + username: editedUsername, + currentPassword: crypto.createHash('sha256').update(password, 'utf8').digest('hex'), + newPassword + } + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + const user = res.body.user; + expect(res.body).to.have.property('success', true); + expect(user.username).to.be.equal(editedUsername); + expect(user.name).to.be.equal(editedName); + }) + .end(done); + }); + + it('should update the user name only', (done) => { + request.post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + username: editedUsername + } + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + const user = res.body.user; + expect(res.body).to.have.property('success', true); + expect(user.username).to.be.equal(editedUsername); + }) + .end(done); + }); + + it('should throw an error when user try change email without the password', (done) => { + request.post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + email: editedEmail + } + }) + .expect('Content-Type', 'application/json') + .expect(400) + .end(done); + }); + + it('should throw an error when user try change password without the actual password', (done) => { + request.post(api('users.updateOwnBasicInfo')) + .set(credentials) + .send({ + data: { + newPassword: 'the new pass' + } + }) + .expect('Content-Type', 'application/json') + .expect(400) + .end(done); + }); + + it('should set new email as \'unverified\'', (done) => { + request.post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + email: editedEmail, + currentPassword: crypto.createHash('sha256').update(newPassword, 'utf8').digest('hex') + } + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + const user = res.body.user; + expect(res.body).to.have.property('success', true); + expect(user.emails[0].address).to.be.equal(editedEmail); + expect(user.emails[0].verified).to.be.false; + }) + .end(done); + }); }); describe('[/users.createToken]', () => { @@ -349,7 +529,7 @@ describe('[Users]', function() { .send({ username: user.username }) .expect('Content-Type', 'application/json') .end((err, res) => { - return err ? done () : request.get(api('me')) + return err ? done() : request.get(api('me')) .set({ 'X-Auth-Token': `${ res.body.data.authToken }`, 'X-User-Id': res.body.data.userId }) .expect(200) .expect((res) => {