Add ability to ban/unban users
authorChocobozzz <me@florianbigard.com>
Thu, 9 Aug 2018 15:51:25 +0000 (17:51 +0200)
committerChocobozzz <me@florianbigard.com>
Thu, 9 Aug 2018 15:55:05 +0000 (17:55 +0200)
17 files changed:
client/src/app/+admin/admin.module.ts
client/src/app/+admin/users/shared/user.service.ts
client/src/app/+admin/users/user-list/user-ban-modal.component.html [new file with mode: 0644]
client/src/app/+admin/users/user-list/user-ban-modal.component.scss [new file with mode: 0644]
client/src/app/+admin/users/user-list/user-ban-modal.component.ts [new file with mode: 0644]
client/src/app/+admin/users/user-list/user-list.component.html
client/src/app/+admin/users/user-list/user-list.component.scss
client/src/app/+admin/users/user-list/user-list.component.ts
client/src/app/login/login.component.html
client/src/app/login/login.component.ts
client/src/app/shared/buttons/action-dropdown.component.html
client/src/app/shared/buttons/action-dropdown.component.scss
client/src/app/shared/buttons/action-dropdown.component.ts
client/src/app/shared/forms/form-validators/user-validators.service.ts
client/src/sass/application.scss
client/src/sass/primeng-custom.scss
server/lib/oauth-model.ts

index d7ae2f7f0ad1aebb5e6c89de8224c9cda181cc22..04b7ec5c1c724c776128fced562cdf712318f5c0 100644 (file)
@@ -13,6 +13,7 @@ import { JobService } from './jobs/shared/job.service'
 import { UserCreateComponent, UserListComponent, UsersComponent, UserService, UserUpdateComponent } from './users'
 import { VideoAbuseListComponent, VideoAbusesComponent } from './video-abuses'
 import { VideoBlacklistComponent, VideoBlacklistListComponent } from './video-blacklist'
