Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
247 lines
7.3 KiB
TypeScript
247 lines
7.3 KiB
TypeScript
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<string, unknown>,
|
|
..._: unknown[]
|
|
) {
|
|
const unset: UpdateFilter<IUser> = {};
|
|
if (!rcSettings.get<boolean>('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<boolean>('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<boolean>('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<number>('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<string, any>,
|
|
...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<string, any> = {},
|
|
...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);
|
|
}
|