Add user moderation in the account page
authorChocobozzz <me@florianbigard.com>
Fri, 5 Oct 2018 14:56:14 +0000 (16:56 +0200)
committerChocobozzz <me@florianbigard.com>
Fri, 5 Oct 2018 15:02:10 +0000 (17:02 +0200)
18 files changed:
client/src/app/+accounts/accounts.component.html
client/src/app/+accounts/accounts.component.scss
client/src/app/+accounts/accounts.component.ts
client/src/app/+admin/users/user-list/user-list.component.html
client/src/app/shared/account/account.model.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/moderation/index.ts
client/src/app/shared/moderation/user-ban-modal.component.ts
client/src/app/shared/moderation/user-moderation-dropdown.component.html
client/src/app/shared/moderation/user-moderation-dropdown.component.ts
client/src/app/shared/users/user.service.ts
server/models/account/account.ts
server/models/video/video-format-utils.ts
server/models/video/video.ts
server/tests/api/users/users-multiple-servers.ts
shared/models/actors/account.model.ts

index 69f6482697c26fa4e06f4f1184b0ead9481ae2b8..036e794d268bae2f5fd2d9ded01d7f216fe36383 100644 (file)
@@ -8,6 +8,11 @@
         <div class="actor-names">
           <div class="actor-display-name">{{ account.displayName }}</div>
           <div class="actor-name">{{ account.nameWithHost }}</div>
+
+          <span *ngIf="user?.blocked" [ngbTooltip]="user.blockedReason" class="badge badge-danger" i18n>Banned</span>
+
+          <my-user-moderation-dropdown buttonSize="small" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()">
+          </my-user-moderation-dropdown>
         </div>
         <div i18n class="actor-followers">{{ account.followersCount }} subscribers</div>
       </div>
index 909b65bc7118d71373c079278d9d6ddc5393a915..3cedda889de1f5db897e71d5e0d5e4a4c951b382 100644 (file)
@@ -3,4 +3,16 @@
 
 .sub-menu {
   @include sub-menu-with-actor;
+}
+
+my-user-moderation-dropdown,
+.badge {
+  margin-left: 10px;
+
+  position: relative;
+  top: 3px;
+}
+
+.badge {
+  font-size: 13px;
 }
\ No newline at end of file
index af0451e910099d3627c22b9329bc24bf1042f953..e19927d6b08b0a83204bb495f4848390f448ce42 100644 (file)
@@ -1,10 +1,14 @@
-import { Component, OnInit, OnDestroy } from '@angular/core'
+import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
 import { AccountService } from '@app/shared/account/account.service'
 import { Account } from '@app/shared/account/account.model'
-import { RestExtractor } from '@app/shared'
-import { catchError, switchMap, distinctUntilChanged, map } from 'rxjs/operators'
+import { RestExtractor, UserService } from '@app/shared'
+import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
 import { Subscription } from 'rxjs'
+import { NotificationsService } from 'angular2-notifications'
+import { User, UserRight } from '../../../../shared'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { AuthService, RedirectService } from '@app/core'
 
 @Component({
   templateUrl: './accounts.component.html',
@@ -12,13 +16,19 @@ import { Subscription } from 'rxjs'
 })
 export class AccountsComponent implements OnInit, OnDestroy {
   account: Account
+  user: User
 
   private routeSub: Subscription
 
   constructor (
     private route: ActivatedRoute,
+    private userService: UserService,
     private accountService: AccountService,
-    private restExtractor: RestExtractor
+    private notificationsService: NotificationsService,
+    private restExtractor: RestExtractor,
+    private redirectService: RedirectService,
+    private authService: AuthService,
+    private i18n: I18n
   ) {}
 
   ngOnInit () {
@@ -27,12 +37,40 @@ export class AccountsComponent implements OnInit, OnDestroy {
         map(params => params[ 'accountId' ]),
         distinctUntilChanged(),
         switchMap(accountId => this.accountService.getAccount(accountId)),
+        tap(account => this.getUserIfNeeded(account)),
         catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))
       )
-      .subscribe(account => this.account = account)
+      .subscribe(
+        account => this.account = account,
+
+        err => this.notificationsService.error(this.i18n('Error'), err.message)
+      )
   }
 
   ngOnDestroy () {
     if (this.routeSub) this.routeSub.unsubscribe()
   }
+
+  onUserChanged () {
+    this.getUserIfNeeded(this.account)
+  }
+
+  onUserDeleted () {
+    this.redirectService.redirectToHomepage()
+  }
+
+  private getUserIfNeeded (account: Account) {
+    if (!account.userId) return
+    if (!this.authService.isLoggedIn()) return
+
+    const user = this.authService.getUser()
+    if (user.hasRight(UserRight.MANAGE_USERS)) {
+      this.userService.getUser(account.userId)
+          .subscribe(
+            user => this.user = user,
+
+            err => this.notificationsService.error(this.i18n('Error'), err.message)
+          )
+    }
+  }
 }