+import { UserBanModalComponent } from '@app/+admin/users/user-list/user-ban-modal.component'
 
 @NgModule({
   imports: [
@@ -33,6 +34,7 @@ import { VideoBlacklistComponent, VideoBlacklistListComponent } from './video-bl
     UserCreateComponent,
     UserUpdateComponent,
     UserListComponent,
+    UserBanModalComponent,
 
     VideoBlacklistComponent,
     VideoBlacklistListComponent,
index 1af1e4ef2ce82ba9f07045d907840e69bceb617d..ad7fb1eeeb5f8d19f84172b69227fd6568ae7d4d 100644 (file)
@@ -59,6 +59,18 @@ export class UserService {
                .pipe(catchError(err => this.restExtractor.handleError(err)))
   }
 
+  banUser (user: User, reason?: string) {
+    const body = reason ? { reason } : {}
+
+    return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/block', body)
+               .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  unbanUser (user: User) {
+    return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/unblock', {})
+               .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
   private formatUser (user: User) {
     let videoQuota
     if (user.videoQuota === -1) {
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.html b/client/src/app/+admin/users/user-list/user-ban-modal.component.html
new file mode 100644 (file)
index 0000000..b2958ca
--- /dev/null
@@ -0,0 +1,32 @@
+<ng-template #modal>
+  <div class="modal-header">
+    <h4 i18n class="modal-title">Ban {{ userToBan.username }}</h4>
+    <span class="close" aria-hidden="true" (click)="hideBanUserModal()"></span>
+  </div>
+
+  <div class="modal-body">
+    <form novalidate [formGroup]="form" (ngSubmit)="banUser()">
+      <div class="form-group">
+        <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }">
+        </textarea>
+        <div *ngIf="formErrors.reason" class="form-error">
+          {{ formErrors.reason }}
+        </div>
+      </div>
+
+      <div i18n>
+        A banned user will no longer be able to login.
+      </div>
+
+      <div class="form-group inputs">
+        <span i18n class="action-button action-button-cancel" (click)="hideBanUserModal()">Cancel</span>
+
+        <input
+          type="submit" i18n-value value="Ban this user" class="action-button-submit"
+          [disabled]="!form.valid"
+        >
+      </div>
+    </form>
+  </div>
+
+</ng-template>
\ No newline at end of file
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.scss b/client/src/app/+admin/users/user-list/user-ban-modal.component.scss
new file mode 100644 (file)
index 0000000..84562f1
--- /dev/null
@@ -0,0 +1,6 @@
+@import 'variables';
+@import 'mixins';
+
+textarea {
+  @include peertube-textarea(100%, 60px);
+}
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.ts b/client/src/app/+admin/users/user-list/user-ban-modal.component.ts
new file mode 100644 (file)
index 0000000..444de1c
--- /dev/null
@@ -0,0 +1,68 @@
+import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
+import { NotificationsService } from 'angular2-notifications'
+import { FormReactive, User, UserValidatorsService } from '../../../shared'
+import { UserService } from '../shared'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+
+@Component({
+  selector: 'my-user-ban-modal',
+  templateUrl: './user-ban-modal.component.html',
+  styleUrls: [ './user-ban-modal.component.scss' ]
+})
+export class UserBanModalComponent extends FormReactive implements OnInit {
+  @ViewChild('modal') modal: NgbModal
+  @Output() userBanned = new EventEmitter<User>()
+
+  private userToBan: User
+  private openedModal: NgbModalRef
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private modalService: NgbModal,
+    private notificationsService: NotificationsService,
+    private userService: UserService,
+    private userValidatorsService: UserValidatorsService,
+    private i18n: I18n
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      reason: this.userValidatorsService.USER_BAN_REASON
+    })
+  }
+
+  openModal (user: User) {
+    this.userToBan = user
+    this.openedModal = this.modalService.open(this.modal)
+  }
+
+  hideBanUserModal () {
+    this.userToBan = undefined
+    this.openedModal.close()
+  }
+
+  async banUser () {
+    const reason = this.form.value['reason'] || undefined
+
+    this.userService.banUser(this.userToBan, reason)
+      .subscribe(
+        () => {
+          this.notificationsService.success(
+            this.i18n('Success'),
+            this.i18n('User {{username}} banned.', { username: this.userToBan.username })
+          )
+
+          this.userBanned.emit(this.userToBan)
+          this.hideBanUserModal()
+        },
+
+          err => this.notificationsService.error(this.i18n('Error'), err.message)
+      )
+  }
+
+}
index ef5a6c6489646a216bd5b80791ecab36df04e6d6..a92fe95ef71a481e8f446a1624b721b03f750e37 100644 (file)
@@ -9,31 +9,50 @@
 
 <p-table
   [value]="users" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage"
-  [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)"
+  [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
 >
   <ng-template pTemplate="header">
     <tr>
+      <th style="width: 40px"></th>
       <th i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th>
       <th i18n>Email</th>
       <th i18n>Video quota</th>
       <th i18n>Role</th>
       <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
-      <th></th>
+      <th style="width: 50px;"></th>
     </tr>
   </ng-template>
 
-  <ng-template pTemplate="body" let-user>
-    <tr>
-      <td>{{ user.username }}</td>
+  <ng-template pTemplate="body" let-expanded="expanded" let-user>
+
+    <tr [ngClass]="{ banned: user.blocked }">
+      <td>
+        <span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user">
+          <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
+        </span>
+      </td>
+      <td>
+        {{ user.username }}
+        <span *ngIf="user.blocked" class="banned-info">(banned)</span>
+      </td>
       <td>{{ user.email }}</td>
       <td>{{ user.videoQuota }}</td>
       <td>{{ user.roleLabel }}</td>
       <td>{{ user.createdAt }}</td>
       <td class="action-cell">
         <my-action-dropdown i18n-label label="Actions" [actions]="userActions" [entry]="user"></my-action-dropdown>
-        <!--<my-edit-button [routerLink]="getRouterUserEditLink(user)"></my-edit-button>-->
-        <!--<my-delete-button (click)="removeUser(user)"></my-delete-button>-->
+      </td>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="rowexpansion" let-user>
+    <tr class="user-blocked-reason">
+      <td colspan="7">
+        <span i18n class="ban-reason-label">Ban reason:</span>
+        {{ user.blockedReason }}
       </td>
     </tr>
   </ng-template>
 </p-table>
+
+<my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal>
\ No newline at end of file
index 4fc36e11ec26a0745a058a7891a241ea13d902cb..2d11dd7a0e76eb6bfc5f40683992a3086199fc64 100644 (file)
@@ -4,3 +4,21 @@
 .add-button {
   @include create-button('../../../../assets/images/global/add.svg');
 }
+
+my-action-dropdown /deep/ .icon {
+  &.icon-ban {
+    background-image: url('../../../../assets/images/global/edit-black.svg');
+  }
+}
+
+tr.banned {
+  color: red;
+}
+
+.banned-info {
+  font-style: italic;
+}
+
+.ban-reason-label {
+  font-weight: $font-semibold;
+}
\ No newline at end of file
index 3c83859e0ed963278b9e9c4596d4deeb48408131..f5f8f3e4a8c200bfa9ab3c46bb4629d28aed206c 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, OnInit } from '@angular/core'
+import { Component, OnInit, ViewChild } from '@angular/core'
 import { NotificationsService } from 'angular2-notifications'
 import { SortMeta } from 'primeng/components/common/sortmeta'
 import { ConfirmService } from '../../../core'
@@ -6,6 +6,8 @@ import { RestPagination, RestTable, User } from '../../../shared'
 import { UserService } from '../shared'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { UserBanModalComponent } from '@app/+admin/users/user-list/user-ban-modal.component'
 
 @Component({
   selector: 'my-user-list',
@@ -13,6 +15,8 @@ import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
   styleUrls: [ './user-list.component.scss' ]
 })
 export class UserListComponent extends RestTable implements OnInit {
+  @ViewChild('userBanModal') userBanModal: UserBanModalComponent
+
   users: User[] = []
   totalRecords = 0
   rowsPerPage = 10
@@ -20,6 +24,9 @@ export class UserListComponent extends RestTable implements OnInit {
   pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
   userActions: DropdownAction<User>[] = []
 
+  private userToBan: User
+  private openedModal: NgbModalRef
+
   constructor (
     private notificationsService: NotificationsService,
     private confirmService: ConfirmService,
@@ -30,12 +37,22 @@ export class UserListComponent extends RestTable implements OnInit {
 
     this.userActions = [
       {
-        type: 'edit',
+        label: this.i18n('Edit'),
         linkBuilder: this.getRouterUserEditLink
       },
       {
-        type: 'delete',
+        label: this.i18n('Delete'),
         handler: user => this.removeUser(user)
+      },
+      {
+        label: this.i18n('Ban'),
+        handler: user => this.openBanUserModal(user),
+        isDisplayed: user => !user.blocked
+      },
+      {
+        label: this.i18n('Unban'),
+        handler: user => this.unbanUser(user),
+        isDisplayed: user => user.blocked
       }
     ]
   }
@@ -44,6 +61,43 @@ export class UserListComponent extends RestTable implements OnInit {
     this.loadSort()
   }
 
+  hideBanUserModal () {
+    this.userToBan = undefined
+    this.openedModal.close()
+  }
+
+  openBanUserModal (user: User) {
+    if (user.username === 'root') {
+      this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.'))
+      return
+    }
+
+    this.userBanModal.openModal(user)
+  }
+
+  onUserBanned () {
+    this.loadData()
+  }
+
+  async unbanUser (user: User) {
+    const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username })
+    const res = await this.confirmService.confirm(message, this.i18n('Unban'))
+    if (res === false) return
+
+    this.userService.unbanUser(user)
+      .subscribe(
+        () => {
+          this.notificationsService.success(
+            this.i18n('Success'),
+            this.i18n('User {{username}} unbanned.', { username: user.username })
+          )
+          this.loadData()
+        },
+
+        err => this.notificationsService.error(this.i18n('Error'), err.message)
+      )
+  }
+
   async removeUser (user: User) {
     if (user.username === 'root') {
       this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.'))
index fac63d44d0d44ff651c2adb25b3338b0bb7b454b..3a6d61327c01125d73a9029b6ce3336f1a34eafa 100644 (file)
@@ -50,7 +50,6 @@
   </form>
 </div>
 
-<!--<ng-template #forgotPasswordModal (onShown)="onForgotPasswordModalShown()">-->
 <ng-template #forgotPasswordModal>
   <div class="modal-header">
     <h4 i18n class="modal-title">Forgot your password</h4>
index 8e88225108d3c615fdd8b2d38b7e3a56a972ce70..7c02208859508562344d9911ddbb19a58ea73655 100644 (file)
@@ -69,7 +69,7 @@ export class LoginComponent extends FormReactive implements OnInit {
   askResetPassword () {
     this.userService.askResetPassword(this.forgotPasswordEmail)
       .subscribe(
-        res => {
+        () => {
           const message = this.i18n(
             'An email with the reset password instructions will be sent to {{email}}.',
             { email: this.forgotPasswordEmail }
index c87ba4c82f8b3d4a84b692a562b189bc28b7ffc1..8b7241379432ca0dcdcb21b22095e1bfecb09bd7 100644 (file)
@@ -1,16 +1,17 @@
-<div class="dropdown-root" dropdown container="body" dropup="true" placement="right" role="button">
-  <div class="action-button" dropdownToggle>
+<div class="dropdown-root" ngbDropdown [placement]="placement">
+  <div class="action-button" ngbDropdownToggle role="button">
     <span class="icon icon-action"></span>
   </div>
 
-  <ul *dropdownMenu class="dropdown-menu" id="more-menu" role="menu" aria-labelledby="single-button">
-    <li role="menuitem" *ngFor="let action of actions">
-      <my-delete-button *ngIf="action.type === 'delete'" [label]="action.label" (click)="action.handler(entry)"></my-delete-button>
-      <my-edit-button *ngIf="action.type === 'edit'" [label]="action.label" [routerLink]="action.linkBuilder(entry)"></my-edit-button>
+  <div ngbDropdownMenu class="dropdown-menu">
+    <ng-container *ngFor="let action of actions">
+      <div class="dropdown-item" *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true">
+        <a *ngIf="action.linkBuilder" class="dropdown-item" [routerLink]="action.linkBuilder(entry)">{{ action.label }}</a>
 
-      <a *ngIf="action.type === 'custom'" class="dropdown-item" href="#" (click)="action.handler(entry)">
-        <span *ngIf="action.iconClass" class="icon" [ngClass]="action.iconClass"></span> <ng-container>{{ action.label }}</ng-container>
-      </a>
-    </li>
-  </ul>
+        <span *ngIf="!action.linkBuilder" class="custom-action" class="dropdown-item" (click)="action.handler(entry)" role="button">
+          {{ action.label }}
+        </span>
+      </div>
+    </ng-container>
+  </div>
 </div>
\ No newline at end of file
index cc459b97254d2130f58fbaf3cf1d438d72726b2c..615511093ae553d02852f91b31967f5f09bc3d4f 100644 (file)
@@ -5,17 +5,28 @@
   @include peertube-button;
   @include grey-button;
 
+  display: inline-block;
+  padding: 0 10px;
+
+  &::after {
+    display: none;
+  }
+
   &:hover, &:active, &:focus {
     background-color: $grey-color;
   }
 
-  display: inline-block;
-  padding: 0 10px;
-
   .icon-action {
     @include icon(21px);
 
     background-image: url('../../../assets/images/video/more.svg');
     top: -1px;
   }
+}
+
+.dropdown-menu {
+  .dropdown-item {
+    cursor: pointer;
+    color: #000 !important;
+  }
 }
\ No newline at end of file
index 407d24b80f31f7c321a153bb76bda6bd5fc8495e..17f9cc6188d54ac839a19084c179a0ee8ee11f76 100644 (file)
@@ -1,11 +1,10 @@
 import { Component, Input } from '@angular/core'
 
 export type DropdownAction<T> = {
-  type: 'custom' | 'delete' | 'edit'
   label?: string
   handler?: (T) => any
   linkBuilder?: (T) => (string | number)[]
-  iconClass?: string
+  isDisplayed?: (T) => boolean
 }
 
 @Component({
@@ -17,4 +16,5 @@ export type DropdownAction<T> = {
 export class ActionDropdownComponent<T> {
   @Input() actions: DropdownAction<T>[] = []
   @Input() entry: T
+  @Input() placement = 'left'
 }
index 5edae2e3803c0d130a5f4591a15bbd642431a47b..ec9566ef38f77413090f1e204a05905d4de1e7a0 100644 (file)
@@ -14,6 +14,8 @@ export class UserValidatorsService {
   readonly USER_DESCRIPTION: BuildFormValidator
   readonly USER_TERMS: BuildFormValidator
 
+  readonly USER_BAN_REASON: BuildFormValidator
+
   constructor (private i18n: I18n) {
 
     this.USER_USERNAME = {
@@ -99,5 +101,16 @@ export class UserValidatorsService {
         'required': this.i18n('You must to agree with the instance terms in order to registering on it.')
       }
     }
+
+    this.USER_BAN_REASON = {
+      VALIDATORS: [
+        Validators.minLength(3),
+        Validators.maxLength(250)
+      ],
+      MESSAGES: {
+        'minlength': this.i18n('Ban reason must be at least 3 characters long.'),
+        'maxlength': this.i18n('Ban reason cannot be more than 250 characters long.')
+      }
+    }
   }
 }
index b120d409007bb7021aa8f80416e1fe28ba92295b..ba8701f5ca321e3a03c4b24e43a791ac5b07c8f2 100644 (file)
@@ -310,8 +310,4 @@ table {
       }
     }
   }
-
-  bs-dropdown-container {
-    z-index: 10000;
-  }
 }
index f06daec5d7066d10a6f00edb3f2ff39537ab8377..674aa649e47dde159945aad98dd6152eff863763 100644 (file)
@@ -17,9 +17,12 @@ p-table {
   td {
     border: 1px solid #E5E5E5 !important;
     padding-left: 15px !important;
-    overflow: hidden !important;
-    text-overflow: ellipsis !important;
-    white-space: nowrap !important;
+
+    &:not(.action-cell) {
+      overflow: hidden !important;
+      text-overflow: ellipsis !important;
+      white-space: nowrap !important;
+    }
   }
 
   tr {
index f13c25795b841200f67c5630a795be8f7f5cc5be..f159ad6a90c4b8d7bd2a47880a346124df8d4d0b 100644 (file)
@@ -1,4 +1,4 @@
-import { AccessDeniedError} from 'oauth2-server'
+import { AccessDeniedError } from 'oauth2-server'
 import { logger } from '../helpers/logger'
 import { UserModel } from '../models/account/user'
 import { OAuthClientModel } from '../models/oauth/oauth-client'