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 @@ {{ user.roleLabel }} {{ user.createdAt }} - - + + + 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' 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' @Component({ selector: 'my-user-list', @@ -17,6 +18,7 @@ export class UserListComponent extends RestTable implements OnInit { rowsPerPage = 10 sort: SortMeta = { field: 'createdAt', order: 1 } pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + userActions: DropdownAction[] = [] constructor ( private notificationsService: NotificationsService, @@ -25,6 +27,17 @@ export class UserListComponent extends RestTable implements OnInit { private i18n: I18n ) { super() + + this.userActions = [ + { + type: 'edit', + linkBuilder: this.getRouterUserEditLink + }, + { + type: 'delete', + handler: user => this.removeUser(user) + } + ] } 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 @@ + \ 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 @@ +@import '_variables'; +@import '_mixins'; + +.action-button { + @include peertube-button; + @include grey-button; + + &: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; + } +} \ 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 @@ +import { Component, Input } from '@angular/core' + +export type DropdownAction = { + type: 'custom' | 'delete' | 'edit' + label?: string + handler?: (T) => any + linkBuilder?: (T) => (string | number)[] + iconClass?: string +} + +@Component({ + selector: 'my-action-dropdown', + styleUrls: [ './action-dropdown.component.scss' ], + templateUrl: './action-dropdown.component.html' +}) + +export class ActionDropdownComponent { + @Input() actions: DropdownAction[] = [] + @Input() entry: T +} diff --git a/client/src/app/shared/misc/button.component.scss b/client/src/app/shared/buttons/button.component.scss similarity index 100% rename from client/src/app/shared/misc/button.component.scss rename to 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 @@ + + + + {{ label }} + Delete + diff --git a/client/src/app/shared/misc/delete-button.component.ts b/client/src/app/shared/buttons/delete-button.component.ts similarity index 89% rename from client/src/app/shared/misc/delete-button.component.ts rename to 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' }) export class DeleteButtonComponent { - @Input() label = 'Delete' + @Input() label: string } diff --git a/client/src/app/shared/misc/edit-button.component.html b/client/src/app/shared/buttons/edit-button.component.html similarity index 50% rename from client/src/app/shared/misc/edit-button.component.html rename to 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 @@ - Edit + + {{ label }} + Edit diff --git a/client/src/app/shared/misc/edit-button.component.ts b/client/src/app/shared/buttons/edit-button.component.ts similarity index 90% rename from client/src/app/shared/misc/edit-button.component.ts rename to 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' }) export class EditButtonComponent { + @Input() label: string @Input() routerLink = [] } 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 @@ - - - {{ label }} - 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' import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' import { AUTH_INTERCEPTOR_PROVIDER } from './auth' -import { DeleteButtonComponent } from './misc/delete-button.component' -import { EditButtonComponent } from './misc/edit-button.component' +import { DeleteButtonComponent } from './buttons/delete-button.component' +import { EditButtonComponent } from './buttons/edit-button.component' import { FromNowPipe } from './misc/from-now.pipe' import { LoaderComponent } from './misc/loader.component' import { NumberFormatterPipe } from './misc/number-formatter.pipe' @@ -52,6 +52,7 @@ import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validator import { VideoCaptionService } from '@app/shared/video-caption' import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' import { VideoImportService } from '@app/shared/video-import/video-import.service' +import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.component' @NgModule({ imports: [ @@ -78,6 +79,7 @@ import { VideoImportService } from '@app/shared/video-import/video-import.servic VideoFeedComponent, DeleteButtonComponent, EditButtonComponent, + ActionDropdownComponent, NumberFormatterPipe, ObjectLengthPipe, FromNowPipe, @@ -110,6 +112,7 @@ import { VideoImportService } from '@app/shared/video-import/video-import.servic VideoFeedComponent, DeleteButtonComponent, EditButtonComponent, + ActionDropdownComponent, MarkdownTextareaComponent, InfiniteScrollerDirective, 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 { VideoChannel } from '../../../../../shared' import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' -import { Actor } from '@app/shared/actor/actor.model' import { Account } from '@app/shared/account/account.model' import { Avatar } from '../../../../../shared/models/avatars/avatar.model' @@ -22,6 +21,9 @@ export type UserConstructorHash = { createdAt?: Date, account?: AccountServerModel, videoChannels?: VideoChannel[] + + blocked?: boolean + blockedReason?: string } export class User implements UserServerModel { id: number @@ -35,35 +37,26 @@ export class User implements UserServerModel { videoChannels: VideoChannel[] createdAt: Date + blocked: boolean + blockedReason?: string + constructor (hash: UserConstructorHash) { this.id = hash.id this.username = hash.username this.email = hash.email this.role = hash.role + this.videoChannels = hash.videoChannels + this.videoQuota = hash.videoQuota + this.nsfwPolicy = hash.nsfwPolicy + this.autoPlayVideo = hash.autoPlayVideo + this.createdAt = hash.createdAt + this.blocked = hash.blocked + this.blockedReason = hash.blockedReason + if (hash.account !== undefined) { this.account = new Account(hash.account) } - - if (hash.videoChannels !== undefined) { - this.videoChannels = hash.videoChannels - } - - if (hash.videoQuota !== undefined) { - this.videoQuota = hash.videoQuota - } - - if (hash.nsfwPolicy !== undefined) { - this.nsfwPolicy = hash.nsfwPolicy - } - - if (hash.autoPlayVideo !== undefined) { - this.autoPlayVideo = hash.autoPlayVideo - } - - if (hash.createdAt !== undefined) { - this.createdAt = hash.createdAt - } } 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 async function blockUser (req: express.Request, res: express.Response, next: express.NextFunction) { const user: UserModel = res.locals.user + const reason = req.body.reason - await changeUserBlock(res, user, true) + await changeUserBlock(res, user, true, reason) return res.status(204).end() } @@ -454,10 +455,11 @@ function success (req: express.Request, res: express.Response, next: express.Nex res.end() } -async function changeUserBlock (res: express.Response, user: UserModel, block: boolean) { +async function changeUserBlock (res: express.Response, user: UserModel, block: boolean, reason?: string) { const oldUserAuditView = new UserAuditView(user.toFormattedJSON()) user.blocked = block + user.blockedReason = reason || null await sequelizeTypescript.transaction(async t => { await OAuthTokenModel.deleteUserToken(user.id, t) @@ -465,6 +467,8 @@ async function changeUserBlock (res: express.Response, user: UserModel, block: b await user.save({ transaction: t }) }) + await Emailer.Instance.addUserBlockJob(user, block, reason) + auditLogger.update( res.locals.oauth.token.User.Account.Actor.getIdentifier(), 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) { return isBooleanValid(value) } +function isUserBlockedReasonValid (value: any) { + return value === null || (exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.USERS.BLOCKED_REASON)) +} + function isUserRoleValid (value: any) { return exists(value) && validator.isInt('' + value) && UserRole[value] !== undefined } @@ -59,6 +63,7 @@ function isAvatarFile (files: { [ fieldname: string ]: Express.Multer.File[] } | export { isUserBlockedValid, isUserPasswordValid, + isUserBlockedReasonValid, isUserRoleValid, isUserVideoQuotaValid, 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 = { DESCRIPTION: { min: 3, max: 250 }, // Length USERNAME: { min: 3, max: 20 }, // Length PASSWORD: { min: 6, max: 255 }, // Length - VIDEO_QUOTA: { min: -1 } + VIDEO_QUOTA: { min: -1 }, + BLOCKED_REASON: { min: 3, max: 250 } // Length }, VIDEO_ABUSES: { 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 @@ import * as Sequelize from 'sequelize' -import { createClient } from 'redis' -import { CONFIG } from '../constants' -import { JobQueue } from '../../lib/job-queue' -import { initDatabaseModels } from '../database' +import { CONSTRAINTS_FIELDS } from '../constants' async function up (utils: { transaction: Sequelize.Transaction @@ -31,6 +28,15 @@ async function up (utils: { } await utils.queryInterface.changeColumn('user', 'blocked', data) } + + { + const data = { + type: Sequelize.STRING(CONSTRAINTS_FIELDS.USERS.BLOCKED_REASON.max), + allowNull: true, + defaultValue: null + } + await utils.queryInterface.addColumn('user', 'blockedReason', data) + } } 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 { return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) } - async addVideoAbuseReport (videoId: number) { + async addVideoAbuseReportJob (videoId: number) { const video = await VideoModel.load(videoId) if (!video) throw new Error('Unknown Video id during Abuse report.') @@ -108,6 +108,27 @@ class Emailer { return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) } + addUserBlockJob (user: UserModel, blocked: boolean, reason?: string) { + const reasonString = reason ? ` for the following reason: ${reason}` : '' + const blockedWord = blocked ? 'blocked' : 'unblocked' + const blockedString = `Your account ${user.username} on ${CONFIG.WEBSERVER.HOST} has been ${blockedWord}${reasonString}.` + + const text = 'Hi,\n\n' + + blockedString + + '\n\n' + + 'Cheers,\n' + + `PeerTube.` + + const to = user.email + const emailPayload: EmailPayload = { + to: [ to ], + subject: '[PeerTube] Account ' + blockedWord, + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + sendMail (to: string[], subject: string, text: string) { if (!this.transporter) { 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' import { omit } from 'lodash' import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' import { - isUserAutoPlayVideoValid, + isUserAutoPlayVideoValid, isUserBlockedReasonValid, isUserDescriptionValid, isUserDisplayNameValid, isUserNSFWPolicyValid, @@ -76,9 +76,10 @@ const usersRemoveValidator = [ const usersBlockingValidator = [ param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), + body('reason').optional().custom(isUserBlockedReasonValid).withMessage('Should have a valid blocking reason'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking usersRemove parameters', { parameters: req.params }) + logger.debug('Checking usersBlocking parameters', { parameters: req.params }) if (areValidationErrors(req, res)) return 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' import { User, UserRole } from '../../../shared/models/users' import { isUserAutoPlayVideoValid, + isUserBlockedReasonValid, isUserBlockedValid, isUserNSFWPolicyValid, isUserPasswordValid, @@ -107,6 +108,12 @@ export class UserModel extends Model { @Column blocked: boolean + @AllowNull(true) + @Default(null) + @Is('UserBlockedReason', value => throwIfNotValid(value, isUserBlockedReasonValid, 'blocked reason')) + @Column + blockedReason: string + @AllowNull(false) @Is('UserRole', value => throwIfNotValid(value, isUserRoleValid, 'role')) @Column @@ -284,6 +291,8 @@ export class UserModel extends Model { roleLabel: USER_ROLE_LABELS[ this.role ], videoQuota: this.videoQuota, createdAt: this.createdAt, + blocked: this.blocked, + blockedReason: this.blockedReason, account: this.Account.toFormattedJSON(), videoChannels: [] } 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 { @AfterCreate static sendEmailNotification (instance: VideoAbuseModel) { - return Emailer.Instance.addVideoAbuseReport(instance.videoId) + return Emailer.Instance.addVideoAbuseReportJob(instance.videoId) } 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 @@ import * as chai from 'chai' import 'mocha' -import { askResetPassword, createUser, reportVideoAbuse, resetPassword, runServer, uploadVideo, userLogin, wait } from '../../utils' +import { + askResetPassword, + blockUser, + createUser, + reportVideoAbuse, + resetPassword, + runServer, + unblockUser, + uploadVideo, + userLogin +} from '../../utils' import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' import { mockSmtpServer } from '../../utils/miscs/email' import { waitJobs } from '../../utils/server/jobs' @@ -112,6 +122,42 @@ describe('Test emails', function () { }) }) + describe('When blocking/unblocking user', async function () { + it('Should send the notification email when blocking a user', async function () { + this.timeout(10000) + + const reason = 'my super bad reason' + await blockUser(server.url, userId, server.accessToken, 204, reason) + + await waitJobs(server) + expect(emails).to.have.lengthOf(3) + + const email = emails[2] + + expect(email['from'][0]['address']).equal('test-admin@localhost') + expect(email['to'][0]['address']).equal('user_1@example.com') + expect(email['subject']).contains(' blocked') + expect(email['text']).contains(' blocked') + expect(email['text']).contains(reason) + }) + + it('Should send the notification email when unblocking a user', async function () { + this.timeout(10000) + + await unblockUser(server.url, userId, server.accessToken, 204) + + await waitJobs(server) + expect(emails).to.have.lengthOf(4) + + const email = emails[3] + + expect(email['from'][0]['address']).equal('test-admin@localhost') + expect(email['to'][0]['address']).equal('user_1@example.com') + expect(email['subject']).contains(' unblocked') + expect(email['text']).contains(' unblocked') + }) + }) + after(async function () { killallServers([ server ]) }) 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, .expect(expectedStatus) } -function blockUser (url: string, userId: number | string, accessToken: string, expectedStatus = 204) { +function blockUser (url: string, userId: number | string, accessToken: string, expectedStatus = 204, reason?: string) { const path = '/api/v1/users' + let body: any + if (reason) body = { reason } return request(url) .post(path + '/' + userId + '/block') + .send(body) .set('Accept', 'application/json') .set('Authorization', 'Bearer ' + accessToken) .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 { createdAt: Date account: Account videoChannels?: VideoChannel[] + + blocked: boolean + blockedReason?: string }