index 2479ce9e47013adbcfef3b8955ac3afedfc304c1..cca057ba1dee6f4dc729252df9e15501856c4ecb 100644 (file)
@@ -40,7 +40,8 @@
       <td>{{ user.roleLabel }}</td>
       <td>{{ user.createdAt }}</td>
       <td class="action-cell">
-        <my-user-moderation-dropdown [user]="user" (userChanged)="onUserChanged()"></my-user-moderation-dropdown>
+        <my-user-moderation-dropdown [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()">
+        </my-user-moderation-dropdown>
       </td>
     </tr>
   </ng-template>
index 5058e372fd9505ecf99fe1d1c191417d99285b92..42f2cfeafbbe57a9d0a68abaad5e451993def60d 100644 (file)
@@ -6,11 +6,14 @@ export class Account extends Actor implements ServerAccount {
   description: string
   nameWithHost: string
 
+  userId?: number
+
   constructor (hash: ServerAccount) {
     super(hash)
 
     this.displayName = hash.displayName
     this.description = hash.description
+    this.userId = hash.userId
     this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
   }
 }
index 8b7241379432ca0dcdcb21b22095e1bfecb09bd7..8110e25158eebd6d243aa51823a75e8d9531f9d8 100644 (file)
@@ -1,5 +1,5 @@
 <div class="dropdown-root" ngbDropdown [placement]="placement">
-  <div class="action-button" ngbDropdownToggle role="button">
+  <div class="action-button" [ngClass]="{ small: buttonSize === 'small' }" ngbDropdownToggle role="button">
     <span class="icon icon-action"></span>
   </div>
 
index 615511093ae553d02852f91b31967f5f09bc3d4f..00f120fb8ea5831292bb334544a290703d89ade6 100644 (file)
     background-image: url('../../../assets/images/video/more.svg');
     top: -1px;
   }
