feat: Create back-end for the new users panel page (#31898)

pull/32272/head^2
Henrique Guimarães Ribeiro 2 years ago committed by GitHub
parent 17bc6313e9
commit 845fd64f45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/fifty-cups-sort.md
  2. 6
      .changeset/pink-parrots-end.md
  3. 97
      apps/meteor/app/api/server/lib/users.ts
  4. 5
      apps/meteor/app/api/server/v1/misc.ts
  5. 59
      apps/meteor/app/api/server/v1/users.ts
  6. 6
      apps/meteor/app/utils/server/functions/isSMTPConfigured.ts
  7. 5
      apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx
  8. 43
      apps/meteor/server/lib/sendWelcomeEmail.ts
  9. 239
      apps/meteor/tests/end-to-end/api/01-users.js
  10. 21
      packages/rest-typings/src/v1/users.ts
  11. 58
      packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts
  12. 17
      packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/rest-typings": minor
---
Created a new endpoint to get a filtered and paginated list of users.

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/rest-typings": minor
---
Created a new endpoint to resend the welcome email to a given user

@ -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';
@ -119,3 +119,98 @@ export function getNonEmptyQuery<T extends IUser>(query: Mongo.Query<T> | undefi
return { ...defaultQuery, ...query };
}
type FindPaginatedUsersByStatusProps = {
uid: string;
offset: number;
count: number;
sort: Record<string, 1 | -1>;
status: 'active' | 'deactivated';
roles: string[] | null;
searchTerm: string;
hasLoggedIn: boolean;
type: string;
};
export async function findPaginatedUsersByStatus({
uid,
offset,
count,
sort,
status,
roles,
searchTerm,
hasLoggedIn,
type,
}: FindPaginatedUsersByStatusProps) {
const projection = {
name: 1,
username: 1,
emails: 1,
roles: 1,
status: 1,
active: 1,
avatarETag: 1,
lastLogin: 1,
type: 1,
reason: 1,
};
const actualSort: Record<string, 1 | -1> = sort || { username: 1 };
if (sort?.status) {
actualSort.active = sort.status;
}
if (sort?.name) {
actualSort.nameInsensitive = sort.name;
}
const match: Filter<IUser & RootFilterOperators<IUser>> = {};
switch (status) {
case 'active':
match.active = true;
break;
case 'deactivated':
match.active = false;
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 = [
...(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,
};
}

@ -26,6 +26,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP
import { passwordPolicy } from '../../../lib/server';
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';
@ -636,9 +637,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() });
},
},
);

@ -6,6 +6,8 @@ import {
isUserSetActiveStatusParamsPOST,
isUserDeactivateIdleParamsPOST,
isUsersInfoParamsGetProps,
isUsersListStatusProps,
isUsersSendWelcomeEmailProps,
isUserRegisterParamsPOST,
isUserLogoutParamsPOST,
isUsersListTeamsProps,
@ -24,6 +26,7 @@ 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 { getUserForCheck, emailCheck } from '../../../2fa/server/code';
import { resetTOTP } from '../../../2fa/server/functions/resetTOTP';
@ -49,7 +52,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',
@ -556,6 +559,60 @@ API.v1.addRoute(
},
);
API.v1.addRoute(
'users.listByStatus',
{
authRequired: true,
validateParams: isUsersListStatusProps,
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, hasLoggedIn, type, roles, searchTerm } = this.queryParams;
return API.v1.success(
await findPaginatedUsersByStatus({
uid: this.userId,
offset,
count,
sort,
status,
roles,
searchTerm,
hasLoggedIn,
type,
}),
);
},
},
);
API.v1.addRoute(
'users.sendWelcomeEmail',
{
authRequired: true,
validateParams: isUsersSendWelcomeEmailProps,
permissionsRequired: ['send-mail'],
},
{
async post() {
const { email } = this.bodyParams;
await sendWelcomeEmail(email);
return API.v1.success();
},
},
);
API.v1.addRoute(
'users.register',
{

@ -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;
};

@ -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 { 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';
@ -11,7 +12,7 @@ import { Roles } from '../../../../../app/models/client';
import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable';
type UsersTableRowProps = {
user: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'emails' | 'active' | 'avatarETag'>;
user: Serialized<DefaultUserInfo>;
onClick: (id: IUser['_id']) => void;
mediaQuery: boolean;
};

@ -0,0 +1,43 @@
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<void> {
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,
});
}
}

@ -3918,4 +3918,243 @@ 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({ 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 request
.get(api('users.listByStatus'))
.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');
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({ 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({ '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({ 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);
});
});
it('should filter users by username', async () => {
await request
.get(api('users.listByStatus'))
.set(credentials)
.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);
});
});
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]');
});
});
});
});

@ -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<IPersonalAccessToken, 'name' | 'lastTokenPart' | 'bypassTwoFactor'> & { createdAt: string };
export type DefaultUserInfo = 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<IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'emails' | 'active' | 'avatarETag'>[];
users: DefaultUserInfo[];
}>;
};
'/v1/users.listByStatus': {
GET: (params: UsersListStatusParamsGET) => PaginatedResult<{
users: DefaultUserInfo[];
}>;
};
'/v1/users.sendWelcomeEmail': {
POST: (params: UsersSendWelcomeEmailParamsPOST) => void;
};
'/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';

@ -0,0 +1,58 @@
import Ajv from 'ajv';
import type { PaginatedRequest } from '../../helpers/PaginatedRequest';
const ajv = new Ajv({
coerceTypes: true,
});
export type UsersListStatusParamsGET = PaginatedRequest<{
status?: 'active' | 'deactivated';
hasLoggedIn?: boolean;
type?: string;
roles?: string[];
searchTerm?: string;
}>;
const UsersListStatusParamsGetSchema = {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['active', 'deactivated'],
},
hasLoggedIn: {
type: 'boolean',
nullable: true,
},
type: {
type: 'string',
nullable: true,
},
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,
},
},
additionalProperties: false,
};
export const isUsersListStatusProps = ajv.compile<UsersListStatusParamsGET>(UsersListStatusParamsGetSchema);

@ -0,0 +1,17 @@
import { ajv } from '../Ajv';
export type UsersSendWelcomeEmailParamsPOST = { email: string };
const UsersSendWelcomeEmailParamsPostSchema = {
type: 'object',
properties: {
email: {
type: 'string',
format: 'email',
},
},
required: ['email'],
additionalProperties: false,
};
export const isUsersSendWelcomeEmailProps = ajv.compile<UsersSendWelcomeEmailParamsPOST>(UsersSendWelcomeEmailParamsPostSchema);
Loading…
Cancel
Save