import { Apps, AppEvents } from '@rocket.chat/apps'; import type { UserStatus, IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { UpdateFilter } from 'mongodb'; import { setEmailFunction } from './setEmail'; import { setUserStatusMethod } from './setUserStatus'; import { getUserInfo } from '../../api/lib/getUserInfo'; import { type AuthenticatedContext, twoFactorRequired } from '../../lib/2fa/twoFactorRequired'; import { passwordPolicy } from '../../lib/auth/passwordPolicy'; import { callbacks } from '../../lib/callbacks'; import { compareUserPassword } from '../../lib/compareUserPassword'; import { compareUserPasswordHistory } from '../../lib/compareUserPasswordHistory'; import { notifyOnUserChange } from '../../lib/notifyListener'; import { saveCustomFields } from '../../lib/users/saveCustomFields'; import { validateUserEditing } from '../../lib/users/saveUser'; import { saveUserIdentity } from '../../lib/users/saveUserIdentity'; import { settings as rcSettings } from '../../settings'; const MAX_BIO_LENGTH = 260; const MAX_NICKNAME_LENGTH = 120; async function saveUserProfile( this: AuthenticatedContext, settings: { email?: string; username?: string; realname?: string; newPassword?: string; statusText?: string; statusType?: string; bio?: string; nickname?: string; }, customFields: Record, ..._: unknown[] ) { const unset: UpdateFilter = {}; if (!rcSettings.get('Accounts_AllowUserProfileChange')) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'saveUserProfile', }); } if (!this.userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'saveUserProfile', }); } await validateUserEditing(this.userId, { _id: this.userId, email: settings.email, username: settings.username, name: settings.realname, password: settings.newPassword, statusText: settings.statusText, }); const user = await Users.findOneById(this.userId); if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'saveUserProfile', }); } if (settings.realname || settings.username) { if ( !(await saveUserIdentity({ _id: this.userId, name: settings.realname, username: settings.username, })) ) { throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', { method: 'saveUserProfile', }); } } if (settings.statusType || settings.statusText != null) { await setUserStatusMethod(user, settings.statusType as UserStatus, settings.statusText); } if (user && (settings.bio || settings.bio === '')) { if (typeof settings.bio !== 'string') { throw new Meteor.Error('error-invalid-field', 'bio', { method: 'saveUserProfile', }); } if (settings.bio.length > MAX_BIO_LENGTH) { throw new Meteor.Error('error-bio-size-exceeded', `Bio size exceeds ${MAX_BIO_LENGTH} characters`, { method: 'saveUserProfile', }); } await Users.setBio(user._id, settings.bio.trim()); } if (user && (settings.nickname || settings.nickname === '')) { if (typeof settings.nickname !== 'string') { throw new Meteor.Error('error-invalid-field', 'nickname', { method: 'saveUserProfile', }); } if (settings.nickname.length > MAX_NICKNAME_LENGTH) { throw new Meteor.Error('error-nickname-size-exceeded', `Nickname size exceeds ${MAX_NICKNAME_LENGTH} characters`, { method: 'saveUserProfile', }); } await Users.setNickname(user._id, settings.nickname.trim()); } if (user && settings.email) { await setEmailFunction(settings.email, user); } const canChangePasswordForOAuth = rcSettings.get('Accounts_AllowPasswordChangeForOAuthUsers'); if (canChangePasswordForOAuth || user?.services?.password) { // Should be the last check to prevent error when trying to check password for users without password if (settings.newPassword && rcSettings.get('Accounts_AllowPasswordChange') === true && user?.services?.password?.bcrypt) { // don't let user change to same password if (user && (await compareUserPassword(user, { plain: settings.newPassword }))) { throw new Meteor.Error('error-password-same-as-current', 'Entered password same as current password', { method: 'saveUserProfile', }); } if (user?.services?.passwordHistory && !(await compareUserPasswordHistory(user, { plain: settings.newPassword }))) { throw new Meteor.Error('error-password-in-history', 'Entered password has been previously used', { method: 'saveUserProfile', }); } passwordPolicy.validate(settings.newPassword); await Accounts.setPasswordAsync(this.userId, settings.newPassword, { logout: false, }); if (user.requirePasswordChange) { await Users.unsetRequirePasswordChange(user._id); unset.requirePasswordChange = true; unset.requirePasswordChangeReason = true; } await Users.addPasswordToHistory( this.userId, user.services?.password.bcrypt, rcSettings.get('Accounts_Password_History_Amount'), ); try { await Users.removeNonLoginTokensExcept(this.userId, this.token); } catch (e) { Accounts._clearAllLoginTokens(this.userId); } } } await Users.setProfile(this.userId, {}); if (customFields && Object.keys(customFields).length) { await saveCustomFields(this.userId, customFields); } // App IPostUserUpdated event hook const updatedUser = await Users.findOneById(this.userId); // This should never happen, but since `Users.findOneById` might return null, we'll handle it just in case if (!updatedUser) { throw new Error('Unexpected error after saving user profile: user not found'); } await callbacks.run('afterSaveUser', { user: updatedUser, oldUser: user, }); void notifyOnUserChange({ clientAction: 'updated', id: updatedUser._id, diff: await getUserInfo(updatedUser), ...(Object.keys(unset).length > 0 && { unset }), }); await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { user: updatedUser, previousUser: user }); return true; } const saveUserProfileWithTwoFactor = twoFactorRequired(saveUserProfile, { requireSecondFactor: true, }); declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { saveUserProfile( settings: { email?: string; username?: string; realname?: string; newPassword?: string; statusText?: string; statusType?: string; bio?: string; nickname?: string; }, customFields: Record, ...args: unknown[] ): boolean; } } export function executeSaveUserProfile( this: AuthenticatedContext, user: IUser, settings: { email?: string; username?: string; realname?: string; newPassword?: string; statusText?: string; statusType?: string; bio?: string; nickname?: string; }, customFields: Record = {}, ...args: unknown[] ) { check(settings, Object); check(customFields, Match.Maybe(Object)); if ( (settings.email && user.emails?.length) || (settings.newPassword && Object.keys(user.services || {}).length && !user.requirePasswordChange) ) { return saveUserProfileWithTwoFactor.call(this, settings, customFields, ...args); } return saveUserProfile.call(this, settings, customFields, ...args); }