diff --git a/client/src/app/+admin/users/user-list/user-list.component.html b/client/src/app/+admin/users/user-list/user-list.component.html index 27d4a5787..9580a3c8a 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.html +++ b/client/src/app/+admin/users/user-list/user-list.component.html @@ -16,14 +16,27 @@ -
- - - Clear filters +
+
+
+
+ +
+ +
+ + Banned users +
+
+ + + Clear filters +
+ Create user @@ -70,7 +83,7 @@ alt="Avatar" >
- + {{ user.account.displayName }} diff --git a/client/src/app/+admin/users/user-list/user-list.component.scss b/client/src/app/+admin/users/user-list/user-list.component.scss index 697b2c11b..2b84dec75 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.scss +++ b/client/src/app/+admin/users/user-list/user-list.component.scss @@ -17,6 +17,12 @@ tr.banned > td { font-weight: $font-semibold; } +.user-table-primary-text .glyphicon { + font-size: 80%; + color: gray; + margin-left: 0.1rem; +} + .caption { justify-content: space-between; @@ -33,3 +39,14 @@ p-tableCheckbox { .chip { @include chip; } + +.input-group { + @include peertube-input-group(300px); + input { + flex: 1; + } + + .dropdown-toggle::after { + margin-left: 0; + } +} diff --git a/client/src/app/+admin/users/user-list/user-list.component.ts b/client/src/app/+admin/users/user-list/user-list.component.ts index 8f01c7d51..0b72b07c1 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.ts +++ b/client/src/app/+admin/users/user-list/user-list.component.ts @@ -5,6 +5,7 @@ import { Actor, DropdownAction } from '@app/shared/shared-main' import { UserBanModalComponent } from '@app/shared/shared-moderation' import { I18n } from '@ngx-translate/i18n-polyfill' import { ServerConfig, User } from '@shared/models' +import { Params, Router, ActivatedRoute } from '@angular/router' @Component({ selector: 'my-user-list', @@ -30,6 +31,8 @@ export class UserListComponent extends RestTable implements OnInit { private serverService: ServerService, private userService: UserService, private auth: AuthService, + private route: ActivatedRoute, + private router: Router, private i18n: I18n ) { super() @@ -50,6 +53,14 @@ export class UserListComponent extends RestTable implements OnInit { this.initialize() + this.route.queryParams + .subscribe(params => { + this.search = params.search || '' + + this.setTableFilter(this.search) + this.loadData() + }) + this.bulkUserActions = [ [ { @@ -102,6 +113,26 @@ export class UserListComponent extends RestTable implements OnInit { this.loadData() } + /* Table filter functions */ + onUserSearch (event: Event) { + this.onSearch(event) + this.setQueryParams((event.target as HTMLInputElement).value) + } + + setQueryParams (search: string) { + const queryParams: Params = {} + if (search) Object.assign(queryParams, { search }) + + this.router.navigate([ '/admin/users/list' ], { queryParams }) + } + + resetTableFilter () { + this.setTableFilter('') + this.setQueryParams('') + this.resetSearch() + } + /* END Table filter functions */ + switchToDefaultAvatar ($event: Event) { ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() } @@ -165,14 +196,17 @@ export class UserListComponent extends RestTable implements OnInit { protected loadData () { this.selectedUsers = [] - this.userService.getUsers(this.pagination, this.sort, this.search) - .subscribe( - resultList => { - this.users = resultList.data - this.totalRecords = resultList.total - }, + this.userService.getUsers({ + pagination: this.pagination, + sort: this.sort, + search: this.search + }).subscribe( + resultList => { + this.users = resultList.data + this.totalRecords = resultList.total + }, - err => this.notifier.error(err.message) - ) + err => this.notifier.error(err.message) + ) } } diff --git a/client/src/app/core/rest/rest.service.ts b/client/src/app/core/rest/rest.service.ts index c12b6bd41..9e32c6d58 100644 --- a/client/src/app/core/rest/rest.service.ts +++ b/client/src/app/core/rest/rest.service.ts @@ -9,11 +9,12 @@ interface QueryStringFilterPrefixes { prefix: string handler?: (v: string) => string | number multiple?: boolean + isBoolean?: boolean } } type ParseQueryStringFilterResult = { - [key: string]: string | number | (string | number)[] + [key: string]: string | number | boolean | (string | number | boolean)[] } @Injectable() @@ -96,6 +97,7 @@ export class RestService { return t }) .filter(t => !!t || t === 0) + .map(t => prefixObj.isBoolean ? t === 'true' : t) if (matchedTokens.length === 0) continue diff --git a/client/src/app/core/users/user.service.ts b/client/src/app/core/users/user.service.ts index ab395b1f9..2c817d45e 100644 --- a/client/src/app/core/users/user.service.ts +++ b/client/src/app/core/users/user.service.ts @@ -290,11 +290,32 @@ export class UserService { }) } - getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable> { + getUsers (parameters: { + pagination: RestPagination + sort: SortMeta + search?: string + }): Observable> { + const { pagination, sort, search } = parameters + let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination, sort) - if (search) params = params.append('search', search) + if (search) { + const filters = this.restService.parseQueryStringFilter(search, { + blocked: { + prefix: 'banned:', + isBoolean: true, + handler: v => { + if (v === 'true') return v + if (v === 'false') return v + + return undefined + } + } + }) + + params = this.restService.addObjectParams(params, filters) + } return this.authHttp.get>(UserService.BASE_USERS_URL, { params }) .pipe( diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index c8e9eaeaa..839431afb 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -18,6 +18,7 @@ import { setDefaultPagination, setDefaultSort, userAutocompleteValidator, + usersListValidator, usersAddValidator, usersGetValidator, usersRegisterValidator, @@ -85,6 +86,7 @@ usersRouter.get('/', usersSortValidator, setDefaultSort, setDefaultPagination, + asyncMiddleware(usersListValidator), asyncMiddleware(listUsers) ) @@ -282,7 +284,13 @@ async function autocompleteUsers (req: express.Request, res: express.Response) { } async function listUsers (req: express.Request, res: express.Response) { - const resultList = await UserModel.listForApi(req.query.start, req.query.count, req.query.sort, req.query.search) + const resultList = await UserModel.listForApi({ + start: req.query.start, + count: req.query.count, + sort: req.query.sort, + search: req.query.search, + blocked: req.query.blocked + }) return res.json(getFormattedObjects(resultList.data, resultList.total, { withAdminFlags: true })) } diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index 4a9ed6830..6860a3bed 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts @@ -38,6 +38,21 @@ import { UserRole } from '../../../shared/models/users' import { MUserDefault } from '@server/types/models' import { Hooks } from '@server/lib/plugins/hooks' +const usersListValidator = [ + query('blocked') + .optional() + .customSanitizer(toBooleanOrNull) + .isBoolean().withMessage('Should be a valid boolean banned state'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking usersList parameters', { parameters: req.query }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + const usersAddValidator = [ body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'), body('password').custom(isUserPasswordValidOrEmpty).withMessage('Should have a valid password'), @@ -444,6 +459,7 @@ const ensureCanManageUser = [ // --------------------------------------------------------------------------- export { + usersListValidator, usersAddValidator, deleteMeValidator, usersRegisterValidator, diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 3bde1e744..de193131a 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -412,11 +412,18 @@ export class UserModel extends Model { return this.count() } - static listForApi (start: number, count: number, sort: string, search?: string) { - let where: WhereOptions + static listForApi (parameters: { + start: number + count: number + sort: string + search?: string + blocked?: boolean + }) { + const { start, count, sort, search, blocked } = parameters + const where: WhereOptions = {} if (search) { - where = { + Object.assign(where, { [Op.or]: [ { email: { @@ -429,7 +436,13 @@ export class UserModel extends Model { } } ] - } + }) + } + + if (blocked !== undefined) { + Object.assign(where, { + blocked: blocked + }) } const query: FindOptions = { diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index cad954fcb..0a66bd1ce 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts @@ -819,12 +819,12 @@ describe('Test users', function () { describe('User blocking', function () { let user16Id let user16AccessToken + const user16 = { + username: 'user_16', + password: 'my super password' + } - it('Should block and unblock a user', async function () { - const user16 = { - username: 'user_16', - password: 'my super password' - } + it('Should block a user', async function () { const resUser = await createUser({ url: server.url, accessToken: server.accessToken, @@ -840,7 +840,31 @@ describe('Test users', function () { await getMyUserInformation(server.url, user16AccessToken, 401) await userLogin(server, user16, 400) + }) + it('Should search user by banned status', async function () { + { + const res = await getUsersListPaginationAndSort(server.url, server.accessToken, 0, 2, 'createdAt', undefined, true) + const users = res.body.data as User[] + + expect(res.body.total).to.equal(1) + expect(users.length).to.equal(1) + + expect(users[0].username).to.equal(user16.username) + } + + { + const res = await getUsersListPaginationAndSort(server.url, server.accessToken, 0, 2, 'createdAt', undefined, false) + const users = res.body.data as User[] + + expect(res.body.total).to.equal(1) + expect(users.length).to.equal(1) + + expect(users[0].username).to.not.equal(user16.username) + } + }) + + it('Should unblock a user', async function () { await unblockUser(server.url, user16Id, server.accessToken) user16AccessToken = await userLogin(server, user16) await getMyUserInformation(server.url, user16AccessToken, 200) diff --git a/shared/extra-utils/users/users.ts b/shared/extra-utils/users/users.ts index 08b7743a6..9f193680d 100644 --- a/shared/extra-utils/users/users.ts +++ b/shared/extra-utils/users/users.ts @@ -164,14 +164,23 @@ function getUsersList (url: string, accessToken: string) { .expect('Content-Type', /json/) } -function getUsersListPaginationAndSort (url: string, accessToken: string, start: number, count: number, sort: string, search?: string) { +function getUsersListPaginationAndSort ( + url: string, + accessToken: string, + start: number, + count: number, + sort: string, + search?: string, + blocked?: boolean +) { const path = '/api/v1/users' const query = { start, count, sort, - search + search, + blocked } return request(url) diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 2fc55b832..3c22a297f 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -518,10 +518,13 @@ paths: get: summary: List users security: - - OAuth2: [] + - OAuth2: + - admin tags: - Users parameters: + - $ref: '#/components/parameters/usersSearch' + - $ref: '#/components/parameters/usersBlocked' - $ref: '#/components/parameters/start' - $ref: '#/components/parameters/count' - $ref: '#/components/parameters/usersSort' @@ -3148,6 +3151,13 @@ components: schema: type: string example: -createdAt + search: + name: search + in: query + required: false + description: Plain text search, applied to various parts of the model depending on endpoint + schema: + type: string searchTarget: name: searchTarget in: query @@ -3224,6 +3234,20 @@ components: - -dislikes - -uuid - -createdAt + usersSearch: + name: search + in: query + required: false + description: Plain text search that will match with user usernames or emails + schema: + type: string + usersBlocked: + name: blocked + in: query + required: false + description: Filter results down to (un)banned users + schema: + type: boolean usersSort: name: sort in: query