aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.html5
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.ts13
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.html16
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.scss21
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.ts20
-rw-r--r--client/src/app/shared/buttons/button.component.scss (renamed from client/src/app/shared/misc/button.component.scss)0
-rw-r--r--client/src/app/shared/buttons/delete-button.component.html6
-rw-r--r--client/src/app/shared/buttons/delete-button.component.ts (renamed from client/src/app/shared/misc/delete-button.component.ts)2
-rw-r--r--client/src/app/shared/buttons/edit-button.component.html (renamed from client/src/app/shared/misc/edit-button.component.html)4
-rw-r--r--client/src/app/shared/buttons/edit-button.component.ts (renamed from client/src/app/shared/misc/edit-button.component.ts)1
-rw-r--r--client/src/app/shared/misc/delete-button.component.html4
-rw-r--r--client/src/app/shared/shared.module.ts7
-rw-r--r--client/src/app/shared/users/user.model.ts35
-rw-r--r--server/controllers/api/users.ts8
-rw-r--r--server/helpers/custom-validators/users.ts5
-rw-r--r--server/initializers/constants.ts3
-rw-r--r--server/initializers/migrations/0245-user-blocked.ts14
-rw-r--r--server/lib/emailer.ts23
-rw-r--r--server/middlewares/validators/users.ts5
-rw-r--r--server/models/account/user.ts9
-rw-r--r--server/models/video/video-abuse.ts2
-rw-r--r--server/tests/api/server/email.ts48
-rw-r--r--server/tests/utils/users/users.ts5
-rw-r--r--shared/models/users/user.model.ts3
24 files changed, 215 insertions, 44 deletions
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 166fafef0..ef5a6c648 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
@@ -30,8 +30,9 @@
30 <td>{{ user.roleLabel }}</td> 30 <td>{{ user.roleLabel }}</td>
31 <td>{{ user.createdAt }}</td> 31 <td>{{ user.createdAt }}</td>
32 <td class="action-cell"> 32 <td class="action-cell">
33 <my-edit-button [routerLink]="getRouterUserEditLink(user)"></my-edit-button> 33 <my-action-dropdown i18n-label label="Actions" [actions]="userActions" [entry]="user"></my-action-dropdown>
34 <my-delete-button (click)="removeUser(user)"></my-delete-button> 34 <!--<my-edit-button [routerLink]="getRouterUserEditLink(user)"></my-edit-button>-->
35 <!--<my-delete-button (click)="removeUser(user)"></my-delete-button>-->
35 </td> 36 </td>
36 </tr> 37 </tr>
37 </ng-template> 38 </ng-template>
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 ab25608c1..3c83859e0 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 { ConfirmService } from '../../../core'
5import { RestPagination, RestTable, User } from '../../../shared' 5import { RestPagination, RestTable, User } from '../../../shared'
6import { UserService } from '../shared' 6import { UserService } from '../shared'
7import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
8 9
9@Component({ 10@Component({
10 selector: 'my-user-list', 11 selector: 'my-user-list',
@@ -17,6 +18,7 @@ export class UserListComponent extends RestTable implements OnInit {
17 rowsPerPage = 10 18 rowsPerPage = 10
18 sort: SortMeta = { field: 'createdAt', order: 1 } 19 sort: SortMeta = { field: 'createdAt', order: 1 }
19 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 20 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
21 userActions: DropdownAction<User>[] = []
20 22
21 constructor ( 23 constructor (
22 private notificationsService: NotificationsService, 24 private notificationsService: NotificationsService,
@@ -25,6 +27,17 @@ export class UserListComponent extends RestTable implements OnInit {
25 private i18n: I18n 27 private i18n: I18n
26 ) { 28 ) {
27 super() 29 super()
30
31 this.userActions = [
32 {
33 type: 'edit',
34 linkBuilder: this.getRouterUserEditLink
35 },
36 {
37 type: 'delete',
38 handler: user => this.removeUser(user)
39 }
40 ]
28 } 41 }
29 42
30 ngOnInit () { 43 ngOnInit () {
diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html
new file mode 100644
index 000000000..c87ba4c82
--- /dev/null
+++ b/client/src/app/shared/buttons/action-dropdown.component.html
@@ -0,0 +1,16 @@
1<div class="dropdown-root" dropdown container="body" dropup="true" placement="right" role="button">
2 <div class="action-button" dropdownToggle>
3 <span class="icon icon-action"></span>
4 </div>
5
6 <ul *dropdownMenu class="dropdown-menu" id="more-menu" role="menu" aria-labelledby="single-button">
7 <li role="menuitem" *ngFor="let action of actions">
8 <my-delete-button *ngIf="action.type === 'delete'" [label]="action.label" (click)="action.handler(entry)"></my-delete-button>
9 <my-edit-button *ngIf="action.type === 'edit'" [label]="action.label" [routerLink]="action.linkBuilder(entry)"></my-edit-button>
10
11 <a *ngIf="action.type === 'custom'" class="dropdown-item" href="#" (click)="action.handler(entry)">
12 <span *ngIf="action.iconClass" class="icon" [ngClass]="action.iconClass"></span> <ng-container>{{ action.label }}</ng-container>
13 </a>
14 </li>
15 </ul>
16</div> \ No newline at end of file
diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss
new file mode 100644
index 000000000..cc459b972
--- /dev/null
+++ b/client/src/app/shared/buttons/action-dropdown.component.scss
@@ -0,0 +1,21 @@
1@import '_variables';
2@import '_mixins';
3
4.action-button {
5 @include peertube-button;
6 @include grey-button;
7
8 &:hover, &:active, &:focus {
9 background-color: $grey-color;
10 }
11
12 display: inline-block;
13 padding: 0 10px;
14
15 .icon-action {
16 @include icon(21px);
17
18 background-image: url('../../../assets/images/video/more.svg');
19 top: -1px;
20 }
21} \ No newline at end of file
diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts
new file mode 100644
index 000000000..407d24b80
--- /dev/null
+++ b/client/src/app/shared/buttons/action-dropdown.component.ts
@@ -0,0 +1,20 @@
1import { Component, Input } from '@angular/core'
2
3export type DropdownAction<T> = {
4 type: 'custom' | 'delete' | 'edit'
5 label?: string
6 handler?: (T) => any
7 linkBuilder?: (T) => (string | number)[]
8 iconClass?: string
9}
10
11@Component({
12 selector: 'my-action-dropdown',
13 styleUrls: [ './action-dropdown.component.scss' ],
14 templateUrl: './action-dropdown.component.html'
15})
16
17export class ActionDropdownComponent<T> {
18 @Input() actions: DropdownAction<T>[] = []
19 @Input() entry: T
20}
diff --git a/client/src/app/shared/misc/button.component.scss b/client/src/app/shared/buttons/button.component.scss
index 343aea207..343aea207 100644
--- a/client/src/app/shared/misc/button.component.scss
+++ b/client/src/app/shared/buttons/button.component.scss
diff --git a/client/src/app/shared/buttons/delete-button.component.html b/client/src/app/shared/buttons/delete-button.component.html
new file mode 100644
index 000000000..792490219
--- /dev/null
+++ b/client/src/app/shared/buttons/delete-button.component.html
@@ -0,0 +1,6 @@
1<span class="action-button action-button-delete" [title]="label" role="button">
2 <span class="icon icon-delete-grey"></span>
3
4 <span class="button-label" *ngIf="label">{{ label }}</span>
5 <span class="button-label" i18n *ngIf="!label">Delete</span>
6</span>
diff --git a/client/src/app/shared/misc/delete-button.component.ts b/client/src/app/shared/buttons/delete-button.component.ts
index 2ffd98212..cd2bcccdf 100644
--- a/client/src/app/shared/misc/delete-button.component.ts
+++ b/client/src/app/shared/buttons/delete-button.component.ts
@@ -7,5 +7,5 @@ import { Component, Input } from '@angular/core'
7}) 7})
8 8
9export class DeleteButtonComponent { 9export class DeleteButtonComponent {
10 @Input() label = 'Delete' 10 @Input() label: string
11} 11}
diff --git a/client/src/app/shared/misc/edit-button.component.html b/client/src/app/shared/buttons/edit-button.component.html
index 78fbc326e..7efc54ce7 100644
--- a/client/src/app/shared/misc/edit-button.component.html
+++ b/client/src/app/shared/buttons/edit-button.component.html
@@ -1,4 +1,6 @@
1<a class="action-button action-button-edit" [routerLink]="routerLink" title="Edit"> 1<a class="action-button action-button-edit" [routerLink]="routerLink" title="Edit">
2 <span class="icon icon-edit"></span> 2 <span class="icon icon-edit"></span>
3 <span i18n class="button-label">Edit</span> 3
4 <span class="button-label" *ngIf="label">{{ label }}</span>
5 <span i18n class="button-label" *ngIf="!label">Edit</span>
4</a> 6</a>
diff --git a/client/src/app/shared/misc/edit-button.component.ts b/client/src/app/shared/buttons/edit-button.component.ts
index 201a618ec..7abaacc26 100644
--- a/client/src/app/shared/misc/edit-button.component.ts
+++ b/client/src/app/shared/buttons/edit-button.component.ts
@@ -7,5 +7,6 @@ import { Component, Input } from '@angular/core'
7}) 7})
8 8
9export class EditButtonComponent { 9export class EditButtonComponent {
10 @Input() label: string
10 @Input() routerLink = [] 11 @Input() routerLink = []
11} 12}
diff --git a/client/src/app/shared/misc/delete-button.component.html b/client/src/app/shared/misc/delete-button.component.html
deleted file mode 100644
index 7387d0a88..000000000
--- a/client/src/app/shared/misc/delete-button.component.html
+++ /dev/null
@@ -1,4 +0,0 @@
1<span class="action-button action-button-delete" [title]="label">
2 <span class="icon icon-delete-grey"></span>
3 <span class="button-label">{{ label }}</span>
4</span>
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 62ce97102..94de3af9f 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -17,8 +17,8 @@ import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
17import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' 17import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared'
18 18
19import { AUTH_INTERCEPTOR_PROVIDER } from './auth' 19import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
20import { DeleteButtonComponent } from './misc/delete-button.component' 20import { DeleteButtonComponent } from './buttons/delete-button.component'
21import { EditButtonComponent } from './misc/edit-button.component' 21import { EditButtonComponent } from './buttons/edit-button.component'
22import { FromNowPipe } from './misc/from-now.pipe' 22import { FromNowPipe } from './misc/from-now.pipe'
23import { LoaderComponent } from './misc/loader.component' 23import { LoaderComponent } from './misc/loader.component'
24import { NumberFormatterPipe } from './misc/number-formatter.pipe' 24import { NumberFormatterPipe } from './misc/number-formatter.pipe'
@@ -52,6 +52,7 @@ import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validator
52import { VideoCaptionService } from '@app/shared/video-caption' 52import { VideoCaptionService } from '@app/shared/video-caption'
53import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' 53import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component'
54import { VideoImportService } from '@app/shared/video-import/video-import.service' 54import { VideoImportService } from '@app/shared/video-import/video-import.service'
55import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.component'
55 56
56@NgModule({ 57@NgModule({
57 imports: [ 58 imports: [
@@ -78,6 +79,7 @@ import { VideoImportService } from '@app/shared/video-import/video-import.servic
78 VideoFeedComponent, 79 VideoFeedComponent,
79 DeleteButtonComponent, 80 DeleteButtonComponent,
80 EditButtonComponent, 81 EditButtonComponent,
82 ActionDropdownComponent,
81 NumberFormatterPipe, 83 NumberFormatterPipe,
82 ObjectLengthPipe, 84 ObjectLengthPipe,
83 FromNowPipe, 85 FromNowPipe,
@@ -110,6 +112,7 @@ import { VideoImportService } from '@app/shared/video-import/video-import.servic
110 VideoFeedComponent, 112 VideoFeedComponent,
111 DeleteButtonComponent, 113 DeleteButtonComponent,
112 EditButtonComponent, 114 EditButtonComponent,
115 ActionDropdownComponent,
113 MarkdownTextareaComponent, 116 MarkdownTextareaComponent,
114 InfiniteScrollerDirective, 117 InfiniteScrollerDirective,
115 HelpComponent, 118 HelpComponent,
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts
index 581ea7859..2748001d0 100644
--- a/client/src/app/shared/users/user.model.ts
+++ b/client/src/app/shared/users/user.model.ts
@@ -7,7 +7,6 @@ import {
7 VideoChannel 7 VideoChannel
8} from '../../../../../shared' 8} from '../../../../../shared'
9import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' 9import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
10import { Actor } from '@app/shared/actor/actor.model'
11import { Account } from '@app/shared/account/account.model' 10import { Account } from '@app/shared/account/account.model'
12import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 11import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
13 12
@@ -22,6 +21,9 @@ export type UserConstructorHash = {
22 createdAt?: Date, 21 createdAt?: Date,
23 account?: AccountServerModel, 22 account?: AccountServerModel,
24 videoChannels?: VideoChannel[] 23 videoChannels?: VideoChannel[]
24
25 blocked?: boolean
26 blockedReason?: string
25} 27}
26export class User implements UserServerModel { 28export class User implements UserServerModel {
27 id: number 29 id: number
@@ -35,35 +37,26 @@ export class User implements UserServerModel {
35 videoChannels: VideoChannel[] 37 videoChannels: VideoChannel[]
36 createdAt: Date 38 createdAt: Date
37 39
40 blocked: boolean
41 blockedReason?: string
42
38 constructor (hash: UserConstructorHash) { 43 constructor (hash: UserConstructorHash) {
39 this.id = hash.id 44 this.id = hash.id
40 this.username = hash.username 45 this.username = hash.username
41 this.email = hash.email 46 this.email = hash.email
42 this.role = hash.role 47 this.role = hash.role
43 48
49 this.videoChannels = hash.videoChannels
50 this.videoQuota = hash.videoQuota
51 this.nsfwPolicy = hash.nsfwPolicy
52 this.autoPlayVideo = hash.autoPlayVideo
53 this.createdAt = hash.createdAt
54 this.blocked = hash.blocked
55 this.blockedReason = hash.blockedReason
56
44 if (hash.account !== undefined) { 57 if (hash.account !== undefined) {
45 this.account = new Account(hash.account) 58 this.account = new Account(hash.account)
46 } 59 }
47
48 if (hash.videoChannels !== undefined) {
49 this.videoChannels = hash.videoChannels
50 }
51
52 if (hash.videoQuota !== undefined) {
53 this.videoQuota = hash.videoQuota
54 }
55
56 if (hash.nsfwPolicy !== undefined) {
57 this.nsfwPolicy = hash.nsfwPolicy
58 }
59
60 if (hash.autoPlayVideo !== undefined) {
61 this.autoPlayVideo = hash.autoPlayVideo
62 }
63
64 if (hash.createdAt !== undefined) {
65 this.createdAt = hash.createdAt
66 }
67 } 60 }
68 61
69 get accountAvatarUrl () { 62 get accountAvatarUrl () {
diff --git a/server/controllers/api/users.ts b/server/controllers/api/users.ts
index 8f429d0b5..0e2be7123 100644
--- a/server/controllers/api/users.ts
+++ b/server/controllers/api/users.ts
@@ -302,8 +302,9 @@ async function unblockUser (req: express.Request, res: express.Response, next: e
302 302
303async function blockUser (req: express.Request, res: express.Response, next: express.NextFunction) { 303async function blockUser (req: express.Request, res: express.Response, next: express.NextFunction) {
304 const user: UserModel = res.locals.user 304 const user: UserModel = res.locals.user
305 const reason = req.body.reason
305 306
306 await changeUserBlock(res, user, true) 307 await changeUserBlock(res, user, true, reason)
307 308
308 return res.status(204).end() 309 return res.status(204).end()
309} 310}
@@ -454,10 +455,11 @@ function success (req: express.Request, res: express.Response, next: express.Nex
454 res.end() 455 res.end()
455} 456}
456 457
457async function changeUserBlock (res: express.Response, user: UserModel, block: boolean) { 458async function changeUserBlock (res: express.Response, user: UserModel, block: boolean, reason?: string) {
458 const oldUserAuditView = new UserAuditView(user.toFormattedJSON()) 459 const oldUserAuditView = new UserAuditView(user.toFormattedJSON())
459 460
460 user.blocked = block 461 user.blocked = block
462 user.blockedReason = reason || null
461 463
462 await sequelizeTypescript.transaction(async t => { 464 await sequelizeTypescript.transaction(async t => {
463 await OAuthTokenModel.deleteUserToken(user.id, t) 465 await OAuthTokenModel.deleteUserToken(user.id, t)
@@ -465,6 +467,8 @@ async function changeUserBlock (res: express.Response, user: UserModel, block: b
465 await user.save({ transaction: t }) 467 await user.save({ transaction: t })
466 }) 468 })
467 469
470 await Emailer.Instance.addUserBlockJob(user, block, reason)
471
468 auditLogger.update( 472 auditLogger.update(
469 res.locals.oauth.token.User.Account.Actor.getIdentifier(), 473 res.locals.oauth.token.User.Account.Actor.getIdentifier(),
470 new UserAuditView(user.toFormattedJSON()), 474 new UserAuditView(user.toFormattedJSON()),
diff --git a/server/helpers/custom-validators/users.ts b/server/helpers/custom-validators/users.ts
index 4a0d79ae5..c3cdefd4e 100644
--- a/server/helpers/custom-validators/users.ts
+++ b/server/helpers/custom-validators/users.ts
@@ -42,6 +42,10 @@ function isUserBlockedValid (value: any) {
42 return isBooleanValid(value) 42 return isBooleanValid(value)
43} 43}
44 44
45function isUserBlockedReasonValid (value: any) {
46 return value === null || (exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.USERS.BLOCKED_REASON))
47}
48
45function isUserRoleValid (value: any) { 49function isUserRoleValid (value: any) {
46 return exists(value) && validator.isInt('' + value) && UserRole[value] !== undefined 50 return exists(value) && validator.isInt('' + value) && UserRole[value] !== undefined
47} 51}
@@ -59,6 +63,7 @@ function isAvatarFile (files: { [ fieldname: string ]: Express.Multer.File[] } |
59export { 63export {
60 isUserBlockedValid, 64 isUserBlockedValid,
61 isUserPasswordValid, 65 isUserPasswordValid,
66 isUserBlockedReasonValid,
62 isUserRoleValid, 67 isUserRoleValid,
63 isUserVideoQuotaValid, 68 isUserVideoQuotaValid,
64 isUserUsernameValid, 69 isUserUsernameValid,
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 0a651beed..ea561b686 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -254,7 +254,8 @@ const CONSTRAINTS_FIELDS = {
254 DESCRIPTION: { min: 3, max: 250 }, // Length 254 DESCRIPTION: { min: 3, max: 250 }, // Length
255 USERNAME: { min: 3, max: 20 }, // Length 255 USERNAME: { min: 3, max: 20 }, // Length
256 PASSWORD: { min: 6, max: 255 }, // Length 256 PASSWORD: { min: 6, max: 255 }, // Length
257 VIDEO_QUOTA: { min: -1 } 257 VIDEO_QUOTA: { min: -1 },
258 BLOCKED_REASON: { min: 3, max: 250 } // Length
258 }, 259 },
259 VIDEO_ABUSES: { 260 VIDEO_ABUSES: {
260 REASON: { min: 2, max: 300 } // Length 261 REASON: { min: 2, max: 300 } // Length
diff --git a/server/initializers/migrations/0245-user-blocked.ts b/server/initializers/migrations/0245-user-blocked.ts
index 67afea5ed..5a04ecd2b 100644
--- a/server/initializers/migrations/0245-user-blocked.ts
+++ b/server/initializers/migrations/0245-user-blocked.ts
@@ -1,8 +1,5 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { createClient } from 'redis' 2import { CONSTRAINTS_FIELDS } from '../constants'
3import { CONFIG } from '../constants'
4import { JobQueue } from '../../lib/job-queue'
5import { initDatabaseModels } from '../database'
6 3
7async function up (utils: { 4async function up (utils: {
8 transaction: Sequelize.Transaction 5 transaction: Sequelize.Transaction
@@ -31,6 +28,15 @@ async function up (utils: {
31 } 28 }
32 await utils.queryInterface.changeColumn('user', 'blocked', data) 29 await utils.queryInterface.changeColumn('user', 'blocked', data)
33 } 30 }
31
32 {
33 const data = {
34 type: Sequelize.STRING(CONSTRAINTS_FIELDS.USERS.BLOCKED_REASON.max),
35 allowNull: true,
36 defaultValue: null
37 }
38 await utils.queryInterface.addColumn('user', 'blockedReason', data)
39 }
34} 40}
35 41
36function down (options) { 42function down (options) {
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index ded321bf7..3faeffd77 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -89,7 +89,7 @@ class Emailer {
89 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 89 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
90 } 90 }
91 91
92 async addVideoAbuseReport (videoId: number) { 92 async addVideoAbuseReportJob (videoId: number) {
93 const video = await VideoModel.load(videoId) 93 const video = await VideoModel.load(videoId)
94 if (!video) throw new Error('Unknown Video id during Abuse report.') 94 if (!video) throw new Error('Unknown Video id during Abuse report.')
95 95
@@ -108,6 +108,27 @@ class Emailer {
108 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 108 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
109 } 109 }
110 110
111 addUserBlockJob (user: UserModel, blocked: boolean, reason?: string) {
112 const reasonString = reason ? ` for the following reason: ${reason}` : ''
113 const blockedWord = blocked ? 'blocked' : 'unblocked'
114 const blockedString = `Your account ${user.username} on ${CONFIG.WEBSERVER.HOST} has been ${blockedWord}${reasonString}.`
115
116 const text = 'Hi,\n\n' +
117 blockedString +
118 '\n\n' +
119 'Cheers,\n' +
120 `PeerTube.`
121
122 const to = user.email
123 const emailPayload: EmailPayload = {
124 to: [ to ],
125 subject: '[PeerTube] Account ' + blockedWord,
126 text
127 }
128
129 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
130 }
131
111 sendMail (to: string[], subject: string, text: string) { 132 sendMail (to: string[], subject: string, text: string) {
112 if (!this.transporter) { 133 if (!this.transporter) {
113 throw new Error('Cannot send mail because SMTP is not configured.') 134 throw new Error('Cannot send mail because SMTP is not configured.')
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index 94d8ab53b..771c414a0 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -5,7 +5,7 @@ import { body, param } from 'express-validator/check'
5import { omit } from 'lodash' 5import { omit } from 'lodash'
6import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' 6import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
7import { 7import {
8 isUserAutoPlayVideoValid, 8 isUserAutoPlayVideoValid, isUserBlockedReasonValid,
9 isUserDescriptionValid, 9 isUserDescriptionValid,
10 isUserDisplayNameValid, 10 isUserDisplayNameValid,
11 isUserNSFWPolicyValid, 11 isUserNSFWPolicyValid,
@@ -76,9 +76,10 @@ const usersRemoveValidator = [
76 76
77const usersBlockingValidator = [ 77const usersBlockingValidator = [
78 param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), 78 param('id').isInt().not().isEmpty().withMessage('Should have a valid id'),
79 body('reason').optional().custom(isUserBlockedReasonValid).withMessage('Should have a valid blocking reason'),
79 80
80 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 81 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
81 logger.debug('Checking usersRemove parameters', { parameters: req.params }) 82 logger.debug('Checking usersBlocking parameters', { parameters: req.params })
82 83
83 if (areValidationErrors(req, res)) return 84 if (areValidationErrors(req, res)) return
84 if (!await checkUserIdExist(req.params.id, res)) return 85 if (!await checkUserIdExist(req.params.id, res)) return
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index ea6d63312..81b0651fd 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -21,6 +21,7 @@ import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared'
21import { User, UserRole } from '../../../shared/models/users' 21import { User, UserRole } from '../../../shared/models/users'
22import { 22import {
23 isUserAutoPlayVideoValid, 23 isUserAutoPlayVideoValid,
24 isUserBlockedReasonValid,
24 isUserBlockedValid, 25 isUserBlockedValid,
25 isUserNSFWPolicyValid, 26 isUserNSFWPolicyValid,
26 isUserPasswordValid, 27 isUserPasswordValid,
@@ -107,6 +108,12 @@ export class UserModel extends Model<UserModel> {
107 @Column 108 @Column
108 blocked: boolean 109 blocked: boolean
109 110
111 @AllowNull(true)
112 @Default(null)
113 @Is('UserBlockedReason', value => throwIfNotValid(value, isUserBlockedReasonValid, 'blocked reason'))
114 @Column
115 blockedReason: string
116
110 @AllowNull(false) 117 @AllowNull(false)
111 @Is('UserRole', value => throwIfNotValid(value, isUserRoleValid, 'role')) 118 @Is('UserRole', value => throwIfNotValid(value, isUserRoleValid, 'role'))
112 @Column 119 @Column
@@ -284,6 +291,8 @@ export class UserModel extends Model<UserModel> {
284 roleLabel: USER_ROLE_LABELS[ this.role ], 291 roleLabel: USER_ROLE_LABELS[ this.role ],
285 videoQuota: this.videoQuota, 292 videoQuota: this.videoQuota,
286 createdAt: this.createdAt, 293 createdAt: this.createdAt,
294 blocked: this.blocked,
295 blockedReason: this.blockedReason,
287 account: this.Account.toFormattedJSON(), 296 account: this.Account.toFormattedJSON(),
288 videoChannels: [] 297 videoChannels: []
289 } 298 }
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts
index a6319bb79..39f0c2cb2 100644
--- a/server/models/video/video-abuse.ts
+++ b/server/models/video/video-abuse.ts
@@ -57,7 +57,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
57 57
58 @AfterCreate 58 @AfterCreate
59 static sendEmailNotification (instance: VideoAbuseModel) { 59 static sendEmailNotification (instance: VideoAbuseModel) {
60 return Emailer.Instance.addVideoAbuseReport(instance.videoId) 60 return Emailer.Instance.addVideoAbuseReportJob(instance.videoId)
61 } 61 }
62 62
63 static listForApi (start: number, count: number, sort: string) { 63 static listForApi (start: number, count: number, sort: string) {
diff --git a/server/tests/api/server/email.ts b/server/tests/api/server/email.ts
index 4be013c84..65d6a759f 100644
--- a/server/tests/api/server/email.ts
+++ b/server/tests/api/server/email.ts
@@ -2,7 +2,17 @@
2 2
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { askResetPassword, createUser, reportVideoAbuse, resetPassword, runServer, uploadVideo, userLogin, wait } from '../../utils' 5import {
6 askResetPassword,
7 blockUser,
8 createUser,
9 reportVideoAbuse,
10 resetPassword,
11 runServer,
12 unblockUser,
13 uploadVideo,
14 userLogin
15} from '../../utils'
6import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' 16import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index'
7import { mockSmtpServer } from '../../utils/miscs/email' 17import { mockSmtpServer } from '../../utils/miscs/email'
8import { waitJobs } from '../../utils/server/jobs' 18import { waitJobs } from '../../utils/server/jobs'
@@ -112,6 +122,42 @@ describe('Test emails', function () {
112 }) 122 })
113 }) 123 })
114 124
125 describe('When blocking/unblocking user', async function () {
126 it('Should send the notification email when blocking a user', async function () {
127 this.timeout(10000)
128
129 const reason = 'my super bad reason'
130 await blockUser(server.url, userId, server.accessToken, 204, reason)
131
132 await waitJobs(server)
133 expect(emails).to.have.lengthOf(3)
134
135 const email = emails[2]
136
137 expect(email['from'][0]['address']).equal('test-admin@localhost')
138 expect(email['to'][0]['address']).equal('user_1@example.com')
139 expect(email['subject']).contains(' blocked')
140 expect(email['text']).contains(' blocked')
141 expect(email['text']).contains(reason)
142 })
143
144 it('Should send the notification email when unblocking a user', async function () {
145 this.timeout(10000)
146
147 await unblockUser(server.url, userId, server.accessToken, 204)
148
149 await waitJobs(server)
150 expect(emails).to.have.lengthOf(4)
151
152 const email = emails[3]
153
154 expect(email['from'][0]['address']).equal('test-admin@localhost')
155 expect(email['to'][0]['address']).equal('user_1@example.com')
156 expect(email['subject']).contains(' unblocked')
157 expect(email['text']).contains(' unblocked')
158 })
159 })
160
115 after(async function () { 161 after(async function () {
116 killallServers([ server ]) 162 killallServers([ server ])
117 }) 163 })
diff --git a/server/tests/utils/users/users.ts b/server/tests/utils/users/users.ts
index 7e15fc86e..f786de6e3 100644
--- a/server/tests/utils/users/users.ts
+++ b/server/tests/utils/users/users.ts
@@ -134,11 +134,14 @@ function removeUser (url: string, userId: number | string, accessToken: string,
134 .expect(expectedStatus) 134 .expect(expectedStatus)
135} 135}
136 136
137function blockUser (url: string, userId: number | string, accessToken: string, expectedStatus = 204) { 137function blockUser (url: string, userId: number | string, accessToken: string, expectedStatus = 204, reason?: string) {
138 const path = '/api/v1/users' 138 const path = '/api/v1/users'
139 let body: any
140 if (reason) body = { reason }
139 141
140 return request(url) 142 return request(url)
141 .post(path + '/' + userId + '/block') 143 .post(path + '/' + userId + '/block')
144 .send(body)
142 .set('Accept', 'application/json') 145 .set('Accept', 'application/json')
143 .set('Authorization', 'Bearer ' + accessToken) 146 .set('Authorization', 'Bearer ' + accessToken)
144 .expect(expectedStatus) 147 .expect(expectedStatus)
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index 188e29ede..d3085267f 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -14,4 +14,7 @@ export interface User {
14 createdAt: Date 14 createdAt: Date
15 account: Account 15 account: Account
16 videoChannels?: VideoChannel[] 16 videoChannels?: VideoChannel[]
17
18 blocked: boolean
19 blockedReason?: string
17} 20}