chore: convert saveUser to ts (#33774)
parent
87ee55d237
commit
d4b3aefbba
@ -1,475 +0,0 @@ |
||||
import { Apps, AppEvents } from '@rocket.chat/apps'; |
||||
import { isUserFederated } from '@rocket.chat/core-typings'; |
||||
import { Users } from '@rocket.chat/models'; |
||||
import Gravatar from 'gravatar'; |
||||
import { Accounts } from 'meteor/accounts-base'; |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import _ from 'underscore'; |
||||
|
||||
import { callbacks } from '../../../../lib/callbacks'; |
||||
import { trim } from '../../../../lib/utils/stringUtils'; |
||||
import { getNewUserRoles } from '../../../../server/services/user/lib/getNewUserRoles'; |
||||
import { getRoles } from '../../../authorization/server'; |
||||
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; |
||||
import * as Mailer from '../../../mailer/server/api'; |
||||
import { settings } from '../../../settings/server'; |
||||
import { safeGetMeteorUser } from '../../../utils/server/functions/safeGetMeteorUser'; |
||||
import { validateEmailDomain } from '../lib'; |
||||
import { generatePassword } from '../lib/generatePassword'; |
||||
import { notifyOnUserChangeById, notifyOnUserChange } from '../lib/notifyListener'; |
||||
import { passwordPolicy } from '../lib/passwordPolicy'; |
||||
import { checkEmailAvailability } from './checkEmailAvailability'; |
||||
import { checkUsernameAvailability } from './checkUsernameAvailability'; |
||||
import { saveUserIdentity } from './saveUserIdentity'; |
||||
import { setEmail } from './setEmail'; |
||||
import { setStatusText } from './setStatusText'; |
||||
import { setUserAvatar } from './setUserAvatar'; |
||||
|
||||
const MAX_BIO_LENGTH = 260; |
||||
const MAX_NICKNAME_LENGTH = 120; |
||||
|
||||
let html = ''; |
||||
let passwordChangedHtml = ''; |
||||
Meteor.startup(() => { |
||||
Mailer.getTemplate('Accounts_UserAddedEmail_Email', (template) => { |
||||
html = template; |
||||
}); |
||||
|
||||
Mailer.getTemplate('Password_Changed_Email', (template) => { |
||||
passwordChangedHtml = template; |
||||
}); |
||||
}); |
||||
|
||||
async function _sendUserEmail(subject, html, userData) { |
||||
const email = { |
||||
to: userData.email, |
||||
from: settings.get('From_Email'), |
||||
subject, |
||||
html, |
||||
data: { |
||||
email: userData.email, |
||||
password: userData.password, |
||||
}, |
||||
}; |
||||
|
||||
if (typeof userData.name !== 'undefined') { |
||||
email.data.name = userData.name; |
||||
} |
||||
|
||||
try { |
||||
await Mailer.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, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
async function validateUserData(userId, userData) { |
||||
const existingRoles = _.pluck(await getRoles(), '_id'); |
||||
|
||||
if (userData.verified && userData._id && userId === userData._id) { |
||||
throw new Meteor.Error('error-action-not-allowed', 'Editing email verification is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Editing_user', |
||||
}); |
||||
} |
||||
|
||||
if (userData._id && userId !== userData._id && !(await hasPermissionAsync(userId, 'edit-other-user-info'))) { |
||||
throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Editing_user', |
||||
}); |
||||
} |
||||
|
||||
if (!userData._id && !(await hasPermissionAsync(userId, 'create-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 id', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Assign_role', |
||||
}); |
||||
} |
||||
|
||||
if (userData.roles && userData.roles.includes('admin') && !(await hasPermissionAsync(userId, 'assign-admin-role'))) { |
||||
throw new Meteor.Error('error-action-not-allowed', 'Assigning admin is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Assign_admin', |
||||
}); |
||||
} |
||||
|
||||
if (settings.get('Accounts_RequireNameForSignUp') && !userData._id && !trim(userData.name)) { |
||||
throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { |
||||
method: 'insertOrUpdateUser', |
||||
field: 'Name', |
||||
}); |
||||
} |
||||
|
||||
if (!userData._id && !trim(userData.username)) { |
||||
throw new Meteor.Error('error-the-field-is-required', 'The field Username is required', { |
||||
method: 'insertOrUpdateUser', |
||||
field: 'Username', |
||||
}); |
||||
} |
||||
|
||||
let nameValidation; |
||||
|
||||
try { |
||||
nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); |
||||
} catch (e) { |
||||
nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); |
||||
} |
||||
|
||||
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', |
||||
}); |
||||
} |
||||
|
||||
if (!userData._id && !userData.password && !userData.setRandomPassword) { |
||||
throw new Meteor.Error('error-the-field-is-required', 'The field Password is required', { |
||||
method: 'insertOrUpdateUser', |
||||
field: 'Password', |
||||
}); |
||||
} |
||||
|
||||
if (!userData._id) { |
||||
if (!(await checkUsernameAvailability(userData.username))) { |
||||
throw new Meteor.Error('error-field-unavailable', `${_.escape(userData.username)} is already in use :(`, { |
||||
method: 'insertOrUpdateUser', |
||||
field: userData.username, |
||||
}); |
||||
} |
||||
|
||||
if (userData.email && !(await checkEmailAvailability(userData.email))) { |
||||
throw new Meteor.Error('error-field-unavailable', `${_.escape(userData.email)} is already in use :(`, { |
||||
method: 'insertOrUpdateUser', |
||||
field: userData.email, |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Validate permissions to edit user fields |
||||
* |
||||
* @param {string} userId |
||||
* @param {{ _id: string, roles?: string[], username?: string, name?: string, statusText?: string, email?: string, password?: string}} userData |
||||
*/ |
||||
export async function validateUserEditing(userId, userData) { |
||||
const editingMyself = userData._id && userId === userData._id; |
||||
|
||||
const canEditOtherUserInfo = await hasPermissionAsync(userId, 'edit-other-user-info'); |
||||
const canEditOtherUserPassword = await hasPermissionAsync(userId, 'edit-other-user-password'); |
||||
const user = await Users.findOneById(userData._id); |
||||
|
||||
const isEditingUserRoles = (previousRoles, newRoles) => |
||||
typeof newRoles !== 'undefined' && !_.isEqual(_.sortBy(previousRoles), _.sortBy(newRoles)); |
||||
const isEditingField = (previousValue, newValue) => typeof newValue !== 'undefined' && newValue !== previousValue; |
||||
|
||||
if (isEditingUserRoles(user.roles, userData.roles) && !(await hasPermissionAsync(userId, 'assign-roles'))) { |
||||
throw new Meteor.Error('error-action-not-allowed', 'Assign roles is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Assign_role', |
||||
}); |
||||
} |
||||
|
||||
if (!settings.get('Accounts_AllowUserProfileChange') && !canEditOtherUserInfo && !canEditOtherUserPassword) { |
||||
throw new Meteor.Error('error-action-not-allowed', 'Edit user profile is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
|
||||
if ( |
||||
isEditingField(user.username, userData.username) && |
||||
!settings.get('Accounts_AllowUsernameChange') && |
||||
(!canEditOtherUserInfo || editingMyself) |
||||
) { |
||||
throw new Meteor.Error('error-action-not-allowed', 'Edit username is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
|
||||
if ( |
||||
isEditingField(user.statusText, userData.statusText) && |
||||
!settings.get('Accounts_AllowUserStatusMessageChange') && |
||||
(!canEditOtherUserInfo || editingMyself) |
||||
) { |
||||
throw new Meteor.Error('error-action-not-allowed', 'Edit user status is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
|
||||
if ( |
||||
isEditingField(user.name, userData.name) && |
||||
!settings.get('Accounts_AllowRealNameChange') && |
||||
(!canEditOtherUserInfo || editingMyself) |
||||
) { |
||||
throw new Meteor.Error('error-action-not-allowed', 'Edit user real name is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
|
||||
if ( |
||||
user.emails?.[0] && |
||||
isEditingField(user.emails[0].address, userData.email) && |
||||
!settings.get('Accounts_AllowEmailChange') && |
||||
(!canEditOtherUserInfo || editingMyself) |
||||
) { |
||||
throw new Meteor.Error('error-action-not-allowed', 'Edit user email is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
|
||||
if (userData.password && !settings.get('Accounts_AllowPasswordChange') && (!canEditOtherUserPassword || editingMyself)) { |
||||
throw new Meteor.Error('error-action-not-allowed', 'Edit user password is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
} |
||||
|
||||
const handleBio = (updateUser, bio) => { |
||||
if (bio && bio.trim()) { |
||||
if (bio.length > MAX_BIO_LENGTH) { |
||||
throw new Meteor.Error('error-bio-size-exceeded', `Bio size exceeds ${MAX_BIO_LENGTH} characters`, { |
||||
method: 'saveUserProfile', |
||||
}); |
||||
} |
||||
updateUser.$set = updateUser.$set || {}; |
||||
updateUser.$set.bio = bio; |
||||
} else { |
||||
updateUser.$unset = updateUser.$unset || {}; |
||||
updateUser.$unset.bio = 1; |
||||
} |
||||
}; |
||||
|
||||
const handleNickname = (updateUser, nickname) => { |
||||
if (nickname && nickname.trim()) { |
||||
if (nickname.length > MAX_NICKNAME_LENGTH) { |
||||
throw new Meteor.Error('error-nickname-size-exceeded', `Nickname size exceeds ${MAX_NICKNAME_LENGTH} characters`, { |
||||
method: 'saveUserProfile', |
||||
}); |
||||
} |
||||
updateUser.$set = updateUser.$set || {}; |
||||
updateUser.$set.nickname = nickname; |
||||
} else { |
||||
updateUser.$unset = updateUser.$unset || {}; |
||||
updateUser.$unset.nickname = 1; |
||||
} |
||||
}; |
||||
|
||||
const saveNewUser = async function (userData, sendPassword) { |
||||
await validateEmailDomain(userData.email); |
||||
|
||||
const roles = (!!userData.roles && userData.roles.length > 0 && userData.roles) || getNewUserRoles(); |
||||
const isGuest = roles && roles.length === 1 && roles.includes('guest'); |
||||
|
||||
// insert user
|
||||
const createUser = { |
||||
username: userData.username, |
||||
password: userData.password, |
||||
joinDefaultChannels: userData.joinDefaultChannels, |
||||
isGuest, |
||||
globalRoles: roles, |
||||
skipNewUserRolesSetting: true, |
||||
}; |
||||
if (userData.email) { |
||||
createUser.email = userData.email; |
||||
} |
||||
|
||||
const _id = await Accounts.createUserAsync(createUser); |
||||
|
||||
const updateUser = { |
||||
$set: { |
||||
...(typeof userData.name !== 'undefined' && { name: userData.name }), |
||||
settings: userData.settings || {}, |
||||
}, |
||||
}; |
||||
|
||||
if (typeof userData.requirePasswordChange !== 'undefined') { |
||||
updateUser.$set.requirePasswordChange = userData.requirePasswordChange; |
||||
} |
||||
|
||||
if (typeof userData.verified === 'boolean') { |
||||
updateUser.$set['emails.0.verified'] = userData.verified; |
||||
} |
||||
|
||||
handleBio(updateUser, userData.bio); |
||||
handleNickname(updateUser, userData.nickname); |
||||
|
||||
await Users.updateOne({ _id }, updateUser); |
||||
|
||||
if (userData.sendWelcomeEmail) { |
||||
await _sendUserEmail(settings.get('Accounts_UserAddedEmail_Subject'), html, userData); |
||||
} |
||||
|
||||
if (sendPassword) { |
||||
await _sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData); |
||||
} |
||||
|
||||
userData._id = _id; |
||||
|
||||
if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email) { |
||||
const gravatarUrl = Gravatar.url(userData.email, { |
||||
default: '404', |
||||
size: '200', |
||||
protocol: 'https', |
||||
}); |
||||
|
||||
try { |
||||
await setUserAvatar(userData, gravatarUrl, '', 'url'); |
||||
} catch (e) { |
||||
// Ignore this error for now, as it not being successful isn't bad
|
||||
} |
||||
} |
||||
|
||||
void notifyOnUserChangeById({ clientAction: 'inserted', id: _id }); |
||||
|
||||
return _id; |
||||
}; |
||||
|
||||
export const saveUser = async function (userId, userData) { |
||||
const oldUserData = userData._id && (await Users.findOneById(userData._id)); |
||||
if (oldUserData && isUserFederated(oldUserData)) { |
||||
throw new Meteor.Error('Edit_Federated_User_Not_Allowed', 'Not possible to edit a federated user'); |
||||
} |
||||
|
||||
await validateUserData(userId, userData); |
||||
|
||||
await callbacks.run('beforeSaveUser', { |
||||
user: userData, |
||||
oldUser: oldUserData, |
||||
}); |
||||
|
||||
let sendPassword = false; |
||||
|
||||
if (userData.hasOwnProperty('setRandomPassword')) { |
||||
if (userData.setRandomPassword) { |
||||
userData.password = generatePassword(); |
||||
userData.requirePasswordChange = true; |
||||
sendPassword = true; |
||||
} |
||||
|
||||
delete userData.setRandomPassword; |
||||
} |
||||
|
||||
if (!userData._id) { |
||||
return saveNewUser(userData, sendPassword); |
||||
} |
||||
|
||||
await validateUserEditing(userId, userData); |
||||
|
||||
// update user
|
||||
if (userData.hasOwnProperty('username') || userData.hasOwnProperty('name')) { |
||||
if ( |
||||
!(await saveUserIdentity({ |
||||
_id: userData._id, |
||||
username: userData.username, |
||||
name: userData.name, |
||||
updateUsernameInBackground: true, |
||||
})) |
||||
) { |
||||
throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', { |
||||
method: 'saveUser', |
||||
}); |
||||
} |
||||
} |
||||
|
||||
if (typeof userData.statusText === 'string') { |
||||
await setStatusText(userData._id, userData.statusText); |
||||
} |
||||
|
||||
if (userData.email) { |
||||
const shouldSendVerificationEmailToUser = userData.verified !== true; |
||||
await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser); |
||||
} |
||||
|
||||
if ( |
||||
userData.password && |
||||
userData.password.trim() && |
||||
(await hasPermissionAsync(userId, 'edit-other-user-password')) && |
||||
passwordPolicy.validate(userData.password) |
||||
) { |
||||
await Accounts.setPasswordAsync(userData._id, userData.password.trim()); |
||||
} else { |
||||
sendPassword = false; |
||||
} |
||||
|
||||
const updateUser = { |
||||
$set: {}, |
||||
$unset: {}, |
||||
}; |
||||
|
||||
handleBio(updateUser, userData.bio); |
||||
handleNickname(updateUser, userData.nickname); |
||||
|
||||
if (userData.roles) { |
||||
updateUser.$set.roles = userData.roles; |
||||
} |
||||
if (userData.settings) { |
||||
updateUser.$set.settings = { preferences: userData.settings.preferences }; |
||||
} |
||||
|
||||
if (userData.language) { |
||||
updateUser.$set.language = userData.language; |
||||
} |
||||
|
||||
if (typeof userData.requirePasswordChange !== 'undefined') { |
||||
updateUser.$set.requirePasswordChange = userData.requirePasswordChange; |
||||
if (!userData.requirePasswordChange) { |
||||
updateUser.$unset.requirePasswordChangeReason = 1; |
||||
} |
||||
} |
||||
|
||||
if (typeof userData.verified === 'boolean') { |
||||
updateUser.$set['emails.0.verified'] = userData.verified; |
||||
} |
||||
|
||||
await Users.updateOne({ _id: userData._id }, updateUser); |
||||
|
||||
// App IPostUserUpdated event hook
|
||||
const userUpdated = await Users.findOneById(userData._id); |
||||
|
||||
await callbacks.run('afterSaveUser', { |
||||
user: userUpdated, |
||||
oldUser: oldUserData, |
||||
}); |
||||
|
||||
await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { |
||||
user: userUpdated, |
||||
previousUser: oldUserData, |
||||
performedBy: await safeGetMeteorUser(), |
||||
}); |
||||
|
||||
if (sendPassword) { |
||||
await _sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData); |
||||
} |
||||
|
||||
if (typeof userData.verified === 'boolean') { |
||||
delete userData.verified; |
||||
} |
||||
void notifyOnUserChange({ |
||||
clientAction: 'updated', |
||||
id: userData._id, |
||||
diff: { |
||||
...userData, |
||||
emails: userUpdated.emails, |
||||
}, |
||||
}); |
||||
|
||||
return true; |
||||
}; |
||||
@ -0,0 +1,22 @@ |
||||
import { MeteorError } from '@rocket.chat/core-services'; |
||||
import type { DeepPartial, DeepWritable, IUser } from '@rocket.chat/core-typings'; |
||||
import type { UpdateFilter } from 'mongodb'; |
||||
|
||||
import type { SaveUserData } from './saveUser'; |
||||
|
||||
const MAX_BIO_LENGTH = 260; |
||||
|
||||
export const handleBio = (updateUser: DeepWritable<UpdateFilter<DeepPartial<IUser>>>, bio: SaveUserData['bio']) => { |
||||
if (bio?.trim()) { |
||||
if (bio.length > MAX_BIO_LENGTH) { |
||||
throw new MeteorError('error-bio-size-exceeded', `Bio size exceeds ${MAX_BIO_LENGTH} characters`, { |
||||
method: 'saveUserProfile', |
||||
}); |
||||
} |
||||
updateUser.$set = updateUser.$set || {}; |
||||
updateUser.$set.bio = bio; |
||||
} else { |
||||
updateUser.$unset = updateUser.$unset || {}; |
||||
updateUser.$unset.bio = 1; |
||||
} |
||||
}; |
||||
@ -0,0 +1,22 @@ |
||||
import { MeteorError } from '@rocket.chat/core-services'; |
||||
import type { DeepPartial, DeepWritable, IUser } from '@rocket.chat/core-typings'; |
||||
import type { UpdateFilter } from 'mongodb'; |
||||
|
||||
import type { SaveUserData } from './saveUser'; |
||||
|
||||
const MAX_NICKNAME_LENGTH = 120; |
||||
|
||||
export const handleNickname = (updateUser: DeepWritable<UpdateFilter<DeepPartial<IUser>>>, nickname: SaveUserData['nickname']) => { |
||||
if (nickname?.trim()) { |
||||
if (nickname.length > MAX_NICKNAME_LENGTH) { |
||||
throw new MeteorError('error-nickname-size-exceeded', `Nickname size exceeds ${MAX_NICKNAME_LENGTH} characters`, { |
||||
method: 'saveUserProfile', |
||||
}); |
||||
} |
||||
updateUser.$set = updateUser.$set || {}; |
||||
updateUser.$set.nickname = nickname; |
||||
} else { |
||||
updateUser.$unset = updateUser.$unset || {}; |
||||
updateUser.$unset.nickname = 1; |
||||
} |
||||
}; |
||||
@ -0,0 +1,2 @@ |
||||
export { saveUser } from './saveUser'; |
||||
export { validateUserEditing } from './validateUserEditing'; |
||||
@ -0,0 +1,84 @@ |
||||
import type { DeepPartial, DeepWritable, IUser, RequiredField } from '@rocket.chat/core-typings'; |
||||
import { Users } from '@rocket.chat/models'; |
||||
import Gravatar from 'gravatar'; |
||||
import type { UpdateFilter } from 'mongodb'; |
||||
|
||||
import { getNewUserRoles } from '../../../../../server/services/user/lib/getNewUserRoles'; |
||||
import { settings } from '../../../../settings/server'; |
||||
import { notifyOnUserChangeById } from '../../lib/notifyListener'; |
||||
import { validateEmailDomain } from '../../lib/validateEmailDomain'; |
||||
import { setUserAvatar } from '../setUserAvatar'; |
||||
import { handleBio } from './handleBio'; |
||||
import { handleNickname } from './handleNickname'; |
||||
import type { SaveUserData } from './saveUser'; |
||||
import { sendPasswordEmail, sendWelcomeEmail } from './sendUserEmail'; |
||||
|
||||
export const saveNewUser = async function (userData: SaveUserData, sendPassword: boolean) { |
||||
await validateEmailDomain(userData.email); |
||||
|
||||
const roles = (!!userData.roles && userData.roles.length > 0 && userData.roles) || getNewUserRoles(); |
||||
const isGuest = roles && roles.length === 1 && roles.includes('guest'); |
||||
|
||||
// insert user
|
||||
const createUser: Record<string, any> = { |
||||
username: userData.username, |
||||
password: userData.password, |
||||
joinDefaultChannels: userData.joinDefaultChannels, |
||||
isGuest, |
||||
globalRoles: roles, |
||||
skipNewUserRolesSetting: true, |
||||
}; |
||||
if (userData.email) { |
||||
createUser.email = userData.email; |
||||
} |
||||
|
||||
const _id = await Accounts.createUserAsync(createUser); |
||||
|
||||
const updateUser: RequiredField<DeepWritable<UpdateFilter<DeepPartial<IUser>>>, '$set'> = { |
||||
$set: { |
||||
...(typeof userData.name !== 'undefined' && { name: userData.name }), |
||||
settings: userData.settings || {}, |
||||
}, |
||||
}; |
||||
|
||||
if (typeof userData.requirePasswordChange !== 'undefined') { |
||||
updateUser.$set.requirePasswordChange = userData.requirePasswordChange; |
||||
} |
||||
|
||||
if (typeof userData.verified === 'boolean') { |
||||
updateUser.$set['emails.0.verified'] = userData.verified; |
||||
} |
||||
|
||||
handleBio(updateUser, userData.bio); |
||||
handleNickname(updateUser, userData.nickname); |
||||
|
||||
await Users.updateOne({ _id }, updateUser as UpdateFilter<IUser>); |
||||
|
||||
if (userData.sendWelcomeEmail) { |
||||
await sendWelcomeEmail(userData); |
||||
} |
||||
|
||||
if (sendPassword) { |
||||
await sendPasswordEmail(userData); |
||||
} |
||||
|
||||
userData._id = _id; |
||||
|
||||
if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email) { |
||||
const gravatarUrl = Gravatar.url(userData.email, { |
||||
default: '404', |
||||
size: '200', |
||||
protocol: 'https', |
||||
}); |
||||
|
||||
try { |
||||
await setUserAvatar({ ...userData, _id }, gravatarUrl, '', 'url'); |
||||
} catch (e) { |
||||
// Ignore this error for now, as it not being successful isn't bad
|
||||
} |
||||
} |
||||
|
||||
void notifyOnUserChangeById({ clientAction: 'inserted', id: _id }); |
||||
|
||||
return _id; |
||||
}; |
||||
@ -0,0 +1,179 @@ |
||||
import { Apps, AppEvents } from '@rocket.chat/apps'; |
||||
import type { DeepWritable, DeepPartial } from '@rocket.chat/core-typings'; |
||||
import { isUserFederated, type IUser, type IRole, type IUserSettings, type RequiredField } from '@rocket.chat/core-typings'; |
||||
import { Users } from '@rocket.chat/models'; |
||||
import { Accounts } from 'meteor/accounts-base'; |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import type { UpdateFilter } from 'mongodb'; |
||||
|
||||
import { callbacks } from '../../../../../lib/callbacks'; |
||||
import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; |
||||
import { safeGetMeteorUser } from '../../../../utils/server/functions/safeGetMeteorUser'; |
||||
import { generatePassword } from '../../lib/generatePassword'; |
||||
import { notifyOnUserChange } from '../../lib/notifyListener'; |
||||
import { passwordPolicy } from '../../lib/passwordPolicy'; |
||||
import { saveUserIdentity } from '../saveUserIdentity'; |
||||
import { setEmail } from '../setEmail'; |
||||
import { setStatusText } from '../setStatusText'; |
||||
import { handleBio } from './handleBio'; |
||||
import { handleNickname } from './handleNickname'; |
||||
import { saveNewUser } from './saveNewUser'; |
||||
import { sendPasswordEmail } from './sendUserEmail'; |
||||
import { validateUserData } from './validateUserData'; |
||||
import { validateUserEditing } from './validateUserEditing'; |
||||
|
||||
export type SaveUserData = { |
||||
_id?: IUser['_id']; |
||||
setRandomPassword?: boolean; |
||||
|
||||
password?: string; |
||||
requirePasswordChange?: boolean; |
||||
|
||||
username?: string; |
||||
name?: string; |
||||
|
||||
statusText?: string; |
||||
email?: string; |
||||
verified?: boolean; |
||||
|
||||
bio?: string; |
||||
nickname?: string; |
||||
|
||||
roles?: IRole['_id'][]; |
||||
settings?: Partial<IUserSettings>; |
||||
language?: string; |
||||
|
||||
joinDefaultChannels?: boolean; |
||||
sendWelcomeEmail?: boolean; |
||||
}; |
||||
|
||||
export const saveUser = async function (userId: IUser['_id'], userData: SaveUserData) { |
||||
const oldUserData = userData._id && (await Users.findOneById(userData._id)); |
||||
if (oldUserData && isUserFederated(oldUserData)) { |
||||
throw new Meteor.Error('Edit_Federated_User_Not_Allowed', 'Not possible to edit a federated user'); |
||||
} |
||||
|
||||
await validateUserData(userId, userData); |
||||
|
||||
await callbacks.run('beforeSaveUser', { |
||||
user: userData, |
||||
oldUser: oldUserData, |
||||
}); |
||||
|
||||
let sendPassword = false; |
||||
|
||||
if (userData.hasOwnProperty('setRandomPassword')) { |
||||
if (userData.setRandomPassword) { |
||||
userData.password = generatePassword(); |
||||
userData.requirePasswordChange = true; |
||||
sendPassword = true; |
||||
} |
||||
|
||||
delete userData.setRandomPassword; |
||||
} |
||||
|
||||
if (!userData._id) { |
||||
return saveNewUser(userData, sendPassword); |
||||
} |
||||
|
||||
await validateUserEditing(userId, userData as RequiredField<SaveUserData, '_id'>); |
||||
|
||||
// update user
|
||||
if (userData.hasOwnProperty('username') || userData.hasOwnProperty('name')) { |
||||
if ( |
||||
!(await saveUserIdentity({ |
||||
_id: userData._id, |
||||
username: userData.username, |
||||
name: userData.name, |
||||
updateUsernameInBackground: true, |
||||
})) |
||||
) { |
||||
throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', { |
||||
method: 'saveUser', |
||||
}); |
||||
} |
||||
} |
||||
|
||||
if (typeof userData.statusText === 'string') { |
||||
await setStatusText(userData._id, userData.statusText); |
||||
} |
||||
|
||||
if (userData.email) { |
||||
const shouldSendVerificationEmailToUser = userData.verified !== true; |
||||
await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser); |
||||
} |
||||
|
||||
if ( |
||||
userData.password?.trim() && |
||||
(await hasPermissionAsync(userId, 'edit-other-user-password')) && |
||||
passwordPolicy.validate(userData.password) |
||||
) { |
||||
await Accounts.setPasswordAsync(userData._id, userData.password.trim()); |
||||
} else { |
||||
sendPassword = false; |
||||
} |
||||
|
||||
const updateUser: RequiredField<DeepWritable<UpdateFilter<DeepPartial<IUser>>>, '$set' | '$unset'> = { |
||||
$set: {}, |
||||
$unset: {}, |
||||
}; |
||||
|
||||
handleBio(updateUser, userData.bio); |
||||
handleNickname(updateUser, userData.nickname); |
||||
|
||||
if (userData.roles) { |
||||
updateUser.$set.roles = userData.roles; |
||||
} |
||||
if (userData.settings) { |
||||
updateUser.$set.settings = { preferences: userData.settings.preferences }; |
||||
} |
||||
|
||||
if (userData.language) { |
||||
updateUser.$set.language = userData.language; |
||||
} |
||||
|
||||
if (typeof userData.requirePasswordChange !== 'undefined') { |
||||
updateUser.$set.requirePasswordChange = userData.requirePasswordChange; |
||||
if (!userData.requirePasswordChange) { |
||||
updateUser.$unset.requirePasswordChangeReason = 1; |
||||
} |
||||
} |
||||
|
||||
if (typeof userData.verified === 'boolean') { |
||||
updateUser.$set['emails.0.verified'] = userData.verified; |
||||
} |
||||
|
||||
await Users.updateOne({ _id: userData._id }, updateUser as UpdateFilter<IUser>); |
||||
|
||||
// App IPostUserUpdated event hook
|
||||
const userUpdated = await Users.findOneById(userData._id); |
||||
|
||||
await callbacks.run('afterSaveUser', { |
||||
user: userUpdated, |
||||
oldUser: oldUserData, |
||||
}); |
||||
|
||||
await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { |
||||
user: userUpdated, |
||||
previousUser: oldUserData, |
||||
performedBy: await safeGetMeteorUser(), |
||||
}); |
||||
|
||||
if (sendPassword) { |
||||
await sendPasswordEmail(userData); |
||||
} |
||||
|
||||
if (typeof userData.verified === 'boolean') { |
||||
delete userData.verified; |
||||
} |
||||
void notifyOnUserChange({ |
||||
clientAction: 'updated', |
||||
id: userData._id, |
||||
diff: { |
||||
...userData, |
||||
emails: userUpdated?.emails, |
||||
}, |
||||
}); |
||||
|
||||
return true; |
||||
}; |
||||
@ -0,0 +1,54 @@ |
||||
import { MeteorError } from '@rocket.chat/core-services'; |
||||
|
||||
import * as Mailer from '../../../../mailer/server/api'; |
||||
import { settings } from '../../../../settings/server'; |
||||
import type { SaveUserData } from './saveUser'; |
||||
|
||||
let html = ''; |
||||
let passwordChangedHtml = ''; |
||||
Meteor.startup(() => { |
||||
Mailer.getTemplate('Accounts_UserAddedEmail_Email', (template) => { |
||||
html = template; |
||||
}); |
||||
|
||||
Mailer.getTemplate('Password_Changed_Email', (template) => { |
||||
passwordChangedHtml = template; |
||||
}); |
||||
}); |
||||
|
||||
export async function sendUserEmail(subject: string, html: string, userData: SaveUserData): Promise<void> { |
||||
if (!userData.email) { |
||||
return; |
||||
} |
||||
|
||||
const email = { |
||||
to: userData.email, |
||||
from: settings.get<string>('From_Email'), |
||||
subject, |
||||
html, |
||||
data: { |
||||
email: userData.email, |
||||
password: userData.password, |
||||
...(typeof userData.name !== 'undefined' ? { name: userData.name } : {}), |
||||
}, |
||||
}; |
||||
|
||||
try { |
||||
await Mailer.send(email); |
||||
} catch (error) { |
||||
const errorMessage = typeof error === 'object' && error && 'message' in error ? error.message : ''; |
||||
|
||||
throw new MeteorError('error-email-send-failed', `Error trying to send email: ${errorMessage}`, { |
||||
function: 'RocketChat.saveUser', |
||||
message: errorMessage, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
export async function sendWelcomeEmail(userData: SaveUserData) { |
||||
return sendUserEmail(settings.get('Accounts_UserAddedEmail_Subject'), html, userData); |
||||
} |
||||
|
||||
export async function sendPasswordEmail(userData: SaveUserData) { |
||||
return sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData); |
||||
} |
||||
@ -0,0 +1,107 @@ |
||||
import { MeteorError } from '@rocket.chat/core-services'; |
||||
import type { IUser } from '@rocket.chat/core-typings'; |
||||
import { makeFunction } from '@rocket.chat/patch-injection'; |
||||
import escape from 'lodash.escape'; |
||||
|
||||
import { trim } from '../../../../../lib/utils/stringUtils'; |
||||
import { getRoleIds } from '../../../../authorization/server/functions/getRoles'; |
||||
import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; |
||||
import { settings } from '../../../../settings/server'; |
||||
import { checkEmailAvailability } from '../checkEmailAvailability'; |
||||
import { checkUsernameAvailability } from '../checkUsernameAvailability'; |
||||
import type { SaveUserData } from './saveUser'; |
||||
|
||||
export const validateUserData = makeFunction(async (userId: IUser['_id'], userData: SaveUserData): Promise<void> => { |
||||
const existingRoles = await getRoleIds(); |
||||
|
||||
if (userData.verified && userData._id && userId === userData._id) { |
||||
throw new MeteorError('error-action-not-allowed', 'Editing email verification is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Editing_user', |
||||
}); |
||||
} |
||||
|
||||
if (userData._id && userId !== userData._id && !(await hasPermissionAsync(userId, 'edit-other-user-info'))) { |
||||
throw new MeteorError('error-action-not-allowed', 'Editing user is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Editing_user', |
||||
}); |
||||
} |
||||
|
||||
if (!userData._id && !(await hasPermissionAsync(userId, 'create-user'))) { |
||||
throw new MeteorError('error-action-not-allowed', 'Adding user is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Adding_user', |
||||
}); |
||||
} |
||||
|
||||
if (userData.roles) { |
||||
const newRoles = userData.roles.filter((roleId) => !existingRoles.includes(roleId)); |
||||
if (newRoles.length > 0) { |
||||
throw new MeteorError('error-action-not-allowed', 'The field Roles consist invalid role id', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Assign_role', |
||||
}); |
||||
} |
||||
} |
||||
|
||||
if (userData.roles?.includes('admin') && !(await hasPermissionAsync(userId, 'assign-admin-role'))) { |
||||
throw new MeteorError('error-action-not-allowed', 'Assigning admin is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Assign_admin', |
||||
}); |
||||
} |
||||
|
||||
if (settings.get('Accounts_RequireNameForSignUp') && !userData._id && !trim(userData.name)) { |
||||
throw new MeteorError('error-the-field-is-required', 'The field Name is required', { |
||||
method: 'insertOrUpdateUser', |
||||
field: 'Name', |
||||
}); |
||||
} |
||||
|
||||
if (!userData._id && !trim(userData.username)) { |
||||
throw new MeteorError('error-the-field-is-required', 'The field Username is required', { |
||||
method: 'insertOrUpdateUser', |
||||
field: 'Username', |
||||
}); |
||||
} |
||||
|
||||
let nameValidation; |
||||
|
||||
try { |
||||
nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); |
||||
} catch (e) { |
||||
nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); |
||||
} |
||||
|
||||
if (userData.username && !nameValidation.test(userData.username)) { |
||||
throw new MeteorError('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 && !userData.setRandomPassword) { |
||||
throw new MeteorError('error-the-field-is-required', 'The field Password is required', { |
||||
method: 'insertOrUpdateUser', |
||||
field: 'Password', |
||||
}); |
||||
} |
||||
|
||||
if (!userData._id) { |
||||
if (userData.username && !(await checkUsernameAvailability(userData.username))) { |
||||
throw new MeteorError('error-field-unavailable', `${escape(userData.username)} is already in use :(`, { |
||||
method: 'insertOrUpdateUser', |
||||
field: userData.username, |
||||
}); |
||||
} |
||||
|
||||
if (userData.email && !(await checkEmailAvailability(userData.email))) { |
||||
throw new MeteorError('error-field-unavailable', `${escape(userData.email)} is already in use :(`, { |
||||
method: 'insertOrUpdateUser', |
||||
field: userData.email, |
||||
}); |
||||
} |
||||
} |
||||
}); |
||||
@ -0,0 +1,96 @@ |
||||
import { MeteorError } from '@rocket.chat/core-services'; |
||||
import type { IUser, RequiredField } from '@rocket.chat/core-typings'; |
||||
import { Users } from '@rocket.chat/models'; |
||||
|
||||
import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; |
||||
import { settings } from '../../../../settings/server'; |
||||
import type { SaveUserData } from './saveUser'; |
||||
|
||||
const isEditingUserRoles = (previousRoles: IUser['roles'], newRoles?: IUser['roles']) => |
||||
newRoles !== undefined && |
||||
(newRoles.some((item) => !previousRoles.includes(item)) || previousRoles.some((item) => !newRoles.includes(item))); |
||||
const isEditingField = (previousValue?: string, newValue?: string) => typeof newValue !== 'undefined' && newValue !== previousValue; |
||||
|
||||
/** |
||||
* Validate permissions to edit user fields |
||||
* |
||||
* @param {string} userId |
||||
* @param {{ _id: string, roles?: string[], username?: string, name?: string, statusText?: string, email?: string, password?: string}} userData |
||||
*/ |
||||
export async function validateUserEditing(userId: IUser['_id'], userData: RequiredField<SaveUserData, '_id'>): Promise<void> { |
||||
const editingMyself = userData._id && userId === userData._id; |
||||
|
||||
const canEditOtherUserInfo = await hasPermissionAsync(userId, 'edit-other-user-info'); |
||||
const canEditOtherUserPassword = await hasPermissionAsync(userId, 'edit-other-user-password'); |
||||
const user = await Users.findOneById(userData._id); |
||||
|
||||
if (!user) { |
||||
throw new MeteorError('error-invalid-user', 'Invalid user'); |
||||
} |
||||
|
||||
if (isEditingUserRoles(user.roles, userData.roles) && !(await hasPermissionAsync(userId, 'assign-roles'))) { |
||||
throw new MeteorError('error-action-not-allowed', 'Assign roles is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Assign_role', |
||||
}); |
||||
} |
||||
|
||||
if (!settings.get('Accounts_AllowUserProfileChange') && !canEditOtherUserInfo && !canEditOtherUserPassword) { |
||||
throw new MeteorError('error-action-not-allowed', 'Edit user profile is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
|
||||
if ( |
||||
isEditingField(user.username, userData.username) && |
||||
!settings.get('Accounts_AllowUsernameChange') && |
||||
(!canEditOtherUserInfo || editingMyself) |
||||
) { |
||||
throw new MeteorError('error-action-not-allowed', 'Edit username is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
|
||||
if ( |
||||
isEditingField(user.statusText, userData.statusText) && |
||||
!settings.get('Accounts_AllowUserStatusMessageChange') && |
||||
(!canEditOtherUserInfo || editingMyself) |
||||
) { |
||||
throw new MeteorError('error-action-not-allowed', 'Edit user status is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
|
||||
if ( |
||||
isEditingField(user.name, userData.name) && |
||||
!settings.get('Accounts_AllowRealNameChange') && |
||||
(!canEditOtherUserInfo || editingMyself) |
||||
) { |
||||
throw new MeteorError('error-action-not-allowed', 'Edit user real name is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
|
||||
if ( |
||||
user.emails?.[0] && |
||||
isEditingField(user.emails[0].address, userData.email) && |
||||
!settings.get('Accounts_AllowEmailChange') && |
||||
(!canEditOtherUserInfo || editingMyself) |
||||
) { |
||||
throw new MeteorError('error-action-not-allowed', 'Edit user email is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
|
||||
if (userData.password && !settings.get('Accounts_AllowPasswordChange') && (!canEditOtherUserPassword || editingMyself)) { |
||||
throw new MeteorError('error-action-not-allowed', 'Edit user password is not allowed', { |
||||
method: 'insertOrUpdateUser', |
||||
action: 'Update_user', |
||||
}); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue