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
}