+
+  &.small {
+    font-size: 14px;
+    height: 20px;
+    line-height: 20px;
+  }
 }
 
 .dropdown-menu {
index 17f9cc6188d54ac839a19084c179a0ee8ee11f76..1838ff697e3953bc1fb91895894a3bdcaa262697 100644 (file)
@@ -17,4 +17,5 @@ export class ActionDropdownComponent<T> {
   @Input() actions: DropdownAction<T>[] = []
   @Input() entry: T
   @Input() placement = 'left'
+  @Input() buttonSize: 'normal' | 'small' = 'normal'
 }
index 2245294c50e3b13a3c149c41cfc2c444659439ff..9a77c64c0e33593b1ce731b854d94aab793c6293 100644 (file)
@@ -1,2 +1,2 @@
 export * from './user-ban-modal.component'
-export * from './user-moderation-dropdown.component'
\ No newline at end of file
+export * from './user-moderation-dropdown.component'
index d49783cd215840a3ef6ebc5dcd02eccc2bbfee9a..67ae38e487ad1d11ec6abcac20a8c78d923fbd4b 100644 (file)
@@ -5,7 +5,8 @@ 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'
 import { FormReactive, UserValidatorsService } from '@app/shared/forms'
-import { User, UserService } from '@app/shared/users'
+import { UserService } from '@app/shared/users'
+import { User } from '../../../../../shared'
 
 @Component({
   selector: 'my-user-ban-modal',
index ed8a4dc66c49e05297e5d7b7e1008d4100967ebd..ed1a4c86350757f80b6b6594e076de55d2367dda 100644 (file)
@@ -1,3 +1,5 @@
-<my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal>
+<ng-container *ngIf="user && userActions.length !== 0">
+  <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal>
 
-<my-action-dropdown i18n-label label="Actions" [actions]="userActions" [entry]="user"></my-action-dropdown>
\ No newline at end of file
+  <my-action-dropdown i18n-label label="Actions" [actions]="userActions" [entry]="user" [buttonSize]="buttonSize"></my-action-dropdown>
+</ng-container>
\ No newline at end of file
index d92423476d001c27c78708d31e4579ca7b7e245e..4f88456defa03212407443483cfa890a47def945 100644 (file)
@@ -4,9 +4,9 @@ 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/shared/moderation/user-ban-modal.component'
-import { User, UserService } from '@app/shared/users'
+import { UserService } from '@app/shared/users'
 import { AuthService, ConfirmService } from '@app/core'
-import { UserRight } from '../../../../../shared/models/users'
+import { User, UserRight } from '../../../../../shared/models/users'
 
 @Component({
   selector: 'my-user-moderation-dropdown',
@@ -17,7 +17,10 @@ export class UserModerationDropdownComponent implements OnInit {
   @ViewChild('userBanModal') userBanModal: UserBanModalComponent
 
   @Input() user: User
+  @Input() buttonSize: 'normal' | 'small' = 'normal'
+
   @Output() userChanged = new EventEmitter()
+  @Output() userDeleted = new EventEmitter()
 
   userActions: DropdownAction<User>[] = []
 
@@ -32,34 +35,7 @@ export class UserModerationDropdownComponent implements OnInit {
   ) { }
 
   ngOnInit () {
-    this.userActions = []
-
-    if (this.authService.isLoggedIn()) {
-      const authUser = this.authService.getUser()
-
-      if (authUser.hasRight(UserRight.MANAGE_USERS)) {
-        this.userActions = this.userActions.concat([
-          {
-            label: this.i18n('Edit'),
-            linkBuilder: this.getRouterUserEditLink
-          },
-          {
-            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
-          }
-        ])
-      }
-    }
+    this.buildActions()
   }
 
   hideBanUserModal () {
@@ -115,7 +91,7 @@ export class UserModerationDropdownComponent implements OnInit {
           this.i18n('Success'),
           this.i18n('User {{username}} deleted.', { username: user.username })
         )
-        this.userChanged.emit()
+        this.userDeleted.emit()
       },
 
       err => this.notificationsService.error(this.i18n('Error'), err.message)
@@ -125,4 +101,35 @@ export class UserModerationDropdownComponent implements OnInit {
   getRouterUserEditLink (user: User) {
     return [ '/admin', 'users', 'update', user.id ]
   }
+
+  private buildActions () {
+    this.userActions = []
+
+    if (this.authService.isLoggedIn()) {
+      const authUser = this.authService.getUser()
+
+      if (authUser.hasRight(UserRight.MANAGE_USERS)) {
+        this.userActions = this.userActions.concat([
+          {
+            label: this.i18n('Edit'),
+            linkBuilder: this.getRouterUserEditLink
+          },
+          {
+            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
+          }
+        ])
+      }
+    }
+  }
 }
index 5ab290a59dea9f3dcf4083a25b59a45c2a614264..d9b81c181f5ae5ec7873a716f149ef8f2090b6ee 100644 (file)
@@ -170,19 +170,19 @@ export class UserService {
                )
   }
 
-  removeUser (user: User) {
+  removeUser (user: { id: number }) {
     return this.authHttp.delete(UserService.BASE_USERS_URL + user.id)
                .pipe(catchError(err => this.restExtractor.handleError(err)))
   }
 
-  banUser (user: User, reason?: string) {
+  banUser (user: { id: number }, 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) {
+  unbanUser (user: { id: number }) {
     return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/unblock', {})
                .pipe(catchError(err => this.restExtractor.handleError(err)))
   }
index 27c75d8861203c3be8c6e8e27b9d134e3dbb334c..5a237d733a0efd06c6fa98ed258c323e7b8c9058 100644 (file)
@@ -248,7 +248,8 @@ export class AccountModel extends Model<AccountModel> {
       displayName: this.getDisplayName(),
       description: this.description,
       createdAt: this.createdAt,
-      updatedAt: this.updatedAt
+      updatedAt: this.updatedAt,
+      userId: this.userId ? this.userId : undefined
     }
 
     return Object.assign(actor, account)
index 78972b199252bbba06e3d0341103cd6f31b95630..905e8444997e8306d07773213960491ad9b41261 100644 (file)
@@ -10,7 +10,7 @@ import {
   getVideoLikesActivityPubUrl,
   getVideoSharesActivityPubUrl
 } from '../../lib/activitypub'
-import { isArray } from 'util'
+import { isArray } from '../../helpers/custom-validators/misc'
 
 export type VideoFormattingJSONOptions = {
   completeDescription?: boolean
index 0a2d7e6de1c6106adf108f41acbf97f2a900bddc..46d823240526ee7daddce7e371057070f07f397f 100644 (file)
@@ -94,7 +94,6 @@ import {
 import * as validator from 'validator'
 import { UserVideoHistoryModel } from '../account/user-video-history'
 
-
 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
 const indexes: Sequelize.DefineIndexesOptions[] = [
   buildTrigramSearchIndex('video_name_trigram', 'name'),
index b67072851702c05c230b9fc051fcac08492f7a3f..d8699db17b274986d2137cb3c4bbc6fbb7952ba7 100644 (file)
@@ -148,6 +148,12 @@ describe('Test users with multiple servers', function () {
       expect(rootServer1Get.displayName).to.equal('my super display name')
       expect(rootServer1Get.description).to.equal('my super description updated')
 
+      if (server.serverNumber === 1) {
+        expect(rootServer1Get.userId).to.be.a('number')
+      } else {
+        expect(rootServer1Get.userId).to.be.undefined
+      }
+
       await testImage(server.url, 'avatar2-resized', rootServer1Get.avatar.path, '.png')
     }
   })
index e1117486dd9e55f2d5b5f99bd73ed2c58ff7dbde..7f1dbbc37755e0392201da5219a40f19bda25e44 100644 (file)
@@ -3,4 +3,6 @@ import { Actor } from './actor.model'
 export interface Account extends Actor {
   displayName: string
   description: string
+
+  userId?: number
 }