Browse Source

add blocked filter in users list to filter banned users

fixes #2914
Rigel Kent 3 years ago
parent
commit
8491293b02

+ 21 - 8
client/src/app/+admin/users/user-list/user-list.component.html

@@ -16,14 +16,27 @@
         </my-action-dropdown>
       </div>
 
-      <div class="ml-auto has-feedback has-clear">
-        <input
-          type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
-          (keyup)="onSearch($event)"
-        >
-        <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
-        <span class="sr-only" i18n>Clear filters</span>
+      <div class="ml-auto">
+        <div class="input-group has-feedback has-clear">
+          <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
+            <div class="input-group-text" ngbDropdownToggle>
+              <span class="caret" aria-haspopup="menu" role="button"></span>
+            </div>
+
+            <div role="menu" ngbDropdownMenu>
+              <h6 class="dropdown-header" i18n>Advanced user filters</h6>
+              <a [routerLink]="[ '/admin/users/list' ]" [queryParams]="{ 'search': 'banned:true' }" class="dropdown-item" i18n>Banned users</a>
+            </div>
+          </div>
+          <input
+            type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
+            (keyup)="onUserSearch($event)"
+          >
+          <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetTableFilter()"></a>
+          <span class="sr-only" i18n>Clear filters</span>
+        </div>
       </div>
+
       <a class="ml-2 add-button" routerLink="/admin/users/create">
         <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
         <ng-container i18n>Create user</ng-container>
@@ -70,7 +83,7 @@
               alt="Avatar"
             >
             <div>
-              <span>
+              <span class="user-table-primary-text">
                 <span *ngIf="user.blocked" i18n-title title="The user was banned" class="glyphicon glyphicon-ban-circle"></span>
                 {{ user.account.displayName }}
               </span>

+ 17 - 0
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;
+  }
+}

+ 42 - 8
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)
+    )
   }
 }

+ 3 - 1
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
 

+ 23 - 2
client/src/app/core/users/user.service.ts

@@ -290,11 +290,32 @@ export class UserService {
     })
   }
 
-  getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<UserServerModel>> {
+  getUsers (parameters: {
+    pagination: RestPagination
+    sort: SortMeta
+    search?: string
+  }): Observable<ResultList<UserServerModel>> {
+    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<ResultList<UserServerModel>>(UserService.BASE_USERS_URL, { params })
                .pipe(

+ 9 - 1
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 }))
 }

+ 16 - 0
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,

+ 17 - 4
server/models/account/user.ts

@@ -412,11 +412,18 @@ export class UserModel extends Model<UserModel> {
     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<UserModel> {
             }
           }
         ]
-      }
+      })
+    }
+
+    if (blocked !== undefined) {
+      Object.assign(where, {
+        blocked: blocked
+      })
     }
 
     const query: FindOptions = {

+ 29 - 5
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)

+ 11 - 2
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)

+ 25 - 1
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