From 00004f7f6b966a975498612117212b5373f4103c Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 27 Oct 2021 09:36:37 +0200 Subject: Put admin users in overview tab --- client/src/app/+admin/overview/index.ts | 2 + client/src/app/+admin/overview/overview.routes.ts | 6 + client/src/app/+admin/overview/users/index.ts | 3 + .../app/+admin/overview/users/user-edit/index.ts | 3 + .../users/user-edit/user-create.component.ts | 98 ++++++++ .../users/user-edit/user-edit.component.html | 237 ++++++++++++++++++++ .../users/user-edit/user-edit.component.scss | 76 +++++++ .../+admin/overview/users/user-edit/user-edit.ts | 102 +++++++++ .../users/user-edit/user-password.component.html | 21 ++ .../users/user-edit/user-password.component.scss | 23 ++ .../users/user-edit/user-password.component.ts | 55 +++++ .../users/user-edit/user-update.component.ts | 139 ++++++++++++ .../app/+admin/overview/users/user-list/index.ts | 1 + .../users/user-list/user-list.component.html | 168 ++++++++++++++ .../users/user-list/user-list.component.scss | 65 ++++++ .../users/user-list/user-list.component.ts | 246 +++++++++++++++++++++ .../src/app/+admin/overview/users/users.routes.ts | 49 ++++ 17 files changed, 1294 insertions(+) create mode 100644 client/src/app/+admin/overview/index.ts create mode 100644 client/src/app/+admin/overview/overview.routes.ts create mode 100644 client/src/app/+admin/overview/users/index.ts create mode 100644 client/src/app/+admin/overview/users/user-edit/index.ts create mode 100644 client/src/app/+admin/overview/users/user-edit/user-create.component.ts create mode 100644 client/src/app/+admin/overview/users/user-edit/user-edit.component.html create mode 100644 client/src/app/+admin/overview/users/user-edit/user-edit.component.scss create mode 100644 client/src/app/+admin/overview/users/user-edit/user-edit.ts create mode 100644 client/src/app/+admin/overview/users/user-edit/user-password.component.html create mode 100644 client/src/app/+admin/overview/users/user-edit/user-password.component.scss create mode 100644 client/src/app/+admin/overview/users/user-edit/user-password.component.ts create mode 100644 client/src/app/+admin/overview/users/user-edit/user-update.component.ts create mode 100644 client/src/app/+admin/overview/users/user-list/index.ts create mode 100644 client/src/app/+admin/overview/users/user-list/user-list.component.html create mode 100644 client/src/app/+admin/overview/users/user-list/user-list.component.scss create mode 100644 client/src/app/+admin/overview/users/user-list/user-list.component.ts create mode 100644 client/src/app/+admin/overview/users/users.routes.ts (limited to 'client/src/app/+admin/overview') diff --git a/client/src/app/+admin/overview/index.ts b/client/src/app/+admin/overview/index.ts new file mode 100644 index 000000000..b71a6a45f --- /dev/null +++ b/client/src/app/+admin/overview/index.ts @@ -0,0 +1,2 @@ +export * from './users' +export * from './overview.routes' diff --git a/client/src/app/+admin/overview/overview.routes.ts b/client/src/app/+admin/overview/overview.routes.ts new file mode 100644 index 000000000..cb5986072 --- /dev/null +++ b/client/src/app/+admin/overview/overview.routes.ts @@ -0,0 +1,6 @@ +import { Routes } from '@angular/router' +import { UsersRoutes } from './users' + +export const OverviewRoutes: Routes = [ + ...UsersRoutes +] diff --git a/client/src/app/+admin/overview/users/index.ts b/client/src/app/+admin/overview/users/index.ts new file mode 100644 index 000000000..6f15ab578 --- /dev/null +++ b/client/src/app/+admin/overview/users/index.ts @@ -0,0 +1,3 @@ +export * from './user-edit' +export * from './user-list' +export * from './users.routes' diff --git a/client/src/app/+admin/overview/users/user-edit/index.ts b/client/src/app/+admin/overview/users/user-edit/index.ts new file mode 100644 index 000000000..ec734ef92 --- /dev/null +++ b/client/src/app/+admin/overview/users/user-edit/index.ts @@ -0,0 +1,3 @@ +export * from './user-create.component' +export * from './user-update.component' +export * from './user-password.component' diff --git a/client/src/app/+admin/overview/users/user-edit/user-create.component.ts b/client/src/app/+admin/overview/users/user-edit/user-create.component.ts new file mode 100644 index 000000000..b61b22fd0 --- /dev/null +++ b/client/src/app/+admin/overview/users/user-edit/user-create.component.ts @@ -0,0 +1,98 @@ +import { Component, OnInit } from '@angular/core' +import { Router } from '@angular/router' +import { ConfigService } from '@app/+admin/config/shared/config.service' +import { AuthService, Notifier, ScreenService, ServerService, UserService } from '@app/core' +import { + USER_CHANNEL_NAME_VALIDATOR, + USER_EMAIL_VALIDATOR, + USER_PASSWORD_OPTIONAL_VALIDATOR, + USER_PASSWORD_VALIDATOR, + USER_ROLE_VALIDATOR, + USER_USERNAME_VALIDATOR, + USER_VIDEO_QUOTA_DAILY_VALIDATOR, + USER_VIDEO_QUOTA_VALIDATOR +} from '@app/shared/form-validators/user-validators' +import { FormValidatorService } from '@app/shared/shared-forms' +import { UserCreate, UserRole } from '@shared/models' +import { UserEdit } from './user-edit' + +@Component({ + selector: 'my-user-create', + templateUrl: './user-edit.component.html', + styleUrls: [ './user-edit.component.scss' ] +}) +export class UserCreateComponent extends UserEdit implements OnInit { + error: string + + constructor ( + protected serverService: ServerService, + protected formValidatorService: FormValidatorService, + protected configService: ConfigService, + protected screenService: ScreenService, + protected auth: AuthService, + private router: Router, + private notifier: Notifier, + private userService: UserService + ) { + super() + + this.buildQuotaOptions() + } + + ngOnInit () { + super.ngOnInit() + + const defaultValues = { + role: UserRole.USER.toString(), + videoQuota: -1, + videoQuotaDaily: -1 + } + + this.buildForm({ + username: USER_USERNAME_VALIDATOR, + channelName: USER_CHANNEL_NAME_VALIDATOR, + email: USER_EMAIL_VALIDATOR, + password: this.isPasswordOptional() ? USER_PASSWORD_OPTIONAL_VALIDATOR : USER_PASSWORD_VALIDATOR, + role: USER_ROLE_VALIDATOR, + videoQuota: USER_VIDEO_QUOTA_VALIDATOR, + videoQuotaDaily: USER_VIDEO_QUOTA_DAILY_VALIDATOR, + byPassAutoBlock: null + }, defaultValues) + } + + formValidated () { + this.error = undefined + + const userCreate: UserCreate = this.form.value + + userCreate.adminFlags = this.buildAdminFlags(this.form.value) + + // A select in HTML is always mapped as a string, we convert it to number + userCreate.videoQuota = parseInt(this.form.value['videoQuota'], 10) + userCreate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10) + + this.userService.addUser(userCreate) + .subscribe({ + next: () => { + this.notifier.success($localize`User ${userCreate.username} created.`) + this.router.navigate([ '/admin/users/list' ]) + }, + + error: err => { + this.error = err.message + } + }) + } + + isCreation () { + return true + } + + isPasswordOptional () { + return this.serverConfig.email.enabled + } + + getFormButtonTitle () { + return $localize`Create user` + } +} diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.component.html b/client/src/app/+admin/overview/users/user-edit/user-edit.component.html new file mode 100644 index 000000000..772ebf272 --- /dev/null +++ b/client/src/app/+admin/overview/users/user-edit/user-edit.component.html @@ -0,0 +1,237 @@ + + + + + + +
+
+ +
+ +
+
+ +
{{ error }}
+ +
+
+
+ + +
+ +
+ +
+
+ + +
+ {{ formErrors.username }} +
+
+ +
+ + +
+ {{ formErrors.channelName }} +
+
+ +
+ + +
+ {{ formErrors.email }} +
+
+ +
+ + + + + If you leave the password empty, an email will be sent to the user. + + + + + + +
+ {{ formErrors.password }} +
+
+ +
+ +
+ +
+ +
+ {{ formErrors.role }} +
+
+ +
+ + + + +
+ Transcoding is enabled. The video quota only takes into account original video size.
+ At most, this user could upload ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}. +
+ +
+ {{ formErrors.videoQuota }} +
+
+ +
+ + + + +
+ {{ formErrors.videoQuotaDaily }} +
+
+ +
+ + +
+ +
+
+ +
+ +
+ + +
+ +
+ +
+ +
+
+ + +
+
+
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.component.scss b/client/src/app/+admin/overview/users/user-edit/user-edit.component.scss new file mode 100644 index 000000000..d7932154b --- /dev/null +++ b/client/src/app/+admin/overview/users/user-edit/user-edit.component.scss @@ -0,0 +1,76 @@ +@use 'sass:math'; +@use '_variables' as *; +@use '_mixins' as *; + +$form-base-input-width: 340px; + +label { + font-weight: $font-regular; + font-size: 100%; +} + +.account-title { + @include settings-big-title; + + &.account-title-danger { + color: lighten($color: #c54130, $amount: 10); + } +} + +input:not([type=submit]) { + @include peertube-input-text($form-base-input-width); + display: block; +} + +my-input-toggle-hidden { + @include responsive-width($form-base-input-width); + + display: block; +} + +.peertube-select-container { + @include peertube-select-container($form-base-input-width); +} + +my-select-custom-value { + @include responsive-width($form-base-input-width); + + display: block; +} + +input[type=submit], +button { + @include peertube-button; + @include orange-button; + + margin-top: 10px; +} + +.transcoding-information { + margin-top: 5px; + font-size: 11px; +} + +.danger-zone { + .reset-password-email { + margin-bottom: 30px; + + button { + @include peertube-button; + @include danger-button; + @include disable-outline; + + display: block; + margin-top: 0; + } + } +} + +.breadcrumb { + @include breadcrumb; +} + +.dashboard { + @include dashboard; + max-width: 900px; +} diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.ts b/client/src/app/+admin/overview/users/user-edit/user-edit.ts new file mode 100644 index 000000000..069b62a53 --- /dev/null +++ b/client/src/app/+admin/overview/users/user-edit/user-edit.ts @@ -0,0 +1,102 @@ +import { Directive, OnInit } from '@angular/core' +import { ConfigService } from '@app/+admin/config/shared/config.service' +import { AuthService, ScreenService, ServerService, User } from '@app/core' +import { FormReactive } from '@app/shared/shared-forms' +import { USER_ROLE_LABELS } from '@shared/core-utils/users' +import { HTMLServerConfig, UserAdminFlag, UserRole, VideoResolution } from '@shared/models' +import { SelectOptionsItem } from '../../../../../types/select-options-item.model' + +@Directive() +// eslint-disable-next-line @angular-eslint/directive-class-suffix +export abstract class UserEdit extends FormReactive implements OnInit { + videoQuotaOptions: SelectOptionsItem[] = [] + videoQuotaDailyOptions: SelectOptionsItem[] = [] + username: string + user: User + + roles: { value: string, label: string }[] = [] + + protected serverConfig: HTMLServerConfig + + protected abstract serverService: ServerService + protected abstract configService: ConfigService + protected abstract screenService: ScreenService + protected abstract auth: AuthService + abstract isCreation (): boolean + abstract getFormButtonTitle (): string + + ngOnInit (): void { + this.serverConfig = this.serverService.getHTMLConfig() + + this.buildRoles() + } + + get subscribersCount () { + const forAccount = this.user + ? this.user.account.followersCount + : 0 + const forChannels = this.user + ? this.user.videoChannels.map(c => c.followersCount).reduce((a, b) => a + b, 0) + : 0 + return forAccount + forChannels + } + + getAuthPlugins () { + return this.serverConfig.plugin.registeredIdAndPassAuths.map(p => p.npmName) + .concat(this.serverConfig.plugin.registeredExternalAuths.map(p => p.npmName)) + } + + isInBigView () { + return this.screenService.getWindowInnerWidth() > 1600 + } + + buildRoles () { + const authUser = this.auth.getUser() + + if (authUser.role === UserRole.ADMINISTRATOR) { + this.roles = Object.keys(USER_ROLE_LABELS) + .map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] })) + return + } + + this.roles = [ + { value: UserRole.USER.toString(), label: USER_ROLE_LABELS[UserRole.USER] } + ] + } + + isTranscodingInformationDisplayed () { + const formVideoQuota = parseInt(this.form.value['videoQuota'], 10) + + return this.serverConfig.transcoding.enabledResolutions.length !== 0 && + formVideoQuota > 0 + } + + computeQuotaWithTranscoding () { + const transcodingConfig = this.serverConfig.transcoding + + const resolutions = transcodingConfig.enabledResolutions + const higherResolution = VideoResolution.H_4K + let multiplier = 0 + + for (const resolution of resolutions) { + multiplier += resolution / higherResolution + } + + if (transcodingConfig.hls.enabled) multiplier *= 2 + + return multiplier * parseInt(this.form.value['videoQuota'], 10) + } + + resetPassword () { + return + } + + protected buildAdminFlags (formValue: any) { + return formValue.byPassAutoBlock ? UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST : UserAdminFlag.NONE + } + + protected buildQuotaOptions () { + this.videoQuotaOptions = this.configService.videoQuotaOptions + this.videoQuotaDailyOptions = this.configService.videoQuotaDailyOptions + } +} diff --git a/client/src/app/+admin/overview/users/user-edit/user-password.component.html b/client/src/app/+admin/overview/users/user-edit/user-password.component.html new file mode 100644 index 000000000..1238d1839 --- /dev/null +++ b/client/src/app/+admin/overview/users/user-edit/user-password.component.html @@ -0,0 +1,21 @@ +
+
+ +
+ +
+ +
+
+
+ {{ formErrors.password }} +
+
+ + +
diff --git a/client/src/app/+admin/overview/users/user-edit/user-password.component.scss b/client/src/app/+admin/overview/users/user-edit/user-password.component.scss new file mode 100644 index 000000000..acb680682 --- /dev/null +++ b/client/src/app/+admin/overview/users/user-edit/user-password.component.scss @@ -0,0 +1,23 @@ +@use '_variables' as *; +@use '_mixins' as *; + +input:not([type=submit]):not([type=checkbox]) { + @include peertube-input-text(340px); + + display: block; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 0; +} + +input[type=submit] { + @include peertube-button; + @include danger-button; + @include disable-outline; + + margin-top: 10px; +} + +.input-group-append { + height: 30px; +} diff --git a/client/src/app/+admin/overview/users/user-edit/user-password.component.ts b/client/src/app/+admin/overview/users/user-edit/user-password.component.ts new file mode 100644 index 000000000..42bf20de1 --- /dev/null +++ b/client/src/app/+admin/overview/users/user-edit/user-password.component.ts @@ -0,0 +1,55 @@ +import { Component, Input, OnInit } from '@angular/core' +import { Notifier, UserService } from '@app/core' +import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' +import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' +import { UserUpdate } from '@shared/models' + +@Component({ + selector: 'my-user-password', + templateUrl: './user-password.component.html', + styleUrls: [ './user-password.component.scss' ] +}) +export class UserPasswordComponent extends FormReactive implements OnInit { + error: string + username: string + showPassword = false + + @Input() userId: number + + constructor ( + protected formValidatorService: FormValidatorService, + private notifier: Notifier, + private userService: UserService + ) { + super() + } + + ngOnInit () { + this.buildForm({ + password: USER_PASSWORD_VALIDATOR + }) + } + + formValidated () { + this.error = undefined + + const userUpdate: UserUpdate = this.form.value + + this.userService.updateUser(this.userId, userUpdate) + .subscribe({ + next: () => this.notifier.success($localize`Password changed for user ${this.username}.`), + + error: err => { + this.error = err.message + } + }) + } + + togglePasswordVisibility () { + this.showPassword = !this.showPassword + } + + getFormButtonTitle () { + return $localize`Update user password` + } +} diff --git a/client/src/app/+admin/overview/users/user-edit/user-update.component.ts b/client/src/app/+admin/overview/users/user-edit/user-update.component.ts new file mode 100644 index 000000000..42599a17e --- /dev/null +++ b/client/src/app/+admin/overview/users/user-edit/user-update.component.ts @@ -0,0 +1,139 @@ +import { Subscription } from 'rxjs' +import { Component, OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { ConfigService } from '@app/+admin/config/shared/config.service' +import { AuthService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core' +import { + USER_EMAIL_VALIDATOR, + USER_ROLE_VALIDATOR, + USER_VIDEO_QUOTA_DAILY_VALIDATOR, + USER_VIDEO_QUOTA_VALIDATOR +} from '@app/shared/form-validators/user-validators' +import { FormValidatorService } from '@app/shared/shared-forms' +import { User as UserType, UserAdminFlag, UserRole, UserUpdate } from '@shared/models' +import { UserEdit } from './user-edit' + +@Component({ + selector: 'my-user-update', + templateUrl: './user-edit.component.html', + styleUrls: [ './user-edit.component.scss' ] +}) +export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { + error: string + + private paramsSub: Subscription + + constructor ( + protected formValidatorService: FormValidatorService, + protected serverService: ServerService, + protected configService: ConfigService, + protected screenService: ScreenService, + protected auth: AuthService, + private route: ActivatedRoute, + private router: Router, + private notifier: Notifier, + private userService: UserService + ) { + super() + + this.buildQuotaOptions() + } + + ngOnInit () { + super.ngOnInit() + + const defaultValues = { + role: UserRole.USER.toString(), + videoQuota: '-1', + videoQuotaDaily: '-1' + } + + this.buildForm({ + email: USER_EMAIL_VALIDATOR, + role: USER_ROLE_VALIDATOR, + videoQuota: USER_VIDEO_QUOTA_VALIDATOR, + videoQuotaDaily: USER_VIDEO_QUOTA_DAILY_VALIDATOR, + byPassAutoBlock: null, + pluginAuth: null + }, defaultValues) + + this.paramsSub = this.route.params.subscribe(routeParams => { + const userId = routeParams['id'] + this.userService.getUser(userId, true) + .subscribe({ + next: user => this.onUserFetched(user), + + error: err => { + this.error = err.message + } + }) + }) + } + + ngOnDestroy () { + this.paramsSub.unsubscribe() + } + + formValidated () { + this.error = undefined + + const userUpdate: UserUpdate = this.form.value + userUpdate.adminFlags = this.buildAdminFlags(this.form.value) + + // A select in HTML is always mapped as a string, we convert it to number + userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10) + userUpdate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10) + + if (userUpdate.pluginAuth === 'null') userUpdate.pluginAuth = null + + this.userService.updateUser(this.user.id, userUpdate) + .subscribe({ + next: () => { + this.notifier.success($localize`User ${this.user.username} updated.`) + this.router.navigate([ '/admin/users/list' ]) + }, + + error: err => { + this.error = err.message + } + }) + } + + isCreation () { + return false + } + + isPasswordOptional () { + return false + } + + getFormButtonTitle () { + return $localize`Update user` + } + + resetPassword () { + this.userService.askResetPassword(this.user.email) + .subscribe({ + next: () => { + this.notifier.success($localize`An email asking for password reset has been sent to ${this.user.username}.`) + }, + + error: err => { + this.error = err.message + } + }) + } + + private onUserFetched (userJson: UserType) { + this.user = new User(userJson) + + this.form.patchValue({ + email: userJson.email, + role: userJson.role.toString(), + videoQuota: userJson.videoQuota, + videoQuotaDaily: userJson.videoQuotaDaily, + pluginAuth: userJson.pluginAuth, + byPassAutoBlock: userJson.adminFlags & UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST + }) + } +} diff --git a/client/src/app/+admin/overview/users/user-list/index.ts b/client/src/app/+admin/overview/users/user-list/index.ts new file mode 100644 index 000000000..1826a4abe --- /dev/null +++ b/client/src/app/+admin/overview/users/user-list/index.ts @@ -0,0 +1 @@ +export * from './user-list.component' diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.html b/client/src/app/+admin/overview/users/user-list/user-list.component.html new file mode 100644 index 000000000..a96dbd7f8 --- /dev/null +++ b/client/src/app/+admin/overview/users/user-list/user-list.component.html @@ -0,0 +1,168 @@ +

+ + Users +

+ + + +
+ + +
+ +
+ +
+
+ + + + + + + + +
+ + + +
+ + {{ getColumn('username').label }} + {{ getColumn('email').label }} + {{ getColumn('quota').label }} + {{ getColumn('quotaDaily').label }} + {{ getColumn('role').label }} + {{ getColumn('pluginAuth').label }} + {{ getColumn('createdAt').label }} + {{ getColumn('lastLoginDate').label }} + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ {{ user.account.displayName }} + {{ user.username }} +
+
+
+ + + + + {{ user.email }} + + + + + + ? {{ user.email }} + + + + ✓ {{ user.email }} + + + + + +
+
+
+ {{ user.videoQuotaUsed }} + {{ user.videoQuota }} +
+ + + +
+
+
+ {{ user.videoQuotaUsedDaily }} + {{ user.videoQuotaDaily }} +
+ + + + {{ user.roleLabel }} + {{ user.roleLabel }} + + + + {{ user.pluginAuth }} + + + {{ user.createdAt | date: 'short' }} + + {{ user.lastLoginDate | date: 'short' }} + +
+ + + + + Ban reason: + {{ user.blockedReason }} + + + +
+ + diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.scss b/client/src/app/+admin/overview/users/user-list/user-list.component.scss new file mode 100644 index 000000000..e425306b5 --- /dev/null +++ b/client/src/app/+admin/overview/users/user-list/user-list.component.scss @@ -0,0 +1,65 @@ +@use '_variables' as *; +@use '_mixins' as *; + +.add-button { + @include create-button; +} + +tr.banned > td { + background-color: lighten($color: $red, $amount: 40) !important; +} + +.table-email { + @include disable-default-a-behaviour; + + color: pvar(--mainForegroundColor); +} + +.banned-info { + font-style: italic; +} + +.ban-reason-label { + font-weight: $font-semibold; +} + +.user-table-primary-text .glyphicon { + @include margin-left(0.1rem); + + font-size: 80%; + color: #808080; +} + +p-tableCheckbox { + position: relative; + top: -2.5px; +} + +my-global-icon { + width: 18px; +} + +.chip { + @include chip; +} + +.badge { + @include table-badge; +} + +.progress { + @include progressbar($small: true); + + width: auto; + max-width: 100%; +} + +@media screen and (max-width: $primeng-breakpoint) { + .progress { + width: 100%; + } + + .empty-cell { + padding: 0; + } +} diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.ts b/client/src/app/+admin/overview/users/user-list/user-list.component.ts new file mode 100644 index 000000000..548e6e80f --- /dev/null +++ b/client/src/app/+admin/overview/users/user-list/user-list.component.ts @@ -0,0 +1,246 @@ +import { SortMeta } from 'primeng/api' +import { Component, OnInit, ViewChild } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, ConfirmService, Notifier, RestPagination, RestTable, ServerService, UserService } from '@app/core' +import { AdvancedInputFilter } from '@app/shared/shared-forms' +import { DropdownAction } from '@app/shared/shared-main' +import { UserBanModalComponent } from '@app/shared/shared-moderation' +import { User, UserRole } from '@shared/models' + +type UserForList = User & { + rawVideoQuota: number + rawVideoQuotaUsed: number + rawVideoQuotaDaily: number + rawVideoQuotaUsedDaily: number +} + +@Component({ + selector: 'my-user-list', + templateUrl: './user-list.component.html', + styleUrls: [ './user-list.component.scss' ] +}) +export class UserListComponent extends RestTable implements OnInit { + @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent + + users: User[] = [] + + totalRecords = 0 + sort: SortMeta = { field: 'createdAt', order: 1 } + pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + + highlightBannedUsers = false + + selectedUsers: User[] = [] + bulkUserActions: DropdownAction[][] = [] + columns: { id: string, label: string }[] + + inputFilters: AdvancedInputFilter[] = [ + { + title: $localize`Advanced filters`, + children: [ + { + queryParams: { search: 'banned:true' }, + label: $localize`Banned users` + } + ] + } + ] + + requiresEmailVerification = false + + private _selectedColumns: string[] + + constructor ( + protected route: ActivatedRoute, + protected router: Router, + private notifier: Notifier, + private confirmService: ConfirmService, + private serverService: ServerService, + private auth: AuthService, + private userService: UserService + ) { + super() + } + + get authUser () { + return this.auth.getUser() + } + + get selectedColumns () { + return this._selectedColumns + } + + set selectedColumns (val: string[]) { + this._selectedColumns = val + } + + ngOnInit () { + this.serverService.getConfig() + .subscribe(config => this.requiresEmailVerification = config.signup.requiresEmailVerification) + + this.initialize() + + this.bulkUserActions = [ + [ + { + label: $localize`Delete`, + description: $localize`Videos will be deleted, comments will be tombstoned.`, + handler: users => this.removeUsers(users), + isDisplayed: users => users.every(u => this.authUser.canManage(u)) + }, + { + label: $localize`Ban`, + description: $localize`User won't be able to login anymore, but videos and comments will be kept as is.`, + handler: users => this.openBanUserModal(users), + isDisplayed: users => users.every(u => this.authUser.canManage(u) && u.blocked === false) + }, + { + label: $localize`Unban`, + handler: users => this.unbanUsers(users), + isDisplayed: users => users.every(u => this.authUser.canManage(u) && u.blocked === true) + } + ], + [ + { + label: $localize`Set Email as Verified`, + handler: users => this.setEmailsAsVerified(users), + isDisplayed: users => { + return this.requiresEmailVerification && + users.every(u => this.authUser.canManage(u) && !u.blocked && u.emailVerified === false) + } + } + ] + ] + + this.columns = [ + { id: 'username', label: $localize`Username` }, + { id: 'email', label: $localize`Email` }, + { id: 'quota', label: $localize`Video quota` }, + { id: 'role', label: $localize`Role` }, + { id: 'createdAt', label: $localize`Created` } + ] + + this.selectedColumns = this.columns.map(c => c.id) + + this.columns.push({ id: 'quotaDaily', label: $localize`Daily quota` }) + this.columns.push({ id: 'pluginAuth', label: $localize`Auth plugin` }) + this.columns.push({ id: 'lastLoginDate', label: $localize`Last login` }) + } + + getIdentifier () { + return 'UserListComponent' + } + + getRoleClass (role: UserRole) { + switch (role) { + case UserRole.ADMINISTRATOR: + return 'badge-purple' + case UserRole.MODERATOR: + return 'badge-blue' + default: + return 'badge-yellow' + } + } + + isSelected (id: string) { + return this.selectedColumns.find(c => c === id) + } + + getColumn (id: string) { + return this.columns.find(c => c.id === id) + } + + getUserVideoQuotaPercentage (user: UserForList) { + return user.rawVideoQuotaUsed * 100 / user.rawVideoQuota + } + + getUserVideoQuotaDailyPercentage (user: UserForList) { + return user.rawVideoQuotaUsedDaily * 100 / user.rawVideoQuotaDaily + } + + openBanUserModal (users: User[]) { + for (const user of users) { + if (user.username === 'root') { + this.notifier.error($localize`You cannot ban root.`) + return + } + } + + this.userBanModal.openModal(users) + } + + onUserChanged () { + this.reloadData() + } + + async unbanUsers (users: User[]) { + const res = await this.confirmService.confirm($localize`Do you really want to unban ${users.length} users?`, $localize`Unban`) + if (res === false) return + + this.userService.unbanUsers(users) + .subscribe({ + next: () => { + this.notifier.success($localize`${users.length} users unbanned.`) + this.reloadData() + }, + + error: err => this.notifier.error(err.message) + }) + } + + async removeUsers (users: User[]) { + for (const user of users) { + if (user.username === 'root') { + this.notifier.error($localize`You cannot delete root.`) + return + } + } + + const message = $localize`If you remove these users, you will not be able to create others with the same username!` + const res = await this.confirmService.confirm(message, $localize`Delete`) + if (res === false) return + + this.userService.removeUser(users) + .subscribe({ + next: () => { + this.notifier.success($localize`${users.length} users deleted.`) + this.reloadData() + }, + + error: err => this.notifier.error(err.message) + }) + } + + setEmailsAsVerified (users: User[]) { + this.userService.updateUsers(users, { emailVerified: true }) + .subscribe({ + next: () => { + this.notifier.success($localize`${users.length} users email set as verified.`) + this.reloadData() + }, + + error: err => this.notifier.error(err.message) + }) + } + + isInSelectionMode () { + return this.selectedUsers.length !== 0 + } + + protected reloadData () { + this.selectedUsers = [] + + this.userService.getUsers({ + pagination: this.pagination, + sort: this.sort, + search: this.search + }).subscribe({ + next: resultList => { + this.users = resultList.data + this.totalRecords = resultList.total + }, + + error: err => this.notifier.error(err.message) + }) + } +} diff --git a/client/src/app/+admin/overview/users/users.routes.ts b/client/src/app/+admin/overview/users/users.routes.ts new file mode 100644 index 000000000..8b63f5bc7 --- /dev/null +++ b/client/src/app/+admin/overview/users/users.routes.ts @@ -0,0 +1,49 @@ +import { Routes } from '@angular/router' +import { UserRightGuard } from '@app/core' +import { UserRight } from '@shared/models' +import { UserCreateComponent, UserUpdateComponent } from './user-edit' +import { UserListComponent } from './user-list' + +export const UsersRoutes: Routes = [ + { + path: 'users', + canActivate: [ UserRightGuard ], + data: { + userRight: UserRight.MANAGE_USERS + }, + children: [ + { + path: '', + redirectTo: 'list', + pathMatch: 'full' + }, + { + path: 'list', + component: UserListComponent, + data: { + meta: { + title: $localize`Users list` + } + } + }, + { + path: 'create', + component: UserCreateComponent, + data: { + meta: { + title: $localize`Create a user` + } + } + }, + { + path: 'update/:id', + component: UserUpdateComponent, + data: { + meta: { + title: $localize`Update a user` + } + } + } + ] + } +] -- cgit v1.2.3