diff --git a/.changeset/soft-rats-behave.md b/.changeset/soft-rats-behave.md new file mode 100644 index 0000000000000..d657151c07f01 --- /dev/null +++ b/.changeset/soft-rats-behave.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds email search filter to `users.list` and `users.info` endpoints. diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 50c65abcd8d12..85e76035fa358 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -18,10 +18,12 @@ import { isUsersSetPreferencesParamsPOST, isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, + isUsersListParamsGET, ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; @@ -52,7 +54,7 @@ import { } from '../../../lib/server/functions/checkUsernameAvailability'; import { deleteUser } from '../../../lib/server/functions/deleteUser'; import { getAvatarSuggestionForUser } from '../../../lib/server/functions/getAvatarSuggestionForUser'; -import { getFullUserDataByIdOrUsernameOrImportId, defaultFields, fullFields } from '../../../lib/server/functions/getFullUserData'; +import { getFullUserDataByIdOrUsernameOrImportIdOrEmail, defaultFields, fullFields } from '../../../lib/server/functions/getFullUserData'; import { generateUsernameSuggestion } from '../../../lib/server/functions/getUsernameSuggestion'; import { saveCustomFields } from '../../../lib/server/functions/saveCustomFields'; import { saveCustomFieldsWithoutValidation } from '../../../lib/server/functions/saveCustomFieldsWithoutValidation'; @@ -432,16 +434,17 @@ API.v1.addRoute( { authRequired: true, validateParams: isUsersInfoParamsGetProps }, { async get() { - const searchTerms: [string, 'id' | 'username' | 'importId'] | false = + const searchTerms: [string, 'id' | 'username' | 'importId' | 'email'] | false = ('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) || ('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) || - ('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']); + ('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']) || + ('email' in this.queryParams && !!this.queryParams.email && [this.queryParams.email, 'email']); if (!searchTerms) { return API.v1.failure('Invalid search query.'); } - const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms); + const user = await getFullUserDataByIdOrUsernameOrImportIdOrEmail(this.userId, ...searchTerms); if (!user) { return API.v1.failure('User not found.'); @@ -482,6 +485,7 @@ API.v1.addRoute( authRequired: true, queryOperations: ['$or', '$and'], permissionsRequired: ['view-d-room'], + query: isUsersListParamsGET, }, { async get() { @@ -491,6 +495,7 @@ API.v1.addRoute( ) { return API.v1.forbidden(); } + const canViewFullOtherUserInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info'); const { offset, count } = await getPaginationItems(this.queryParams); const { sort, fields, query } = await this.parseJsonQuery(); @@ -501,7 +506,18 @@ API.v1.addRoute( const inclusiveFieldsKeys = Object.keys(inclusiveFields); - const nonEmptyQuery = getNonEmptyQuery(query, await hasPermissionAsync(this.userId, 'view-full-other-user-info')); + const nonEmptyQuery = getNonEmptyQuery(query, canViewFullOtherUserInfo); + + if ('email' in this.queryParams && this.queryParams.email) { + if (!canViewFullOtherUserInfo) { + return API.v1.forbidden(); + } + const escapedEmail = escapeRegExp(this.queryParams.email as string); + nonEmptyQuery['emails.address'] = { + $regex: `^${escapedEmail}$`, + $options: 'i', + }; + } // if user provided a query, validate it with their allowed operators // otherwise we use the default query (with $regex and $options) diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index 4270ad6960fc3..c3c035e16211b 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.ts +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -1,4 +1,4 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, IUserEmail } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { Users } from '@rocket.chat/models'; @@ -75,23 +75,31 @@ const getFields = (canViewAllInfo: boolean): Record => ({ ...getCustomFields(canViewAllInfo), }); -export async function getFullUserDataByIdOrUsernameOrImportId( +const findTargetUser = (type: string, value: string, opts: any) => { + if (type === 'importId') return Users.findOneByImportId(value, opts); + if (type === 'email') return Users.findOneByEmailAddress(value, opts); + return Users.findOneByIdOrUsername(value, opts); +}; + +export async function getFullUserDataByIdOrUsernameOrImportIdOrEmail( userId: string, searchValue: string, - searchType: 'id' | 'username' | 'importId', + searchType: 'id' | 'username' | 'importId' | 'email', ): Promise { - const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1 } }); + const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1, emails: 1 } }); if (!caller) { return null; } const myself = (searchType === 'id' && searchValue === userId) || (searchType === 'username' && searchValue === caller.username) || - (searchType === 'importId' && caller.importIds?.includes(searchValue)); + (searchType === 'importId' && caller.importIds?.includes(searchValue)) || + (searchType === 'email' && + caller.emails?.some((email: IUserEmail) => email.address.trim().toLowerCase() === searchValue.trim().toLowerCase())); const canViewAllInfo = !!myself || (await hasPermissionAsync(userId, 'view-full-other-user-info')); - // Only search for importId if the user has permission to view the import id - if (searchType === 'importId' && !canViewAllInfo) { + // Only search for importId/email if the user has permission to view them + if ((searchType === 'importId' && !canViewAllInfo) || (searchType === 'email' && !canViewAllInfo)) { return null; } @@ -104,9 +112,8 @@ export async function getFullUserDataByIdOrUsernameOrImportId( }, }; - const user = await (searchType === 'importId' - ? Users.findOneByImportId(searchValue, options) - : Users.findOneByIdOrUsername(searchValue, options)); + const user = await findTargetUser(searchType, searchValue, options); + if (!user) { return null; } diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index 049429291babe..fade0321b5250 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -12,7 +12,7 @@ import { getCredentials, api, request, credentials, apiEmail, apiUsername, wait, import { imgURL } from '../../data/interactions'; import { createAgent, makeAgentAvailable } from '../../data/livechat/rooms'; import { removeAgent, getAgent } from '../../data/livechat/users'; -import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { updatePermission, updateSetting, restorePermissionToRoles } from '../../data/permissions.helper'; import type { ActionRoomParams } from '../../data/rooms.helper'; import { actionRoom, createRoom, deleteRoom } from '../../data/rooms.helper'; import { createTeam, deleteTeam } from '../../data/teams.helper'; @@ -182,7 +182,7 @@ const updateUserInDb = async (userId: IUser['_id'], userData: Partial) => }; describe('[Users]', () => { - let targetUser: { _id: IUser['_id']; username: string }; + let targetUser: { _id: IUser['_id']; username: string; emails: { address: string }[] }; let userCredentials: Credentials; before((done) => getCredentials(done)); @@ -197,6 +197,7 @@ describe('[Users]', () => { targetUser = { _id: user._id, username: user.username, + emails: user.emails, }; userCredentials = await login(user.username, password); }); @@ -1166,6 +1167,87 @@ describe('[Users]', () => { }) .end(done); }); + + describe('querying by user email', () => { + after(async () => { + await restorePermissionToRoles('view-full-other-user-info'); + }); + + describe("with 'view-full-other-user-info' permission", () => { + before(async () => { + await updatePermission('view-full-other-user-info', ['admin']); + }); + + it('should query information about a user by email', async () => { + const targetEmail = targetUser.emails[0].address; + + await request + .get(api('users.info')) + .set(credentials) + .query({ + email: targetEmail, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', targetUser.username); + expect(res.body).to.have.nested.property('user._id', targetUser._id); + expect(res.body).to.have.nested.property('user.emails[0].address', targetEmail); + }); + }); + it('should return an error when querying by an email that does not exist', async () => { + await request + .get(api('users.info')) + .set(credentials) + .query({ + email: 'this_is_a_fake_email_that_does_not_exist@invalid.com', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User not found.'); + }); + }); + }); + + describe("without 'view-full-other-user-info' permission", () => { + before(async () => { + await updatePermission('view-full-other-user-info', []); + }); + + it('should return an error when querying another user by email and lacking "view-full-other-user-info" permission', async () => { + await request + .get(api('users.info')) + .set(credentials) + .query({ + email: targetUser.emails[0].address, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User not found.'); + }); + }); + it('should query information about myself by email', async () => { + await request + .get(api('users.info')) + .set(userCredentials) + .query({ + email: targetUser.emails[0].address, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', targetUser.username); + expect(res.body).to.have.nested.property('user.emails[0].address', targetUser.emails[0].address); + }); + }); + }); + }); }); describe('[/users.getPresence]', () => { it("should query a user's presence by userId", (done) => { @@ -1443,28 +1525,6 @@ describe('[Users]', () => { .end(done); }); - it('should query all users in the system by name', (done) => { - // filtering user list - void request - .get(api('users.list')) - .set(credentials) - .query({ - name: { $regex: 'g' }, - sort: JSON.stringify({ - createdAt: -1, - }), - }) - .field('username', 1) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('count'); - expect(res.body).to.have.property('total'); - }) - .end(done); - }); - it('should query all users in the system when logged as normal user and `view-outside-room` not granted', async () => { await updatePermission('view-outside-room', ['admin']); await request @@ -1549,6 +1609,66 @@ describe('[Users]', () => { }); }); }); + + describe('querying by user email', async () => { + after(async () => { + await restorePermissionToRoles('view-full-other-user-info'); + }); + describe("with 'view-full-other-user-info' permission", async () => { + before(async () => { + await updatePermission('view-full-other-user-info', ['admin']); + }); + it('should return the specific user with the "emails" property', async () => { + const targetEmail = targetUser.emails[0].address; + + await request + .get(api('users.list')) + .query({ email: targetEmail }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(1); + + const returnedUser = res.body.users[0]; + expect(returnedUser).to.have.property('_id', targetUser._id); + expect(returnedUser).to.have.property('emails').that.is.an('array'); + expect(returnedUser).to.have.nested.property('emails[0].address', targetEmail); + }); + }); + it('should return an empty array when querying by an email that does not exist', async () => { + await request + .get(api('users.list')) + .query({ email: 'this_email_does_not_exist@invalid.com' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(0); + expect(res.body).to.have.property('count', 0); + }); + }); + }); + describe("without 'view-full-other-user-info' permission", async () => { + before(async () => { + await updatePermission('view-full-other-user-info', []); + }); + it('should return 403 Forbidden', async () => { + await request + .get(api('users.list')) + .query({ email: targetUser.emails[0].address }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + }); + }); }); describe('Avatars', () => { diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index deb8359b1f896..c5950ea114b3c 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -238,6 +238,7 @@ export * from './v1/users/UsersUpdateOwnBasicInfoParamsPOST'; export * from './v1/users/UsersUpdateParamsPOST'; export * from './v1/users/UsersCheckUsernameAvailabilityParamsGET'; export * from './v1/users/UsersSendConfirmationEmailParamsPOST'; +export * from './v1/users/UsersListParamsGET'; export * from './v1/moderation'; export * from './v1/server-events'; diff --git a/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts b/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts index 30dd4e9131c62..3b82a2b908215 100644 --- a/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts +++ b/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts @@ -1,6 +1,6 @@ import { ajvQuery } from '../Ajv'; -export type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string }) & { +export type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string } | { email: string }) & { fields?: string; includeUserRooms?: string; }; @@ -58,6 +58,23 @@ const UsersInfoParamsGetSchema = { required: ['importId'], additionalProperties: false, }, + { + type: 'object', + properties: { + email: { + type: 'string', + }, + includeUserRooms: { + type: 'string', + }, + fields: { + type: 'string', + nullable: true, + }, + }, + required: ['email'], + additionalProperties: false, + }, ], }; diff --git a/packages/rest-typings/src/v1/users/UsersListParamsGET.ts b/packages/rest-typings/src/v1/users/UsersListParamsGET.ts new file mode 100644 index 0000000000000..ae26ca8f66fc2 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersListParamsGET.ts @@ -0,0 +1,23 @@ +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; +import { ajvQuery } from '../Ajv'; + +export type UsersListParamsGET = PaginatedRequest<{ + fields?: string; + query?: string; + email?: string; +}>; + +const UsersListParamsGetSchema = { + type: 'object', + properties: { + fields: { type: 'string', nullable: true }, + query: { type: 'string', nullable: true }, + count: { type: 'number', nullable: true }, + offset: { type: 'number', nullable: true }, + sort: { type: 'string', nullable: true }, + email: { type: 'string', minLength: 1, nullable: true }, + }, + additionalProperties: false, +}; + +export const isUsersListParamsGET = ajvQuery.compile(UsersListParamsGetSchema);