From 35a3b76cd88049723bcb71b3964472221f0e410f Mon Sep 17 00:00:00 2001 From: rique223 Date: Tue, 5 Mar 2024 14:07:39 -0300 Subject: [PATCH 01/23] refactor: :recycle: Create isSMTPConfigured helper Created a helper function named isSMTPConfigured.ts to run when calling the smtp.check endpoint of the misc.ts group. --- apps/meteor/app/api/server/v1/misc.ts | 5 ++--- apps/meteor/app/utils/server/functions/isSMTPConfigured.ts | 6 ++++++ 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 apps/meteor/app/utils/server/functions/isSMTPConfigured.ts diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index 7b6c964a50bb..b8c5c06b2eba 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -27,6 +27,7 @@ import { passwordPolicy } from '../../../lib/server'; import { apiDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../settings/server'; import { getDefaultUserFields } from '../../../utils/server/functions/getDefaultUserFields'; +import { isSMTPConfigured } from '../../../utils/server/functions/isSMTPConfigured'; import { getURL } from '../../../utils/server/getURL'; import { API } from '../api'; import { getLoggedInUser } from '../helpers/getLoggedInUser'; @@ -634,9 +635,7 @@ API.v1.addRoute( { authRequired: true }, { async get() { - const isMailURLSet = !(process.env.MAIL_URL === 'undefined' || process.env.MAIL_URL === undefined); - const isSMTPConfigured = Boolean(settings.get('SMTP_Host')) || isMailURLSet; - return API.v1.success({ isSMTPConfigured }); + return API.v1.success({ isSMTPConfigured: isSMTPConfigured() }); }, }, ); diff --git a/apps/meteor/app/utils/server/functions/isSMTPConfigured.ts b/apps/meteor/app/utils/server/functions/isSMTPConfigured.ts new file mode 100644 index 000000000000..fa300cb37ee2 --- /dev/null +++ b/apps/meteor/app/utils/server/functions/isSMTPConfigured.ts @@ -0,0 +1,6 @@ +import { settings } from '../../../settings/server'; + +export const isSMTPConfigured = (): boolean => { + const isMailURLSet = !(process.env.MAIL_URL === 'undefined' || process.env.MAIL_URL === undefined); + return Boolean(settings.get('SMTP_Host')) || isMailURLSet; +}; From c7ea10e8c530ad503c9bb6d04272348837df5a09 Mon Sep 17 00:00:00 2001 From: rique223 Date: Tue, 5 Mar 2024 15:08:46 -0300 Subject: [PATCH 02/23] feat: :sparkles: Create users.listByStatys and users.sendWelcomeEmail endpoints Created the two new endpoints that represent the back-end for the new users page. --- apps/meteor/app/api/server/v1/users.ts | 127 ++++++++++++++++++ .../meteor/server/methods/sendWelcomeEmail.ts | 49 +++++++ packages/rest-typings/src/v1/users.ts | 21 ++- .../src/v1/users/UsersListStatusParamsGET.ts | 50 +++++++ .../users/UsersSendWelcomeEmailParamsPOST.ts | 20 +++ 5 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/server/methods/sendWelcomeEmail.ts create mode 100644 packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts create mode 100644 packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 10ea2f0b5ac2..e49b63d75591 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -6,6 +6,8 @@ import { isUserSetActiveStatusParamsPOST, isUserDeactivateIdleParamsPOST, isUsersInfoParamsGetProps, + isUsersListStatusProps, + isUsersSendWelcomeEmailProps, isUserRegisterParamsPOST, isUserLogoutParamsPOST, isUsersListTeamsProps, @@ -17,6 +19,7 @@ import { isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, } from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -25,6 +28,7 @@ import type { Filter } from 'mongodb'; import { i18n } from '../../../../server/lib/i18n'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; import { saveUserPreferences } from '../../../../server/methods/saveUserPreferences'; +import { sendWelcomeEmail } from '../../../../server/methods/sendWelcomeEmail'; import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; import { resetTOTP } from '../../../2fa/server/functions/resetTOTP'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -555,6 +559,129 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'users.listByStatus', + { + authRequired: true, + validateParams: isUsersListStatusProps, + permissionsRequired: ['view-d-room', 'view-outside-room'], + }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields } = await this.parseJsonQuery(); + const { status, roles, searchTerm } = this.queryParams; + + const projection = { + name: 1, + username: 1, + emails: 1, + roles: 1, + status: 1, + active: 1, + avatarETag: 1, + lastLogin: 1, + type: 1, + reason: 0, + ...fields, + }; + + const actualSort: Record = sort || { username: 1 }; + + if (sort?.status) { + actualSort.active = sort.status; + } + + if (sort?.name) { + actualSort.nameInsensitive = sort.name; + } + + let match: Filter; + + switch (status) { + case 'active': + match = { + active: true, + lastLogin: { $exists: true }, + }; + break; + case 'all': + match = {}; + break; + case 'deactivated': + match = { + active: false, + lastLogin: { $exists: true }, + }; + break; + case 'pending': + match = { + lastLogin: { $exists: false }, + type: { $nin: ['bot', 'app'] }, + }; + projection.reason = 1; + break; + default: + throw new Meteor.Error('invalid-params', 'Invalid status parameter'); + } + + const canSeeAllUserInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info'); + + match = { + ...match, + $or: [ + ...(canSeeAllUserInfo ? [{ 'emails.address': { $regex: escapeRegExp(searchTerm), $options: 'i' } }] : []), + { username: { $regex: escapeRegExp(searchTerm), $options: 'i' } }, + { name: { $regex: escapeRegExp(searchTerm), $options: 'i' } }, + ], + }; + + if (roles?.length && !roles.includes('all')) { + match = { + ...match, + roles: { $in: roles }, + }; + } + + const { cursor, totalCount } = await Users.findPaginated( + { + ...match, + }, + { + sort: actualSort, + skip: offset, + limit: count, + projection, + }, + ); + const [users, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + users, + count: users.length, + offset, + total, + }); + }, + }, +); + +API.v1.addRoute( + 'users.sendWelcomeEmail', + { + authRequired: true, + validateParams: isUsersSendWelcomeEmailProps, + }, + { + async post() { + const { email } = this.bodyParams; + await sendWelcomeEmail(email); + + return API.v1.success(); + }, + }, +); + API.v1.addRoute( 'users.register', { diff --git a/apps/meteor/server/methods/sendWelcomeEmail.ts b/apps/meteor/server/methods/sendWelcomeEmail.ts new file mode 100644 index 000000000000..a31d642ff73a --- /dev/null +++ b/apps/meteor/server/methods/sendWelcomeEmail.ts @@ -0,0 +1,49 @@ +import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import * as Mailer from '../../app/mailer/server/api'; +import { settings } from '../../app/settings/server'; +import { isSMTPConfigured } from '../../app/utils/server/functions/isSMTPConfigured'; + +export async function sendWelcomeEmail(to: string): Promise { + if (typeof to !== 'string') { + throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { + method: 'sendWelcomeEmail', + }); + } + + if (!isSMTPConfigured()) { + throw new Meteor.Error('error-email-send-failed', 'SMTP is not configured', { + method: 'sendWelcomeEmail', + }); + } + + const email = to.trim(); + + const user = await Users.findOneByEmailAddress(email, { projection: { _id: 1 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'sendWelcomeEmail', + }); + } + + try { + let html = ''; + Mailer.getTemplate('Accounts_UserAddedEmail_Email', (template) => { + html = template; + }); + + await Mailer.send({ + to: email, + from: settings.get('From_Email'), + subject: settings.get('Accounts_UserAddedEmail_Subject'), + html, + }); + } catch (error: any) { + throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${error.message}`, { + method: 'sendWelcomeEmail', + message: error.message, + }); + } +} diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 15f9ad840d42..6ec08410b034 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -10,8 +10,10 @@ import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST'; import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST'; import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET'; import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet'; +import type { UsersListStatusParamsGET } from './users/UsersListStatusParamsGET'; import type { UsersListTeamsParamsGET } from './users/UsersListTeamsParamsGET'; import type { UsersSendConfirmationEmailParamsPOST } from './users/UsersSendConfirmationEmailParamsPOST'; +import type { UsersSendWelcomeEmailParamsPOST } from './users/UsersSendWelcomeEmailParamsPOST'; import type { UsersSetPreferencesParamsPOST } from './users/UsersSetPreferenceParamsPOST'; import type { UsersUpdateOwnBasicInfoParamsPOST } from './users/UsersUpdateOwnBasicInfoParamsPOST'; import type { UsersUpdateParamsPOST } from './users/UsersUpdateParamsPOST'; @@ -110,6 +112,11 @@ export type UserPresence = Readonly< export type UserPersonalTokens = Pick & { createdAt: string }; +export type PickedUser = Pick< + IUser, + '_id' | 'username' | 'name' | 'status' | 'roles' | 'emails' | 'active' | 'avatarETag' | 'lastLogin' | 'type' +>; + export type UsersEndpoints = { '/v1/users.2fa.enableEmail': { POST: () => void; @@ -139,10 +146,20 @@ export type UsersEndpoints = { '/v1/users.list': { GET: (params: PaginatedRequest<{ fields: string }>) => PaginatedResult<{ - users: Pick[]; + users: PickedUser[][]; }>; }; + '/v1/users.listByStatus': { + GET: (params: UsersListStatusParamsGET) => PaginatedResult<{ + users: PickedUser[]; + }>; + }; + + '/v1/users.sendWelcomeEmail': { + POST: (params: UsersSendWelcomeEmailParamsPOST) => { success: boolean }; + }; + '/v1/users.setAvatar': { POST: (params: UsersSetAvatar) => void; }; @@ -373,6 +390,8 @@ export * from './users/UserCreateParamsPOST'; export * from './users/UserSetActiveStatusParamsPOST'; export * from './users/UserDeactivateIdleParamsPOST'; export * from './users/UsersInfoParamsGet'; +export * from './users/UsersListStatusParamsGET'; +export * from './users/UsersSendWelcomeEmailParamsPOST'; export * from './users/UserRegisterParamsPOST'; export * from './users/UserLogoutParamsPOST'; export * from './users/UsersListTeamsParamsGET'; diff --git a/packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts b/packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts new file mode 100644 index 000000000000..a991790911be --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts @@ -0,0 +1,50 @@ +import Ajv from 'ajv'; + +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersListStatusParamsGET = PaginatedRequest<{ + status: 'active' | 'all' | 'deactivated' | 'pending'; + roles?: string[]; + searchTerm?: string; +}>; + +const UsersListStatusParamsGetSchema = { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['active', 'all', 'deactivated', 'pending'], + }, + roles: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + searchTerm: { + type: 'string', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + count: { + type: 'number', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + }, + required: ['status'], + additionalProperties: false, +}; + +export const isUsersListStatusProps = ajv.compile(UsersListStatusParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts new file mode 100644 index 000000000000..7bfd6f04c021 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts @@ -0,0 +1,20 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersSendWelcomeEmailParamsPOST = { email: string }; + +const UsersSendWelcomeEmailParamsPostSchema = { + type: 'object', + properties: { + email: { + type: 'string', + }, + }, + required: ['email'], + additionalProperties: false, +}; + +export const isUsersSendWelcomeEmailProps = ajv.compile(UsersSendWelcomeEmailParamsPostSchema); From ed5cfec74d0372fc1d648cf9da0539fd6ffeed60 Mon Sep 17 00:00:00 2001 From: rique223 Date: Tue, 5 Mar 2024 15:33:08 -0300 Subject: [PATCH 03/23] test: :white_check_mark: Create tests for the users.listByStatus and users.sendWelcomeEmail endpoints --- apps/meteor/tests/end-to-end/api/01-users.js | 257 +++++++++++++++++++ 1 file changed, 257 insertions(+) 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 1aa30d3322bb..04e1120db4c5 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -4285,4 +4285,261 @@ describe('[Users]', function () { }); }); }); + + describe('[/users.listByStatus]', () => { + let user; + let otherUser; + let otherUserCredentials; + + before(async () => { + user = await createUser(); + otherUser = await createUser(); + otherUserCredentials = await login(otherUser.username, password); + }); + + after(async () => { + await deleteUser(user); + await deleteUser(otherUser); + await updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user']); + await updatePermission('view-d-room', ['admin', 'owner', 'moderator', 'user']); + }); + + it('should list pending users', async () => { + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ status: 'pending', count: 50 }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + + const { users } = res.body; + const ids = users.map((user) => user._id); + + expect(ids).to.include(user._id); + }); + }); + + it('should list all users', async () => { + await login(user.username, password); + + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ status: 'all' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + + const { users } = res.body; + const ids = users.map((user) => user._id); + + expect(ids).to.include(user._id); + }); + }); + + it('should list active users', async () => { + await login(user.username, password); + + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ status: 'active' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + + const { users } = res.body; + const ids = users.map((user) => user._id); + + expect(ids).to.include(user._id); + }); + }); + + it('should filter users by role', async () => { + await login(user.username, password); + + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ 'status': 'active', 'roles[]': 'admin' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + + const { users } = res.body; + const ids = users.map((user) => user._id); + + expect(ids).to.not.include(user._id); + }); + }); + + it('should list deactivated users', async () => { + await request.post(api('users.setActiveStatus')).set(credentials).send({ + userId: user._id, + activeStatus: false, + confirmRelinquish: false, + }); + + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ status: 'deactivated' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + + const { users } = res.body; + const ids = users.map((user) => user._id); + + expect(ids).to.include(user._id); + }); + }); + + it('should filter users by username', async () => { + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ status: 'all', searchTerm: user.username }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + + const { users } = res.body; + const ids = users.map((user) => user._id); + + expect(ids).to.include(user._id); + }); + }); + + it('should return error for invalid status params', async () => { + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ status: 'abcd' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.error).to.be.equal('must be equal to one of the allowed values [invalid-params]'); + }); + }); + + it('should throw unauthorized error to user without "view-d-room" permission', async () => { + await updatePermission('view-d-room', ['admin']); + await request + .get(api('users.listByStatus')) + .set(otherUserCredentials) + .query({ status: 'active' }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + it('should throw unauthorized error to user without "view-outside-room" permission', async () => { + await updatePermission('view-outside-room', ['admin']); + await request + .get(api('users.listByStatus')) + .set(otherUserCredentials) + .query({ status: 'active' }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[/users.sendWelcomeEmail]', async () => { + let user; + let otherUser; + + before(async () => { + user = await createUser(); + otherUser = await createUser(); + }); + + after(async () => { + await deleteUser(user); + await deleteUser(otherUser); + }); + + it('should send Welcome Email to user', async () => { + await updateSetting('SMTP_Host', 'localhost'); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({ email: user.emails[0].address }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should fail to send Welcome Email due to SMTP settings missing', async () => { + await updateSetting('SMTP_Host', ''); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({ email: user.emails[0].address }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('SMTP is not configured [error-email-send-failed]'); + }); + }); + + it('should fail to send Welcome Email due to missing param', async () => { + await updateSetting('SMTP_Host', ''); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error', "must have required property 'email' [invalid-params]"); + }); + }); + + it('should fail to send Welcome Email due missing user', async () => { + await updateSetting('SMTP_Host', 'localhost'); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({ email: 'fake_user32132131231@rocket.chat' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-user'); + expect(res.body).to.have.property('error', 'Invalid user [error-invalid-user]'); + }); + }); + }); }); From 4b1549793e0b10b34b039d92b0445d8587432e47 Mon Sep 17 00:00:00 2001 From: rique223 Date: Tue, 5 Mar 2024 16:09:51 -0300 Subject: [PATCH 04/23] Typecheck --- .../client/views/admin/users/UsersTable/UsersTableRow.tsx | 5 +++-- packages/rest-typings/src/v1/users.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index 4010b118e57d..b7460123af7d 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -1,5 +1,6 @@ -import type { IRole, IUser } from '@rocket.chat/core-typings'; +import type { IRole, IUser, Serialized } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; +import type { PickedUser } from '@rocket.chat/rest-typings'; import { capitalize } from '@rocket.chat/string-helpers'; import { UserAvatar } from '@rocket.chat/ui-avatar'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; @@ -11,7 +12,7 @@ import { Roles } from '../../../../../app/models/client'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; type UsersTableRowProps = { - user: Pick; + user: Serialized; onClick: (id: IUser['_id']) => void; mediaQuery: boolean; }; diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 6ec08410b034..eab2b421a14f 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -146,7 +146,7 @@ export type UsersEndpoints = { '/v1/users.list': { GET: (params: PaginatedRequest<{ fields: string }>) => PaginatedResult<{ - users: PickedUser[][]; + users: PickedUser[]; }>; }; From cde16a17bccdb1faf6b03f26a92c329527a96fcb Mon Sep 17 00:00:00 2001 From: rique223 Date: Wed, 6 Mar 2024 16:03:16 -0300 Subject: [PATCH 05/23] feat: :sparkles: Add all tab to users page and implement useFilteredUsers Added a tab layout to the new users page, created the useFilteredUsers custom hook and implemented it. Also created necessary typing and translation entries. --- .../views/admin/users/AdminUsersPage.tsx | 60 ++++- .../admin/users/UsersTable/UsersTable.tsx | 212 +++++++++--------- .../admin/users/UsersTable/UsersTableRow.tsx | 7 +- .../admin/users/hooks/useFilteredUsers.ts | 49 ++++ packages/core-typings/src/IUser.ts | 2 + packages/i18n/src/locales/en.i18n.json | 1 + 6 files changed, 220 insertions(+), 111 deletions(-) create mode 100644 apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index a54f2cf6f6f6..b64c0e067f95 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -1,11 +1,15 @@ -import { Button, ButtonGroup, ContextualbarIcon } from '@rocket.chat/fuselage'; +import type { IAdminUserTabs } from '@rocket.chat/core-typings'; +import { Button, ButtonGroup, ContextualbarIcon, Tabs, TabsItem } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { usePermission, useRouteParameter, useTranslation, useRouter } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import React, { useRef } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import UserPageHeaderContentWithSeatsCap from '../../../../ee/client/views/admin/users/UserPageHeaderContentWithSeatsCap'; import { useSeatsCap } from '../../../../ee/client/views/admin/users/useSeatsCap'; import { Contextualbar, ContextualbarHeader, ContextualbarTitle, ContextualbarClose } from '../../../components/Contextualbar'; +import { usePagination } from '../../../components/GenericTable/hooks/usePagination'; +import { useSort } from '../../../components/GenericTable/hooks/useSort'; import { Page, PageHeader, PageContent } from '../../../components/Page'; import { useShouldPreventAction } from '../../../hooks/useShouldPreventAction'; import AdminInviteUsers from './AdminInviteUsers'; @@ -14,12 +18,16 @@ import AdminUserFormWithData from './AdminUserFormWithData'; import AdminUserInfoWithData from './AdminUserInfoWithData'; import AdminUserUpgrade from './AdminUserUpgrade'; import UsersTable from './UsersTable'; +import useFilteredUsers from './hooks/useFilteredUsers'; -const UsersPage = (): ReactElement => { +export type UsersFilters = { + text: string; +}; + +const AdminUsersPage = (): ReactElement => { const t = useTranslation(); const seatsCap = useSeatsCap(); - const reload = useRef(() => null); const router = useRouter(); const context = useRouteParameter('context'); @@ -30,12 +38,36 @@ const UsersPage = (): ReactElement => { const isCreateUserDisabled = useShouldPreventAction('activeUsers'); + const paginationData = usePagination(); + const sortData = useSort<'name' | 'username' | 'emails.address' | 'status'>('name'); + + const [tab, setTab] = useState('all'); + const [userFilters, setUserFilters] = useState({ text: '' }); + + const searchTerm = useDebouncedValue(userFilters.text, 500); + const prevSearchTerm = useRef(''); + + const filteredUsersQueryResult = useFilteredUsers({ + searchTerm, + prevSearchTerm, + sortData, + paginationData, + tab, + }); + const handleReload = (): void => { seatsCap?.reload(); - reload.current(); + filteredUsersQueryResult?.refetch(); }; - const isRoutePrevented = context && ['new', 'invite'].includes(context) && isCreateUserDisabled; + useEffect(() => { + prevSearchTerm.current = searchTerm; + }, [searchTerm]); + + const isRoutePrevented = useMemo( + () => context && ['new', 'invite'].includes(context) && isCreateUserDisabled, + [context, isCreateUserDisabled], + ); return ( @@ -59,7 +91,19 @@ const UsersPage = (): ReactElement => { )} - + + setTab('all')}> + {t('All')} + + + {context && ( @@ -85,4 +129,4 @@ const UsersPage = (): ReactElement => { ); }; -export default UsersPage; +export default AdminUsersPage; diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index 981e75483d03..67ac9c192213 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -1,12 +1,12 @@ -import { Pagination } from '@rocket.chat/fuselage'; -import { useMediaQuery, useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { escapeRegExp } from '@rocket.chat/string-helpers'; -import { useEndpoint, useRoute, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; -import type { ReactElement, MutableRefObject } from 'react'; -import React, { useRef, useMemo, useState, useEffect } from 'react'; - -import FilterByText from '../../../../components/FilterByText'; +import type { IAdminUserTabs, Serialized } from '@rocket.chat/core-typings'; +import { Box, Icon, Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle, TextInput } from '@rocket.chat/fuselage'; +import { useMediaQuery, useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import type { PaginatedResult, PickedUser } from '@rocket.chat/rest-typings'; +import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; +import type { UseQueryResult } from '@tanstack/react-query'; +import type { ReactElement, Dispatch, SetStateAction } from 'react'; +import React, { useCallback, useMemo } from 'react'; + import GenericNoResults from '../../../../components/GenericNoResults'; import { GenericTable, @@ -15,93 +15,67 @@ import { GenericTableBody, GenericTableLoadingTable, } from '../../../../components/GenericTable'; -import { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; -import { useSort } from '../../../../components/GenericTable/hooks/useSort'; +import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; +import type { useSort } from '../../../../components/GenericTable/hooks/useSort'; +import type { UsersFilters } from '../AdminUsersPage'; import UsersTableRow from './UsersTableRow'; type UsersTableProps = { - reload: MutableRefObject<() => void>; + tab: IAdminUserTabs; + onReload: () => void; + setUserFilters: Dispatch>; + filteredUsersQueryResult: UseQueryResult[] }>>; + paginationData: ReturnType; + sortData: ReturnType>; }; // TODO: Missing error state -const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => { +const UsersTable = ({ + filteredUsersQueryResult, + setUserFilters, + tab, + onReload, + paginationData, + sortData, +}: UsersTableProps): ReactElement | null => { const t = useTranslation(); - const usersRoute = useRoute('admin-users'); + const router = useRouter(); const mediaQuery = useMediaQuery('(min-width: 1024px)'); - const [text, setText] = useState(''); - const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination(); - const { sortBy, sortDirection, setSort } = useSort<'name' | 'username' | 'emails.address' | 'status'>('name'); + const { data, isLoading, isError, isSuccess } = filteredUsersQueryResult; - const searchTerm = useDebouncedValue(text, 500); - const prevSearchTerm = useRef(''); + const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = paginationData; + const { sortBy, sortDirection, setSort } = sortData; - const query = useDebouncedValue( - useMemo(() => { - if (searchTerm !== prevSearchTerm.current) { - setCurrent(0); - } + const isKeyboardEvent = ( + event: React.MouseEvent | React.KeyboardEvent, + ): event is React.KeyboardEvent => { + return (event as React.KeyboardEvent).key !== undefined; + }; - return { - fields: JSON.stringify({ - name: 1, - username: 1, - emails: 1, - roles: 1, - status: 1, - avatarETag: 1, - active: 1, - }), - query: JSON.stringify({ - $or: [ - { 'emails.address': { $regex: escapeRegExp(searchTerm), $options: 'i' } }, - { username: { $regex: escapeRegExp(searchTerm), $options: 'i' } }, - { name: { $regex: escapeRegExp(searchTerm), $options: 'i' } }, - ], - }), - sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, - count: itemsPerPage, - offset: searchTerm === prevSearchTerm.current ? current : 0, - }; - }, [searchTerm, sortBy, sortDirection, itemsPerPage, current, setCurrent]), - 500, - ); + const handleClickOrKeyDown = useEffectEvent( + (id, e: React.MouseEvent | React.KeyboardEvent): void => { + e.stopPropagation(); - const getUsers = useEndpoint('GET', '/v1/users.list'); + const keyboardSubmitKeys = ['Enter', ' ']; - const dispatchToastMessage = useToastMessageDispatch(); + if (isKeyboardEvent(e) && !keyboardSubmitKeys.includes(e.key)) { + return; + } - const { data, isLoading, error, isSuccess, refetch } = useQuery( - ['users', query], - async () => { - const users = await getUsers(query); - return users; + router.navigate({ + name: 'admin-users', + params: { + context: 'info', + id, + }, + }); }, - { - onError: (error) => { - dispatchToastMessage({ type: 'error', message: error }); - }, - }, - ); - - useEffect(() => { - reload.current = refetch; - }, [reload, refetch]); - - useEffect(() => { - prevSearchTerm.current = searchTerm; - }, [searchTerm]); - - const handleClick = useMutableCallback((id): void => - usersRoute.push({ - context: 'info', - id, - }), ); const headers = useMemo( () => [ - + {t('Name')} , mediaQuery && ( @@ -116,48 +90,87 @@ const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => { {t('Username')} ), - - {t('Email')} - , + mediaQuery && ( + + {t('Email')} + + ), mediaQuery && ( {t('Roles')} ), - - {t('Status')} - , + tab === 'all' && ( + + {t('Registration_status')} + + ), + , ], - [mediaQuery, setSort, sortBy, sortDirection, t], + [mediaQuery, setSort, sortBy, sortDirection, t, tab], ); - if (error) { - return null; - } - + const handleSearchTextChange = useCallback( + (event) => { + const text = event.currentTarget.value; + setUserFilters({ text }); + }, + [setUserFilters], + ); return ( <> - setText(text)} /> + + } + onChange={handleSearchTextChange} + /> + + {isLoading && ( {headers} - {isLoading && } + + + )} - {data?.users && data.count > 0 && isSuccess && ( + + {isError && ( + + + {t('Something_went_wrong')} + + {t('Reload_page')} + + + )} + + {isSuccess && data.users.length === 0 && } + + {isSuccess && !!data?.users && ( <> {headers} - {data?.users.map((user) => ( - + {data.users.map((user) => ( + ))} @@ -172,7 +185,6 @@ const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => { /> )} - {isSuccess && data?.count === 0 && } ); }; diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index b7460123af7d..128652f272b2 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -13,13 +13,14 @@ import { GenericTableRow, GenericTableCell } from '../../../../components/Generi type UsersTableRowProps = { user: Serialized; - onClick: (id: IUser['_id']) => void; + onClick: (id: IUser['_id'], e: React.MouseEvent | React.KeyboardEvent) => void; mediaQuery: boolean; }; const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): ReactElement => { const t = useTranslation(); const { _id, emails, username, name, roles, status, active, avatarETag } = user; + const statusText = active ? t(capitalize(status as string) as TranslationKey) : t('Disabled'); const roleNames = (roles || []) @@ -29,8 +30,8 @@ const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): React return ( onClick(_id)} - onClick={(): void => onClick(_id)} + onKeyDown={(e): void => onClick(_id, e)} + onClick={(e): void => onClick(_id, e)} tabIndex={0} role='link' action diff --git a/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts new file mode 100644 index 000000000000..a4ccf2f38f20 --- /dev/null +++ b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts @@ -0,0 +1,49 @@ +import type { IAdminUserTabs } from '@rocket.chat/core-typings'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import type { MutableRefObject } from 'react'; +import { useMemo } from 'react'; + +import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; +import type { useSort } from '../../../../components/GenericTable/hooks/useSort'; + +type useFilteredUsersOptions = { + searchTerm: string; + prevSearchTerm: MutableRefObject; + tab: IAdminUserTabs; + paginationData: ReturnType; + sortData: ReturnType>; +}; + +const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData, tab }: useFilteredUsersOptions) => { + const { setCurrent, itemsPerPage, current } = paginationData; + const { sortBy, sortDirection } = sortData; + + const payload = useMemo(() => { + if (searchTerm !== prevSearchTerm.current) { + setCurrent(0); + } + + return { + status: tab, + searchTerm, + sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, + count: itemsPerPage, + offset: searchTerm === prevSearchTerm.current ? current : 0, + }; + }, [current, itemsPerPage, prevSearchTerm, searchTerm, setCurrent, sortBy, sortDirection, tab]); + + const getUsers = useEndpoint('GET', '/v1/users.listByStatus'); + + const dispatchToastMessage = useToastMessageDispatch(); + + const usersListQueryResult = useQuery(['users.list', payload, tab], async () => getUsers(payload), { + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }); + + return usersListQueryResult; +}; + +export default useFilteredUsers; diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index 29864ae81ed1..453259ef0b3a 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -229,3 +229,5 @@ export type AvatarServiceObject = { }; export type AvatarObject = AvatarReset | AvatarUrlObj | FormData | AvatarServiceObject; + +export type IAdminUserTabs = 'all' | 'active' | 'deactivated' | 'pending'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 95aadb64a5d5..c2d7c6a1b805 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4290,6 +4290,7 @@ "register-on-cloud": "Register On Cloud", "register-on-cloud_description": "Permission to register on cloud", "Registration": "Registration", + "Registration_status": "Registration status", "Registration_Succeeded": "Registration Succeeded", "Registration_via_Admin": "Registration via Admin", "Regular_Expressions": "Regular Expressions", From 4dd02cf5fb108316b9e961aa5569705192710d57 Mon Sep 17 00:00:00 2001 From: rique223 Date: Wed, 6 Mar 2024 17:02:41 -0300 Subject: [PATCH 06/23] feat: :sparkles: Add Registration Status column to users table Added a new column to the users table called registration status that shows the status of the current user. Also created new translation entries. --- .../admin/users/UsersTable/UsersTable.tsx | 3 +- .../admin/users/UsersTable/UsersTableRow.tsx | 48 +++++++++++++------ packages/i18n/src/locales/en.i18n.json | 2 + 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index 67ac9c192213..96ab2235c63d 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -119,7 +119,6 @@ const UsersTable = ({ {t('Registration_status')} ), - , ], [mediaQuery, setSort, sortBy, sortDirection, t, tab], ); @@ -170,7 +169,7 @@ const UsersTable = ({ {headers} {data.users.map((user) => ( - + ))} diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index 128652f272b2..648fc26ce98f 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -1,27 +1,43 @@ -import type { IRole, IUser, Serialized } from '@rocket.chat/core-typings'; +import { UserStatus as Status } from '@rocket.chat/core-typings'; +import type { IAdminUserTabs, IRole, IUser, Serialized } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; import type { PickedUser } from '@rocket.chat/rest-typings'; -import { capitalize } from '@rocket.chat/string-helpers'; import { UserAvatar } from '@rocket.chat/ui-avatar'; -import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useMemo } from 'react'; import { Roles } from '../../../../../app/models/client'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; +import { UserStatus } from '../../../../components/UserStatus'; type UsersTableRowProps = { user: Serialized; onClick: (id: IUser['_id'], e: React.MouseEvent | React.KeyboardEvent) => void; mediaQuery: boolean; + tab: IAdminUserTabs; }; -const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): ReactElement => { +const UsersTableRow = ({ user, onClick, mediaQuery, tab }: UsersTableRowProps): ReactElement => { const t = useTranslation(); - const { _id, emails, username, name, roles, status, active, avatarETag } = user; + const { _id, emails, username, name, roles, status, active, avatarETag, lastLogin, type } = user; + const registrationStatusText = useMemo(() => { + const usersExcludedFromPending = ['bot', 'app']; - const statusText = active ? t(capitalize(status as string) as TranslationKey) : t('Disabled'); + console.log(user); + + if (!lastLogin && !usersExcludedFromPending.includes(type)) { + return t('Pending'); + } + + if (active && lastLogin) { + return t('Active'); + } + + if (!active && lastLogin) { + return t('Deactivated'); + } + }, [active, lastLogin, t, type, user]); const roleNames = (roles || []) .map((roleId) => (Roles.findOne(roleId, { fields: { name: 1 } }) as IRole | undefined)?.name) @@ -43,12 +59,14 @@ const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): React + + + {name || username} {!mediaQuery && name && ( - {' '} - {`@${username}`}{' '} + {`@${username}`} )} @@ -59,15 +77,17 @@ const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): React {username} - {' '} + )} - {emails?.length && emails[0].address} + {mediaQuery && {emails?.length && emails[0].address}} {mediaQuery && {roleNames}} - - {statusText} - + {tab === 'all' && ( + + {registrationStatusText} + + )} ); }; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c2d7c6a1b805..942bdd6a9eb4 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1562,6 +1562,7 @@ "DDP_Rate_Limit_User_Interval_Time": "Limit by User: interval time", "DDP_Rate_Limit_User_Requests_Allowed": "Limit by User: requests allowed", "Deactivate": "Deactivate", + "Deactivated": "Deactivated", "Decline": "Decline", "Decode_Key": "Decode Key", "default": "default", @@ -4054,6 +4055,7 @@ "pdf_success_message": "PDF Transcript successfully generated", "pdf_error_message": "Error generating PDF Transcript", "Peer_Password": "Peer Password", + "Pending": "Pending", "Pending Avatars": "Pending Avatars", "Pending Files": "Pending Files", "People": "People", From f5d306835740dad66149722278a799458b74c5d2 Mon Sep 17 00:00:00 2001 From: rique223 Date: Fri, 8 Mar 2024 16:41:41 -0300 Subject: [PATCH 07/23] Reviews first part --- apps/meteor/app/api/server/lib/users.ts | 92 +++++++++++++++ apps/meteor/app/api/server/v1/users.ts | 106 +++--------------- .../{methods => lib}/sendWelcomeEmail.ts | 6 - .../users/UsersSendWelcomeEmailParamsPOST.ts | 7 +- 4 files changed, 107 insertions(+), 104 deletions(-) rename apps/meteor/server/{methods => lib}/sendWelcomeEmail.ts (89%) diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 990fda0a0209..ff482e1f9b75 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -119,3 +119,95 @@ export function getNonEmptyQuery(query: Mongo.Query | undefi return { ...defaultQuery, ...query }; } + +type findPaginatedUsersByStatusProps = { + uid: string; + offset: number; + count: number; + sort: Record; + status: 'active' | 'all' | 'deactivated' | 'pending'; + roles: string[] | null; + searchTerm: string; +}; + +export async function findPaginatedUsersByStatus({ uid, offset, count, sort, status, roles, searchTerm }: findPaginatedUsersByStatusProps) { + const projection = { + name: 1, + username: 1, + emails: 1, + roles: 1, + status: 1, + active: 1, + avatarETag: 1, + lastLogin: 1, + type: 1, + reason: 0, + }; + + const actualSort: Record = sort || { username: 1 }; + + if (sort?.status) { + actualSort.active = sort.status; + } + + if (sort?.name) { + actualSort.nameInsensitive = sort.name; + } + + const match: Filter = { $or: [] }; + + switch (status) { + case 'active': + match.active = true; + match.lastLogin = { $exists: true }; + break; + case 'all': + break; + case 'deactivated': + match.active = false; + match.lastLogin = { $exists: true }; + break; + case 'pending': + match.lastLogin = { $exists: false }; + match.type = { $nin: ['bot', 'app'] }; + projection.reason = 1; + break; + } + + const canSeeAllUserInfo = await hasPermissionAsync(uid, 'view-full-other-user-info'); + + match.$or = [ + ...(canSeeAllUserInfo ? [{ 'emails.address': { $regex: escapeRegExp(searchTerm || ''), $options: 'i' } }] : []), + { + username: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' }, + }, + { + name: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' }, + }, + ]; + + if (roles?.length && !roles.includes('all')) { + match.roles = { $in: roles }; + } + + const { cursor, totalCount } = await Users.findPaginated( + { + ...match, + }, + { + sort: actualSort, + skip: offset, + limit: count, + projection, + }, + ); + + const [users, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + users, + count: users.length, + offset, + total, + }; +} diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index e49b63d75591..7d4530724abf 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -19,7 +19,6 @@ import { isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, } from '@rocket.chat/rest-typings'; -import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -27,8 +26,8 @@ import type { Filter } from 'mongodb'; import { i18n } from '../../../../server/lib/i18n'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; +import { sendWelcomeEmail } from '../../../../server/lib/sendWelcomeEmail'; import { saveUserPreferences } from '../../../../server/methods/saveUserPreferences'; -import { sendWelcomeEmail } from '../../../../server/methods/sendWelcomeEmail'; import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; import { resetTOTP } from '../../../2fa/server/functions/resetTOTP'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -52,7 +51,7 @@ import { getUserFromParams } from '../helpers/getUserFromParams'; import { isUserFromParams } from '../helpers/isUserFromParams'; import { getUploadFormData } from '../lib/getUploadFormData'; import { isValidQuery } from '../lib/isValidQuery'; -import { findUsersToAutocomplete, getInclusiveFields, getNonEmptyFields, getNonEmptyQuery } from '../lib/users'; +import { findPaginatedUsersByStatus, findUsersToAutocomplete, getInclusiveFields, getNonEmptyFields, getNonEmptyQuery } from '../lib/users'; API.v1.addRoute( 'users.getAvatar', @@ -569,99 +568,20 @@ API.v1.addRoute( { async get() { const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields } = await this.parseJsonQuery(); + const { sort } = await this.parseJsonQuery(); const { status, roles, searchTerm } = this.queryParams; - const projection = { - name: 1, - username: 1, - emails: 1, - roles: 1, - status: 1, - active: 1, - avatarETag: 1, - lastLogin: 1, - type: 1, - reason: 0, - ...fields, - }; - - const actualSort: Record = sort || { username: 1 }; - - if (sort?.status) { - actualSort.active = sort.status; - } - - if (sort?.name) { - actualSort.nameInsensitive = sort.name; - } - - let match: Filter; - - switch (status) { - case 'active': - match = { - active: true, - lastLogin: { $exists: true }, - }; - break; - case 'all': - match = {}; - break; - case 'deactivated': - match = { - active: false, - lastLogin: { $exists: true }, - }; - break; - case 'pending': - match = { - lastLogin: { $exists: false }, - type: { $nin: ['bot', 'app'] }, - }; - projection.reason = 1; - break; - default: - throw new Meteor.Error('invalid-params', 'Invalid status parameter'); - } - - const canSeeAllUserInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info'); - - match = { - ...match, - $or: [ - ...(canSeeAllUserInfo ? [{ 'emails.address': { $regex: escapeRegExp(searchTerm), $options: 'i' } }] : []), - { username: { $regex: escapeRegExp(searchTerm), $options: 'i' } }, - { name: { $regex: escapeRegExp(searchTerm), $options: 'i' } }, - ], - }; - - if (roles?.length && !roles.includes('all')) { - match = { - ...match, - roles: { $in: roles }, - }; - } - - const { cursor, totalCount } = await Users.findPaginated( - { - ...match, - }, - { - sort: actualSort, - skip: offset, - limit: count, - projection, - }, + return API.v1.success( + await findPaginatedUsersByStatus({ + uid: this.userId, + offset, + count, + sort, + status, + roles, + searchTerm, + }), ); - const [users, total] = await Promise.all([cursor.toArray(), totalCount]); - - return API.v1.success({ - users, - count: users.length, - offset, - total, - }); }, }, ); diff --git a/apps/meteor/server/methods/sendWelcomeEmail.ts b/apps/meteor/server/lib/sendWelcomeEmail.ts similarity index 89% rename from apps/meteor/server/methods/sendWelcomeEmail.ts rename to apps/meteor/server/lib/sendWelcomeEmail.ts index a31d642ff73a..3daa8df94555 100644 --- a/apps/meteor/server/methods/sendWelcomeEmail.ts +++ b/apps/meteor/server/lib/sendWelcomeEmail.ts @@ -6,12 +6,6 @@ import { settings } from '../../app/settings/server'; import { isSMTPConfigured } from '../../app/utils/server/functions/isSMTPConfigured'; export async function sendWelcomeEmail(to: string): Promise { - if (typeof to !== 'string') { - throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { - method: 'sendWelcomeEmail', - }); - } - if (!isSMTPConfigured()) { throw new Meteor.Error('error-email-send-failed', 'SMTP is not configured', { method: 'sendWelcomeEmail', diff --git a/packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts index 7bfd6f04c021..59e85db8a5cb 100644 --- a/packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts +++ b/packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts @@ -1,8 +1,4 @@ -import Ajv from 'ajv'; - -const ajv = new Ajv({ - coerceTypes: true, -}); +import { ajv } from '../Ajv'; export type UsersSendWelcomeEmailParamsPOST = { email: string }; @@ -11,6 +7,7 @@ const UsersSendWelcomeEmailParamsPostSchema = { properties: { email: { type: 'string', + format: 'email', }, }, required: ['email'], From f3e17312f197daa5018de910efb78f6b14dc82cd Mon Sep 17 00:00:00 2001 From: rique223 Date: Fri, 8 Mar 2024 16:47:42 -0300 Subject: [PATCH 08/23] Reviews second part --- apps/meteor/app/api/server/v1/users.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 7d4530724abf..f9c8feed16ba 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -563,10 +563,17 @@ API.v1.addRoute( { authRequired: true, validateParams: isUsersListStatusProps, - permissionsRequired: ['view-d-room', 'view-outside-room'], + permissionsRequired: ['view-d-room'], }, { async get() { + if ( + settings.get('API_Apply_permission_view-outside-room_on_users-list') && + !(await hasPermissionAsync(this.userId, 'view-outside-room')) + ) { + return API.v1.unauthorized(); + } + const { offset, count } = await getPaginationItems(this.queryParams); const { sort } = await this.parseJsonQuery(); const { status, roles, searchTerm } = this.queryParams; From 1208a885dfb88c8d5f036bc6ba2cfbb0295be1b1 Mon Sep 17 00:00:00 2001 From: rique223 Date: Fri, 8 Mar 2024 18:40:43 -0300 Subject: [PATCH 09/23] Reviews third part --- apps/meteor/app/api/server/lib/users.ts | 4 ++-- apps/meteor/app/api/server/v1/users.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index ff482e1f9b75..22f18495b6d0 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -2,7 +2,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Users, Subscriptions } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { Mongo } from 'meteor/mongo'; -import type { Filter } from 'mongodb'; +import type { Filter, RootFilterOperators } from 'mongodb'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { settings } from '../../../settings/server'; @@ -154,7 +154,7 @@ export async function findPaginatedUsersByStatus({ uid, offset, count, sort, sta actualSort.nameInsensitive = sort.name; } - const match: Filter = { $or: [] }; + const match: Filter> = {}; switch (status) { case 'active': diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index f9c8feed16ba..ba8175f67412 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -598,6 +598,7 @@ API.v1.addRoute( { authRequired: true, validateParams: isUsersSendWelcomeEmailProps, + permissionsRequired: ['send-mail'], }, { async post() { From 60876b84778e71c450a198d5bbea5edeeb3fd950 Mon Sep 17 00:00:00 2001 From: rique223 Date: Thu, 14 Mar 2024 15:08:27 -0300 Subject: [PATCH 10/23] Review --- apps/meteor/app/api/server/lib/users.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 22f18495b6d0..3ab174fd28e5 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -120,7 +120,7 @@ export function getNonEmptyQuery(query: Mongo.Query | undefi return { ...defaultQuery, ...query }; } -type findPaginatedUsersByStatusProps = { +type FindPaginatedUsersByStatusProps = { uid: string; offset: number; count: number; @@ -130,7 +130,7 @@ type findPaginatedUsersByStatusProps = { searchTerm: string; }; -export async function findPaginatedUsersByStatus({ uid, offset, count, sort, status, roles, searchTerm }: findPaginatedUsersByStatusProps) { +export async function findPaginatedUsersByStatus({ uid, offset, count, sort, status, roles, searchTerm }: FindPaginatedUsersByStatusProps) { const projection = { name: 1, username: 1, From 3b91f00a8301de47689d4555e0f34b131c96e19c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Guimar=C3=A3es=20Ribeiro?= Date: Thu, 14 Mar 2024 15:17:19 -0300 Subject: [PATCH 11/23] Create fifty-cups-sort.md --- .changeset/fifty-cups-sort.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fifty-cups-sort.md diff --git a/.changeset/fifty-cups-sort.md b/.changeset/fifty-cups-sort.md new file mode 100644 index 000000000000..8f3e4512f055 --- /dev/null +++ b/.changeset/fifty-cups-sort.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": patch +--- + +Created a new endpoint to get a filtered and paginated list of users. From ebc30fefe9c37a9f820b42ddc12c8d0369ae4735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Guimar=C3=A3es=20Ribeiro?= Date: Thu, 14 Mar 2024 15:19:09 -0300 Subject: [PATCH 12/23] Create chilly-poems-explode.md --- .changeset/chilly-poems-explode.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/chilly-poems-explode.md diff --git a/.changeset/chilly-poems-explode.md b/.changeset/chilly-poems-explode.md new file mode 100644 index 000000000000..af8f6ce75ab8 --- /dev/null +++ b/.changeset/chilly-poems-explode.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": patch +"@rocket.chat/i18n": patch +--- + +Introduced a tab layout to the users page and implemented a tab called "All" that lists all users. From 8dafa9e21aa655b83443e2edef6cef30fb2a74ec Mon Sep 17 00:00:00 2001 From: rique223 Date: Fri, 15 Mar 2024 19:23:52 -0300 Subject: [PATCH 13/23] Remove console.log --- .../client/views/admin/users/UsersTable/UsersTableRow.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index 648fc26ce98f..382d0fde6584 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -24,8 +24,6 @@ const UsersTableRow = ({ user, onClick, mediaQuery, tab }: UsersTableRowProps): const registrationStatusText = useMemo(() => { const usersExcludedFromPending = ['bot', 'app']; - console.log(user); - if (!lastLogin && !usersExcludedFromPending.includes(type)) { return t('Pending'); } @@ -37,7 +35,7 @@ const UsersTableRow = ({ user, onClick, mediaQuery, tab }: UsersTableRowProps): if (!active && lastLogin) { return t('Deactivated'); } - }, [active, lastLogin, t, type, user]); + }, [active, lastLogin, t, type]); const roleNames = (roles || []) .map((roleId) => (Roles.findOne(roleId, { fields: { name: 1 } }) as IRole | undefined)?.name) From a6d49b2c1e8d6d647f600cf24d818668254b19e3 Mon Sep 17 00:00:00 2001 From: rique223 Date: Tue, 19 Mar 2024 18:43:56 -0300 Subject: [PATCH 14/23] Review --- .../client/views/admin/users/UsersTable/UsersTableRow.tsx | 4 ++-- packages/rest-typings/src/v1/users.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index b7460123af7d..e0d46c2ba6fb 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -1,6 +1,6 @@ import type { IRole, IUser, Serialized } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; -import type { PickedUser } from '@rocket.chat/rest-typings'; +import type { DefaultUserInfo } from '@rocket.chat/rest-typings'; import { capitalize } from '@rocket.chat/string-helpers'; import { UserAvatar } from '@rocket.chat/ui-avatar'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; @@ -12,7 +12,7 @@ import { Roles } from '../../../../../app/models/client'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; type UsersTableRowProps = { - user: Serialized; + user: Serialized; onClick: (id: IUser['_id']) => void; mediaQuery: boolean; }; diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index eab2b421a14f..bcf99f951664 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -112,7 +112,7 @@ export type UserPresence = Readonly< export type UserPersonalTokens = Pick & { createdAt: string }; -export type PickedUser = Pick< +export type DefaultUserInfo = Pick< IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'emails' | 'active' | 'avatarETag' | 'lastLogin' | 'type' >; @@ -146,18 +146,18 @@ export type UsersEndpoints = { '/v1/users.list': { GET: (params: PaginatedRequest<{ fields: string }>) => PaginatedResult<{ - users: PickedUser[]; + users: DefaultUserInfo[]; }>; }; '/v1/users.listByStatus': { GET: (params: UsersListStatusParamsGET) => PaginatedResult<{ - users: PickedUser[]; + users: DefaultUserInfo[]; }>; }; '/v1/users.sendWelcomeEmail': { - POST: (params: UsersSendWelcomeEmailParamsPOST) => { success: boolean }; + POST: (params: UsersSendWelcomeEmailParamsPOST) => void; }; '/v1/users.setAvatar': { From bf1a5a01e56a485f59491c346fe3826c4b32e91f Mon Sep 17 00:00:00 2001 From: rique223 Date: Wed, 20 Mar 2024 15:41:41 -0300 Subject: [PATCH 15/23] Change PickedUser to DefaultUserInfo --- .../meteor/client/views/admin/users/UsersTable/UsersTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index 96ab2235c63d..1d46e9ae9b37 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -1,7 +1,7 @@ import type { IAdminUserTabs, Serialized } from '@rocket.chat/core-typings'; import { Box, Icon, Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle, TextInput } from '@rocket.chat/fuselage'; import { useMediaQuery, useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import type { PaginatedResult, PickedUser } from '@rocket.chat/rest-typings'; +import type { PaginatedResult, DefaultUserInfo } from '@rocket.chat/rest-typings'; import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import type { ReactElement, Dispatch, SetStateAction } from 'react'; @@ -24,7 +24,7 @@ type UsersTableProps = { tab: IAdminUserTabs; onReload: () => void; setUserFilters: Dispatch>; - filteredUsersQueryResult: UseQueryResult[] }>>; + filteredUsersQueryResult: UseQueryResult[] }>>; paginationData: ReturnType; sortData: ReturnType>; }; From 1010637008f034db178e82ae224ab4bce794c3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Guimar=C3=A3es=20Ribeiro?= Date: Thu, 21 Mar 2024 15:04:50 -0300 Subject: [PATCH 16/23] Create pink-parrots-end.md --- .changeset/pink-parrots-end.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/pink-parrots-end.md diff --git a/.changeset/pink-parrots-end.md b/.changeset/pink-parrots-end.md new file mode 100644 index 000000000000..133c7aec1350 --- /dev/null +++ b/.changeset/pink-parrots-end.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": patch +--- + +Created a new endpoint to resend the welcome email to a given user From 19a12b45205e4528640b7ce3195494b1eecd05c1 Mon Sep 17 00:00:00 2001 From: rique223 Date: Fri, 22 Mar 2024 16:51:41 -0300 Subject: [PATCH 17/23] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20Refactor?= =?UTF-8?q?=20listUsersByStatus=20endpoint=20to=20be=20more=20flexible=20a?= =?UTF-8?q?nd=20update=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/meteor/app/api/server/lib/users.ts | 43 +++++++++++--------- apps/meteor/app/api/server/v1/users.ts | 4 +- apps/meteor/tests/end-to-end/api/01-users.js | 28 +++---------- 3 files changed, 31 insertions(+), 44 deletions(-) diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 3ab174fd28e5..f80d662771df 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -125,12 +125,24 @@ type FindPaginatedUsersByStatusProps = { offset: number; count: number; sort: Record; - status: 'active' | 'all' | 'deactivated' | 'pending'; + status: 'active' | 'deactivated'; roles: string[] | null; searchTerm: string; + hasLoggedIn: boolean; + type: string; }; -export async function findPaginatedUsersByStatus({ uid, offset, count, sort, status, roles, searchTerm }: FindPaginatedUsersByStatusProps) { +export async function findPaginatedUsersByStatus({ + uid, + offset, + count, + sort, + status, + roles, + searchTerm, + hasLoggedIn, + type, +}: FindPaginatedUsersByStatusProps) { const projection = { name: 1, username: 1, @@ -141,39 +153,34 @@ export async function findPaginatedUsersByStatus({ uid, offset, count, sort, sta avatarETag: 1, lastLogin: 1, type: 1, - reason: 0, + reason: 1, }; const actualSort: Record = sort || { username: 1 }; - if (sort?.status) { actualSort.active = sort.status; } - if (sort?.name) { actualSort.nameInsensitive = sort.name; } - const match: Filter> = {}; - switch (status) { case 'active': match.active = true; - match.lastLogin = { $exists: true }; - break; - case 'all': break; case 'deactivated': match.active = false; - match.lastLogin = { $exists: true }; - break; - case 'pending': - match.lastLogin = { $exists: false }; - match.type = { $nin: ['bot', 'app'] }; - projection.reason = 1; break; } + if (hasLoggedIn !== undefined) { + match.lastLogin = { $exists: hasLoggedIn }; + } + + if (type) { + match.type = type; + } + const canSeeAllUserInfo = await hasPermissionAsync(uid, 'view-full-other-user-info'); match.$or = [ @@ -185,11 +192,9 @@ export async function findPaginatedUsersByStatus({ uid, offset, count, sort, sta name: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' }, }, ]; - if (roles?.length && !roles.includes('all')) { match.roles = { $in: roles }; } - const { cursor, totalCount } = await Users.findPaginated( { ...match, @@ -201,9 +206,7 @@ export async function findPaginatedUsersByStatus({ uid, offset, count, sort, sta projection, }, ); - const [users, total] = await Promise.all([cursor.toArray(), totalCount]); - return { users, count: users.length, diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index ba8175f67412..2b85adf74169 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -576,7 +576,7 @@ API.v1.addRoute( const { offset, count } = await getPaginationItems(this.queryParams); const { sort } = await this.parseJsonQuery(); - const { status, roles, searchTerm } = this.queryParams; + const { status, hasLoggedIn, type, roles, searchTerm } = this.queryParams; return API.v1.success( await findPaginatedUsersByStatus({ @@ -587,6 +587,8 @@ API.v1.addRoute( status, roles, searchTerm, + hasLoggedIn, + type, }), ); }, 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 64a6b2f825e9..2d3f3a1d14ec 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -4500,76 +4500,63 @@ describe('[Users]', function () { await request .get(api('users.listByStatus')) .set(credentials) - .query({ status: 'pending', count: 50 }) + .query({ hasLoggedIn: false, type: 'user', count: 50 }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('users'); - const { users } = res.body; const ids = users.map((user) => user._id); - expect(ids).to.include(user._id); }); }); it('should list all users', async () => { - await login(user.username, password); - await request .get(api('users.listByStatus')) .set(credentials) - .query({ status: 'all' }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('users'); - const { users } = res.body; const ids = users.map((user) => user._id); - expect(ids).to.include(user._id); }); }); it('should list active users', async () => { await login(user.username, password); - await request .get(api('users.listByStatus')) .set(credentials) - .query({ status: 'active' }) + .query({ hasLoggedIn: true, status: 'active' }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('users'); - const { users } = res.body; const ids = users.map((user) => user._id); - expect(ids).to.include(user._id); }); }); it('should filter users by role', async () => { await login(user.username, password); - await request .get(api('users.listByStatus')) .set(credentials) - .query({ 'status': 'active', 'roles[]': 'admin' }) + .query({ 'roles[]': 'admin' }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('users'); - const { users } = res.body; const ids = users.map((user) => user._id); - expect(ids).to.not.include(user._id); }); }); @@ -4580,20 +4567,17 @@ describe('[Users]', function () { activeStatus: false, confirmRelinquish: false, }); - await request .get(api('users.listByStatus')) .set(credentials) - .query({ status: 'deactivated' }) + .query({ hasLoggedIn: true, status: 'deactivated' }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('users'); - const { users } = res.body; const ids = users.map((user) => user._id); - expect(ids).to.include(user._id); }); }); @@ -4602,16 +4586,14 @@ describe('[Users]', function () { await request .get(api('users.listByStatus')) .set(credentials) - .query({ status: 'all', searchTerm: user.username }) + .query({ searchTerm: user.username }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('users'); - const { users } = res.body; const ids = users.map((user) => user._id); - expect(ids).to.include(user._id); }); }); From 8923dcaeb2514cf8f5f5fa3c9a0c528cec31acfb Mon Sep 17 00:00:00 2001 From: rique223 Date: Fri, 22 Mar 2024 17:19:12 -0300 Subject: [PATCH 18/23] Update listByStatus typing --- .../src/v1/users/UsersListStatusParamsGET.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts b/packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts index a991790911be..25024aee12cb 100644 --- a/packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts +++ b/packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts @@ -7,17 +7,26 @@ const ajv = new Ajv({ }); export type UsersListStatusParamsGET = PaginatedRequest<{ - status: 'active' | 'all' | 'deactivated' | 'pending'; + status?: 'active' | 'deactivated'; + hasLoggedIn?: boolean; + type?: string; roles?: string[]; searchTerm?: string; }>; - const UsersListStatusParamsGetSchema = { type: 'object', properties: { status: { type: 'string', - enum: ['active', 'all', 'deactivated', 'pending'], + enum: ['active', 'deactivated'], + }, + hasLoggedIn: { + type: 'boolean', + nullable: true, + }, + type: { + type: 'string', + nullable: true, }, roles: { type: 'array', @@ -43,7 +52,6 @@ const UsersListStatusParamsGetSchema = { nullable: true, }, }, - required: ['status'], additionalProperties: false, }; From 5b741499874cedf58747f3b503cb3fb52754a879 Mon Sep 17 00:00:00 2001 From: rique223 Date: Fri, 22 Mar 2024 18:42:25 -0300 Subject: [PATCH 19/23] Typecheck --- .../views/admin/users/AdminUsersPage.tsx | 2 ++ .../admin/users/hooks/useFilteredUsers.ts | 31 +++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index 9fb1dac73408..98aed050cd81 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -30,6 +30,8 @@ export type UsersFilters = { text: string; }; +export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status' | 'active'; + const AdminUsersPage = (): ReactElement => { const t = useTranslation(); diff --git a/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts index a4ccf2f38f20..f8ea02a34d82 100644 --- a/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts +++ b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts @@ -1,4 +1,5 @@ import type { IAdminUserTabs } from '@rocket.chat/core-typings'; +import type { UsersListStatusParamsGET } from '@rocket.chat/rest-typings'; import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { MutableRefObject } from 'react'; @@ -6,16 +7,17 @@ import { useMemo } from 'react'; import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import type { useSort } from '../../../../components/GenericTable/hooks/useSort'; +import type { UsersTableSortingOptions } from '../AdminUsersPage'; -type useFilteredUsersOptions = { +type UseFilteredUsersOptions = { searchTerm: string; prevSearchTerm: MutableRefObject; tab: IAdminUserTabs; paginationData: ReturnType; - sortData: ReturnType>; + sortData: ReturnType>; }; -const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData, tab }: useFilteredUsersOptions) => { +const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData, tab }: UseFilteredUsersOptions) => { const { setCurrent, itemsPerPage, current } = paginationData; const { sortBy, sortDirection } = sortData; @@ -24,26 +26,37 @@ const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData setCurrent(0); } + const listUsersPayload: Partial> = { + all: {}, + pending: { + hasLoggedIn: false, + type: 'user', + }, + active: { + hasLoggedIn: true, + status: 'active', + }, + deactivated: { + hasLoggedIn: true, + status: 'deactivated', + }, + }; + return { - status: tab, + ...listUsersPayload[tab], searchTerm, sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, count: itemsPerPage, offset: searchTerm === prevSearchTerm.current ? current : 0, }; }, [current, itemsPerPage, prevSearchTerm, searchTerm, setCurrent, sortBy, sortDirection, tab]); - const getUsers = useEndpoint('GET', '/v1/users.listByStatus'); - const dispatchToastMessage = useToastMessageDispatch(); - const usersListQueryResult = useQuery(['users.list', payload, tab], async () => getUsers(payload), { onError: (error) => { dispatchToastMessage({ type: 'error', message: error }); }, }); - return usersListQueryResult; }; - export default useFilteredUsers; From e2db32c7d54bd78e1a23d0a44b47dc39862cb5d7 Mon Sep 17 00:00:00 2001 From: rique223 Date: Fri, 22 Mar 2024 19:59:27 -0300 Subject: [PATCH 20/23] Typecheck --- apps/meteor/client/views/admin/users/AdminUsersPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index 98aed050cd81..d72c7051cf6c 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -30,7 +30,7 @@ export type UsersFilters = { text: string; }; -export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status' | 'active'; +export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status'; const AdminUsersPage = (): ReactElement => { const t = useTranslation(); From fbb307b7a0ba73301e11911e6638408d90d6da1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Guimar=C3=A3es=20Ribeiro?= Date: Mon, 1 Apr 2024 16:44:55 -0300 Subject: [PATCH 21/23] Update fifty-cups-sort.md --- .changeset/fifty-cups-sort.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fifty-cups-sort.md b/.changeset/fifty-cups-sort.md index 8f3e4512f055..389391ef8cc9 100644 --- a/.changeset/fifty-cups-sort.md +++ b/.changeset/fifty-cups-sort.md @@ -1,6 +1,6 @@ --- "@rocket.chat/meteor": minor -"@rocket.chat/rest-typings": patch +"@rocket.chat/rest-typings": minor --- Created a new endpoint to get a filtered and paginated list of users. From afd510840c81b58b3b2ecadfc5d150e8f9fcbc83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Guimar=C3=A3es=20Ribeiro?= Date: Mon, 1 Apr 2024 16:45:24 -0300 Subject: [PATCH 22/23] Update pink-parrots-end.md --- .changeset/pink-parrots-end.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pink-parrots-end.md b/.changeset/pink-parrots-end.md index 133c7aec1350..9f1863f6915c 100644 --- a/.changeset/pink-parrots-end.md +++ b/.changeset/pink-parrots-end.md @@ -1,6 +1,6 @@ --- "@rocket.chat/meteor": minor -"@rocket.chat/rest-typings": patch +"@rocket.chat/rest-typings": minor --- Created a new endpoint to resend the welcome email to a given user From eda4df80da0eb17ff4a50328cb47c2ffa7873c00 Mon Sep 17 00:00:00 2001 From: rique223 Date: Wed, 24 Apr 2024 10:40:13 -0300 Subject: [PATCH 23/23] Update changeset --- .changeset/chilly-poems-explode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/chilly-poems-explode.md b/.changeset/chilly-poems-explode.md index af8f6ce75ab8..17acf3c5ba85 100644 --- a/.changeset/chilly-poems-explode.md +++ b/.changeset/chilly-poems-explode.md @@ -1,6 +1,6 @@ --- "@rocket.chat/meteor": minor -"@rocket.chat/core-typings": patch +"@rocket.chat/core-typings": minor "@rocket.chat/i18n": patch ---