diff options
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' | |||
5 | import { RestPagination, RestTable, User } from '../../../shared' | 5 | import { RestPagination, RestTable, User } from '../../../shared' |
6 | import { UserService } from '../shared' | 6 | import { UserService } from '../shared' |
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | 7 | import { I18n } from '@ngx-translate/i18n-polyfill' |
8 | import { 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | |||
3 | export 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 | |||
17 | export 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 | ||
9 | export class DeleteButtonComponent { | 9 | export 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 | ||
9 | export class EditButtonComponent { | 9 | export 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' | |||
17 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' | 17 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' |
18 | 18 | ||
19 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' | 19 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' |
20 | import { DeleteButtonComponent } from './misc/delete-button.component' | 20 | import { DeleteButtonComponent } from './buttons/delete-button.component' |
21 | import { EditButtonComponent } from './misc/edit-button.component' | 21 | import { EditButtonComponent } from './buttons/edit-button.component' |
22 | import { FromNowPipe } from './misc/from-now.pipe' | 22 | import { FromNowPipe } from './misc/from-now.pipe' |
23 | import { LoaderComponent } from './misc/loader.component' | 23 | import { LoaderComponent } from './misc/loader.component' |
24 | import { NumberFormatterPipe } from './misc/number-formatter.pipe' | 24 | import { NumberFormatterPipe } from './misc/number-formatter.pipe' |
@@ -52,6 +52,7 @@ import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validator | |||
52 | import { VideoCaptionService } from '@app/shared/video-caption' | 52 | import { VideoCaptionService } from '@app/shared/video-caption' |
53 | import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' | 53 | import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' |
54 | import { VideoImportService } from '@app/shared/video-import/video-import.service' | 54 | import { VideoImportService } from '@app/shared/video-import/video-import.service' |
55 | import { 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' |
9 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' | 9 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' |
10 | import { Actor } from '@app/shared/actor/actor.model' | ||
11 | import { Account } from '@app/shared/account/account.model' | 10 | import { Account } from '@app/shared/account/account.model' |
12 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 11 | import { 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 | } |
26 | export class User implements UserServerModel { | 28 | export 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 | ||
303 | async function blockUser (req: express.Request, res: express.Response, next: express.NextFunction) { | 303 | async 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 | ||
457 | async function changeUserBlock (res: express.Response, user: UserModel, block: boolean) { | 458 | async 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 | ||
45 | function isUserBlockedReasonValid (value: any) { | ||
46 | return value === null || (exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.USERS.BLOCKED_REASON)) | ||
47 | } | ||
48 | |||
45 | function isUserRoleValid (value: any) { | 49 | function 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[] } | | |||
59 | export { | 63 | export { |
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 @@ | |||
1 | import * as Sequelize from 'sequelize' | 1 | import * as Sequelize from 'sequelize' |
2 | import { createClient } from 'redis' | 2 | import { CONSTRAINTS_FIELDS } from '../constants' |
3 | import { CONFIG } from '../constants' | ||
4 | import { JobQueue } from '../../lib/job-queue' | ||
5 | import { initDatabaseModels } from '../database' | ||
6 | 3 | ||
7 | async function up (utils: { | 4 | async 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 | ||
36 | function down (options) { | 42 | function 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' | |||
5 | import { omit } from 'lodash' | 5 | import { omit } from 'lodash' |
6 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' | 6 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' |
7 | import { | 7 | import { |
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 | ||
77 | const usersBlockingValidator = [ | 77 | const 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' | |||
21 | import { User, UserRole } from '../../../shared/models/users' | 21 | import { User, UserRole } from '../../../shared/models/users' |
22 | import { | 22 | import { |
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 | ||
3 | import * as chai from 'chai' | 3 | import * as chai from 'chai' |
4 | import 'mocha' | 4 | import 'mocha' |
5 | import { askResetPassword, createUser, reportVideoAbuse, resetPassword, runServer, uploadVideo, userLogin, wait } from '../../utils' | 5 | import { |
6 | askResetPassword, | ||
7 | blockUser, | ||
8 | createUser, | ||
9 | reportVideoAbuse, | ||
10 | resetPassword, | ||
11 | runServer, | ||
12 | unblockUser, | ||
13 | uploadVideo, | ||
14 | userLogin | ||
15 | } from '../../utils' | ||
6 | import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' | 16 | import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' |
7 | import { mockSmtpServer } from '../../utils/miscs/email' | 17 | import { mockSmtpServer } from '../../utils/miscs/email' |
8 | import { waitJobs } from '../../utils/server/jobs' | 18 | import { 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 | ||
137 | function blockUser (url: string, userId: number | string, accessToken: string, expectedStatus = 204) { | 137 | function 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 | } |