From 6b6f9dcb74dcfea8e9355d8ecab925c0801c4a26 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 17 Jun 2022 10:37:17 -0300 Subject: [PATCH] Chore: Convert users endpoints (#25635) Co-authored-by: Guilherme Gazzo Co-authored-by: Tasso Evangelista --- apps/meteor/app/api/server/api.d.ts | 11 +- apps/meteor/app/api/server/lib/users.ts | 15 +- .../app/api/server/v1/{users.js => users.ts} | 1041 ++++++++--------- ...{getFullUserData.js => getFullUserData.ts} | 40 +- .../app/lib/server/functions/setUserAvatar.ts | 22 +- apps/meteor/client/lib/presence.ts | 14 - apps/meteor/server/sdk/types/ITeamService.ts | 1 + apps/meteor/tests/end-to-end/api/01-users.js | 2 +- packages/core-typings/src/IUser.ts | 1 + packages/rest-typings/src/index.ts | 6 + packages/rest-typings/src/v1/users.ts | 233 +++- .../src/v1/users/UserCreateParamsPOST.ts | 51 + .../v1/users/UserDeactivateIdleParamsPOST.ts | 26 + .../src/v1/users/UserLogoutParamsPOST.ts | 22 + .../src/v1/users/UserRegisterParamsPOST.ts | 47 + .../v1/users/UserSetActiveStatusParamsPOST.ts | 24 + .../v1/users/UsersAutocompleteParamsGET.ts | 20 + .../src/v1/users/UsersInfoParamsGet.ts | 44 + .../src/v1/users/UsersListTeamsParamsGET.ts | 21 + .../src/v1/users/UsersSetAvatarParamsPOST.ts | 33 + .../v1/users/UsersSetPreferenceParamsPOST.ts | 190 +++ .../UsersUpdateOwnBasicInfoParamsPOST.ts | 67 ++ .../src/v1/users/UsersUpdateParamsPOST.ts | 108 ++ 23 files changed, 1370 insertions(+), 669 deletions(-) rename apps/meteor/app/api/server/v1/{users.js => users.ts} (66%) rename apps/meteor/app/lib/server/functions/{getFullUserData.js => getFullUserData.ts} (65%) create mode 100644 packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserDeactivateIdleParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserLogoutParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserSetActiveStatusParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersAutocompleteParamsGET.ts create mode 100644 packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts create mode 100644 packages/rest-typings/src/v1/users/UsersListTeamsParamsGET.ts create mode 100644 packages/rest-typings/src/v1/users/UsersSetAvatarParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersUpdateOwnBasicInfoParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersUpdateParamsPOST.ts diff --git a/apps/meteor/app/api/server/api.d.ts b/apps/meteor/app/api/server/api.d.ts index a1dd63713747..ebc4871610e3 100644 --- a/apps/meteor/app/api/server/api.d.ts +++ b/apps/meteor/app/api/server/api.d.ts @@ -9,7 +9,7 @@ import type { } from '@rocket.chat/rest-typings'; import type { IUser, IMethodConnection, IRoom } from '@rocket.chat/core-typings'; import type { ValidateFunction } from 'ajv'; -import type { Request } from 'express'; +import type { Request, Response } from 'express'; import { ITwoFactorOptions } from '../../2fa/server/code'; @@ -73,11 +73,13 @@ type Options = ( type PartialThis = { readonly request: Request & { query: Record }; + readonly response: Response; }; type ActionThis = { readonly requestIp: string; urlParams: UrlParams; + readonly response: Response; // TODO make it unsafe readonly queryParams: TMethod extends 'GET' ? TOptions extends { validateParams: ValidateFunction } @@ -91,6 +93,9 @@ type ActionThis>; readonly request: Request; + + readonly queryOperations: TOptions extends { queryOperations: infer T } ? T : never; + /* @deprecated */ requestParams(): OperationParams; getLoggedInUser(): TOptions extends { authRequired: true } ? IUser : IUser | undefined; @@ -106,6 +111,8 @@ type ActionThis declare class APIClass { fieldSeparator: string; + updateRateLimiterDictionaryForRoute(route: string, rateLimiterDictionary: number): void; + limitedUserFieldsToExclude(fields: { [x: string]: unknown }, limitedUserFieldsToExclude: unknown): { [x: string]: unknown }; limitedUserFieldsToExcludeIfIsPrivilegedUser( diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 8ff1737cc692..7762b9b20a18 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -16,7 +16,7 @@ export async function findUsersToAutocomplete({ term: string; }; }): Promise<{ - items: IUser[]; + items: Required>[]; }> { if (!(await hasPermissionAsync(uid, 'view-outside-room'))) { return { items: [] }; @@ -69,16 +69,7 @@ export function getInclusiveFields(query: { [k: string]: 1 }): {} { * get the default fields if **fields** are empty (`{}`) or `undefined`/`null` * @param {Object|null|undefined} fields the fields from parsed jsonQuery */ -export function getNonEmptyFields(fields: {}): { - name: number; - username: number; - emails: number; - roles: number; - status: number; - active: number; - avatarETag: number; - lastLogin: number; -} { +export function getNonEmptyFields(fields: { [k: string]: 1 | 0 }): { [k: string]: 1 } { const defaultFields = { name: 1, username: 1, @@ -88,7 +79,7 @@ export function getNonEmptyFields(fields: {}): { active: 1, avatarETag: 1, lastLogin: 1, - }; + } as const; if (!fields || Object.keys(fields).length === 0) { return defaultFields; diff --git a/apps/meteor/app/api/server/v1/users.js b/apps/meteor/app/api/server/v1/users.ts similarity index 66% rename from apps/meteor/app/api/server/v1/users.js rename to apps/meteor/app/api/server/v1/users.ts index 094570518e2d..8e6808fd34b4 100644 --- a/apps/meteor/app/api/server/v1/users.js +++ b/apps/meteor/app/api/server/v1/users.ts @@ -1,57 +1,236 @@ +import { + isUserCreateParamsPOST, + isUserSetActiveStatusParamsPOST, + isUserDeactivateIdleParamsPOST, + isUsersInfoParamsGetProps, + isUserRegisterParamsPOST, + isUserLogoutParamsPOST, + isUsersListTeamsProps, + isUsersAutocompleteProps, + isUsersSetAvatarProps, + isUsersUpdateParamsPOST, + isUsersUpdateOwnBasicInfoParamsPOST, + isUsersSetPreferencesParamsPOST, +} from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import _ from 'underscore'; +import { IExportOperation, IPersonalAccessToken, IUser } from '@rocket.chat/core-typings'; import { Users, Subscriptions } from '../../../models/server'; import { Users as UsersRaw } from '../../../models/server/raw'; -import { hasPermission } from '../../../authorization'; +import { hasPermission } from '../../../authorization/server'; import { settings } from '../../../settings/server'; -import { getURL } from '../../../utils'; import { validateCustomFields, saveUser, saveCustomFieldsWithoutValidation, checkUsernameAvailability, + setStatusText, setUserAvatar, saveCustomFields, - setStatusText, } from '../../../lib/server'; import { getFullUserDataByIdOrUsername } from '../../../lib/server/functions/getFullUserData'; import { API } from '../api'; -import { getUploadFormData } from '../lib/getUploadFormData'; import { findUsersToAutocomplete, getInclusiveFields, getNonEmptyFields, getNonEmptyQuery } from '../lib/users'; import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; -import { setUserStatus } from '../../../../imports/users-presence/server/activeUsers'; import { resetTOTP } from '../../../2fa/server/functions/resetTOTP'; import { Team } from '../../../../server/sdk'; import { isValidQuery } from '../lib/isValidQuery'; +import { setUserStatus } from '../../../../imports/users-presence/server/activeUsers'; +import { getURL } from '../../../utils/server'; +import { getUploadFormData } from '../lib/getUploadFormData'; API.v1.addRoute( - 'users.create', - { authRequired: true }, + 'users.getAvatar', + { authRequired: false }, + { + get() { + const user = this.getUserFromParams(); + + const url = getURL(`/avatar/${user.username}`, { cdn: false, full: true }); + this.response.setHeader('Location', url); + + return { + statusCode: 307, + body: url, + }; + }, + }, +); + +API.v1.addRoute( + 'users.update', + { authRequired: true, twoFactorRequired: true, validateParams: isUsersUpdateParamsPOST }, + { + post() { + const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data }; + + Meteor.runAsUser(this.userId, () => saveUser(this.userId, userData)); + + if (this.bodyParams.data.customFields) { + saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields); + } + + if (typeof this.bodyParams.data.active !== 'undefined') { + const { + userId, + data: { active }, + confirmRelinquish, + } = this.bodyParams; + + Meteor.call('setUserActiveStatus', userId, active, Boolean(confirmRelinquish)); + } + const { fields } = this.parseJsonQuery(); + + return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields }) }); + }, + }, +); + +API.v1.addRoute( + 'users.updateOwnBasicInfo', + { authRequired: true, validateParams: isUsersUpdateOwnBasicInfoParamsPOST }, + { + post() { + const userData = { + email: this.bodyParams.data.email, + realname: this.bodyParams.data.name, + username: this.bodyParams.data.username, + nickname: this.bodyParams.data.nickname, + statusText: this.bodyParams.data.statusText, + newPassword: this.bodyParams.data.newPassword, + typedPassword: this.bodyParams.data.currentPassword, + }; + + // saveUserProfile now uses the default two factor authentication procedures, so we need to provide that + const twoFactorOptions = !userData.typedPassword + ? null + : { + twoFactorCode: userData.typedPassword, + twoFactorMethod: 'password', + }; + + Meteor.call('saveUserProfile', userData, this.bodyParams.customFields, twoFactorOptions); + + return API.v1.success({ + user: Users.findOneById(this.userId, { fields: API.v1.defaultFieldsToExclude }), + }); + }, + }, +); + +API.v1.addRoute( + 'users.setPreferences', + { authRequired: true, validateParams: isUsersSetPreferencesParamsPOST }, { post() { - check(this.bodyParams, { - email: String, - name: String, - password: String, - username: String, - active: Match.Maybe(Boolean), - bio: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - roles: Match.Maybe(Array), - joinDefaultChannels: Match.Maybe(Boolean), - requirePasswordChange: Match.Maybe(Boolean), - setRandomPassword: Match.Maybe(Boolean), - sendWelcomeEmail: Match.Maybe(Boolean), - verified: Match.Maybe(Boolean), - customFields: Match.Maybe(Object), + if (this.bodyParams.userId && this.bodyParams.userId !== this.userId && !hasPermission(this.userId, 'edit-other-user-info')) { + throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed'); + } + const userId = this.bodyParams.userId ? this.bodyParams.userId : this.userId; + if (!Users.findOneById(userId)) { + throw new Meteor.Error('error-invalid-user', 'The optional "userId" param provided does not match any users'); + } + + Meteor.runAsUser(userId, () => Meteor.call('saveUserPreferences', this.bodyParams.data)); + const user = Users.findOneById(userId, { + fields: { + 'settings.preferences': 1, + 'language': 1, + }, }); + return API.v1.success({ + user: { + _id: user._id, + settings: { + preferences: { + ...user.settings.preferences, + language: user.language, + }, + }, + }, + }); + }, + }, +); + +API.v1.addRoute( + 'users.setAvatar', + { authRequired: true, validateParams: isUsersSetAvatarProps }, + { + async post() { + const canEditOtherUserAvatar = hasPermission(this.userId, 'edit-other-user-avatar'); + + if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { + throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed', { + method: 'users.setAvatar', + }); + } + + let user = ((): IUser | undefined => { + if (this.isUserFromParams()) { + return Meteor.users.findOne(this.userId) as IUser | undefined; + } + if (canEditOtherUserAvatar) { + return this.getUserFromParams(); + } + })(); + + if (!user) { + return API.v1.unauthorized(); + } + + if (this.bodyParams.avatarUrl) { + setUserAvatar(user, this.bodyParams.avatarUrl, '', 'url'); + return API.v1.success(); + } + + const [image, fields] = await getUploadFormData( + { + request: this.request, + }, + { + field: 'image', + }, + ); + + if (!image) { + return API.v1.failure("The 'image' param is required"); + } + + const sentTheUserByFormData = fields.userId || fields.username; + if (sentTheUserByFormData) { + if (fields.userId) { + user = Users.findOneById(fields.userId, { fields: { username: 1 } }); + } else if (fields.username) { + user = Users.findOneByUsernameIgnoringCase(fields.username, { fields: { username: 1 } }); + } + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'The optional "userId" or "username" param provided does not match any users'); + } + + const isAnotherUser = this.userId !== user._id; + if (isAnotherUser && !hasPermission(this.userId, 'edit-other-user-avatar')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + } + setUserAvatar(user, image.fileBuffer, image.mimetype, 'rest'); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'users.create', + { authRequired: true, validateParams: isUserCreateParamsPOST }, + { + post() { // New change made by pull request #5152 if (typeof this.bodyParams.joinDefaultChannels === 'undefined') { this.bodyParams.joinDefaultChannels = true; @@ -68,9 +247,7 @@ API.v1.addRoute( } if (typeof this.bodyParams.active !== 'undefined') { - Meteor.runAsUser(this.userId, () => { - Meteor.call('setUserActiveStatus', newUserId, this.bodyParams.active); - }); + Meteor.call('setUserActiveStatus', newUserId, this.bodyParams.active); } const { fields } = this.parseJsonQuery(); @@ -92,9 +269,7 @@ API.v1.addRoute( const user = this.getUserFromParams(); const { confirmRelinquish = false } = this.requestParams(); - Meteor.runAsUser(this.userId, () => { - Meteor.call('deleteUser', user._id, confirmRelinquish); - }); + Meteor.call('deleteUser', user._id, confirmRelinquish); return API.v1.success(); }, @@ -116,52 +291,24 @@ API.v1.addRoute( const { confirmRelinquish = false } = this.requestParams(); - Meteor.runAsUser(this.userId, () => { - Meteor.call('deleteUserOwnAccount', password, confirmRelinquish); - }); + Meteor.call('deleteUserOwnAccount', password, confirmRelinquish); return API.v1.success(); }, }, ); -API.v1.addRoute( - 'users.getAvatar', - { authRequired: false }, - { - get() { - const user = this.getUserFromParams(); - - const url = getURL(`/avatar/${user.username}`, { cdn: false, full: true }); - this.response.setHeader('Location', url); - - return { - statusCode: 307, - body: url, - }; - }, - }, -); - API.v1.addRoute( 'users.setActiveStatus', - { authRequired: true }, + { authRequired: true, validateParams: isUserSetActiveStatusParamsPOST }, { post() { - check(this.bodyParams, { - userId: String, - activeStatus: Boolean, - confirmRelinquish: Match.Maybe(Boolean), - }); - if (!hasPermission(this.userId, 'edit-other-user-active-status')) { return API.v1.unauthorized(); } - Meteor.runAsUser(this.userId, () => { - const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; - Meteor.call('setUserActiveStatus', userId, activeStatus, confirmRelinquish); - }); + const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; + Meteor.call('setUserActiveStatus', userId, activeStatus, confirmRelinquish); return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields: { active: 1 } }), }); @@ -171,14 +318,9 @@ API.v1.addRoute( API.v1.addRoute( 'users.deactivateIdle', - { authRequired: true }, + { authRequired: true, validateParams: isUserDeactivateIdleParamsPOST }, { post() { - check(this.bodyParams, { - daysIdle: Match.Integer, - role: Match.Optional(String), - }); - if (!hasPermission(this.userId, 'edit-other-user-active-status')) { return API.v1.unauthorized(); } @@ -197,68 +339,41 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.getPresence', - { authRequired: true }, - { - get() { - if (this.isUserFromParams()) { - const user = Users.findOneById(this.userId); - return API.v1.success({ - presence: user.status, - connectionStatus: user.statusConnection, - lastLogin: user.lastLogin, - }); - } - - const user = this.getUserFromParams(); - - return API.v1.success({ - presence: user.status, - }); - }, - }, -); - API.v1.addRoute( 'users.info', - { authRequired: true }, + { authRequired: true, validateParams: isUsersInfoParamsGetProps }, { - get() { - const { username, userId } = this.requestParams(); + async get() { const { fields } = this.parseJsonQuery(); - check(userId, Match.Maybe(String)); - check(username, Match.Maybe(String)); - - if (userId !== undefined && username !== undefined) { - throw new Meteor.Error('invalid-filter', 'Cannot filter by id and username at once'); - } - - if (!userId && !username) { - throw new Meteor.Error('invalid-filter', 'Must filter by id or username'); - } - - const user = getFullUserDataByIdOrUsername({ userId: this.userId, filterId: userId, filterUsername: username }); + const user = await getFullUserDataByIdOrUsername(this.userId, { + filterId: (this.queryParams as any).userId, + filterUsername: (this.queryParams as any).username, + }); if (!user) { return API.v1.failure('User not found.'); } const myself = user._id === this.userId; if (fields.userRooms === 1 && (myself || hasPermission(this.userId, 'view-other-user-channels'))) { - user.rooms = Subscriptions.findByUserId(user._id, { - fields: { - rid: 1, - name: 1, - t: 1, - roles: 1, - unread: 1, - }, - sort: { - t: 1, - name: 1, + return API.v1.success({ + user: { + ...user, + rooms: Subscriptions.findByUserId(user._id, { + projection: { + rid: 1, + name: 1, + t: 1, + roles: 1, + unread: 1, + }, + sort: { + t: 1, + name: 1, + }, + }).fetch(), }, - }).fetch(); + }); } return API.v1.success({ @@ -298,14 +413,14 @@ API.v1.addRoute( inclusiveFieldsKeys.includes('emails') && 'emails.address.*', inclusiveFieldsKeys.includes('username') && 'username.*', inclusiveFieldsKeys.includes('name') && 'name.*', - ].filter(Boolean), + ].filter(Boolean) as string[], this.queryOperations, ) ) { throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n')); } - const actualSort = sort && sort.name ? { nameInsensitive: sort.name, ...sort } : sort || { username: 1 }; + const actualSort = sort?.name ? { nameInsensitive: sort.name, ...sort } : sort || { username: 1 }; const limit = count !== 0 @@ -373,6 +488,7 @@ API.v1.addRoute( numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser'), intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), }, + validateParams: isUserRegisterParamsPOST, }, { post() { @@ -380,306 +496,40 @@ API.v1.addRoute( return API.v1.failure('Logged in users can not register again.'); } - // We set their username here, so require it - // The `registerUser` checks for the other requirements - check( - this.bodyParams, - Match.ObjectIncluding({ - username: String, - }), - ); - if (!checkUsernameAvailability(this.bodyParams.username)) { return API.v1.failure('Username is already in use'); } // Register the user - const userId = Meteor.call('registerUser', this.bodyParams); - - // Now set their username - Meteor.runAsUser(userId, () => Meteor.call('setUsername', this.bodyParams.username)); - const { fields } = this.parseJsonQuery(); - - return API.v1.success({ user: Users.findOneById(userId, { fields }) }); - }, - }, -); - -API.v1.addRoute( - 'users.resetAvatar', - { authRequired: true }, - { - post() { - const user = this.getUserFromParams(); - - if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { - Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar')); - } else if (hasPermission(this.userId, 'edit-other-user-avatar')) { - Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar', user._id)); - } else { - throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { - method: 'users.resetAvatar', - }); - } - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.setAvatar', - { authRequired: true }, - { - async post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - avatarUrl: Match.Maybe(String), - userId: Match.Maybe(String), - username: Match.Maybe(String), - }), - ); - const canEditOtherUserAvatar = hasPermission(this.userId, 'edit-other-user-avatar'); - - if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { - throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed', { - method: 'users.setAvatar', - }); - } - - let user; - if (this.isUserFromParams()) { - user = Meteor.users.findOne(this.userId); - } else if (canEditOtherUserAvatar) { - user = this.getUserFromParams(); - } else { - return API.v1.unauthorized(); - } - - if (this.bodyParams.avatarUrl) { - setUserAvatar(user, this.bodyParams.avatarUrl, '', 'url'); - return API.v1.success(); - } - - const [image, fields] = await getUploadFormData( - { - request: this.request, - }, - { field: 'image' }, - ); - - if (!image) { - return API.v1.failure("The 'image' param is required"); - } - - const sentTheUserByFormData = fields.userId || fields.username; - if (sentTheUserByFormData) { - if (fields.userId) { - user = Users.findOneById(fields.userId, { fields: { username: 1 } }); - } else if (fields.username) { - user = Users.findOneByUsernameIgnoringCase(fields.username, { fields: { username: 1 } }); - } - - if (!user) { - throw new Meteor.Error('error-invalid-user', 'The optional "userId" or "username" param provided does not match any users'); - } - - const isAnotherUser = this.userId !== user._id; - if (isAnotherUser && !hasPermission(this.userId, 'edit-other-user-avatar')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } - } - - setUserAvatar(user, image.fileBuffer, image.mimetype, 'rest'); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.getStatus', - { authRequired: true }, - { - get() { - if (this.isUserFromParams()) { - const user = Users.findOneById(this.userId); - return API.v1.success({ - _id: user._id, - message: user.statusText, - connectionStatus: user.statusConnection, - status: user.status, - }); - } - - const user = this.getUserFromParams(); - - return API.v1.success({ - _id: user._id, - message: user.statusText, - status: user.status, - }); - }, - }, -); - -API.v1.addRoute( - 'users.setStatus', - { authRequired: true }, - { - post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - status: Match.Maybe(String), - message: Match.Maybe(String), - }), - ); - - if (!settings.get('Accounts_AllowUserStatusMessageChange')) { - throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', { - method: 'users.setStatus', - }); - } - - let user; - if (this.isUserFromParams()) { - user = Meteor.users.findOne(this.userId); - } else if (hasPermission(this.userId, 'edit-other-user-info')) { - user = this.getUserFromParams(); - } else { - return API.v1.unauthorized(); - } - - Meteor.runAsUser(user._id, () => { - if (this.bodyParams.message || this.bodyParams.message === '') { - setStatusText(user._id, this.bodyParams.message); - } - if (this.bodyParams.status) { - const validStatus = ['online', 'away', 'offline', 'busy']; - if (validStatus.includes(this.bodyParams.status)) { - const { status } = this.bodyParams; - - if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { - throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { - method: 'users.setStatus', - }); - } - - Meteor.users.update(user._id, { - $set: { - status, - statusDefault: status, - }, - }); - - setUserStatus(user, status); - } else { - throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { - method: 'users.setStatus', - }); - } - } - }); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.update', - { authRequired: true, twoFactorRequired: true }, - { - post() { - check(this.bodyParams, { - userId: String, - data: Match.ObjectIncluding({ - email: Match.Maybe(String), - name: Match.Maybe(String), - password: Match.Maybe(String), - username: Match.Maybe(String), - bio: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - active: Match.Maybe(Boolean), - roles: Match.Maybe(Array), - joinDefaultChannels: Match.Maybe(Boolean), - requirePasswordChange: Match.Maybe(Boolean), - sendWelcomeEmail: Match.Maybe(Boolean), - verified: Match.Maybe(Boolean), - customFields: Match.Maybe(Object), - }), - }); - - const userData = _.extend({ _id: this.bodyParams.userId }, this.bodyParams.data); - - Meteor.runAsUser(this.userId, () => saveUser(this.userId, userData)); - - if (this.bodyParams.data.customFields) { - saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields); - } - - if (typeof this.bodyParams.data.active !== 'undefined') { - const { - userId, - data: { active }, - confirmRelinquish = false, - } = this.bodyParams; + const userId = Meteor.call('registerUser', this.bodyParams); - Meteor.runAsUser(this.userId, () => { - Meteor.call('setUserActiveStatus', userId, active, confirmRelinquish); - }); - } + // Now set their username + Meteor.runAsUser(userId, () => Meteor.call('setUsername', this.bodyParams.username)); const { fields } = this.parseJsonQuery(); - return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields }) }); + return API.v1.success({ user: Users.findOneById(userId, { fields }) }); }, }, ); API.v1.addRoute( - 'users.updateOwnBasicInfo', + 'users.resetAvatar', { authRequired: true }, { post() { - check(this.bodyParams, { - data: Match.ObjectIncluding({ - email: Match.Maybe(String), - name: Match.Maybe(String), - username: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - currentPassword: Match.Maybe(String), - newPassword: Match.Maybe(String), - }), - customFields: Match.Maybe(Object), - }); - - const userData = { - email: this.bodyParams.data.email, - realname: this.bodyParams.data.name, - username: this.bodyParams.data.username, - nickname: this.bodyParams.data.nickname, - statusText: this.bodyParams.data.statusText, - newPassword: this.bodyParams.data.newPassword, - typedPassword: this.bodyParams.data.currentPassword, - }; - - // saveUserProfile now uses the default two factor authentication procedures, so we need to provide that - const twoFactorOptions = !userData.typedPassword - ? null - : { - twoFactorCode: userData.typedPassword, - twoFactorMethod: 'password', - }; + const user = this.getUserFromParams(); - Meteor.runAsUser(this.userId, () => Meteor.call('saveUserProfile', userData, this.bodyParams.customFields, twoFactorOptions)); + if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { + Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar')); + } else if (hasPermission(this.userId, 'edit-other-user-avatar')) { + Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar', user._id)); + } else { + throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { + method: 'users.resetAvatar', + }); + } - return API.v1.success({ - user: Users.findOneById(this.userId, { fields: API.v1.defaultFieldsToExclude }), - }); + return API.v1.success(); }, }, ); @@ -690,10 +540,7 @@ API.v1.addRoute( { post() { const user = this.getUserFromParams(); - let data; - Meteor.runAsUser(this.userId, () => { - data = Meteor.call('createToken', user._id); - }); + const data = Meteor.call('createToken', user._id); return data ? API.v1.success({ data }) : API.v1.unauthorized(); }, }, @@ -718,77 +565,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.setPreferences', - { authRequired: true }, - { - post() { - check(this.bodyParams, { - userId: Match.Maybe(String), - data: Match.ObjectIncluding({ - newRoomNotification: Match.Maybe(String), - newMessageNotification: Match.Maybe(String), - clockMode: Match.Maybe(Number), - useEmojis: Match.Maybe(Boolean), - convertAsciiEmoji: Match.Maybe(Boolean), - saveMobileBandwidth: Match.Maybe(Boolean), - collapseMediaByDefault: Match.Maybe(Boolean), - autoImageLoad: Match.Maybe(Boolean), - emailNotificationMode: Match.Maybe(String), - unreadAlert: Match.Maybe(Boolean), - notificationsSoundVolume: Match.Maybe(Number), - desktopNotifications: Match.Maybe(String), - pushNotifications: Match.Maybe(String), - enableAutoAway: Match.Maybe(Boolean), - highlights: Match.Maybe(Array), - desktopNotificationRequireInteraction: Match.Maybe(Boolean), - messageViewMode: Match.Maybe(Number), - showMessageInMainThread: Match.Maybe(Boolean), - hideUsernames: Match.Maybe(Boolean), - hideRoles: Match.Maybe(Boolean), - displayAvatars: Match.Maybe(Boolean), - hideFlexTab: Match.Maybe(Boolean), - sendOnEnter: Match.Maybe(String), - language: Match.Maybe(String), - sidebarShowFavorites: Match.Optional(Boolean), - sidebarShowUnread: Match.Optional(Boolean), - sidebarSortby: Match.Optional(String), - sidebarViewMode: Match.Optional(String), - sidebarDisplayAvatar: Match.Optional(Boolean), - sidebarGroupByType: Match.Optional(Boolean), - muteFocusedConversations: Match.Optional(Boolean), - }), - }); - if (this.bodyParams.userId && this.bodyParams.userId !== this.userId && !hasPermission(this.userId, 'edit-other-user-info')) { - throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed'); - } - const userId = this.bodyParams.userId ? this.bodyParams.userId : this.userId; - if (!Users.findOneById(userId)) { - throw new Meteor.Error('error-invalid-user', 'The optional "userId" param provided does not match any users'); - } - - Meteor.runAsUser(userId, () => Meteor.call('saveUserPreferences', this.bodyParams.data)); - const user = Users.findOneById(userId, { - fields: { - 'settings.preferences': 1, - 'language': 1, - }, - }); - return API.v1.success({ - user: { - _id: user._id, - settings: { - preferences: { - ...user.settings.preferences, - language: user.language, - }, - }, - }, - }); - }, - }, -); - API.v1.addRoute( 'users.forgotPassword', { authRequired: false }, @@ -810,7 +586,7 @@ API.v1.addRoute( { authRequired: true }, { get() { - const result = Meteor.runAsUser(this.userId, () => Meteor.call('getUsernameSuggestion')); + const result = Meteor.call('getUsernameSuggestion'); return API.v1.success({ result }); }, @@ -826,7 +602,7 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - const token = Meteor.runAsUser(this.userId, () => Meteor.call('personalAccessTokens:generateToken', { tokenName, bypassTwoFactor })); + const token = Meteor.call('personalAccessTokens:generateToken', { tokenName, bypassTwoFactor }); return API.v1.success({ token }); }, @@ -842,7 +618,7 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - const token = Meteor.runAsUser(this.userId, () => Meteor.call('personalAccessTokens:regenerateToken', { tokenName })); + const token = Meteor.call('personalAccessTokens:regenerateToken', { tokenName }); return API.v1.success({ token }); }, @@ -857,19 +633,19 @@ API.v1.addRoute( if (!hasPermission(this.userId, 'create-personal-access-tokens')) { throw new Meteor.Error('not-authorized', 'Not Authorized'); } - const loginTokens = Users.getLoginTokensByUserId(this.userId).fetch()[0]; - const getPersonalAccessTokens = () => - loginTokens.services.resume.loginTokens - .filter((loginToken) => loginToken.type && loginToken.type === 'personalAccessToken') - .map((loginToken) => ({ - name: loginToken.name, - createdAt: loginToken.createdAt, - lastTokenPart: loginToken.lastTokenPart, - bypassTwoFactor: loginToken.bypassTwoFactor, - })); + + const user = Users.getLoginTokensByUserId(this.userId).fetch()[0] as IUser | undefined; return API.v1.success({ - tokens: loginTokens ? getPersonalAccessTokens() : [], + tokens: + user?.services?.resume?.loginTokens + ?.filter((loginToken: any) => loginToken.type === 'personalAccessToken') + .map((loginToken: IPersonalAccessToken) => ({ + name: loginToken.name, + createdAt: loginToken.createdAt.toISOString(), + lastTokenPart: loginToken.lastTokenPart, + bypassTwoFactor: Boolean(loginToken.bypassTwoFactor), + })) || [], }); }, }, @@ -884,11 +660,9 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - Meteor.runAsUser(this.userId, () => - Meteor.call('personalAccessTokens:removeToken', { - tokenName, - }), - ); + Meteor.call('personalAccessTokens:removeToken', { + tokenName, + }); return API.v1.success(); }, @@ -931,7 +705,7 @@ API.v1.addRoute('users.2fa.sendEmailCode', { const userId = this.userId || Users[method](emailOrUsername, { fields: { _id: 1 } })?._id; if (!userId) { - this.logger.error('[2fa] User was not found when requesting 2fa email code'); + // this.logger.error('[2fa] User was not found when requesting 2fa email code'); return API.v1.success(); } @@ -968,7 +742,7 @@ API.v1.addRoute( if (from) { const ts = new Date(from); - const diff = (Date.now() - ts) / 1000 / 60; + const diff = (Date.now() - Number(ts)) / 1000 / 60; if (diff < 10) { return API.v1.success({ @@ -992,10 +766,13 @@ API.v1.addRoute( { get() { const { fullExport = false } = this.queryParams; - const result = Meteor.runAsUser(this.userId, () => Meteor.call('requestDataDownload', { fullExport: fullExport === 'true' })); + const result = Meteor.call('requestDataDownload', { fullExport: fullExport === 'true' }) as { + requested: boolean; + exportOperation: IExportOperation; + }; return API.v1.success({ - requested: result.requested, + requested: Boolean(result.requested), exportOperation: result.exportOperation, }); }, @@ -1007,48 +784,43 @@ API.v1.addRoute( { authRequired: true }, { async post() { - try { - const hashedToken = Accounts._hashLoginToken(this.request.headers['x-auth-token']); + const xAuthToken = this.request.headers['x-auth-token'] as string; - if (!(await UsersRaw.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + if (!xAuthToken) { + throw new Meteor.Error('error-parameter-required', 'x-auth-token is required'); + } + const hashedToken = Accounts._hashLoginToken(xAuthToken); - const me = await UsersRaw.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1 } }); + if (!(await UsersRaw.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - const token = me.services.resume.loginTokens.find((token) => token.hashedToken === hashedToken); + const me = (await UsersRaw.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1 } })) as Pick; - const tokenExpires = new Date(token.when.getTime() + settings.get('Accounts_LoginExpiration') * 1000); + const token = me.services?.resume?.loginTokens?.find((token) => token.hashedToken === hashedToken); - return API.v1.success({ - token: this.request.headers['x-auth-token'], - tokenExpires, - }); - } catch (error) { - return API.v1.failure(error); - } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const tokenExpires = new Date(token!.when.getTime() + settings.get('Accounts_LoginExpiration') * 1000); + + return API.v1.success({ + token: xAuthToken, + tokenExpires: tokenExpires.toISOString() || '', + }); }, }, ); API.v1.addRoute( 'users.autocomplete', - { authRequired: true }, + { authRequired: true, validateParams: isUsersAutocompleteProps }, { - get() { + async get() { const { selector } = this.queryParams; - - if (!selector) { - return API.v1.failure("The 'selector' param is required"); - } - return API.v1.success( - Promise.await( - findUsersToAutocomplete({ - uid: this.userId, - selector: JSON.parse(selector), - }), - ), + await findUsersToAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }), ); }, }, @@ -1059,7 +831,7 @@ API.v1.addRoute( { authRequired: true }, { post() { - API.v1.success(Meteor.call('removeOtherTokens')); + return API.v1.success(Meteor.call('removeOtherTokens')); }, }, ); @@ -1069,30 +841,28 @@ API.v1.addRoute( { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, { post() { - // reset own keys - if (this.isUserFromParams()) { - resetUserE2EEncriptionKey(this.userId, false); - return API.v1.success(); - } + if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { + // reset other user keys + const user = this.getUserFromParams(); + if (!user) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - // reset other user keys - const user = this.getUserFromParams(); - if (!user) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!hasPermission(this.userId, 'edit-other-user-e2ee')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!hasPermission(Meteor.userId(), 'edit-other-user-e2ee')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!resetUserE2EEncriptionKey(user._id, true)) { + return API.v1.failure(); + } - if (!resetUserE2EEncriptionKey(user._id, true)) { - return API.v1.failure(); + return API.v1.success(); } - + resetUserE2EEncriptionKey(this.userId, false); return API.v1.success(); }, }, @@ -1102,29 +872,28 @@ API.v1.addRoute( 'users.resetTOTP', { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, { - post() { - // reset own keys - if (this.isUserFromParams()) { - Promise.await(resetTOTP(this.userId, false)); - return API.v1.success(); - } - - // reset other user keys - const user = this.getUserFromParams(); - if (!user) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + async post() { + // // reset own keys + if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { + // reset other user keys + if (!hasPermission(this.userId, 'edit-other-user-totp')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!hasPermission(Meteor.userId(), 'edit-other-user-totp')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + const user = this.getUserFromParams(); + if (!user) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - Promise.await(resetTOTP(user._id, true)); + await resetTOTP(user._id, true); + return API.v1.success(); + } + await resetTOTP(this.userId, false); return API.v1.success(); }, }, @@ -1132,25 +901,22 @@ API.v1.addRoute( API.v1.addRoute( 'users.listTeams', - { authRequired: true }, + { authRequired: true, validateParams: isUsersListTeamsProps }, { - get() { + async get() { check( this.queryParams, Match.ObjectIncluding({ userId: Match.Maybe(String), }), ); - const { userId } = this.queryParams; - if (!userId) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + const { userId } = this.queryParams; // If the caller has permission to view all teams, there's no need to filter the teams const adminId = hasPermission(this.userId, 'view-all-teams') ? undefined : this.userId; - const teams = Promise.await(Team.findBySubscribedUserIds(userId, adminId)); + const teams = await Team.findBySubscribedUserIds(userId, adminId); return API.v1.success({ teams, @@ -1161,7 +927,7 @@ API.v1.addRoute( API.v1.addRoute( 'users.logout', - { authRequired: true }, + { authRequired: true, validateParams: isUserLogoutParamsPOST }, { post() { const userId = this.bodyParams.userId || this.userId; @@ -1182,7 +948,130 @@ API.v1.addRoute( }, ); -settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { +API.v1.addRoute( + 'users.getPresence', + { authRequired: true }, + { + get() { + if (this.isUserFromParams()) { + const user = Users.findOneById(this.userId); + return API.v1.success({ + presence: user.status || 'offline', + connectionStatus: user.statusConnection || 'offline', + ...(user.lastLogin && { lastLogin: user.lastLogin }), + }); + } + + const user = this.getUserFromParams(); + + return API.v1.success({ + presence: user.status || 'offline', + }); + }, + }, +); + +API.v1.addRoute( + 'users.setStatus', + { authRequired: true }, + { + post() { + check( + this.bodyParams, + Match.ObjectIncluding({ + status: Match.Maybe(String), + message: Match.Maybe(String), + }), + ); + + if (!settings.get('Accounts_AllowUserStatusMessageChange')) { + throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', { + method: 'users.setStatus', + }); + } + + const user = ((): IUser | undefined => { + if (this.isUserFromParams()) { + return Meteor.users.findOne(this.userId) as IUser; + } + if (hasPermission(this.userId, 'edit-other-user-info')) { + return this.getUserFromParams(); + } + })(); + + if (user === undefined) { + return API.v1.unauthorized(); + } + + Meteor.runAsUser(user._id, () => { + if (this.bodyParams.message || this.bodyParams.message === '') { + setStatusText(user._id, this.bodyParams.message); + } + if (this.bodyParams.status) { + const validStatus = ['online', 'away', 'offline', 'busy']; + if (validStatus.includes(this.bodyParams.status)) { + const { status } = this.bodyParams; + + if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { + throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { + method: 'users.setStatus', + }); + } + + Meteor.users.update(user._id, { + $set: { + status, + statusDefault: status, + }, + }); + + setUserStatus(user, status); + } else { + throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { + method: 'users.setStatus', + }); + } + } + }); + + return API.v1.success(); + }, + }, +); + +// status: 'online' | 'offline' | 'away' | 'busy'; +// message?: string; +// _id: string; +// connectionStatus?: 'online' | 'offline' | 'away' | 'busy'; +// }; + +API.v1.addRoute( + 'users.getStatus', + { authRequired: true }, + { + get() { + if (this.isUserFromParams()) { + const user = Users.findOneById(this.userId); + return API.v1.success({ + _id: user._id, + // message: user.statusText, + connectionStatus: (user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy', + status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + }); + } + + const user = this.getUserFromParams(); + + return API.v1.success({ + _id: user._id, + // message: user.statusText, + status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + }); + }, + }, +); + +settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { const userRegisterRoute = '/api/v1/users.registerpost'; API.v1.updateRateLimiterDictionaryForRoute(userRegisterRoute, value); diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.js b/apps/meteor/app/lib/server/functions/getFullUserData.ts similarity index 65% rename from apps/meteor/app/lib/server/functions/getFullUserData.js rename to apps/meteor/app/lib/server/functions/getFullUserData.ts index d328f3b05832..74bf410aee9b 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.js +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -1,7 +1,9 @@ -import { Logger } from '../../../logger'; +import { IUser } from '@rocket.chat/core-typings'; + +import { Logger } from '../../../logger/server'; import { settings } from '../../../settings/server'; import { Users } from '../../../models/server'; -import { hasPermission } from '../../../authorization'; +import { hasPermission } from '../../../authorization/server'; const logger = new Logger('getFullUserData'); @@ -18,7 +20,7 @@ const defaultFields = { statusText: 1, avatarETag: 1, extension: 1, -}; +} as const; const fullFields = { emails: 1, @@ -31,12 +33,12 @@ const fullFields = { requirePasswordChange: 1, requirePasswordChangeReason: 1, roles: 1, -}; +} as const; -let publicCustomFields = {}; -let customFields = {}; +let publicCustomFields: Record = {}; +let customFields: Record = {}; -settings.watch('Accounts_CustomFields', (value) => { +settings.watch('Accounts_CustomFields', (value) => { publicCustomFields = {}; customFields = {}; @@ -58,29 +60,23 @@ settings.watch('Accounts_CustomFields', (value) => { } }); -const getCustomFields = (canViewAllInfo) => (canViewAllInfo ? customFields : publicCustomFields); +const getCustomFields = (canViewAllInfo: boolean): Record => (canViewAllInfo ? customFields : publicCustomFields); -const getFields = (canViewAllInfo) => ({ +const getFields = (canViewAllInfo: boolean): Record => ({ ...defaultFields, ...(canViewAllInfo && fullFields), ...getCustomFields(canViewAllInfo), }); -const removePasswordInfo = (user) => { - if (user && user.services) { - delete user.services.password; - delete user.services.email; - delete user.services.resume; - delete user.services.emailCode; - delete user.services.cloud; - delete user.services.email2fa; - delete user.services.totp; - } - - return user; +const removePasswordInfo = (user: IUser): Omit => { + const { services, ...result } = user; + return result; }; -export function getFullUserDataByIdOrUsername({ userId, filterId, filterUsername }) { +export async function getFullUserDataByIdOrUsername( + userId: string, + { filterId, filterUsername }: { filterId: string; filterUsername?: undefined } | { filterId?: undefined; filterUsername: string }, +): Promise { const caller = Users.findOneById(userId, { fields: { username: 1 } }); const targetUser = filterId || filterUsername; const myself = (filterId && targetUser === userId) || (filterUsername && targetUser === caller.username); diff --git a/apps/meteor/app/lib/server/functions/setUserAvatar.ts b/apps/meteor/app/lib/server/functions/setUserAvatar.ts index e72b847c5cee..4718303b21ca 100644 --- a/apps/meteor/app/lib/server/functions/setUserAvatar.ts +++ b/apps/meteor/app/lib/server/functions/setUserAvatar.ts @@ -8,12 +8,26 @@ import { SystemLogger } from '../../../../server/lib/logger/system'; import { api } from '../../../../server/sdk/api'; import { fetch } from '../../../../server/lib/http/fetch'; -export const setUserAvatar = function ( +export function setUserAvatar( + user: Pick, + dataURI: Buffer, + contentType: string, + service: 'rest', + etag?: string, +): void; +export function setUserAvatar( user: Pick, dataURI: string, contentType: string, service: 'initials' | 'url' | 'rest' | string, etag?: string, +): void; +export function setUserAvatar( + user: Pick, + dataURI: string | Buffer, + contentType: string, + service: 'initials' | 'url' | 'rest' | string, + etag?: string, ): void { if (service === 'initials') { Users.setAvatarData(user._id, service, null); @@ -22,7 +36,7 @@ export const setUserAvatar = function ( const { buffer, type } = Promise.await( (async (): Promise<{ buffer: Buffer; type: string }> => { - if (service === 'url') { + if (service === 'url' && typeof dataURI === 'string') { let response: Response; try { response = await fetch(dataURI); @@ -69,7 +83,7 @@ export const setUserAvatar = function ( if (service === 'rest') { return { - buffer: Buffer.from(dataURI, 'binary'), + buffer: dataURI instanceof Buffer ? dataURI : Buffer.from(dataURI, 'binary'), type: contentType, }; } @@ -103,4 +117,4 @@ export const setUserAvatar = function ( avatarETag, }); }, 500); -}; +} diff --git a/apps/meteor/client/lib/presence.ts b/apps/meteor/client/lib/presence.ts index 5c81820e7e4e..4a14c1478b28 100644 --- a/apps/meteor/client/lib/presence.ts +++ b/apps/meteor/client/lib/presence.ts @@ -27,11 +27,6 @@ export type UserPresence = Readonly< Partial> & Required> >; -type UsersPresencePayload = { - users: UserPresence[]; - full: boolean; -}; - const isUid = (eventType: keyof Events): eventType is UserPresence['_id'] => Boolean(eventType) && typeof eventType === 'string' && !['reset', 'restart', 'remove'].includes(eventType); @@ -51,15 +46,6 @@ const notify = (presence: UserPresence): void => { } }; -declare module '@rocket.chat/rest-typings' { - // eslint-disable-next-line @typescript-eslint/interface-name-prefix - export interface Endpoints { - '/v1/users.presence': { - GET: (params: { ids: string[] }) => UsersPresencePayload; - }; - } -} - const getPresence = ((): ((uid: UserPresence['_id']) => void) => { let timer: ReturnType; diff --git a/apps/meteor/server/sdk/types/ITeamService.ts b/apps/meteor/server/sdk/types/ITeamService.ts index 1564ae5c6cdd..607413f82889 100644 --- a/apps/meteor/server/sdk/types/ITeamService.ts +++ b/apps/meteor/server/sdk/types/ITeamService.ts @@ -112,4 +112,5 @@ export interface ITeamService { removeAllMembersFromTeam(teamId: string): Promise; removeRolesFromMember(teamId: string, userId: string, roles: Array): Promise; getStatistics(): Promise; + findBySubscribedUserIds(userId: string, callerId?: string): Promise; } diff --git a/apps/meteor/tests/end-to-end/api/01-users.js b/apps/meteor/tests/end-to-end/api/01-users.js index 6efd6989c969..77949612333b 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -328,6 +328,7 @@ describe('[Users]', function () { email, name: 'name', username, + pass: 'test', }) .expect('Content-Type', 'application/json') .expect(400) @@ -3117,7 +3118,6 @@ describe('[Users]', function () { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal("The 'selector' param is required"); }) .end(done); }); diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index 0dde24a2d7ba..15cb0d6bf9ee 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -54,6 +54,7 @@ export interface IUserServices { resume?: { loginTokens?: LoginToken[]; }; + cloud?: unknown; google?: any; facebook?: any; github?: any; diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index e50250dbb060..8ee8f93a7c50 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -176,3 +176,9 @@ export * from './helpers/PaginatedRequest'; export * from './helpers/PaginatedResult'; export * from './helpers/ReplacePlaceholders'; export * from './v1/emojiCustom'; + +export * from './v1/users'; +export * from './v1/users/UsersSetAvatarParamsPOST'; +export * from './v1/users/UsersSetPreferenceParamsPOST'; +export * from './v1/users/UsersUpdateOwnBasicInfoParamsPOST'; +export * from './v1/users/UsersUpdateParamsPOST'; diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 9d9413034dde..3a49e8c38ceb 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -1,6 +1,14 @@ -import type { ITeam, IUser } from '@rocket.chat/core-typings'; +import type { IExportOperation, ISubscription, ITeam, IUser } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; +import type { UserCreateParamsPOST } from './users/UserCreateParamsPOST'; +import type { UserDeactivateIdleParamsPOST } from './users/UserDeactivateIdleParamsPOST'; +import type { UserLogoutParamsPOST } from './users/UserLogoutParamsPOST'; +import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST'; +import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET'; +import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST'; +import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet'; +import type { UsersListTeamsParamsGET } from './users/UsersListTeamsParamsGET'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; import type { PaginatedResult } from '../helpers/PaginatedResult'; @@ -43,36 +51,6 @@ const Users2faSendEmailCodeSchema = { export const isUsers2faSendEmailCodeProps = ajv.compile(Users2faSendEmailCodeSchema); -type UsersAutocomplete = { selector: string }; - -const UsersAutocompleteSchema = { - type: 'object', - properties: { - selector: { - type: 'string', - }, - }, - required: ['selector'], - additionalProperties: false, -}; - -export const isUsersAutocompleteProps = ajv.compile(UsersAutocompleteSchema); - -type UsersListTeams = { userId: IUser['_id'] }; - -const UsersListTeamsSchema = { - type: 'object', - properties: { - userId: { - type: 'string', - }, - }, - required: ['userId'], - additionalProperties: false, -}; - -export const isUsersListTeamsProps = ajv.compile(UsersListTeamsSchema); - type UsersSetAvatar = { userId?: IUser['_id']; username?: IUser['username']; avatarUrl?: string }; const UsersSetAvatarSchema = { @@ -127,31 +105,208 @@ export type UserPresence = Readonly< >; export type UsersEndpoints = { - '/v1/users.info': { - GET: (params: UsersInfo) => { - user: IUser; - }; + '/v1/users.2fa.enableEmail': { + POST: () => void; + }; + + '/v1/users.2fa.disableEmail': { + POST: () => void; }; + '/v1/users.2fa.sendEmailCode': { POST: (params: Users2faSendEmailCode) => void; }; + + '/v1/users.listTeams': { + GET: (params: UsersListTeamsParamsGET) => { teams: ITeam[] }; + }; '/v1/users.autocomplete': { - GET: (params: UsersAutocomplete) => { + GET: (params: UsersAutocompleteParamsGET) => { items: Required>[]; }; }; + '/v1/users.list': { GET: (params: PaginatedRequest<{ query: string }>) => PaginatedResult<{ users: Pick[]; }>; }; - '/v1/users.listTeams': { - GET: (params: UsersListTeams) => { teams: Array }; - }; + '/v1/users.setAvatar': { POST: (params: UsersSetAvatar) => void; }; '/v1/users.resetAvatar': { POST: (params: UsersResetAvatar) => void; }; + + '/v1/users.requestDataDownload': { + GET: (params: { fullExport?: 'true' | 'false' }) => { + requested: boolean; + exportOperation: IExportOperation; + }; + }; + '/v1/users.logoutOtherClients': { + POST: () => { + token: string; + tokenExpires: string; + }; + }; + '/v1/users.removeOtherTokens': { + POST: () => void; + }; + '/v1/users.resetE2EKey': { + POST: ( + params: + | { + userId: string; + } + | { + username: string; + } + | { + user: string; + }, + ) => void; + }; + '/v1/users.resetTOTP': { + POST: ( + params: + | { + userId: string; + } + | { + username: string; + } + | { + user: string; + }, + ) => void; + }; + + '/v1/users.presence': { + GET: (params: { from?: string; ids: string | string[] }) => UsersPresencePayload; + }; + + '/v1/users.removePersonalAccessToken': { + POST: (params: { tokenName: string }) => void; + }; + + '/v1/users.getPersonalAccessTokens': { + GET: () => { + tokens: { + name?: string; + createdAt: string; + lastTokenPart: string; + bypassTwoFactor: boolean; + }[]; + }; + }; + '/v1/users.regeneratePersonalAccessToken': { + POST: (params: { tokenName: string }) => { + token: string; + }; + }; + '/v1/users.generatePersonalAccessToken': { + POST: (params: { tokenName: string; bypassTwoFactor: boolean }) => { + token: string; + }; + }; + '/v1/users.getUsernameSuggestion': { + GET: () => { + result: string; + }; + }; + '/v1/users.forgotPassword': { + POST: (params: { email: string }) => void; + }; + '/v1/users.getPreferences': { + GET: () => { + preferences: Required['settings']['preferences']; + }; + }; + '/v1/users.createToken': { + POST: () => { + data: { + userId: string; + authToken: string; + }; + }; + }; + + '/v1/users.create': { + POST: (params: UserCreateParamsPOST) => { + user: IUser; + }; + }; + + '/v1/users.setActiveStatus': { + POST: (params: UserSetActiveStatusParamsPOST) => { + user: IUser; + }; + }; + + '/v1/users.deactivateIdle': { + POST: (params: UserDeactivateIdleParamsPOST) => { + count: number; + }; + }; + + '/v1/users.getPresence': { + GET: ( + params: + | { + userId: string; + } + | { + username: string; + } + | { + user: string; + }, + ) => { + presence: 'online' | 'offline' | 'away' | 'busy'; + connectionStatus?: 'online' | 'offline' | 'away' | 'busy'; + lastLogin?: string; + }; + }; + + '/v1/users.setStatus': { + POST: (params: { message?: string; status?: 'online' | 'offline' | 'away' | 'busy' }) => void; + }; + + '/v1/users.getStatus': { + GET: () => { + status: 'online' | 'offline' | 'away' | 'busy'; + message?: string; + _id: string; + connectionStatus?: 'online' | 'offline' | 'away' | 'busy'; + }; + }; + + '/v1/users.info': { + GET: (params: UsersInfoParamsGet) => { + user: IUser & { rooms?: Pick[] }; + }; + }; + + '/v1/users.register': { + POST: (params: UserRegisterParamsPOST) => { + user: Partial; + }; + }; + + '/v1/users.logout': { + POST: (params: UserLogoutParamsPOST) => { + message: string; + }; + }; }; + +export * from './users/UserCreateParamsPOST'; +export * from './users/UserSetActiveStatusParamsPOST'; +export * from './users/UserDeactivateIdleParamsPOST'; +export * from './users/UsersInfoParamsGet'; +export * from './users/UserRegisterParamsPOST'; +export * from './users/UserLogoutParamsPOST'; +export * from './users/UsersListTeamsParamsGET'; +export * from './users/UsersAutocompleteParamsGET'; diff --git a/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts b/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts new file mode 100644 index 000000000000..347498999011 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts @@ -0,0 +1,51 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserCreateParamsPOST = { + email: string; + name: string; + password: string; + username: string; + active?: boolean; + bio?: string; + nickname?: string; + statusText?: string; + roles?: string[]; + joinDefaultChannels?: boolean; + requirePasswordChange?: boolean; + setRandomPassword?: boolean; + sendWelcomeEmail?: boolean; + verified?: boolean; + customFields?: object; + /* @deprecated */ + fields: string; +}; + +const userCreateParamsPostSchema = { + type: 'object', + properties: { + email: { type: 'string' }, + name: { type: 'string' }, + password: { type: 'string' }, + username: { type: 'string' }, + active: { type: 'boolean', nullable: true }, + bio: { type: 'string', nullable: true }, + nickname: { type: 'string', nullable: true }, + statusText: { type: 'string', nullable: true }, + roles: { type: 'array', items: { type: 'string' } }, + joinDefaultChannels: { type: 'boolean', nullable: true }, + requirePasswordChange: { type: 'boolean', nullable: true }, + setRandomPassword: { type: 'boolean', nullable: true }, + sendWelcomeEmail: { type: 'boolean', nullable: true }, + verified: { type: 'boolean', nullable: true }, + customFields: { type: 'object' }, + fields: { type: 'string', nullable: true }, + }, + additionalProperties: false, + required: ['email', 'name', 'password', 'username'], +}; + +export const isUserCreateParamsPOST = ajv.compile(userCreateParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UserDeactivateIdleParamsPOST.ts b/packages/rest-typings/src/v1/users/UserDeactivateIdleParamsPOST.ts new file mode 100644 index 000000000000..0b7fb03ed5d1 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UserDeactivateIdleParamsPOST.ts @@ -0,0 +1,26 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserDeactivateIdleParamsPOST = { + daysIdle: number; + role?: string; +}; + +const userDeactivateIdleSchema = { + type: 'object', + properties: { + daysIdle: { + type: 'number', + }, + role: { + type: 'string', + }, + }, + required: ['daysIdle'], + additionalProperties: false, +}; + +export const isUserDeactivateIdleParamsPOST = ajv.compile(userDeactivateIdleSchema); diff --git a/packages/rest-typings/src/v1/users/UserLogoutParamsPOST.ts b/packages/rest-typings/src/v1/users/UserLogoutParamsPOST.ts new file mode 100644 index 000000000000..cd51b37495d6 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UserLogoutParamsPOST.ts @@ -0,0 +1,22 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserLogoutParamsPOST = { + userId?: string; +}; + +const UserLogoutParamsPostSchema = { + type: 'object', + properties: { + userId: { + type: 'string', + nullable: true, + }, + }, + required: [], +}; + +export const isUserLogoutParamsPOST = ajv.compile(UserLogoutParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts b/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts new file mode 100644 index 000000000000..0551e7839206 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts @@ -0,0 +1,47 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserRegisterParamsPOST = { + username: string; + name?: string; + email: string; + pass: string; + secret?: string; + reason?: string; +}; + +const UserRegisterParamsPostSchema = { + type: 'object', + properties: { + username: { + type: 'string', + minLength: 3, + }, + + name: { + type: 'string', + nullable: true, + }, + email: { + type: 'string', + }, + pass: { + type: 'string', + }, + secret: { + type: 'string', + nullable: true, + }, + reason: { + type: 'string', + nullable: true, + }, + }, + required: ['username', 'email', 'pass'], + additionalProperties: false, +}; + +export const isUserRegisterParamsPOST = ajv.compile(UserRegisterParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UserSetActiveStatusParamsPOST.ts b/packages/rest-typings/src/v1/users/UserSetActiveStatusParamsPOST.ts new file mode 100644 index 000000000000..ad503f5d2984 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UserSetActiveStatusParamsPOST.ts @@ -0,0 +1,24 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserSetActiveStatusParamsPOST = { + userId: string; + activeStatus: boolean; + confirmRelinquish?: boolean; +}; + +const UserCreateParamsPostSchema = { + type: 'object', + properties: { + userId: { type: 'string' }, + activeStatus: { type: 'boolean' }, + confirmRelinquish: { type: 'boolean', nullable: true }, + }, + required: ['userId', 'activeStatus'], + additionalProperties: false, +}; + +export const isUserSetActiveStatusParamsPOST = ajv.compile(UserCreateParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UsersAutocompleteParamsGET.ts b/packages/rest-typings/src/v1/users/UsersAutocompleteParamsGET.ts new file mode 100644 index 000000000000..bdb6578db24d --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersAutocompleteParamsGET.ts @@ -0,0 +1,20 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersAutocompleteParamsGET = { selector: string }; + +const UsersAutocompleteParamsGetSchema = { + type: 'object', + properties: { + selector: { + type: 'string', + }, + }, + required: ['selector'], + additionalProperties: false, +}; + +export const isUsersAutocompleteProps = ajv.compile(UsersAutocompleteParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts b/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts new file mode 100644 index 000000000000..ddae73750ea8 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts @@ -0,0 +1,44 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersInfoParamsGet = ({ userId: string } | { username: string }) & { + fields?: string; +}; + +const UsersInfoParamsGetSchema = { + anyOf: [ + { + type: 'object', + properties: { + userId: { + type: 'string', + }, + fields: { + type: 'string', + nullable: true, + }, + }, + required: ['userId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + username: { + type: 'string', + }, + fields: { + type: 'string', + nullable: true, + }, + }, + required: ['username'], + additionalProperties: false, + }, + ], +}; + +export const isUsersInfoParamsGetProps = ajv.compile(UsersInfoParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersListTeamsParamsGET.ts b/packages/rest-typings/src/v1/users/UsersListTeamsParamsGET.ts new file mode 100644 index 000000000000..336fd6b3144a --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersListTeamsParamsGET.ts @@ -0,0 +1,21 @@ +import Ajv from 'ajv'; +import type { IUser } from '@rocket.chat/core-typings'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersListTeamsParamsGET = { userId: IUser['_id'] }; + +const UsersListTeamsParamsGetSchema = { + type: 'object', + properties: { + userId: { + type: 'string', + }, + }, + required: ['userId'], + additionalProperties: false, +}; + +export const isUsersListTeamsProps = ajv.compile(UsersListTeamsParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersSetAvatarParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSetAvatarParamsPOST.ts new file mode 100644 index 000000000000..9a43dc7b07f3 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersSetAvatarParamsPOST.ts @@ -0,0 +1,33 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserSetAvatarParamsPOST = { + avatarUrl?: string; + userId?: string; + username?: string; +}; + +const UserSetAvatarParamsPostSchema = { + type: 'object', + properties: { + avatarUrl: { + type: 'string', + nullable: true, + }, + userId: { + type: 'string', + nullable: true, + }, + username: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, +}; + +export const isUserSetAvatarParamsPOST = ajv.compile(UserSetAvatarParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts new file mode 100644 index 000000000000..5379276b4dee --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts @@ -0,0 +1,190 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersSetPreferencesParamsPOST = { + userId?: string; + data: { + newRoomNotification?: string; + newMessageNotification?: string; + clockMode?: number; + useEmojis?: boolean; + convertAsciiEmoji?: boolean; + saveMobileBandwidth?: boolean; + collapseMediaByDefault?: boolean; + autoImageLoad?: boolean; + emailNotificationMode?: string; + unreadAlert?: boolean; + notificationsSoundVolume?: number; + desktopNotifications?: string; + pushNotifications?: string; + enableAutoAway?: boolean; + highlights?: string[]; + desktopNotificationRequireInteraction?: boolean; + messageViewMode?: number; + showMessageInMainThread?: boolean; + hideUsernames?: boolean; + hideRoles?: boolean; + displayAvatars?: boolean; + hideFlexTab?: boolean; + sendOnEnter?: string; + language?: string; + sidebarShowFavorites?: boolean; + sidebarShowUnread?: boolean; + sidebarSortby?: string; + sidebarViewMode?: string; + sidebarDisplayAvatar?: boolean; + sidebarGroupByType?: boolean; + muteFocusedConversations?: boolean; + }; +}; + +const UsersSetPreferencesParamsPostSchema = { + type: 'object', + properties: { + userId: { + type: 'string', + nullable: true, + }, + data: { + type: 'object', + properties: { + newRoomNotification: { + type: 'string', + nullable: true, + }, + newMessageNotification: { + type: 'string', + nullable: true, + }, + clockMode: { + type: 'number', + nullable: true, + }, + useEmojis: { + type: 'boolean', + nullable: true, + }, + convertAsciiEmoji: { + type: 'boolean', + nullable: true, + }, + saveMobileBandwidth: { + type: 'boolean', + nullable: true, + }, + collapseMediaByDefault: { + type: 'boolean', + nullable: true, + }, + autoImageLoad: { + type: 'boolean', + nullable: true, + }, + emailNotificationMode: { + type: 'string', + nullable: true, + }, + unreadAlert: { + type: 'boolean', + nullable: true, + }, + notificationsSoundVolume: { + type: 'number', + nullable: true, + }, + desktopNotifications: { + type: 'string', + nullable: true, + }, + pushNotifications: { + type: 'string', + nullable: true, + }, + enableAutoAway: { + type: 'boolean', + nullable: true, + }, + highlights: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + desktopNotificationRequireInteraction: { + type: 'boolean', + nullable: true, + }, + messageViewMode: { + type: 'number', + nullable: true, + }, + showMessageInMainThread: { + type: 'boolean', + nullable: true, + }, + hideUsernames: { + type: 'boolean', + nullable: true, + }, + hideRoles: { + type: 'boolean', + nullable: true, + }, + displayAvatars: { + type: 'boolean', + nullable: true, + }, + hideFlexTab: { + type: 'boolean', + nullable: true, + }, + sendOnEnter: { + type: 'string', + nullable: true, + }, + language: { + type: 'string', + nullable: true, + }, + sidebarShowFavorites: { + type: 'boolean', + nullable: true, + }, + sidebarShowUnread: { + type: 'boolean', + nullable: true, + }, + sidebarSortby: { + type: 'string', + nullable: true, + }, + sidebarViewMode: { + type: 'string', + nullable: true, + }, + sidebarDisplayAvatar: { + type: 'boolean', + nullable: true, + }, + sidebarGroupByType: { + type: 'boolean', + nullable: true, + }, + muteFocusedConversations: { + type: 'boolean', + nullable: true, + }, + }, + required: [], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, +}; + +export const isUsersSetPreferencesParamsPOST = ajv.compile(UsersSetPreferencesParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UsersUpdateOwnBasicInfoParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersUpdateOwnBasicInfoParamsPOST.ts new file mode 100644 index 000000000000..90f80dedc130 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersUpdateOwnBasicInfoParamsPOST.ts @@ -0,0 +1,67 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersUpdateOwnBasicInfoParamsPOST = { + data: { + email?: string; + name?: string; + username?: string; + nickname?: string; + statusText?: string; + currentPassword?: string; + newPassword?: string; + }; + customFields?: {}; +}; + +const UsersUpdateOwnBasicInfoParamsPostSchema = { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + email: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + nullable: true, + }, + username: { + type: 'string', + nullable: true, + }, + nickname: { + type: 'string', + nullable: true, + }, + statusText: { + type: 'string', + nullable: true, + }, + currentPassword: { + type: 'string', + nullable: true, + }, + newPassword: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, + }, + customFields: { + type: 'object', + nullable: true, + }, + }, + required: ['data'], + additionalProperties: false, +}; + +export const isUsersUpdateOwnBasicInfoParamsPOST = ajv.compile(UsersUpdateOwnBasicInfoParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UsersUpdateParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersUpdateParamsPOST.ts new file mode 100644 index 000000000000..3d0d6c07b5be --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersUpdateParamsPOST.ts @@ -0,0 +1,108 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersUpdateParamsPOST = { + userId: string; + data: { + email?: string; + name?: string; + password?: string; + username?: string; + bio?: string; + nickname?: string; + statusText?: string; + active?: boolean; + roles?: string[]; + joinDefaultChannels?: boolean; + requirePasswordChange?: boolean; + sendWelcomeEmail?: boolean; + verified?: boolean; + customFields?: {}; + }; + confirmRelinquish?: boolean; +}; + +const UsersUpdateParamsPostSchema = { + type: 'object', + properties: { + userId: { + type: 'string', + }, + confirmRelinquish: { + type: 'boolean', + }, + data: { + type: 'object', + properties: { + email: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + nullable: true, + }, + password: { + type: 'string', + nullable: true, + }, + username: { + type: 'string', + nullable: true, + }, + bio: { + type: 'string', + nullable: true, + }, + nickname: { + type: 'string', + nullable: true, + }, + statusText: { + type: 'string', + nullable: true, + }, + active: { + type: 'boolean', + nullable: true, + }, + roles: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + joinDefaultChannels: { + type: 'boolean', + nullable: true, + }, + requirePasswordChange: { + type: 'boolean', + nullable: true, + }, + sendWelcomeEmail: { + type: 'boolean', + nullable: true, + }, + verified: { + type: 'boolean', + nullable: true, + }, + customFields: { + type: 'object', + nullable: true, + }, + }, + required: [], + additionalProperties: false, + }, + }, + required: ['userId', 'data'], + additionalProperties: false, +}; + +export const isUsersUpdateParamsPOST = ajv.compile(UsersUpdateParamsPostSchema);