aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html4
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss5
-rw-r--r--client/src/app/+admin/users/user-edit/user-create.component.ts2
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.component.html268
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.component.scss35
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.ts19
-rw-r--r--client/src/app/+admin/users/user-edit/user-password.component.ts2
-rw-r--r--client/src/app/+admin/users/user-edit/user-update.component.ts33
-rw-r--r--client/src/app/+my-account/my-account.module.ts2
-rw-r--r--client/src/app/+my-account/shared/actor-avatar-info.component.html13
-rw-r--r--client/src/app/shared/shared.module.ts7
-rw-r--r--client/src/app/shared/users/user.model.ts10
-rw-r--r--client/src/app/shared/users/user.service.ts5
-rw-r--r--client/src/app/shared/video/modals/video-report.component.html3
-rw-r--r--client/src/sass/include/_mixins.scss82
-rw-r--r--server/middlewares/validators/users.ts9
-rw-r--r--server/models/account/user.ts149
-rw-r--r--server/tests/api/users/users.ts122
-rw-r--r--shared/extra-utils/users/users.ts3
-rw-r--r--shared/models/users/user.model.ts5
20 files changed, 606 insertions, 172 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
index b3b4f7728..9991e1f63 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
@@ -579,7 +579,7 @@
579 i18n-labelText labelText="Allow additional extensions" 579 i18n-labelText labelText="Allow additional extensions"
580 > 580 >
581 <ng-container ngProjectAs="description"> 581 <ng-container ngProjectAs="description">
582 <span i18n>Allow your users to upload .mkv, .mov, .avi and .flv videos.</span> 582 <span i18n>Allows users to upload .mkv, .mov, .avi and .flv videos.</span>
583 </ng-container> 583 </ng-container>
584 </my-peertube-checkbox> 584 </my-peertube-checkbox>
585 </div> 585 </div>
@@ -590,7 +590,7 @@
590 i18n-labelText labelText="Allow audio files upload" 590 i18n-labelText labelText="Allow audio files upload"
591 > 591 >
592 <ng-container ngProjectAs="description"> 592 <ng-container ngProjectAs="description">
593 <span i18n>Allow your users to upload audio files that will be merged with the preview file on upload.</span> 593 <span i18n>Allows users to upload audio files that will be merged with the preview file on upload.</span>
594 </ng-container> 594 </ng-container>
595 </my-peertube-checkbox> 595 </my-peertube-checkbox>
596 </div> 596 </div>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
index 8b1cdcbba..d8bc30d55 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
@@ -50,6 +50,7 @@ input[type=submit] {
50textarea { 50textarea {
51 @include peertube-textarea(500px, 150px); 51 @include peertube-textarea(500px, 150px);
52 52
53 max-width: 100%;
53 display: block; 54 display: block;
54 55
55 &.small { 56 &.small {
@@ -72,6 +73,10 @@ my-markdown-textarea ::ng-deep {
72 @media screen and (max-width: 1400px) { 73 @media screen and (max-width: 1400px) {
73 flex-direction: column !important; 74 flex-direction: column !important;
74 } 75 }
76
77 textarea {
78 max-width: 100%;
79 }
75 } 80 }
76} 81}
77 82
diff --git a/client/src/app/+admin/users/user-edit/user-create.component.ts b/client/src/app/+admin/users/user-edit/user-create.component.ts
index 1769c0de0..a394418cb 100644
--- a/client/src/app/+admin/users/user-edit/user-create.component.ts
+++ b/client/src/app/+admin/users/user-edit/user-create.component.ts
@@ -8,6 +8,7 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val
8import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' 8import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
9import { ConfigService } from '@app/+admin/config/shared/config.service' 9import { ConfigService } from '@app/+admin/config/shared/config.service'
10import { UserService } from '@app/shared' 10import { UserService } from '@app/shared'
11import { ScreenService } from '@app/shared/misc/screen.service'
11 12
12@Component({ 13@Component({
13 selector: 'my-user-create', 14 selector: 'my-user-create',
@@ -21,6 +22,7 @@ export class UserCreateComponent extends UserEdit implements OnInit {
21 protected serverService: ServerService, 22 protected serverService: ServerService,
22 protected formValidatorService: FormValidatorService, 23 protected formValidatorService: FormValidatorService,
23 protected configService: ConfigService, 24 protected configService: ConfigService,
25 protected screenService: ScreenService,
24 protected auth: AuthService, 26 protected auth: AuthService,
25 private userValidatorsService: UserValidatorsService, 27 private userValidatorsService: UserValidatorsService,
26 private route: ActivatedRoute, 28 private route: ActivatedRoute,
diff --git a/client/src/app/+admin/users/user-edit/user-edit.component.html b/client/src/app/+admin/users/user-edit/user-edit.component.html
index dbb0e36b9..6c42fde57 100644
--- a/client/src/app/+admin/users/user-edit/user-edit.component.html
+++ b/client/src/app/+admin/users/user-edit/user-edit.component.html
@@ -1,112 +1,204 @@
1<div i18n class="form-sub-title" *ngIf="isCreation() === true">Create user</div> 1<nav aria-label="breadcrumb">
2<div i18n class="form-sub-title" *ngIf="isCreation() === false">Edit user {{ username }}</div> 2 <ol class="breadcrumb">
3 <li class="breadcrumb-item">
4 <a routerLink="/admin/users" i18n>Users</a>
5 </li>
3 6
4<div *ngIf="error" class="alert alert-danger">{{ error }}</div> 7 <ng-container *ngIf="isCreation()">
8 <li class="breadcrumb-item active" i18n>Create</li>
9 </ng-container>
10 <ng-container *ngIf="!isCreation()">
11 <li class="breadcrumb-item active" i18n>Edit</li>
12 <li class="breadcrumb-item active" aria-current="page">
13 <a *ngIf="user" [routerLink]="[ '/accounts', user?.username ]">{{ user?.username }}</a>
14 </li>
15 </ng-container>
16 </ol>
17</nav>
5 18
6<form role="form" (ngSubmit)="formValidated()" [formGroup]="form"> 19<ng-template #dashboard>
7 <div class="form-group" *ngIf="isCreation()"> 20 <div *ngIf="!isCreation() && user" class="dashboard">
8 <label i18n for="username">Username</label> 21 <div>
9 <input 22 <a>
10 type="text" id="username" i18n-placeholder placeholder="john" 23 <div class="dashboard-num">{{ user.videosCount }} ({{ user.videoQuotaUsed | bytes: 0 }})</div>
11 formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }" 24 <div class="dashboard-label" i18n>{user.videosCount, plural, =1 {Video} other {Videos}}</div>
12 > 25 </a>
13 <div *ngIf="formErrors.username" class="form-error">
14 {{ formErrors.username }}
15 </div> 26 </div>
16 </div> 27 <div>
17 28 <a>
18 <div class="form-group"> 29 <div class="dashboard-num">{{ user.videoChannels.length || 0 }}</div>
19 <label i18n for="email">Email</label> 30 <div class="dashboard-label" i18n>{user.videoChannels.length, plural, =1 {Channel} other {Channels}}</div>
20 <input 31 </a>
21 type="text" id="email" i18n-placeholder placeholder="mail@example.com"
22 formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }"
23 autocomplete="off"
24 >
25 <div *ngIf="formErrors.email" class="form-error">
26 {{ formErrors.email }}
27 </div> 32 </div>
28 </div> 33 <div>
29 34 <a>
30 <div class="form-group" *ngIf="isCreation()"> 35 <div class="dashboard-num">{{ subscribersCount }}</div>
31 <label i18n for="password">Password</label> 36 <div class="dashboard-label" i18n>{subscribersCount, plural, =1 {Subscriber} other {Subscribers}}</div>
32 <my-help *ngIf="isPasswordOptional()"> 37 </a>
33 <ng-template ptTemplate="customHtml"> 38 </div>
34 <ng-container i18n> 39 <div>
35 If you leave the password empty, an email will be sent to the user. 40 <a>
36 </ng-container> 41 <div class="dashboard-num">{{ user.videoAbusesCount }}</div>
37 </ng-template> 42 <div class="dashboard-label" i18n>Incriminated in reports</div>
38 </my-help> 43 </a>
39 <input 44 </div>
40 type="password" id="password" autocomplete="new-password" 45 <div>
41 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" 46 <a>
42 > 47 <div class="dashboard-num">{{ user.videoAbusesAcceptedCount }} / {{ user.videoAbusesCreatedCount }}</div>
43 <div *ngIf="formErrors.password" class="form-error"> 48 <div class="dashboard-label" i18n>Authored reports accepted</div>
44 {{ formErrors.password }} 49 </a>
50 </div>
51 <div>
52 <a>
53 <div class="dashboard-num">{{ user.videoCommentsCount }}</div>
54 <div class="dashboard-label" i18n>{user.videoCommentsCount, plural, =1 {Comment} other {Comments}}</div>
55 </a>
45 </div> 56 </div>
46 </div> 57 </div>
58</ng-template>
47 59
48 <div class="form-group"> 60<div class="form-row" *ngIf="!isInBigView()"> <!-- hidden on large screens, as it is then displayed on the right side of the form -->
49 <label i18n for="role">Role</label> 61 <div class="col-12 col-xl-3"></div>
50 <div class="peertube-select-container">
51 <select id="role" formControlName="role">
52 <option *ngFor="let role of roles" [value]="role.value">
53 {{ role.label }}
54 </option>
55 </select>
56 </div>
57 62
58 <div *ngIf="formErrors.role" class="form-error"> 63 <div class="form-group-right col-12 col-xl-9">
59 {{ formErrors.role }} 64 <ng-template *ngTemplateOutlet="dashboard"></ng-template>
60 </div>
61 </div> 65 </div>
66</div>
62 67
63 <div class="form-group"> 68<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
64 <label i18n for="videoQuota">Video quota</label>
65 <div class="peertube-select-container">
66 <select id="videoQuota" formControlName="videoQuota">
67 <option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value">
68 {{ videoQuotaOption.label }}
69 </option>
70 </select>
71 </div>
72 69
73 <div i18n class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()"> 70<div class="form-row mt-4"> <!-- user grid -->
74 Transcoding is enabled on server. The video quota only take in account <strong>original</strong> video. <br /> 71 <div class="form-group col-12 col-lg-4 col-xl-3">
75 At most, this user could use ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}. 72 <div class="anchor" id="user"></div> <!-- user anchor -->
73 <div *ngIf="isCreation()" class="account-title" i18n>NEW USER</div>
74 <div *ngIf="!isCreation() && user" class="account-title">
75 <my-actor-avatar-info [actor]="user.account"></my-actor-avatar-info>
76 </div> 76 </div>
77 </div> 77 </div>
78 78
79 <div class="form-group"> 79 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9" [ngClass]="{ 'form-row': isInBigView() }">
80 <label i18n for="videoQuotaDaily">Daily video quota</label> 80
81 <div class="peertube-select-container"> 81 <form role="form" (ngSubmit)="formValidated()" [formGroup]="form" [ngClass]="{ 'col-5': isInBigView() }">
82 <select id="videoQuotaDaily" formControlName="videoQuotaDaily"> 82 <div class="form-group" *ngIf="isCreation()">
83 <option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value"> 83 <label i18n for="username">Username</label>
84 {{ videoQuotaDailyOption.label }} 84 <input
85 </option> 85 type="text" id="username" i18n-placeholder placeholder="john"
86 </select> 86 formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }"
87 >
88 <div *ngIf="formErrors.username" class="form-error">
89 {{ formErrors.username }}
90 </div>
91 </div>
92
93 <div class="form-group">
94 <label i18n for="email">Email</label>
95 <input
96 type="text" id="email" i18n-placeholder placeholder="mail@example.com"
97 formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }"
98 autocomplete="off"
99 >
100 <div *ngIf="formErrors.email" class="form-error">
101 {{ formErrors.email }}
102 </div>
103 </div>
104
105 <div class="form-group" *ngIf="isCreation()">
106 <label i18n for="password">Password</label>
107 <my-help *ngIf="isPasswordOptional()">
108 <ng-template ptTemplate="customHtml">
109 <ng-container i18n>
110 If you leave the password empty, an email will be sent to the user.
111 </ng-container>
112 </ng-template>
113 </my-help>
114 <input
115 type="password" id="password" autocomplete="new-password"
116 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
117 >
118 <div *ngIf="formErrors.password" class="form-error">
119 {{ formErrors.password }}
120 </div>
121 </div>
122
123 <div class="form-group">
124 <label i18n for="role">Role</label>
125 <div class="peertube-select-container">
126 <select id="role" formControlName="role">
127 <option *ngFor="let role of roles" [value]="role.value">
128 {{ role.label }}
129 </option>
130 </select>
131 </div>
132
133 <div *ngIf="formErrors.role" class="form-error">
134 {{ formErrors.role }}
135 </div>
136 </div>
137
138 <div class="form-group">
139 <label i18n for="videoQuota">Video quota</label>
140 <div class="peertube-select-container">
141 <select id="videoQuota" formControlName="videoQuota">
142 <option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value">
143 {{ videoQuotaOption.label }}
144 </option>
145 </select>
146 </div>
147
148 <div i18n class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()">
149 Transcoding is enabled. The video quota only takes into account <strong>original</strong> video size. <br />
150 At most, this user could upload ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}.
151 </div>
152 </div>
153
154 <div class="form-group">
155 <label i18n for="videoQuotaDaily">Daily video quota</label>
156 <div class="peertube-select-container">
157 <select id="videoQuotaDaily" formControlName="videoQuotaDaily">
158 <option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value">
159 {{ videoQuotaDailyOption.label }}
160 </option>
161 </select>
162 </div>
163 </div>
164
165 <div class="form-group">
166 <my-peertube-checkbox
167 inputName="byPassAutoBlacklist" formControlName="byPassAutoBlacklist"
168 i18n-labelText labelText="Doesn't need review before a video goes public"
169 ></my-peertube-checkbox>
170 </div>
171
172 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
173 </form>
174
175 <div *ngIf="isInBigView()" class="col-7">
176 <ng-template *ngTemplateOutlet="dashboard"></ng-template>
87 </div> 177 </div>
178
88 </div> 179 </div>
180</div>
181
89 182
90 <div class="form-group"> 183<div *ngIf="!isCreation() && user" class="form-row mt-4"> <!-- danger zone grid -->
91 <my-peertube-checkbox 184 <div class="form-group col-12 col-lg-4 col-xl-3">
92 inputName="byPassAutoBlacklist" formControlName="byPassAutoBlacklist" 185 <div class="anchor" id="danger"></div> <!-- danger zone anchor -->
93 i18n-labelText labelText="Bypass video auto blacklist" 186 <div i18n class="account-title">DANGER ZONE</div>
94 ></my-peertube-checkbox>
95 </div> 187 </div>
96 188
97 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> 189 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9" [ngClass]="{ 'form-row': isInBigView() }">
98</form>
99 190
100<div *ngIf="!isCreation()" class="danger-zone"> 191 <div class="danger-zone">
101 <div class="account-title" i18n>Danger Zone</div> 192 <div class="form-group reset-password-email">
193 <label i18n>Send a link to reset the password by email to the user</label>
194 <button (click)="resetPassword()" i18n>Ask for new password</button>
195 </div>
102 196
103 <div class="form-group reset-password-email"> 197 <div class="form-group">
104 <label i18n>Send a link to reset the password by email to the user</label> 198 <label i18n>Manually set the user password</label>
105 <button (click)="resetPassword()" i18n>Ask for new password</button> 199 <my-user-password [userId]="user.id"></my-user-password>
106 </div> 200 </div>
201 </div>
107 202
108 <div class="form-group">
109 <label i18n>Manually set the user password</label>
110 <my-user-password [userId]="userId"></my-user-password>
111 </div> 203 </div>
112</div> 204</div>
diff --git a/client/src/app/+admin/users/user-edit/user-edit.component.scss b/client/src/app/+admin/users/user-edit/user-edit.component.scss
index c1cc4ca45..d4c1b600e 100644
--- a/client/src/app/+admin/users/user-edit/user-edit.component.scss
+++ b/client/src/app/+admin/users/user-edit/user-edit.component.scss
@@ -1,8 +1,13 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4.form-sub-title { 4label {
5 margin-bottom: 30px; 5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
9.account-title {
10 @include settings-big-title;
6} 11}
7 12
8input:not([type=submit]) { 13input:not([type=submit]) {
@@ -26,18 +31,9 @@ input[type=submit], button {
26 font-size: 11px; 31 font-size: 11px;
27} 32}
28 33
29.account-title {
30 @include in-content-small-title;
31
32 margin-top: 55px;
33 margin-bottom: 30px;
34}
35
36.danger-zone { 34.danger-zone {
37 .reset-password-email { 35 .reset-password-email {
38 margin-bottom: 30px; 36 margin-bottom: 30px;
39 padding-bottom: 30px;
40 border-bottom: 1px solid rgba(0, 0, 0, 0.1);
41 37
42 button { 38 button {
43 display: block; 39 display: block;
@@ -45,3 +41,20 @@ input[type=submit], button {
45 } 41 }
46 } 42 }
47} 43}
44
45.breadcrumb {
46 @include breadcrumb;
47}
48
49.dashboard {
50 @include dashboard;
51 max-width: 900px;
52}
53
54my-actor-avatar-info ::ng-deep {
55 .actor-img-edit-container,
56 .actor-info-followers,
57 .actor-info-username {
58 display: none;
59 }
60}
diff --git a/client/src/app/+admin/users/user-edit/user-edit.ts b/client/src/app/+admin/users/user-edit/user-edit.ts
index 47b57d2ec..a23cd9033 100644
--- a/client/src/app/+admin/users/user-edit/user-edit.ts
+++ b/client/src/app/+admin/users/user-edit/user-edit.ts
@@ -4,12 +4,14 @@ import { ServerConfig, USER_ROLE_LABELS, UserRole, VideoResolution } from '../..
4import { ConfigService } from '@app/+admin/config/shared/config.service' 4import { ConfigService } from '@app/+admin/config/shared/config.service'
5import { UserAdminFlag } from '@shared/models/users/user-flag.model' 5import { UserAdminFlag } from '@shared/models/users/user-flag.model'
6import { OnInit } from '@angular/core' 6import { OnInit } from '@angular/core'
7import { User } from '@app/shared/users/user.model'
8import { ScreenService } from '@app/shared/misc/screen.service'
7 9
8export abstract class UserEdit extends FormReactive implements OnInit { 10export abstract class UserEdit extends FormReactive implements OnInit {
9 videoQuotaOptions: { value: string, label: string }[] = [] 11 videoQuotaOptions: { value: string, label: string }[] = []
10 videoQuotaDailyOptions: { value: string, label: string }[] = [] 12 videoQuotaDailyOptions: { value: string, label: string }[] = []
11 username: string 13 username: string
12 userId: number 14 user: User
13 15
14 roles: { value: string, label: string }[] = [] 16 roles: { value: string, label: string }[] = []
15 17
@@ -17,6 +19,7 @@ export abstract class UserEdit extends FormReactive implements OnInit {
17 19
18 protected abstract serverService: ServerService 20 protected abstract serverService: ServerService
19 protected abstract configService: ConfigService 21 protected abstract configService: ConfigService
22 protected abstract screenService: ScreenService
20 protected abstract auth: AuthService 23 protected abstract auth: AuthService
21 abstract isCreation (): boolean 24 abstract isCreation (): boolean
22 abstract getFormButtonTitle (): string 25 abstract getFormButtonTitle (): string
@@ -29,6 +32,20 @@ export abstract class UserEdit extends FormReactive implements OnInit {
29 this.buildRoles() 32 this.buildRoles()
30 } 33 }
31 34
35 get subscribersCount () {
36 const forAccount = this.user
37 ? this.user.account.followersCount
38 : 0
39 const forChannels = this.user
40 ? this.user.videoChannels.map(c => c.followersCount).reduce((a, b) => a + b, 0)
41 : 0
42 return forAccount + forChannels
43 }
44
45 isInBigView () {
46 return this.screenService.getWindowInnerWidth() > 1600
47 }
48
32 buildRoles () { 49 buildRoles () {
33 const authUser = this.auth.getUser() 50 const authUser = this.auth.getUser()
34 51
diff --git a/client/src/app/+admin/users/user-edit/user-password.component.ts b/client/src/app/+admin/users/user-edit/user-password.component.ts
index 5b3040440..ecad000f7 100644
--- a/client/src/app/+admin/users/user-edit/user-password.component.ts
+++ b/client/src/app/+admin/users/user-edit/user-password.component.ts
@@ -23,8 +23,6 @@ export class UserPasswordComponent extends FormReactive implements OnInit {
23 constructor ( 23 constructor (
24 protected formValidatorService: FormValidatorService, 24 protected formValidatorService: FormValidatorService,
25 private userValidatorsService: UserValidatorsService, 25 private userValidatorsService: UserValidatorsService,
26 private route: ActivatedRoute,
27 private router: Router,
28 private notifier: Notifier, 26 private notifier: Notifier,
29 private userService: UserService, 27 private userService: UserService,
30 private i18n: I18n 28 private i18n: I18n
diff --git a/client/src/app/+admin/users/user-edit/user-update.component.ts b/client/src/app/+admin/users/user-edit/user-update.component.ts
index 1ab2e9dbf..fbe3d6950 100644
--- a/client/src/app/+admin/users/user-edit/user-update.component.ts
+++ b/client/src/app/+admin/users/user-edit/user-update.component.ts
@@ -4,13 +4,15 @@ import { Subscription } from 'rxjs'
4import { AuthService, Notifier } from '@app/core' 4import { AuthService, Notifier } from '@app/core'
5import { ServerService } from '../../../core' 5import { ServerService } from '../../../core'
6import { UserEdit } from './user-edit' 6import { UserEdit } from './user-edit'
7import { User, UserUpdate } from '../../../../../../shared' 7import { User as UserType, UserUpdate, UserRole } from '../../../../../../shared'
8import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
9import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 9import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
10import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' 10import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
11import { ConfigService } from '@app/+admin/config/shared/config.service' 11import { ConfigService } from '@app/+admin/config/shared/config.service'
12import { UserService } from '@app/shared' 12import { UserService } from '@app/shared'
13import { UserAdminFlag } from '@shared/models/users/user-flag.model' 13import { UserAdminFlag } from '@shared/models/users/user-flag.model'
14import { User } from '@app/shared/users/user.model'
15import { ScreenService } from '@app/shared/misc/screen.service'
14 16
15@Component({ 17@Component({
16 selector: 'my-user-update', 18 selector: 'my-user-update',
@@ -19,9 +21,6 @@ import { UserAdminFlag } from '@shared/models/users/user-flag.model'
19}) 21})
20export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { 22export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
21 error: string 23 error: string
22 userId: number
23 userEmail: string
24 username: string
25 24
26 private paramsSub: Subscription 25 private paramsSub: Subscription
27 26
@@ -29,6 +28,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
29 protected formValidatorService: FormValidatorService, 28 protected formValidatorService: FormValidatorService,
30 protected serverService: ServerService, 29 protected serverService: ServerService,
31 protected configService: ConfigService, 30 protected configService: ConfigService,
31 protected screenService: ScreenService,
32 protected auth: AuthService, 32 protected auth: AuthService,
33 private userValidatorsService: UserValidatorsService, 33 private userValidatorsService: UserValidatorsService,
34 private route: ActivatedRoute, 34 private route: ActivatedRoute,
@@ -45,7 +45,12 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
45 ngOnInit () { 45 ngOnInit () {
46 super.ngOnInit() 46 super.ngOnInit()
47 47
48 const defaultValues = { videoQuota: '-1', videoQuotaDaily: '-1' } 48 const defaultValues = {
49 role: UserRole.USER.toString(),
50 videoQuota: '-1',
51 videoQuotaDaily: '-1'
52 }
53
49 this.buildForm({ 54 this.buildForm({
50 email: this.userValidatorsService.USER_EMAIL, 55 email: this.userValidatorsService.USER_EMAIL,
51 role: this.userValidatorsService.USER_ROLE, 56 role: this.userValidatorsService.USER_ROLE,
@@ -56,7 +61,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
56 61
57 this.paramsSub = this.route.params.subscribe(routeParams => { 62 this.paramsSub = this.route.params.subscribe(routeParams => {
58 const userId = routeParams['id'] 63 const userId = routeParams['id']
59 this.userService.getUser(userId).subscribe( 64 this.userService.getUser(userId, true).subscribe(
60 user => this.onUserFetched(user), 65 user => this.onUserFetched(user),
61 66
62 err => this.error = err.message 67 err => this.error = err.message
@@ -78,9 +83,9 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
78 userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10) 83 userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10)
79 userUpdate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10) 84 userUpdate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10)
80 85
81 this.userService.updateUser(this.userId, userUpdate).subscribe( 86 this.userService.updateUser(this.user.id, userUpdate).subscribe(
82 () => { 87 () => {
83 this.notifier.success(this.i18n('User {{username}} updated.', { username: this.username })) 88 this.notifier.success(this.i18n('User {{user.username}} updated.', { username: this.user.username }))
84 this.router.navigate([ '/admin/users/list' ]) 89 this.router.navigate([ '/admin/users/list' ])
85 }, 90 },
86 91
@@ -101,10 +106,10 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
101 } 106 }
102 107
103 resetPassword () { 108 resetPassword () {
104 this.userService.askResetPassword(this.userEmail).subscribe( 109 this.userService.askResetPassword(this.user.email).subscribe(
105 () => { 110 () => {
106 this.notifier.success( 111 this.notifier.success(
107 this.i18n('An email asking for password reset has been sent to {{username}}.', { username: this.username }) 112 this.i18n('An email asking for password reset has been sent to {{username}}.', { username: this.user.username })
108 ) 113 )
109 }, 114 },
110 115
@@ -112,14 +117,12 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
112 ) 117 )
113 } 118 }
114 119
115 private onUserFetched (userJson: User) { 120 private onUserFetched (userJson: UserType) {
116 this.userId = userJson.id 121 this.user = new User(userJson)
117 this.username = userJson.username
118 this.userEmail = userJson.email
119 122
120 this.form.patchValue({ 123 this.form.patchValue({
121 email: userJson.email, 124 email: userJson.email,
122 role: userJson.role, 125 role: userJson.role.toString(),
123 videoQuota: userJson.videoQuota, 126 videoQuota: userJson.videoQuota,
124 videoQuotaDaily: userJson.videoQuotaDaily, 127 videoQuotaDaily: userJson.videoQuotaDaily,
125 byPassAutoBlacklist: userJson.adminFlags & UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST 128 byPassAutoBlacklist: userJson.adminFlags & UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index db8ffac16..f8c04cb4d 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -15,7 +15,6 @@ import { MyAccountProfileComponent } from '@app/+my-account/my-account-settings/
15import { MyAccountVideoChannelsComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channels.component' 15import { MyAccountVideoChannelsComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channels.component'
16import { MyAccountVideoChannelCreateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-create.component' 16import { MyAccountVideoChannelCreateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-create.component'
17import { MyAccountVideoChannelUpdateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-update.component' 17import { MyAccountVideoChannelUpdateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-update.component'
18import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component'
19import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component' 18import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component'
20import { MyAccountDangerZoneComponent } from '@app/+my-account/my-account-settings/my-account-danger-zone' 19import { MyAccountDangerZoneComponent } from '@app/+my-account/my-account-settings/my-account-danger-zone'
21import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component' 20import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component'
@@ -63,7 +62,6 @@ import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-setti
63 MyAccountVideoChannelsComponent, 62 MyAccountVideoChannelsComponent,
64 MyAccountVideoChannelCreateComponent, 63 MyAccountVideoChannelCreateComponent,
65 MyAccountVideoChannelUpdateComponent, 64 MyAccountVideoChannelUpdateComponent,
66 ActorAvatarInfoComponent,
67 MyAccountVideoImportsComponent, 65 MyAccountVideoImportsComponent,
68 MyAccountDangerZoneComponent, 66 MyAccountDangerZoneComponent,
69 MyAccountSubscriptionsComponent, 67 MyAccountSubscriptionsComponent,
diff --git a/client/src/app/+my-account/shared/actor-avatar-info.component.html b/client/src/app/+my-account/shared/actor-avatar-info.component.html
index 2050950be..82f5123de 100644
--- a/client/src/app/+my-account/shared/actor-avatar-info.component.html
+++ b/client/src/app/+my-account/shared/actor-avatar-info.component.html
@@ -1,14 +1,17 @@
1<ng-container *ngIf="actor"> 1<ng-container *ngIf="actor">
2 <div class="actor"> 2 <div class="actor">
3 <img [src]="actor.avatarUrl" alt="Avatar" /> 3 <div class="d-flex">
4 <img [src]="actor.avatarUrl" alt="Avatar" />
4 5
5 <div class="actor-img-edit-container"> 6 <div class="actor-img-edit-container">
6 <div class="actor-img-edit-button" [ngbTooltip]="'(extensions: '+ avatarExtensions +', '+ maxSizeText +': '+ maxAvatarSizeInBytes +')'" placement="right" container="body"> 7 <div class="actor-img-edit-button" [ngbTooltip]="'(extensions: '+ avatarExtensions +', '+ maxSizeText +': '+ maxAvatarSizeInBytes +')'" placement="right" container="body">
7 <my-global-icon iconName="edit"></my-global-icon> 8 <my-global-icon iconName="edit"></my-global-icon>
8 <input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/> 9 <input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/>
10 </div>
9 </div> 11 </div>
10 </div> 12 </div>
11 13
14
12 <div class="actor-info"> 15 <div class="actor-info">
13 <div class="actor-info-names"> 16 <div class="actor-info-names">
14 <div class="actor-info-display-name">{{ actor.displayName }}</div> 17 <div class="actor-info-display-name">{{ actor.displayName }}</div>
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 75aa30dab..b89f0a8d1 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -106,6 +106,7 @@ import { InputSwitchModule } from 'primeng/inputswitch'
106 106
107import { MyAccountVideoSettingsComponent } from '@app/+my-account/my-account-settings/my-account-video-settings' 107import { MyAccountVideoSettingsComponent } from '@app/+my-account/my-account-settings/my-account-video-settings'
108import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface' 108import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface'
109import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component'
109 110
110@NgModule({ 111@NgModule({
111 imports: [ 112 imports: [
@@ -189,7 +190,8 @@ import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account
189 PreviewUploadComponent, 190 PreviewUploadComponent,
190 191
191 MyAccountVideoSettingsComponent, 192 MyAccountVideoSettingsComponent,
192 MyAccountInterfaceSettingsComponent 193 MyAccountInterfaceSettingsComponent,
194 ActorAvatarInfoComponent
193 ], 195 ],
194 196
195 exports: [ 197 exports: [
@@ -270,7 +272,8 @@ import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account
270 VideoDurationPipe, 272 VideoDurationPipe,
271 273
272 MyAccountVideoSettingsComponent, 274 MyAccountVideoSettingsComponent,
273 MyAccountInterfaceSettingsComponent 275 MyAccountInterfaceSettingsComponent,
276 ActorAvatarInfoComponent
274 ], 277 ],
275 278
276 providers: [ 279 providers: [
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts
index a37cae749..76c57d2fb 100644
--- a/client/src/app/shared/users/user.model.ts
+++ b/client/src/app/shared/users/user.model.ts
@@ -51,6 +51,11 @@ export class User implements UserServerModel {
51 videoQuotaDaily: number 51 videoQuotaDaily: number
52 videoQuotaUsed?: number 52 videoQuotaUsed?: number
53 videoQuotaUsedDaily?: number 53 videoQuotaUsedDaily?: number
54 videosCount?: number
55 videoAbusesCount?: number
56 videoAbusesAcceptedCount?: number
57 videoAbusesCreatedCount?: number
58 videoCommentsCount?: number
54 59
55 theme: string 60 theme: string
56 61
@@ -79,6 +84,11 @@ export class User implements UserServerModel {
79 this.videoQuotaDaily = hash.videoQuotaDaily 84 this.videoQuotaDaily = hash.videoQuotaDaily
80 this.videoQuotaUsed = hash.videoQuotaUsed 85 this.videoQuotaUsed = hash.videoQuotaUsed
81 this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily 86 this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily
87 this.videosCount = hash.videosCount
88 this.videoAbusesCount = hash.videoAbusesCount
89 this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount
90 this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount
91 this.videoCommentsCount = hash.videoCommentsCount
82 92
83 this.nsfwPolicy = hash.nsfwPolicy 93 this.nsfwPolicy = hash.nsfwPolicy
84 this.webTorrentEnabled = hash.webTorrentEnabled 94 this.webTorrentEnabled = hash.webTorrentEnabled
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts
index a79343646..5442a8453 100644
--- a/client/src/app/shared/users/user.service.ts
+++ b/client/src/app/shared/users/user.service.ts
@@ -234,8 +234,9 @@ export class UserService {
234 return this.userCache[userId] 234 return this.userCache[userId]
235 } 235 }
236 236
237 getUser (userId: number) { 237 getUser (userId: number, withStats = false) {
238 return this.authHttp.get<UserServerModel>(UserService.BASE_USERS_URL + userId) 238 const params = new HttpParams().append('withStats', withStats + '')
239 return this.authHttp.get<UserServerModel>(UserService.BASE_USERS_URL + userId, { params })
239 .pipe(catchError(err => this.restExtractor.handleError(err))) 240 .pipe(catchError(err => this.restExtractor.handleError(err)))
240 } 241 }
241 242
diff --git a/client/src/app/shared/video/modals/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html
index b9434da26..cc1d361b3 100644
--- a/client/src/app/shared/video/modals/video-report.component.html
+++ b/client/src/app/shared/video/modals/video-report.component.html
@@ -7,8 +7,7 @@
7 <div class="modal-body"> 7 <div class="modal-body">
8 8
9 <div i18n class="information"> 9 <div i18n class="information">
10 Your report will be sent to moderators of {{ currentHost }}. 10 Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>.
11 <ng-container *ngIf="isRemoteVideo()"> It will be forwarded to origin instance {{ originHost }} too.</ng-container>
12 </div> 11 </div>
13 12
14 <form novalidate [formGroup]="form" (ngSubmit)="report()"> 13 <form novalidate [formGroup]="form" (ngSubmit)="report()">
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index e8dfb79bc..f96a43b34 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -621,3 +621,85 @@
621 } 621 }
622 } 622 }
623} 623}
624
625@mixin breadcrumb {
626 display: flex;
627 flex-wrap: wrap;
628 padding: 0.75rem 1rem;
629 margin-bottom: 1rem;
630 list-style: none;
631 background-color: var(--submenuColor);
632 border-radius: 0.25rem;
633
634 .breadcrumb-item {
635 display: flex;
636
637 a {
638 color: var(--mainColor);
639 }
640
641 & + .breadcrumb-item {
642 padding-left: 0.5rem;
643 &::before {
644 display: inline-block;
645 padding-right: 0.5rem;
646 color: #6c757d;
647 content: "/";
648 }
649 }
650
651 &.active {
652 color: #6c757d;
653 }
654 }
655}
656
657@mixin dashboard {
658 display: flex;
659 flex-wrap: wrap;
660 margin: 0 -5px;
661
662 & > div {
663 box-sizing: border-box;
664 flex: 0 0 percentage(1/3);
665 padding: 0 5px;
666 margin-bottom: 10px;
667
668 & > a {
669 text-decoration: none;
670 color: inherit;
671 display: block;
672 font-size: 18px;
673
674 &:active,
675 &:focus,
676 &:hover {
677 opacity: .8;
678 }
679 }
680
681 & > a,
682 & > div {
683 padding: 20px;
684 background: var(--submenuColor);
685 border-radius: 4px;
686 box-sizing: border-box;
687 height: 100%;
688 }
689 }
690
691 .dashboard-num, .dashboard-text {
692 text-align: center;
693 font-size: 130%;
694 line-height: 21px;
695 color: var(--mainForegroundColor);
696 line-height: 30px;
697 margin-bottom: 20px;
698 }
699
700 .dashboard-label {
701 font-size: 90%;
702 color: var(--inputPlaceholderColor);
703 text-align: center;
704 }
705}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index adc67a046..840b9fc74 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -1,6 +1,6 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import * as express from 'express' 2import * as express from 'express'
3import { body, param } from 'express-validator' 3import { body, param, query } from 'express-validator'
4import { omit } from 'lodash' 4import { omit } from 'lodash'
5import { isIdOrUUIDValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' 5import { isIdOrUUIDValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
6import { 6import {
@@ -256,12 +256,13 @@ const usersUpdateMeValidator = [
256 256
257const usersGetValidator = [ 257const usersGetValidator = [
258 param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), 258 param('id').isInt().not().isEmpty().withMessage('Should have a valid id'),
259 query('withStats').optional().isBoolean().withMessage('Should have a valid stats flag'),
259 260
260 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 261 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
261 logger.debug('Checking usersGet parameters', { parameters: req.params }) 262 logger.debug('Checking usersGet parameters', { parameters: req.params })
262 263
263 if (areValidationErrors(req, res)) return 264 if (areValidationErrors(req, res)) return
264 if (!await checkUserIdExist(req.params.id, res)) return 265 if (!await checkUserIdExist(req.params.id, res, req.query.withStats)) return
265 266
266 return next() 267 return next()
267 } 268 }
@@ -460,9 +461,9 @@ export {
460 461
461// --------------------------------------------------------------------------- 462// ---------------------------------------------------------------------------
462 463
463function checkUserIdExist (idArg: number | string, res: express.Response) { 464function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
464 const id = parseInt(idArg + '', 10) 465 const id = parseInt(idArg + '', 10)
465 return checkUserExist(() => UserModel.loadById(id), res) 466 return checkUserExist(() => UserModel.loadById(id, withStats), res)
466} 467}
467 468
468function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { 469function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index 777f09666..026bf1318 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -19,7 +19,7 @@ import {
19 Table, 19 Table,
20 UpdatedAt 20 UpdatedAt
21} from 'sequelize-typescript' 21} from 'sequelize-typescript'
22import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, VideoPlaylistType, VideoPrivacy } from '../../../shared' 22import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, VideoPlaylistType, VideoPrivacy, VideoAbuseState } from '../../../shared'
23import { User, UserRole } from '../../../shared/models/users' 23import { User, UserRole } from '../../../shared/models/users'
24import { 24import {
25 isNoInstanceConfigWarningModal, 25 isNoInstanceConfigWarningModal,
@@ -70,8 +70,26 @@ import {
70 MVideoFullLight 70 MVideoFullLight
71} from '@server/typings/models' 71} from '@server/typings/models'
72 72
73const literalVideoQuotaUsed: any = [
74 literal(
75 '(' +
76 'SELECT COALESCE(SUM("size"), 0) ' +
77 'FROM (' +
78 'SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' +
79 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
80 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
81 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
82 'WHERE "account"."userId" = "UserModel"."id" GROUP BY "video"."id"' +
83 ') t' +
84 ')'
85 ),
86 'videoQuotaUsed'
87]
88
73enum ScopeNames { 89enum ScopeNames {
74 FOR_ME_API = 'FOR_ME_API' 90 FOR_ME_API = 'FOR_ME_API',
91 WITH_VIDEOCHANNELS = 'WITH_VIDEOCHANNELS',
92 WITH_STATS = 'WITH_STATS'
75} 93}
76 94
77@DefaultScope(() => ({ 95@DefaultScope(() => ({
@@ -112,6 +130,86 @@ enum ScopeNames {
112 required: true 130 required: true
113 } 131 }
114 ] 132 ]
133 },
134 [ScopeNames.WITH_VIDEOCHANNELS]: {
135 include: [
136 {
137 model: AccountModel,
138 include: [
139 {
140 model: VideoChannelModel
141 },
142 {
143 attributes: [ 'id', 'name', 'type' ],
144 model: VideoPlaylistModel.unscoped(),
145 required: true,
146 where: {
147 type: {
148 [Op.ne]: VideoPlaylistType.REGULAR
149 }
150 }
151 }
152 ]
153 }
154 ]
155 },
156 [ScopeNames.WITH_STATS]: {
157 attributes: {
158 include: [
159 literalVideoQuotaUsed,
160 [
161 literal(
162 '(' +
163 'SELECT COUNT("video"."id") ' +
164 'FROM "video" ' +
165 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
166 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
167 'WHERE "account"."userId" = "UserModel"."id"' +
168 ')'
169 ),
170 'videosCount'
171 ],
172 [
173 literal(
174 '(' +
175 `SELECT concat_ws(':', "abuses", "acceptedAbuses") ` +
176 'FROM (' +
177 'SELECT COUNT("videoAbuse"."id") AS "abuses", ' +
178 `COUNT("videoAbuse"."id") FILTER (WHERE "videoAbuse"."state" = ${VideoAbuseState.ACCEPTED}) AS "acceptedAbuses" ` +
179 'FROM "videoAbuse" ' +
180 'INNER JOIN "video" ON "videoAbuse"."videoId" = "video"."id" ' +
181 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
182 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
183 'WHERE "account"."userId" = "UserModel"."id"' +
184 ') t' +
185 ')'
186 ),
187 'videoAbusesCount'
188 ],
189 [
190 literal(
191 '(' +
192 'SELECT COUNT("videoAbuse"."id") ' +
193 'FROM "videoAbuse" ' +
194 'INNER JOIN "account" ON "account"."id" = "videoAbuse"."reporterAccountId" ' +
195 'WHERE "account"."userId" = "UserModel"."id"' +
196 ')'
197 ),
198 'videoAbusesCreatedCount'
199 ],
200 [
201 literal(
202 '(' +
203 'SELECT COUNT("videoComment"."id") ' +
204 'FROM "videoComment" ' +
205 'INNER JOIN "account" ON "account"."id" = "videoComment"."accountId" ' +
206 'WHERE "account"."userId" = "UserModel"."id"' +
207 ')'
208 ),
209 'videoCommentsCount'
210 ]
211 ]
212 }
115 } 213 }
116})) 214}))
117@Table({ 215@Table({
@@ -332,23 +430,7 @@ export class UserModel extends Model<UserModel> {
332 430
333 const query: FindOptions = { 431 const query: FindOptions = {
334 attributes: { 432 attributes: {
335 include: [ 433 include: [ literalVideoQuotaUsed ]
336 [
337 literal(
338 '(' +
339 'SELECT COALESCE(SUM("size"), 0) ' +
340 'FROM (' +
341 'SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' +
342 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
343 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
344 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
345 'WHERE "account"."userId" = "UserModel"."id" GROUP BY "video"."id"' +
346 ') t' +
347 ')'
348 ),
349 'videoQuotaUsed'
350 ]
351 ]
352 }, 434 },
353 offset: start, 435 offset: start,
354 limit: count, 436 limit: count,
@@ -430,8 +512,14 @@ export class UserModel extends Model<UserModel> {
430 return UserModel.findAll(query) 512 return UserModel.findAll(query)
431 } 513 }
432 514
433 static loadById (id: number): Bluebird<MUserDefault> { 515 static loadById (id: number, withStats = false): Bluebird<MUserDefault> {
434 return UserModel.findByPk(id) 516 const scopes = [
517 ScopeNames.WITH_VIDEOCHANNELS
518 ]
519
520 if (withStats) scopes.push(ScopeNames.WITH_STATS)
521
522 return UserModel.scope(scopes).findByPk(id)
435 } 523 }
436 524
437 static loadByUsername (username: string): Bluebird<MUserDefault> { 525 static loadByUsername (username: string): Bluebird<MUserDefault> {
@@ -637,6 +725,10 @@ export class UserModel extends Model<UserModel> {
637 toFormattedJSON (this: MUserFormattable, parameters: { withAdminFlags?: boolean } = {}): User { 725 toFormattedJSON (this: MUserFormattable, parameters: { withAdminFlags?: boolean } = {}): User {
638 const videoQuotaUsed = this.get('videoQuotaUsed') 726 const videoQuotaUsed = this.get('videoQuotaUsed')
639 const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily') 727 const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily')
728 const videosCount = this.get('videosCount')
729 const [ videoAbusesCount, videoAbusesAcceptedCount ] = (this.get('videoAbusesCount') as string || ':').split(':')
730 const videoAbusesCreatedCount = this.get('videoAbusesCreatedCount')
731 const videoCommentsCount = this.get('videoCommentsCount')
640 732
641 const json: User = { 733 const json: User = {
642 id: this.id, 734 id: this.id,
@@ -666,6 +758,21 @@ export class UserModel extends Model<UserModel> {
666 videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined 758 videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined
667 ? parseInt(videoQuotaUsedDaily + '', 10) 759 ? parseInt(videoQuotaUsedDaily + '', 10)
668 : undefined, 760 : undefined,
761 videosCount: videosCount !== undefined
762 ? parseInt(videosCount + '', 10)
763 : undefined,
764 videoAbusesCount: videoAbusesCount
765 ? parseInt(videoAbusesCount, 10)
766 : undefined,
767 videoAbusesAcceptedCount: videoAbusesAcceptedCount
768 ? parseInt(videoAbusesAcceptedCount, 10)
769 : undefined,
770 videoAbusesCreatedCount: videoAbusesCreatedCount !== undefined
771 ? parseInt(videoAbusesCreatedCount + '', 10)
772 : undefined,
773 videoCommentsCount: videoCommentsCount !== undefined
774 ? parseInt(videoCommentsCount + '', 10)
775 : undefined,
669 776
670 noInstanceConfigWarningModal: this.noInstanceConfigWarningModal, 777 noInstanceConfigWarningModal: this.noInstanceConfigWarningModal,
671 noWelcomeModal: this.noWelcomeModal, 778 noWelcomeModal: this.noWelcomeModal,
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 502eac0bb..3e1a0c19b 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -2,7 +2,7 @@
2 2
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { MyUser, User, UserRole, Video, VideoPlaylistType } from '../../../../shared/index' 5import { MyUser, User, UserRole, Video, VideoPlaylistType, VideoAbuseState, VideoAbuseUpdate } from '../../../../shared/index'
6import { 6import {
7 blockUser, 7 blockUser,
8 cleanupTests, 8 cleanupTests,
@@ -33,7 +33,11 @@ import {
33 updateMyUser, 33 updateMyUser,
34 updateUser, 34 updateUser,
35 uploadVideo, 35 uploadVideo,
36 userLogin 36 userLogin,
37 reportVideoAbuse,
38 addVideoCommentThread,
39 updateVideoAbuse,
40 getVideoAbusesList
37} from '../../../../shared/extra-utils' 41} from '../../../../shared/extra-utils'
38import { follow } from '../../../../shared/extra-utils/server/follows' 42import { follow } from '../../../../shared/extra-utils/server/follows'
39import { setAccessTokensToServers } from '../../../../shared/extra-utils/users/login' 43import { setAccessTokensToServers } from '../../../../shared/extra-utils/users/login'
@@ -254,7 +258,7 @@ describe('Test users', function () {
254 const res1 = await getMyUserInformation(server.url, accessTokenUser) 258 const res1 = await getMyUserInformation(server.url, accessTokenUser)
255 const userMe: MyUser = res1.body 259 const userMe: MyUser = res1.body
256 260
257 const res2 = await getUserInformation(server.url, server.accessToken, userMe.id) 261 const res2 = await getUserInformation(server.url, server.accessToken, userMe.id, true)
258 const userGet: User = res2.body 262 const userGet: User = res2.body
259 263
260 for (const user of [ userMe, userGet ]) { 264 for (const user of [ userMe, userGet ]) {
@@ -273,6 +277,16 @@ describe('Test users', function () {
273 277
274 expect(userMe.specialPlaylists).to.have.lengthOf(1) 278 expect(userMe.specialPlaylists).to.have.lengthOf(1)
275 expect(userMe.specialPlaylists[0].type).to.equal(VideoPlaylistType.WATCH_LATER) 279 expect(userMe.specialPlaylists[0].type).to.equal(VideoPlaylistType.WATCH_LATER)
280
281 // Check stats are included with withStats
282 expect(userGet.videosCount).to.be.a('number')
283 expect(userGet.videosCount).to.equal(0)
284 expect(userGet.videoCommentsCount).to.be.a('number')
285 expect(userGet.videoCommentsCount).to.equal(0)
286 expect(userGet.videoAbusesCount).to.be.a('number')
287 expect(userGet.videoAbusesCount).to.equal(0)
288 expect(userGet.videoAbusesAcceptedCount).to.be.a('number')
289 expect(userGet.videoAbusesAcceptedCount).to.equal(0)
276 }) 290 })
277 }) 291 })
278 292
@@ -623,7 +637,6 @@ describe('Test users', function () {
623 }) 637 })
624 638
625 describe('Updating another user', function () { 639 describe('Updating another user', function () {
626
627 it('Should be able to update another user', async function () { 640 it('Should be able to update another user', async function () {
628 await updateUser({ 641 await updateUser({
629 url: server.url, 642 url: server.url,
@@ -698,6 +711,8 @@ describe('Test users', function () {
698 }) 711 })
699 712
700 describe('Registering a new user', function () { 713 describe('Registering a new user', function () {
714 let user15AccessToken
715
701 it('Should register a new user', async function () { 716 it('Should register a new user', async function () {
702 const user = { displayName: 'super user 15', username: 'user_15', password: 'my super password' } 717 const user = { displayName: 'super user 15', username: 'user_15', password: 'my super password' }
703 const channel = { name: 'my_user_15_channel', displayName: 'my channel rocks' } 718 const channel = { name: 'my_user_15_channel', displayName: 'my channel rocks' }
@@ -711,18 +726,18 @@ describe('Test users', function () {
711 password: 'my super password' 726 password: 'my super password'
712 } 727 }
713 728
714 accessToken = await userLogin(server, user15) 729 user15AccessToken = await userLogin(server, user15)
715 }) 730 })
716 731
717 it('Should have the correct display name', async function () { 732 it('Should have the correct display name', async function () {
718 const res = await getMyUserInformation(server.url, accessToken) 733 const res = await getMyUserInformation(server.url, user15AccessToken)
719 const user: User = res.body 734 const user: User = res.body
720 735
721 expect(user.account.displayName).to.equal('super user 15') 736 expect(user.account.displayName).to.equal('super user 15')
722 }) 737 })
723 738
724 it('Should have the correct video quota', async function () { 739 it('Should have the correct video quota', async function () {
725 const res = await getMyUserInformation(server.url, accessToken) 740 const res = await getMyUserInformation(server.url, user15AccessToken)
726 const user = res.body 741 const user = res.body
727 742
728 expect(user.videoQuota).to.equal(5 * 1024 * 1024) 743 expect(user.videoQuota).to.equal(5 * 1024 * 1024)
@@ -740,7 +755,7 @@ describe('Test users', function () {
740 expect(res.body.data.find(u => u.username === 'user_15')).to.not.be.undefined 755 expect(res.body.data.find(u => u.username === 'user_15')).to.not.be.undefined
741 } 756 }
742 757
743 await deleteMe(server.url, accessToken) 758 await deleteMe(server.url, user15AccessToken)
744 759
745 { 760 {
746 const res = await getUsersList(server.url, server.accessToken) 761 const res = await getUsersList(server.url, server.accessToken)
@@ -750,6 +765,9 @@ describe('Test users', function () {
750 }) 765 })
751 766
752 describe('User blocking', function () { 767 describe('User blocking', function () {
768 let user16Id
769 let user16AccessToken
770
753 it('Should block and unblock a user', async function () { 771 it('Should block and unblock a user', async function () {
754 const user16 = { 772 const user16 = {
755 username: 'user_16', 773 username: 'user_16',
@@ -761,19 +779,95 @@ describe('Test users', function () {
761 username: user16.username, 779 username: user16.username,
762 password: user16.password 780 password: user16.password
763 }) 781 })
764 const user16Id = resUser.body.user.id 782 user16Id = resUser.body.user.id
765 783
766 accessToken = await userLogin(server, user16) 784 user16AccessToken = await userLogin(server, user16)
767 785
768 await getMyUserInformation(server.url, accessToken, 200) 786 await getMyUserInformation(server.url, user16AccessToken, 200)
769 await blockUser(server.url, user16Id, server.accessToken) 787 await blockUser(server.url, user16Id, server.accessToken)
770 788
771 await getMyUserInformation(server.url, accessToken, 401) 789 await getMyUserInformation(server.url, user16AccessToken, 401)
772 await userLogin(server, user16, 400) 790 await userLogin(server, user16, 400)
773 791
774 await unblockUser(server.url, user16Id, server.accessToken) 792 await unblockUser(server.url, user16Id, server.accessToken)
775 accessToken = await userLogin(server, user16) 793 user16AccessToken = await userLogin(server, user16)
776 await getMyUserInformation(server.url, accessToken, 200) 794 await getMyUserInformation(server.url, user16AccessToken, 200)
795 })
796 })
797
798 describe('User stats', function () {
799 let user17Id
800 let user17AccessToken
801
802 it('Should report correct initial statistics about a user', async function () {
803 const user17 = {
804 username: 'user_17',
805 password: 'my super password'
806 }
807 const resUser = await createUser({
808 url: server.url,
809 accessToken: server.accessToken,
810 username: user17.username,
811 password: user17.password
812 })
813
814 user17Id = resUser.body.user.id
815 user17AccessToken = await userLogin(server, user17)
816
817 const res = await getUserInformation(server.url, server.accessToken, user17Id, true)
818 const user: User = res.body
819
820 expect(user.videosCount).to.equal(0)
821 expect(user.videoCommentsCount).to.equal(0)
822 expect(user.videoAbusesCount).to.equal(0)
823 expect(user.videoAbusesCreatedCount).to.equal(0)
824 expect(user.videoAbusesAcceptedCount).to.equal(0)
825 })
826
827 it('Should report correct videos count', async function () {
828 const videoAttributes = {
829 name: 'video to test user stats'
830 }
831 await uploadVideo(server.url, user17AccessToken, videoAttributes)
832 const res1 = await getVideosList(server.url)
833 videoId = res1.body.data.find(video => video.name === videoAttributes.name).id
834
835 const res2 = await getUserInformation(server.url, server.accessToken, user17Id, true)
836 const user: User = res2.body
837
838 expect(user.videosCount).to.equal(1)
839 })
840
841 it('Should report correct video comments for user', async function () {
842 const text = 'super comment'
843 await addVideoCommentThread(server.url, user17AccessToken, videoId, text)
844
845 const res = await getUserInformation(server.url, server.accessToken, user17Id, true)
846 const user: User = res.body
847
848 expect(user.videoCommentsCount).to.equal(1)
849 })
850
851 it('Should report correct video abuses counts', async function () {
852 const reason = 'my super bad reason'
853 await reportVideoAbuse(server.url, user17AccessToken, videoId, reason)
854
855 const res1 = await getVideoAbusesList(server.url, server.accessToken)
856 const abuseId = res1.body.data[0].id
857
858 const res2 = await getUserInformation(server.url, server.accessToken, user17Id, true)
859 const user2: User = res2.body
860
861 expect(user2.videoAbusesCount).to.equal(1) // number of incriminations
862 expect(user2.videoAbusesCreatedCount).to.equal(1) // number of reports created
863
864 const body: VideoAbuseUpdate = { state: VideoAbuseState.ACCEPTED }
865 await updateVideoAbuse(server.url, server.accessToken, videoId, abuseId, body)
866
867 const res3 = await getUserInformation(server.url, server.accessToken, user17Id, true)
868 const user3: User = res3.body
869
870 expect(user3.videoAbusesAcceptedCount).to.equal(1) // number of reports created accepted
777 }) 871 })
778 }) 872 })
779 873
diff --git a/shared/extra-utils/users/users.ts b/shared/extra-utils/users/users.ts
index 248af2d6e..54b506bce 100644
--- a/shared/extra-utils/users/users.ts
+++ b/shared/extra-utils/users/users.ts
@@ -130,11 +130,12 @@ function getMyUserVideoQuotaUsed (url: string, accessToken: string, specialStatu
130 .expect('Content-Type', /json/) 130 .expect('Content-Type', /json/)
131} 131}
132 132
133function getUserInformation (url: string, accessToken: string, userId: number) { 133function getUserInformation (url: string, accessToken: string, userId: number, withStats = false) {
134 const path = '/api/v1/users/' + userId 134 const path = '/api/v1/users/' + userId
135 135
136 return request(url) 136 return request(url)
137 .get(path) 137 .get(path)
138 .query({ withStats })
138 .set('Accept', 'application/json') 139 .set('Accept', 'application/json')
139 .set('Authorization', 'Bearer ' + accessToken) 140 .set('Authorization', 'Bearer ' + accessToken)
140 .expect(200) 141 .expect(200)
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index efb451014..a9c9bce30 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -31,6 +31,11 @@ export interface User {
31 videoQuotaDaily: number 31 videoQuotaDaily: number
32 videoQuotaUsed?: number 32 videoQuotaUsed?: number
33 videoQuotaUsedDaily?: number 33 videoQuotaUsedDaily?: number
34 videosCount?: number
35 videoAbusesCount?: number
36 videoAbusesAcceptedCount?: number
37 videoAbusesCreatedCount?: number
38 videoCommentsCount? : number
34 39
35 theme: string 40 theme: string
36 41