chore: convert saveUser to ts (#33774)

pull/33855/head
Pierre Lehnen 1 year ago committed by GitHub
parent 87ee55d237
commit d4b3aefbba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      apps/meteor/app/authorization/server/functions/getRoles.ts
  2. 475
      apps/meteor/app/lib/server/functions/saveUser.js
  3. 22
      apps/meteor/app/lib/server/functions/saveUser/handleBio.ts
  4. 22
      apps/meteor/app/lib/server/functions/saveUser/handleNickname.ts
  5. 2
      apps/meteor/app/lib/server/functions/saveUser/index.ts
  6. 84
      apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts
  7. 179
      apps/meteor/app/lib/server/functions/saveUser/saveUser.ts
  8. 54
      apps/meteor/app/lib/server/functions/saveUser/sendUserEmail.ts
  9. 107
      apps/meteor/app/lib/server/functions/saveUser/validateUserData.ts
  10. 96
      apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts
  11. 6
      apps/meteor/app/lib/server/methods/insertOrUpdateUser.ts
  12. 2
      apps/meteor/package.json
  13. 8
      packages/core-typings/src/utils.ts
  14. 11
      yarn.lock

@ -2,3 +2,6 @@ import type { IRole } from '@rocket.chat/core-typings';
import { Roles } from '@rocket.chat/models';
export const getRoles = async (): Promise<IRole[]> => Roles.find().toArray();
export const getRoleIds = async (): Promise<IRole['_id'][]> =>
(await Roles.find({}, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id);

@ -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',
});
}
}

@ -19,12 +19,14 @@ Meteor.methods<ServerMethods>({
check(userData, Object);
if (!Meteor.userId()) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'insertOrUpdateUser',
});
}
return saveUser(Meteor.userId(), userData);
return saveUser(userId, userData);
}),
});

@ -121,6 +121,7 @@
"@types/lodash": "^4.14.200",
"@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.escape": "^4.0.9",
"@types/lodash.get": "^4.4.9",
"@types/mailparser": "^3.4.4",
"@types/marked": "^4.0.8",
@ -369,6 +370,7 @@
"localforage": "^1.10.0",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8",
"lodash.escape": "^4.0.1",
"lodash.get": "^4.4.2",
"mailparser": "^3.4.0",
"marked": "^4.2.5",

@ -34,3 +34,11 @@ export type DeepWritable<T> = T extends (...args: any) => any
};
export type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never;
export type ValueOfUnion<T, K extends KeyOfEach<T>> = T extends any ? (K extends keyof T ? T[K] : undefined) : undefined;
export type ValueOfOptional<T, K extends KeyOfEach<T>> = T extends undefined ? undefined : T extends object ? ValueOfUnion<T, K> : null;
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[] ? DeepPartial<U>[] : T[P] extends object | undefined ? DeepPartial<T[P]> : T[P];
};

@ -9281,6 +9281,7 @@ __metadata:
"@types/lodash": "npm:^4.14.200"
"@types/lodash.clonedeep": "npm:^4.5.9"
"@types/lodash.debounce": "npm:^4.0.9"
"@types/lodash.escape": "npm:^4.0.9"
"@types/lodash.get": "npm:^4.4.9"
"@types/mailparser": "npm:^3.4.4"
"@types/marked": "npm:^4.0.8"
@ -9422,6 +9423,7 @@ __metadata:
localforage: "npm:^1.10.0"
lodash.clonedeep: "npm:^4.5.0"
lodash.debounce: "npm:^4.0.8"
lodash.escape: "npm:^4.0.1"
lodash.get: "npm:^4.4.2"
mailparser: "npm:^3.4.0"
marked: "npm:^4.2.5"
@ -12729,6 +12731,15 @@ __metadata:
languageName: node
linkType: hard
"@types/lodash.escape@npm:^4.0.9":
version: 4.0.9
resolution: "@types/lodash.escape@npm:4.0.9"
dependencies:
"@types/lodash": "npm:*"
checksum: 10/bae0be4bddefaacfb8e96afec895138643cb59d1d5d8c29fda0d61ed77468a8a2d9f913d51cd67ab08afc7a09f2602fec1a289abd9aab335821671a4b3c1245f
languageName: node
linkType: hard
"@types/lodash.get@npm:^4.4.9":
version: 4.4.9
resolution: "@types/lodash.get@npm:4.4.9"

Loading…
Cancel
Save