aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src
diff options
context:
space:
mode:
Diffstat (limited to 'client/src')
-rw-r--r--client/src/app/+accounts/accounts.component.html5
-rw-r--r--client/src/app/+accounts/accounts.component.scss12
-rw-r--r--client/src/app/+accounts/accounts.component.ts48
-rw-r--r--client/src/app/+admin/admin.module.ts5
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts24
-rw-r--r--client/src/app/+admin/users/index.ts1
-rw-r--r--client/src/app/+admin/users/shared/index.ts1
-rw-r--r--client/src/app/+admin/users/shared/user.service.ts96
-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-update.component.ts2
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.html4
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.ts95
-rw-r--r--client/src/app/app.component.ts13
-rw-r--r--client/src/app/shared/account/account.model.ts3
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.html2
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.scss6
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.ts1
-rw-r--r--client/src/app/shared/moderation/index.ts2
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.html (renamed from client/src/app/+admin/users/user-list/user-ban-modal.component.html)0
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.scss (renamed from client/src/app/+admin/users/user-list/user-ban-modal.component.scss)0
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.ts (renamed from client/src/app/+admin/users/user-list/user-ban-modal.component.ts)6
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.html5
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.scss0
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.ts135
-rw-r--r--client/src/app/shared/shared.module.ts8
-rw-r--r--client/src/app/shared/users/user.service.ts91
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts2
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.html10
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.scss13
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.ts8
-rw-r--r--client/src/app/shared/video/video.model.ts6
-rw-r--r--client/src/app/shared/video/video.service.ts4
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts13
-rw-r--r--client/src/assets/player/peertube-player.ts8
-rw-r--r--client/src/assets/player/peertube-videojs-plugin.ts34
-rw-r--r--client/src/assets/player/peertube-videojs-typings.ts10
36 files changed, 425 insertions, 250 deletions
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html
index 69f648269..036e794d2 100644
--- a/client/src/app/+accounts/accounts.component.html
+++ b/client/src/app/+accounts/accounts.component.html
@@ -8,6 +8,11 @@
8 <div class="actor-names"> 8 <div class="actor-names">
9 <div class="actor-display-name">{{ account.displayName }}</div> 9 <div class="actor-display-name">{{ account.displayName }}</div>
10 <div class="actor-name">{{ account.nameWithHost }}</div> 10 <div class="actor-name">{{ account.nameWithHost }}</div>
11
12 <span *ngIf="user?.blocked" [ngbTooltip]="user.blockedReason" class="badge badge-danger" i18n>Banned</span>
13
14 <my-user-moderation-dropdown buttonSize="small" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()">
15 </my-user-moderation-dropdown>
11 </div> 16 </div>
12 <div i18n class="actor-followers">{{ account.followersCount }} subscribers</div> 17 <div i18n class="actor-followers">{{ account.followersCount }} subscribers</div>
13 </div> 18 </div>
diff --git a/client/src/app/+accounts/accounts.component.scss b/client/src/app/+accounts/accounts.component.scss
index 909b65bc7..3cedda889 100644
--- a/client/src/app/+accounts/accounts.component.scss
+++ b/client/src/app/+accounts/accounts.component.scss
@@ -3,4 +3,16 @@
3 3
4.sub-menu { 4.sub-menu {
5 @include sub-menu-with-actor; 5 @include sub-menu-with-actor;
6}
7
8my-user-moderation-dropdown,
9.badge {
10 margin-left: 10px;
11
12 position: relative;
13 top: 3px;
14}
15
16.badge {
17 font-size: 13px;
6} \ No newline at end of file 18} \ No newline at end of file
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts
index af0451e91..e19927d6b 100644
--- a/client/src/app/+accounts/accounts.component.ts
+++ b/client/src/app/+accounts/accounts.component.ts
@@ -1,10 +1,14 @@
1import { Component, OnInit, OnDestroy } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute } from '@angular/router' 2import { ActivatedRoute } from '@angular/router'
3import { AccountService } from '@app/shared/account/account.service' 3import { AccountService } from '@app/shared/account/account.service'
4import { Account } from '@app/shared/account/account.model' 4import { Account } from '@app/shared/account/account.model'
5import { RestExtractor } from '@app/shared' 5import { RestExtractor, UserService } from '@app/shared'
6import { catchError, switchMap, distinctUntilChanged, map } from 'rxjs/operators' 6import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
7import { Subscription } from 'rxjs' 7import { Subscription } from 'rxjs'
8import { NotificationsService } from 'angular2-notifications'
9import { User, UserRight } from '../../../../shared'
10import { I18n } from '@ngx-translate/i18n-polyfill'
11import { AuthService, RedirectService } from '@app/core'
8 12
9@Component({ 13@Component({
10 templateUrl: './accounts.component.html', 14 templateUrl: './accounts.component.html',
@@ -12,13 +16,19 @@ import { Subscription } from 'rxjs'
12}) 16})
13export class AccountsComponent implements OnInit, OnDestroy { 17export class AccountsComponent implements OnInit, OnDestroy {
14 account: Account 18 account: Account
19 user: User
15 20
16 private routeSub: Subscription 21 private routeSub: Subscription
17 22
18 constructor ( 23 constructor (
19 private route: ActivatedRoute, 24 private route: ActivatedRoute,
25 private userService: UserService,
20 private accountService: AccountService, 26 private accountService: AccountService,
21 private restExtractor: RestExtractor 27 private notificationsService: NotificationsService,
28 private restExtractor: RestExtractor,
29 private redirectService: RedirectService,
30 private authService: AuthService,
31 private i18n: I18n
22 ) {} 32 ) {}
23 33
24 ngOnInit () { 34 ngOnInit () {
@@ -27,12 +37,40 @@ export class AccountsComponent implements OnInit, OnDestroy {
27 map(params => params[ 'accountId' ]), 37 map(params => params[ 'accountId' ]),
28 distinctUntilChanged(), 38 distinctUntilChanged(),
29 switchMap(accountId => this.accountService.getAccount(accountId)), 39 switchMap(accountId => this.accountService.getAccount(accountId)),
40 tap(account => this.getUserIfNeeded(account)),
30 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ])) 41 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))
31 ) 42 )
32 .subscribe(account => this.account = account) 43 .subscribe(
44 account => this.account = account,
45
46 err => this.notificationsService.error(this.i18n('Error'), err.message)
47 )
33 } 48 }
34 49
35 ngOnDestroy () { 50 ngOnDestroy () {
36 if (this.routeSub) this.routeSub.unsubscribe() 51 if (this.routeSub) this.routeSub.unsubscribe()
37 } 52 }
53
54 onUserChanged () {
55 this.getUserIfNeeded(this.account)
56 }
57
58 onUserDeleted () {
59 this.redirectService.redirectToHomepage()
60 }
61
62 private getUserIfNeeded (account: Account) {
63 if (!account.userId) return
64 if (!this.authService.isLoggedIn()) return
65
66 const user = this.authService.getUser()
67 if (user.hasRight(UserRight.MANAGE_USERS)) {
68 this.userService.getUser(account.userId)
69 .subscribe(
70 user => this.user = user,
71
72 err => this.notificationsService.error(this.i18n('Error'), err.message)
73 )
74 }
75 }
38} 76}
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index 5784609ef..8c6db98d9 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -10,9 +10,8 @@ import { FollowingListComponent } from './follows/following-list/following-list.
10import { JobsComponent } from './jobs/job.component' 10import { JobsComponent } from './jobs/job.component'
11import { JobsListComponent } from './jobs/jobs-list/jobs-list.component' 11import { JobsListComponent } from './jobs/jobs-list/jobs-list.component'
12import { JobService } from './jobs/shared/job.service' 12import { JobService } from './jobs/shared/job.service'
13import { UserCreateComponent, UserListComponent, UsersComponent, UserService, UserUpdateComponent } from './users' 13import { UserCreateComponent, UserListComponent, UsersComponent, UserUpdateComponent } from './users'
14import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlacklistListComponent } from './moderation' 14import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlacklistListComponent } from './moderation'
15import { UserBanModalComponent } from '@app/+admin/users/user-list/user-ban-modal.component'
16import { ModerationComponent } from '@app/+admin/moderation/moderation.component' 15import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
17import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' 16import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component'
18import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' 17import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service'
@@ -37,7 +36,6 @@ import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service
37 UserCreateComponent, 36 UserCreateComponent,
38 UserUpdateComponent, 37 UserUpdateComponent,
39 UserListComponent, 38 UserListComponent,
40 UserBanModalComponent,
41 39
42 ModerationComponent, 40 ModerationComponent,
43 VideoBlacklistListComponent, 41 VideoBlacklistListComponent,
@@ -58,7 +56,6 @@ import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service
58 providers: [ 56 providers: [
59 FollowService, 57 FollowService,
60 RedundancyService, 58 RedundancyService,
61 UserService,
62 JobService, 59 JobService,
63 ConfigService 60 ConfigService
64 ] 61 ]
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 4983b0425..25b303f44 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -1,6 +1,5 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { ConfigService } from '@app/+admin/config/shared/config.service' 2import { ConfigService } from '@app/+admin/config/shared/config.service'
3import { ConfirmService } from '@app/core'
4import { ServerService } from '@app/core/server/server.service' 3import { ServerService } from '@app/core/server/server.service'
5import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared' 4import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared'
6import { NotificationsService } from 'angular2-notifications' 5import { NotificationsService } from 'angular2-notifications'
@@ -29,7 +28,6 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
29 private notificationsService: NotificationsService, 28 private notificationsService: NotificationsService,
30 private configService: ConfigService, 29 private configService: ConfigService,
31 private serverService: ServerService, 30 private serverService: ServerService,
32 private confirmService: ConfirmService,
33 private i18n: I18n 31 private i18n: I18n
34 ) { 32 ) {
35 super() 33 super()
@@ -124,28 +122,6 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
124 } 122 }
125 123
126 async formValidated () { 124 async formValidated () {
127 const newCustomizationJavascript = this.form.value['customizationJavascript']
128 const newCustomizationCSS = this.form.value['customizationCSS']
129
130 const customizations = []
131 if (newCustomizationJavascript && newCustomizationJavascript !== this.oldCustomJavascript) customizations.push('JavaScript')
132 if (newCustomizationCSS && newCustomizationCSS !== this.oldCustomCSS) customizations.push('CSS')
133
134 if (customizations.length !== 0) {
135 const customizationsText = customizations.join('/')
136
137 // FIXME: i18n service does not support string concatenation
138 const message = this.i18n('You set custom {{customizationsText}}. ', { customizationsText }) +
139 this.i18n('This could lead to security issues or bugs if you do not understand it. ') +
140 this.i18n('Are you sure you want to update the configuration?')
141
142 const label = this.i18n('Please type') + ` "I understand the ${customizationsText} I set" ` + this.i18n('to confirm.')
143 const expectedInputValue = `I understand the ${customizationsText} I set`
144
145 const confirmRes = await this.confirmService.confirmWithInput(message, label, expectedInputValue)
146 if (confirmRes === false) return
147 }
148
149 const data: CustomConfig = { 125 const data: CustomConfig = {
150 instance: { 126 instance: {
151 name: this.form.value['instanceName'], 127 name: this.form.value['instanceName'],
diff --git a/client/src/app/+admin/users/index.ts b/client/src/app/+admin/users/index.ts
index efcd0d9cb..156e54d89 100644
--- a/client/src/app/+admin/users/index.ts
+++ b/client/src/app/+admin/users/index.ts
@@ -1,4 +1,3 @@
1export * from './shared'
2export * from './user-edit' 1export * from './user-edit'
3export * from './user-list' 2export * from './user-list'
4export * from './users.component' 3export * from './users.component'
diff --git a/client/src/app/+admin/users/shared/index.ts b/client/src/app/+admin/users/shared/index.ts
deleted file mode 100644
index 1f1302dc5..000000000
--- a/client/src/app/+admin/users/shared/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './user.service'
diff --git a/client/src/app/+admin/users/shared/user.service.ts b/client/src/app/+admin/users/shared/user.service.ts
deleted file mode 100644
index 470beef08..000000000
--- a/client/src/app/+admin/users/shared/user.service.ts
+++ /dev/null
@@ -1,96 +0,0 @@
1import { catchError, map } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { BytesPipe } from 'ngx-pipes'
5import { SortMeta } from 'primeng/components/common/sortmeta'
6import { Observable } from 'rxjs'
7import { ResultList, UserCreate, UserUpdate, User, UserRole } from '../../../../../../shared'
8import { environment } from '../../../../environments/environment'
9import { RestExtractor, RestPagination, RestService } from '../../../shared'
10import { I18n } from '@ngx-translate/i18n-polyfill'
11
12@Injectable()
13export class UserService {
14 private static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/'
15 private bytesPipe = new BytesPipe()
16
17 constructor (
18 private authHttp: HttpClient,
19 private restService: RestService,
20 private restExtractor: RestExtractor,
21 private i18n: I18n
22 ) { }
23
24 addUser (userCreate: UserCreate) {
25 return this.authHttp.post(UserService.BASE_USERS_URL, userCreate)
26 .pipe(
27 map(this.restExtractor.extractDataBool),
28 catchError(err => this.restExtractor.handleError(err))
29 )
30 }
31
32 updateUser (userId: number, userUpdate: UserUpdate) {
33 return this.authHttp.put(UserService.BASE_USERS_URL + userId, userUpdate)
34 .pipe(
35 map(this.restExtractor.extractDataBool),
36 catchError(err => this.restExtractor.handleError(err))
37 )
38 }
39
40 getUser (userId: number) {
41 return this.authHttp.get<User>(UserService.BASE_USERS_URL + userId)
42 .pipe(catchError(err => this.restExtractor.handleError(err)))
43 }
44
45 getUsers (pagination: RestPagination, sort: SortMeta): Observable<ResultList<User>> {
46 let params = new HttpParams()
47 params = this.restService.addRestGetParams(params, pagination, sort)
48
49 return this.authHttp.get<ResultList<User>>(UserService.BASE_USERS_URL, { params })
50 .pipe(
51 map(res => this.restExtractor.convertResultListDateToHuman(res)),
52 map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))),
53 catchError(err => this.restExtractor.handleError(err))
54 )
55 }
56
57 removeUser (user: User) {
58 return this.authHttp.delete(UserService.BASE_USERS_URL + user.id)
59 .pipe(catchError(err => this.restExtractor.handleError(err)))
60 }
61
62 banUser (user: User, reason?: string) {
63 const body = reason ? { reason } : {}
64
65 return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/block', body)
66 .pipe(catchError(err => this.restExtractor.handleError(err)))
67 }
68
69 unbanUser (user: User) {
70 return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/unblock', {})
71 .pipe(catchError(err => this.restExtractor.handleError(err)))
72 }
73
74 private formatUser (user: User) {
75 let videoQuota
76 if (user.videoQuota === -1) {
77 videoQuota = this.i18n('Unlimited')
78 } else {
79 videoQuota = this.bytesPipe.transform(user.videoQuota, 0)
80 }
81
82 const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0)
83
84 const roleLabels: { [ id in UserRole ]: string } = {
85 [UserRole.USER]: this.i18n('User'),
86 [UserRole.ADMINISTRATOR]: this.i18n('Administrator'),
87 [UserRole.MODERATOR]: this.i18n('Moderator')
88 }
89
90 return Object.assign(user, {
91 roleLabel: roleLabels[user.role],
92 videoQuota,
93 videoQuotaUsed
94 })
95 }
96}
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 132e280b9..dd8e4efd5 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
@@ -1,7 +1,6 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { Router } from '@angular/router' 2import { Router } from '@angular/router'
3import { NotificationsService } from 'angular2-notifications' 3import { NotificationsService } from 'angular2-notifications'
4import { UserService } from '../shared'
5import { ServerService } from '../../../core' 4import { ServerService } from '../../../core'
6import { UserCreate, UserRole } from '../../../../../../shared' 5import { UserCreate, UserRole } from '../../../../../../shared'
7import { UserEdit } from './user-edit' 6import { UserEdit } from './user-edit'
@@ -9,6 +8,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
9import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 8import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
10import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' 9import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
11import { ConfigService } from '@app/+admin/config/shared/config.service' 10import { ConfigService } from '@app/+admin/config/shared/config.service'
11import { UserService } from '@app/shared'
12 12
13@Component({ 13@Component({
14 selector: 'my-user-create', 14 selector: 'my-user-create',
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 9eb91ac95..cd3885a99 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
@@ -2,7 +2,6 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { Subscription } from 'rxjs' 3import { Subscription } from 'rxjs'
4import { NotificationsService } from 'angular2-notifications' 4import { NotificationsService } from 'angular2-notifications'
5import { UserService } from '../shared'
6import { ServerService } from '../../../core' 5import { ServerService } from '../../../core'
7import { UserEdit } from './user-edit' 6import { UserEdit } from './user-edit'
8import { User, UserUpdate } from '../../../../../../shared' 7import { User, UserUpdate } from '../../../../../../shared'
@@ -10,6 +9,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
10import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 9import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
11import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' 10import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
12import { ConfigService } from '@app/+admin/config/shared/config.service' 11import { ConfigService } from '@app/+admin/config/shared/config.service'
12import { UserService } from '@app/shared'
13 13
14@Component({ 14@Component({
15 selector: 'my-user-update', 15 selector: 'my-user-update',
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 bb1b26442..cca057ba1 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
@@ -40,7 +40,8 @@
40 <td>{{ user.roleLabel }}</td> 40 <td>{{ user.roleLabel }}</td>
41 <td>{{ user.createdAt }}</td> 41 <td>{{ user.createdAt }}</td>
42 <td class="action-cell"> 42 <td class="action-cell">
43 <my-action-dropdown i18n-label label="Actions" [actions]="userActions" [entry]="user"></my-action-dropdown> 43 <my-user-moderation-dropdown [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()">
44 </my-user-moderation-dropdown>
44 </td> 45 </td>
45 </tr> 46 </tr>
46 </ng-template> 47 </ng-template>
@@ -55,4 +56,3 @@
55 </ng-template> 56 </ng-template>
56</p-table> 57</p-table>
57 58
58<my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal> \ No newline at end of file
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 100ffc00e..dee3ed643 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
@@ -1,13 +1,9 @@
1import { Component, OnInit, ViewChild } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { NotificationsService } from 'angular2-notifications' 2import { NotificationsService } from 'angular2-notifications'
3import { SortMeta } from 'primeng/components/common/sortmeta' 3import { SortMeta } from 'primeng/components/common/sortmeta'
4import { ConfirmService } from '../../../core' 4import { ConfirmService } from '../../../core'
5import { RestPagination, RestTable } from '../../../shared' 5import { RestPagination, RestTable, UserService } from '../../../shared'
6import { UserService } from '../shared'
7import { I18n } from '@ngx-translate/i18n-polyfill' 6import { I18n } from '@ngx-translate/i18n-polyfill'
8import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
9import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
10import { UserBanModalComponent } from '@app/+admin/users/user-list/user-ban-modal.component'
11import { User } from '../../../../../../shared' 7import { User } from '../../../../../../shared'
12 8
13@Component({ 9@Component({
@@ -16,16 +12,11 @@ import { User } from '../../../../../../shared'
16 styleUrls: [ './user-list.component.scss' ] 12 styleUrls: [ './user-list.component.scss' ]
17}) 13})
18export class UserListComponent extends RestTable implements OnInit { 14export class UserListComponent extends RestTable implements OnInit {
19 @ViewChild('userBanModal') userBanModal: UserBanModalComponent
20
21 users: User[] = [] 15 users: User[] = []
22 totalRecords = 0 16 totalRecords = 0
23 rowsPerPage = 10 17 rowsPerPage = 10
24 sort: SortMeta = { field: 'createdAt', order: 1 } 18 sort: SortMeta = { field: 'createdAt', order: 1 }
25 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 19 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
26 userActions: DropdownAction<User>[] = []
27
28 private openedModal: NgbModalRef
29 20
30 constructor ( 21 constructor (
31 private notificationsService: NotificationsService, 22 private notificationsService: NotificationsService,
@@ -34,96 +25,16 @@ export class UserListComponent extends RestTable implements OnInit {
34 private i18n: I18n 25 private i18n: I18n
35 ) { 26 ) {
36 super() 27 super()
37
38 this.userActions = [
39 {
40 label: this.i18n('Edit'),
41 linkBuilder: this.getRouterUserEditLink
42 },
43 {
44 label: this.i18n('Delete'),
45 handler: user => this.removeUser(user)
46 },
47 {
48 label: this.i18n('Ban'),
49 handler: user => this.openBanUserModal(user),
50 isDisplayed: user => !user.blocked
51 },
52 {
53 label: this.i18n('Unban'),
54 handler: user => this.unbanUser(user),
55 isDisplayed: user => user.blocked
56 }
57 ]
58 } 28 }
59 29
60 ngOnInit () { 30 ngOnInit () {
61 this.loadSort() 31 this.loadSort()
62 } 32 }
63 33
64 hideBanUserModal () { 34 onUserChanged () {
65 this.openedModal.close()
66 }
67
68 openBanUserModal (user: User) {
69 if (user.username === 'root') {
70 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.'))
71 return
72 }
73
74 this.userBanModal.openModal(user)
75 }
76
77 onUserBanned () {
78 this.loadData() 35 this.loadData()
79 } 36 }
80 37
81 async unbanUser (user: User) {
82 const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username })
83 const res = await this.confirmService.confirm(message, this.i18n('Unban'))
84 if (res === false) return
85
86 this.userService.unbanUser(user)
87 .subscribe(
88 () => {
89 this.notificationsService.success(
90 this.i18n('Success'),
91 this.i18n('User {{username}} unbanned.', { username: user.username })
92 )
93 this.loadData()
94 },
95
96 err => this.notificationsService.error(this.i18n('Error'), err.message)
97 )
98 }
99
100 async removeUser (user: User) {
101 if (user.username === 'root') {
102 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.'))
103 return
104 }
105
106 const message = this.i18n('If you remove this user, you will not be able to create another with the same username!')
107 const res = await this.confirmService.confirm(message, this.i18n('Delete'))
108 if (res === false) return
109
110 this.userService.removeUser(user).subscribe(
111 () => {
112 this.notificationsService.success(
113 this.i18n('Success'),
114 this.i18n('User {{username}} deleted.', { username: user.username })
115 )
116 this.loadData()
117 },
118
119 err => this.notificationsService.error(this.i18n('Error'), err.message)
120 )
121 }
122
123 getRouterUserEditLink (user: User) {
124 return [ '/admin', 'users', 'update', user.id ]
125 }
126
127 protected loadData () { 38 protected loadData () {
128 this.userService.getUsers(this.pagination, this.sort) 39 this.userService.getUsers(this.pagination, this.sort)
129 .subscribe( 40 .subscribe(
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts
index 7cd0fff1b..dc4d0bf6a 100644
--- a/client/src/app/app.component.ts
+++ b/client/src/app/app.component.ts
@@ -4,9 +4,10 @@ import { GuardsCheckStart, NavigationEnd, Router } from '@angular/router'
4import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core' 4import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core'
5import { is18nPath } from '../../../shared/models/i18n' 5import { is18nPath } from '../../../shared/models/i18n'
6import { ScreenService } from '@app/shared/misc/screen.service' 6import { ScreenService } from '@app/shared/misc/screen.service'
7import { skip } from 'rxjs/operators' 7import { skip, debounceTime } from 'rxjs/operators'
8import { HotkeysService, Hotkey } from 'angular2-hotkeys' 8import { HotkeysService, Hotkey } from 'angular2-hotkeys'
9import { I18n } from '@ngx-translate/i18n-polyfill' 9import { I18n } from '@ngx-translate/i18n-polyfill'
10import { fromEvent } from 'rxjs'
10 11
11@Component({ 12@Component({
12 selector: 'my-app', 13 selector: 'my-app',
@@ -28,6 +29,7 @@ export class AppComponent implements OnInit {
28 } 29 }
29 30
30 isMenuDisplayed = true 31 isMenuDisplayed = true
32 isMenuChangedByUser = false
31 33
32 customCSS: SafeHtml 34 customCSS: SafeHtml
33 35
@@ -165,6 +167,10 @@ export class AppComponent implements OnInit {
165 return false 167 return false
166 }, undefined, this.i18n('Toggle Dark theme')) 168 }, undefined, this.i18n('Toggle Dark theme'))
167 ]) 169 ])
170
171 fromEvent(window, 'resize')
172 .pipe(debounceTime(200))
173 .subscribe(() => this.onResize())
168 } 174 }
169 175
170 isUserLoggedIn () { 176 isUserLoggedIn () {
@@ -173,5 +179,10 @@ export class AppComponent implements OnInit {
173 179
174 toggleMenu () { 180 toggleMenu () {
175 this.isMenuDisplayed = !this.isMenuDisplayed 181 this.isMenuDisplayed = !this.isMenuDisplayed
182 this.isMenuChangedByUser = true
183 }
184
185 onResize () {
186 this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
176 } 187 }
177} 188}
diff --git a/client/src/app/shared/account/account.model.ts b/client/src/app/shared/account/account.model.ts
index 5058e372f..42f2cfeaf 100644
--- a/client/src/app/shared/account/account.model.ts
+++ b/client/src/app/shared/account/account.model.ts
@@ -6,11 +6,14 @@ export class Account extends Actor implements ServerAccount {
6 description: string 6 description: string
7 nameWithHost: string 7 nameWithHost: string
8 8
9 userId?: number
10
9 constructor (hash: ServerAccount) { 11 constructor (hash: ServerAccount) {
10 super(hash) 12 super(hash)
11 13
12 this.displayName = hash.displayName 14 this.displayName = hash.displayName
13 this.description = hash.description 15 this.description = hash.description
16 this.userId = hash.userId
14 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) 17 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
15 } 18 }
16} 19}
diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html
index 8b7241379..8110e2515 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.html
+++ b/client/src/app/shared/buttons/action-dropdown.component.html
@@ -1,5 +1,5 @@
1<div class="dropdown-root" ngbDropdown [placement]="placement"> 1<div class="dropdown-root" ngbDropdown [placement]="placement">
2 <div class="action-button" ngbDropdownToggle role="button"> 2 <div class="action-button" [ngClass]="{ small: buttonSize === 'small' }" ngbDropdownToggle role="button">
3 <span class="icon icon-action"></span> 3 <span class="icon icon-action"></span>
4 </div> 4 </div>
5 5
diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss
index 615511093..00f120fb8 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.scss
+++ b/client/src/app/shared/buttons/action-dropdown.component.scss
@@ -22,6 +22,12 @@
22 background-image: url('../../../assets/images/video/more.svg'); 22 background-image: url('../../../assets/images/video/more.svg');
23 top: -1px; 23 top: -1px;
24 } 24 }
25
26 &.small {
27 font-size: 14px;
28 height: 20px;
29 line-height: 20px;
30 }
25} 31}
26 32
27.dropdown-menu { 33.dropdown-menu {
diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts
index 17f9cc618..1838ff697 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.ts
+++ b/client/src/app/shared/buttons/action-dropdown.component.ts
@@ -17,4 +17,5 @@ export class ActionDropdownComponent<T> {
17 @Input() actions: DropdownAction<T>[] = [] 17 @Input() actions: DropdownAction<T>[] = []
18 @Input() entry: T 18 @Input() entry: T
19 @Input() placement = 'left' 19 @Input() placement = 'left'
20 @Input() buttonSize: 'normal' | 'small' = 'normal'
20} 21}
diff --git a/client/src/app/shared/moderation/index.ts b/client/src/app/shared/moderation/index.ts
new file mode 100644
index 000000000..9a77c64c0
--- /dev/null
+++ b/client/src/app/shared/moderation/index.ts
@@ -0,0 +1,2 @@
1export * from './user-ban-modal.component'
2export * from './user-moderation-dropdown.component'
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.html b/client/src/app/shared/moderation/user-ban-modal.component.html
index b2958caa4..b2958caa4 100644
--- a/client/src/app/+admin/users/user-list/user-ban-modal.component.html
+++ b/client/src/app/shared/moderation/user-ban-modal.component.html
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.scss b/client/src/app/shared/moderation/user-ban-modal.component.scss
index 84562f15c..84562f15c 100644
--- a/client/src/app/+admin/users/user-list/user-ban-modal.component.scss
+++ b/client/src/app/shared/moderation/user-ban-modal.component.scss
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.ts b/client/src/app/shared/moderation/user-ban-modal.component.ts
index 4fd4d561c..67ae38e48 100644
--- a/client/src/app/+admin/users/user-list/user-ban-modal.component.ts
+++ b/client/src/app/shared/moderation/user-ban-modal.component.ts
@@ -1,12 +1,12 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { NotificationsService } from 'angular2-notifications' 2import { NotificationsService } from 'angular2-notifications'
3import { FormReactive, UserValidatorsService } from '../../../shared'
4import { UserService } from '../shared'
5import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 4import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
8import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
9import { User } from '../../../../../../shared' 7import { FormReactive, UserValidatorsService } from '@app/shared/forms'
8import { UserService } from '@app/shared/users'
9import { User } from '../../../../../shared'
10 10
11@Component({ 11@Component({
12 selector: 'my-user-ban-modal', 12 selector: 'my-user-ban-modal',
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.html b/client/src/app/shared/moderation/user-moderation-dropdown.component.html
new file mode 100644
index 000000000..ed1a4c863
--- /dev/null
+++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.html
@@ -0,0 +1,5 @@
1<ng-container *ngIf="user && userActions.length !== 0">
2 <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal>
3
4 <my-action-dropdown i18n-label label="Actions" [actions]="userActions" [entry]="user" [buttonSize]="buttonSize"></my-action-dropdown>
5</ng-container> \ No newline at end of file
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.scss b/client/src/app/shared/moderation/user-moderation-dropdown.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.scss
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
new file mode 100644
index 000000000..4f88456de
--- /dev/null
+++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
@@ -0,0 +1,135 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { NotificationsService } from 'angular2-notifications'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
6import { UserBanModalComponent } from '@app/shared/moderation/user-ban-modal.component'
7import { UserService } from '@app/shared/users'
8import { AuthService, ConfirmService } from '@app/core'
9import { User, UserRight } from '../../../../../shared/models/users'
10
11@Component({
12 selector: 'my-user-moderation-dropdown',
13 templateUrl: './user-moderation-dropdown.component.html',
14 styleUrls: [ './user-moderation-dropdown.component.scss' ]
15})
16export class UserModerationDropdownComponent implements OnInit {
17 @ViewChild('userBanModal') userBanModal: UserBanModalComponent
18
19 @Input() user: User
20 @Input() buttonSize: 'normal' | 'small' = 'normal'
21
22 @Output() userChanged = new EventEmitter()
23 @Output() userDeleted = new EventEmitter()
24
25 userActions: DropdownAction<User>[] = []
26
27 private openedModal: NgbModalRef
28
29 constructor (
30 private authService: AuthService,
31 private notificationsService: NotificationsService,
32 private confirmService: ConfirmService,
33 private userService: UserService,
34 private i18n: I18n
35 ) { }
36
37 ngOnInit () {
38 this.buildActions()
39 }
40
41 hideBanUserModal () {
42 this.openedModal.close()
43 }
44
45 openBanUserModal (user: User) {
46 if (user.username === 'root') {
47 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.'))
48 return
49 }
50
51 this.userBanModal.openModal(user)
52 }
53
54 onUserBanned () {
55 this.userChanged.emit()
56 }
57
58 async unbanUser (user: User) {
59 const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username })
60 const res = await this.confirmService.confirm(message, this.i18n('Unban'))
61 if (res === false) return
62
63 this.userService.unbanUser(user)
64 .subscribe(
65 () => {
66 this.notificationsService.success(
67 this.i18n('Success'),
68 this.i18n('User {{username}} unbanned.', { username: user.username })
69 )
70
71 this.userChanged.emit()
72 },
73
74 err => this.notificationsService.error(this.i18n('Error'), err.message)
75 )
76 }
77
78 async removeUser (user: User) {
79 if (user.username === 'root') {
80 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.'))
81 return
82 }
83
84 const message = this.i18n('If you remove this user, you will not be able to create another with the same username!')
85 const res = await this.confirmService.confirm(message, this.i18n('Delete'))
86 if (res === false) return
87
88 this.userService.removeUser(user).subscribe(
89 () => {
90 this.notificationsService.success(
91 this.i18n('Success'),
92 this.i18n('User {{username}} deleted.', { username: user.username })
93 )
94 this.userDeleted.emit()
95 },
96
97 err => this.notificationsService.error(this.i18n('Error'), err.message)
98 )
99 }
100
101 getRouterUserEditLink (user: User) {
102 return [ '/admin', 'users', 'update', user.id ]
103 }
104
105 private buildActions () {
106 this.userActions = []
107
108 if (this.authService.isLoggedIn()) {
109 const authUser = this.authService.getUser()
110
111 if (authUser.hasRight(UserRight.MANAGE_USERS)) {
112 this.userActions = this.userActions.concat([
113 {
114 label: this.i18n('Edit'),
115 linkBuilder: this.getRouterUserEditLink
116 },
117 {
118 label: this.i18n('Delete'),
119 handler: user => this.removeUser(user)
120 },
121 {
122 label: this.i18n('Ban'),
123 handler: user => this.openBanUserModal(user),
124 isDisplayed: user => !user.blocked
125 },
126 {
127 label: this.i18n('Unban'),
128 handler: user => this.unbanUser(user),
129 isDisplayed: user => user.blocked
130 }
131 ])
132 }
133 }
134 }
135}
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 076f1d275..9647a7966 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -56,6 +56,8 @@ import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, N
56import { SubscribeButtonComponent, RemoteSubscribeComponent, UserSubscriptionService } from '@app/shared/user-subscription' 56import { SubscribeButtonComponent, RemoteSubscribeComponent, UserSubscriptionService } from '@app/shared/user-subscription'
57import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component' 57import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component'
58import { OverviewService } from '@app/shared/overview' 58import { OverviewService } from '@app/shared/overview'
59import { UserBanModalComponent } from '@app/shared/moderation'
60import { UserModerationDropdownComponent } from '@app/shared/moderation/user-moderation-dropdown.component'
59 61
60@NgModule({ 62@NgModule({
61 imports: [ 63 imports: [
@@ -94,7 +96,9 @@ import { OverviewService } from '@app/shared/overview'
94 PeertubeCheckboxComponent, 96 PeertubeCheckboxComponent,
95 SubscribeButtonComponent, 97 SubscribeButtonComponent,
96 RemoteSubscribeComponent, 98 RemoteSubscribeComponent,
97 InstanceFeaturesTableComponent 99 InstanceFeaturesTableComponent,
100 UserBanModalComponent,
101 UserModerationDropdownComponent
98 ], 102 ],
99 103
100 exports: [ 104 exports: [
@@ -130,6 +134,8 @@ import { OverviewService } from '@app/shared/overview'
130 SubscribeButtonComponent, 134 SubscribeButtonComponent,
131 RemoteSubscribeComponent, 135 RemoteSubscribeComponent,
132 InstanceFeaturesTableComponent, 136 InstanceFeaturesTableComponent,
137 UserBanModalComponent,
138 UserModerationDropdownComponent,
133 139
134 NumberFormatterPipe, 140 NumberFormatterPipe,
135 ObjectLengthPipe, 141 ObjectLengthPipe,
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts
index bd5cd45d4..d9b81c181 100644
--- a/client/src/app/shared/users/user.service.ts
+++ b/client/src/app/shared/users/user.service.ts
@@ -2,20 +2,26 @@ import { Observable } from 'rxjs'
2import { catchError, map } from 'rxjs/operators' 2import { catchError, map } from 'rxjs/operators'
3import { HttpClient, HttpParams } from '@angular/common/http' 3import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { UserCreate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' 5import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared'
6import { environment } from '../../../environments/environment' 6import { environment } from '../../../environments/environment'
7import { RestExtractor } from '../rest' 7import { RestExtractor, RestPagination, RestService } from '../rest'
8import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 8import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
9import { SortMeta } from 'primeng/api'
10import { BytesPipe } from 'ngx-pipes'
11import { I18n } from '@ngx-translate/i18n-polyfill'
9 12
10@Injectable() 13@Injectable()
11export class UserService { 14export class UserService {
12 static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/' 15 static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/'
13 16
17 private bytesPipe = new BytesPipe()
18
14 constructor ( 19 constructor (
15 private authHttp: HttpClient, 20 private authHttp: HttpClient,
16 private restExtractor: RestExtractor 21 private restExtractor: RestExtractor,
17 ) { 22 private restService: RestService,
18 } 23 private i18n: I18n
24 ) { }
19 25
20 changePassword (currentPassword: string, newPassword: string) { 26 changePassword (currentPassword: string, newPassword: string) {
21 const url = UserService.BASE_USERS_URL + 'me' 27 const url = UserService.BASE_USERS_URL + 'me'
@@ -128,4 +134,79 @@ export class UserService {
128 .get<string[]>(url, { params }) 134 .get<string[]>(url, { params })
129 .pipe(catchError(res => this.restExtractor.handleError(res))) 135 .pipe(catchError(res => this.restExtractor.handleError(res)))
130 } 136 }
137
138 /* ###### Admin methods ###### */
139
140 addUser (userCreate: UserCreate) {
141 return this.authHttp.post(UserService.BASE_USERS_URL, userCreate)
142 .pipe(
143 map(this.restExtractor.extractDataBool),
144 catchError(err => this.restExtractor.handleError(err))
145 )
146 }
147
148 updateUser (userId: number, userUpdate: UserUpdate) {
149 return this.authHttp.put(UserService.BASE_USERS_URL + userId, userUpdate)
150 .pipe(
151 map(this.restExtractor.extractDataBool),
152 catchError(err => this.restExtractor.handleError(err))
153 )
154 }
155
156 getUser (userId: number) {
157 return this.authHttp.get<User>(UserService.BASE_USERS_URL + userId)
158 .pipe(catchError(err => this.restExtractor.handleError(err)))
159 }
160
161 getUsers (pagination: RestPagination, sort: SortMeta): Observable<ResultList<User>> {
162 let params = new HttpParams()
163 params = this.restService.addRestGetParams(params, pagination, sort)
164
165 return this.authHttp.get<ResultList<User>>(UserService.BASE_USERS_URL, { params })
166 .pipe(
167 map(res => this.restExtractor.convertResultListDateToHuman(res)),
168 map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))),
169 catchError(err => this.restExtractor.handleError(err))
170 )
171 }
172
173 removeUser (user: { id: number }) {
174 return this.authHttp.delete(UserService.BASE_USERS_URL + user.id)
175 .pipe(catchError(err => this.restExtractor.handleError(err)))
176 }
177
178 banUser (user: { id: number }, reason?: string) {
179 const body = reason ? { reason } : {}
180
181 return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/block', body)
182 .pipe(catchError(err => this.restExtractor.handleError(err)))
183 }
184
185 unbanUser (user: { id: number }) {
186 return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/unblock', {})
187 .pipe(catchError(err => this.restExtractor.handleError(err)))
188 }
189
190 private formatUser (user: User) {
191 let videoQuota
192 if (user.videoQuota === -1) {
193 videoQuota = this.i18n('Unlimited')
194 } else {
195 videoQuota = this.bytesPipe.transform(user.videoQuota, 0)
196 }
197
198 const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0)
199
200 const roleLabels: { [ id in UserRole ]: string } = {
201 [UserRole.USER]: this.i18n('User'),
202 [UserRole.ADMINISTRATOR]: this.i18n('Administrator'),
203 [UserRole.MODERATOR]: this.i18n('Moderator')
204 }
205
206 return Object.assign(user, {
207 roleLabel: roleLabels[user.role],
208 videoQuota,
209 videoQuotaUsed
210 })
211 }
131} 212}
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index 6a758ebe0..763791165 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -83,7 +83,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
83 83
84 pageByVideoId (index: number, page: Video[]) { 84 pageByVideoId (index: number, page: Video[]) {
85 // Video are unique in all pages 85 // Video are unique in all pages
86 return page[0].id 86 return page.length !== 0 ? page[0].id : 0
87 } 87 }
88 88
89 videoById (index: number, video: Video) { 89 videoById (index: number, video: Video) {
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html
index c1d45ea18..d25666916 100644
--- a/client/src/app/shared/video/video-thumbnail.component.html
+++ b/client/src/app/shared/video/video-thumbnail.component.html
@@ -2,9 +2,11 @@
2 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" 2 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name"
3 class="video-thumbnail" 3 class="video-thumbnail"
4> 4>
5<img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> 5 <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
6 6
7<div class="video-thumbnail-overlay"> 7 <div class="video-thumbnail-overlay">{{ video.durationLabel }}</div>
8 {{ video.durationLabel }} 8
9</div> 9 <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
10 <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
11 </div>
10</a> 12</a>
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss
index 1dd8e5338..4772edaf0 100644
--- a/client/src/app/shared/video/video-thumbnail.component.scss
+++ b/client/src/app/shared/video/video-thumbnail.component.scss
@@ -29,6 +29,19 @@
29 } 29 }
30 } 30 }
31 31
32 .progress-bar {
33 height: 3px;
34 width: 100%;
35 position: relative;
36 top: -3px;
37 background-color: rgba(0, 0, 0, 0.20);
38
39 div {
40 height: 100%;
41 background-color: var(--mainColor);
42 }
43 }
44
32 .video-thumbnail-overlay { 45 .video-thumbnail-overlay {
33 position: absolute; 46 position: absolute;
34 right: 5px; 47 right: 5px;
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts
index 86d8f6f74..ca43700c7 100644
--- a/client/src/app/shared/video/video-thumbnail.component.ts
+++ b/client/src/app/shared/video/video-thumbnail.component.ts
@@ -22,4 +22,12 @@ export class VideoThumbnailComponent {
22 22
23 return this.video.thumbnailUrl 23 return this.video.thumbnailUrl
24 } 24 }
25
26 getProgressPercent () {
27 if (!this.video.userHistory) return 0
28
29 const currentTime = this.video.userHistory.currentTime
30
31 return (currentTime / this.video.duration) * 100
32 }
25} 33}
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index 80794faa6..b92c96450 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -66,6 +66,10 @@ export class Video implements VideoServerModel {
66 avatar: Avatar 66 avatar: Avatar
67 } 67 }
68 68
69 userHistory?: {
70 currentTime: number
71 }
72
69 static buildClientUrl (videoUUID: string) { 73 static buildClientUrl (videoUUID: string) {
70 return '/videos/watch/' + videoUUID 74 return '/videos/watch/' + videoUUID
71 } 75 }
@@ -116,6 +120,8 @@ export class Video implements VideoServerModel {
116 120
117 this.blacklisted = hash.blacklisted 121 this.blacklisted = hash.blacklisted
118 this.blacklistedReason = hash.blacklistedReason 122 this.blacklistedReason = hash.blacklistedReason
123
124 this.userHistory = hash.userHistory
119 } 125 }
120 126
121 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { 127 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 2255a18a2..724a0bde9 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -58,6 +58,10 @@ export class VideoService implements VideosProvider {
58 return VideoService.BASE_VIDEO_URL + uuid + '/views' 58 return VideoService.BASE_VIDEO_URL + uuid + '/views'
59 } 59 }
60 60
61 getUserWatchingVideoUrl (uuid: string) {
62 return VideoService.BASE_VIDEO_URL + uuid + '/watching'
63 }
64
61 getVideo (uuid: string): Observable<VideoDetails> { 65 getVideo (uuid: string): Observable<VideoDetails> {
62 return this.serverService.localeObservable 66 return this.serverService.localeObservable
63 .pipe( 67 .pipe(
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index ea10b22ad..c5deddf05 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -369,7 +369,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
369 ) 369 )
370 } 370 }
371 371
372 private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTime = 0) { 372 private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTimeFromUrl: number) {
373 this.video = video 373 this.video = video
374 374
375 // Re init attributes 375 // Re init attributes
@@ -377,6 +377,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
377 this.completeDescriptionShown = false 377 this.completeDescriptionShown = false
378 this.remoteServerDown = false 378 this.remoteServerDown = false
379 379
380 let startTime = startTimeFromUrl || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
381 // Don't start the video if we are at the end
382 if (this.video.duration - startTime <= 1) startTime = 0
383
380 if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { 384 if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) {
381 const res = await this.confirmService.confirm( 385 const res = await this.confirmService.confirm(
382 this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), 386 this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
@@ -414,7 +418,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
414 poster: this.video.previewUrl, 418 poster: this.video.previewUrl,
415 startTime, 419 startTime,
416 theaterMode: true, 420 theaterMode: true,
417 language: this.localeId 421 language: this.localeId,
422
423 userWatching: this.user ? {
424 url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
425 authorizationHeader: this.authService.getRequestHeaderValue()
426 } : undefined
418 }) 427 })
419 428
420 if (this.videojsLocaleLoaded === false) { 429 if (this.videojsLocaleLoaded === false) {
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts
index 1bf6c9267..792662b6c 100644
--- a/client/src/assets/player/peertube-player.ts
+++ b/client/src/assets/player/peertube-player.ts
@@ -10,7 +10,7 @@ import './webtorrent-info-button'
10import './peertube-videojs-plugin' 10import './peertube-videojs-plugin'
11import './peertube-load-progress-bar' 11import './peertube-load-progress-bar'
12import './theater-button' 12import './theater-button'
13import { VideoJSCaption, videojsUntyped } from './peertube-videojs-typings' 13import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
14import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' 14import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
15import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' 15import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
16 16
@@ -34,10 +34,13 @@ function getVideojsOptions (options: {
34 startTime: number | string 34 startTime: number | string
35 theaterMode: boolean, 35 theaterMode: boolean,
36 videoCaptions: VideoJSCaption[], 36 videoCaptions: VideoJSCaption[],
37
37 language?: string, 38 language?: string,
38 controls?: boolean, 39 controls?: boolean,
39 muted?: boolean, 40 muted?: boolean,
40 loop?: boolean 41 loop?: boolean
42
43 userWatching?: UserWatching
41}) { 44}) {
42 const videojsOptions = { 45 const videojsOptions = {
43 // We don't use text track settings for now 46 // We don't use text track settings for now
@@ -57,7 +60,8 @@ function getVideojsOptions (options: {
57 playerElement: options.playerElement, 60 playerElement: options.playerElement,
58 videoViewUrl: options.videoViewUrl, 61 videoViewUrl: options.videoViewUrl,
59 videoDuration: options.videoDuration, 62 videoDuration: options.videoDuration,
60 startTime: options.startTime 63 startTime: options.startTime,
64 userWatching: options.userWatching
61 } 65 }
62 }, 66 },
63 controlBar: { 67 controlBar: {
diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts
index adc376e94..2330f476f 100644
--- a/client/src/assets/player/peertube-videojs-plugin.ts
+++ b/client/src/assets/player/peertube-videojs-plugin.ts
@@ -3,7 +3,7 @@ import * as WebTorrent from 'webtorrent'
3import { VideoFile } from '../../../../shared/models/videos/video.model' 3import { VideoFile } from '../../../../shared/models/videos/video.model'
4import { renderVideo } from './video-renderer' 4import { renderVideo } from './video-renderer'
5import './settings-menu-button' 5import './settings-menu-button'
6import { PeertubePluginOptions, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' 6import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
7import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils' 7import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils'
8import * as CacheChunkStore from 'cache-chunk-store' 8import * as CacheChunkStore from 'cache-chunk-store'
9import { PeertubeChunkStore } from './peertube-chunk-store' 9import { PeertubeChunkStore } from './peertube-chunk-store'
@@ -32,7 +32,8 @@ class PeerTubePlugin extends Plugin {
32 AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it 32 AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
33 AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check 33 AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
34 AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds 34 AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
35 BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth 35 BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5, // Last 5 seconds to build average bandwidth
36 USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
36 } 37 }
37 38
38 private readonly webtorrent = new WebTorrent({ 39 private readonly webtorrent = new WebTorrent({
@@ -67,6 +68,7 @@ class PeerTubePlugin extends Plugin {
67 private videoViewInterval 68 private videoViewInterval
68 private torrentInfoInterval 69 private torrentInfoInterval
69 private autoQualityInterval 70 private autoQualityInterval
71 private userWatchingVideoInterval
70 private addTorrentDelay 72 private addTorrentDelay
71 private qualityObservationTimer 73 private qualityObservationTimer
72 private runAutoQualitySchedulerTimer 74 private runAutoQualitySchedulerTimer
@@ -100,6 +102,8 @@ class PeerTubePlugin extends Plugin {
100 this.runTorrentInfoScheduler() 102 this.runTorrentInfoScheduler()
101 this.runViewAdd() 103 this.runViewAdd()
102 104
105 if (options.userWatching) this.runUserWatchVideo(options.userWatching)
106
103 this.player.one('play', () => { 107 this.player.one('play', () => {
104 // Don't run immediately scheduler, wait some seconds the TCP connections are made 108 // Don't run immediately scheduler, wait some seconds the TCP connections are made
105 this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) 109 this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
@@ -121,6 +125,8 @@ class PeerTubePlugin extends Plugin {
121 clearInterval(this.torrentInfoInterval) 125 clearInterval(this.torrentInfoInterval)
122 clearInterval(this.autoQualityInterval) 126 clearInterval(this.autoQualityInterval)
123 127
128 if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
129
124 // Don't need to destroy renderer, video player will be destroyed 130 // Don't need to destroy renderer, video player will be destroyed
125 this.flushVideoFile(this.currentVideoFile, false) 131 this.flushVideoFile(this.currentVideoFile, false)
126 132
@@ -524,6 +530,21 @@ class PeerTubePlugin extends Plugin {
524 }, 1000) 530 }, 1000)
525 } 531 }
526 532
533 private runUserWatchVideo (options: UserWatching) {
534 let lastCurrentTime = 0
535
536 this.userWatchingVideoInterval = setInterval(() => {
537 const currentTime = Math.floor(this.player.currentTime())
538
539 if (currentTime - lastCurrentTime >= 1) {
540 lastCurrentTime = currentTime
541
542 this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
543 .catch(err => console.error('Cannot notify user is watching.', err))
544 }
545 }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
546 }
547
527 private clearVideoViewInterval () { 548 private clearVideoViewInterval () {
528 if (this.videoViewInterval !== undefined) { 549 if (this.videoViewInterval !== undefined) {
529 clearInterval(this.videoViewInterval) 550 clearInterval(this.videoViewInterval)
@@ -537,6 +558,15 @@ class PeerTubePlugin extends Plugin {
537 return fetch(this.videoViewUrl, { method: 'POST' }) 558 return fetch(this.videoViewUrl, { method: 'POST' })
538 } 559 }
539 560
561 private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) {
562 const body = new URLSearchParams()
563 body.append('currentTime', currentTime.toString())
564
565 const headers = new Headers({ 'Authorization': authorizationHeader })
566
567 return fetch(url, { method: 'PUT', body, headers })
568 }
569
540 private fallbackToHttp (done?: Function, play = true) { 570 private fallbackToHttp (done?: Function, play = true) {
541 this.disableAutoResolution(true) 571 this.disableAutoResolution(true)
542 572
diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts
index 993d5ee6b..b117007af 100644
--- a/client/src/assets/player/peertube-videojs-typings.ts
+++ b/client/src/assets/player/peertube-videojs-typings.ts
@@ -22,6 +22,11 @@ type VideoJSCaption = {
22 src: string 22 src: string
23} 23}
24 24
25type UserWatching = {
26 url: string,
27 authorizationHeader: string
28}
29
25type PeertubePluginOptions = { 30type PeertubePluginOptions = {
26 videoFiles: VideoFile[] 31 videoFiles: VideoFile[]
27 playerElement: HTMLVideoElement 32 playerElement: HTMLVideoElement
@@ -30,6 +35,8 @@ type PeertubePluginOptions = {
30 startTime: number | string 35 startTime: number | string
31 autoplay: boolean, 36 autoplay: boolean,
32 videoCaptions: VideoJSCaption[] 37 videoCaptions: VideoJSCaption[]
38
39 userWatching?: UserWatching
33} 40}
34 41
35// videojs typings don't have some method we need 42// videojs typings don't have some method we need
@@ -39,5 +46,6 @@ export {
39 VideoJSComponentInterface, 46 VideoJSComponentInterface,
40 PeertubePluginOptions, 47 PeertubePluginOptions,
41 videojsUntyped, 48 videojsUntyped,
42 VideoJSCaption 49 VideoJSCaption,
50 UserWatching
43} 51}