Add overview of a user's actions in user-edit (#2558)

pull/2568/head
Rigel Kent 2020-03-27 15:19:03 +01:00 committed by GitHub
parent 56d72521ec
commit 76314386ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 618 additions and 184 deletions

View File

@ -579,7 +579,7 @@
i18n-labelText labelText="Allow additional extensions"
>
<ng-container ngProjectAs="description">
<span i18n>Allow your users to upload .mkv, .mov, .avi and .flv videos.</span>
<span i18n>Allows users to upload .mkv, .mov, .avi and .flv videos.</span>
</ng-container>
</my-peertube-checkbox>
</div>
@ -590,7 +590,7 @@
i18n-labelText labelText="Allow audio files upload"
>
<ng-container ngProjectAs="description">
<span i18n>Allow your users to upload audio files that will be merged with the preview file on upload.</span>
<span i18n>Allows users to upload audio files that will be merged with the preview file on upload.</span>
</ng-container>
</my-peertube-checkbox>
</div>

View File

@ -50,6 +50,7 @@ input[type=submit] {
textarea {
@include peertube-textarea(500px, 150px);
max-width: 100%;
display: block;
&.small {
@ -72,6 +73,10 @@ my-markdown-textarea ::ng-deep {
@media screen and (max-width: 1400px) {
flex-direction: column !important;
}
textarea {
max-width: 100%;
}
}
}

View File

@ -8,6 +8,7 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val
import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
import { ConfigService } from '@app/+admin/config/shared/config.service'
import { UserService } from '@app/shared'
import { ScreenService } from '@app/shared/misc/screen.service'
@Component({
selector: 'my-user-create',
@ -21,6 +22,7 @@ export class UserCreateComponent extends UserEdit implements OnInit {
protected serverService: ServerService,
protected formValidatorService: FormValidatorService,
protected configService: ConfigService,
protected screenService: ScreenService,
protected auth: AuthService,
private userValidatorsService: UserValidatorsService,
private route: ActivatedRoute,

View File

@ -1,112 +1,204 @@
<div i18n class="form-sub-title" *ngIf="isCreation() === true">Create user</div>
<div i18n class="form-sub-title" *ngIf="isCreation() === false">Edit user {{ username }}</div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a routerLink="/admin/users" i18n>Users</a>
</li>
<ng-container *ngIf="isCreation()">
<li class="breadcrumb-item active" i18n>Create</li>
</ng-container>
<ng-container *ngIf="!isCreation()">
<li class="breadcrumb-item active" i18n>Edit</li>
<li class="breadcrumb-item active" aria-current="page">
<a *ngIf="user" [routerLink]="[ '/accounts', user?.username ]">{{ user?.username }}</a>
</li>
</ng-container>
</ol>
</nav>
<ng-template #dashboard>
<div *ngIf="!isCreation() && user" class="dashboard">
<div>
<a>
<div class="dashboard-num">{{ user.videosCount }} ({{ user.videoQuotaUsed | bytes: 0 }})</div>
<div class="dashboard-label" i18n>{user.videosCount, plural, =1 {Video} other {Videos}}</div>
</a>
</div>
<div>
<a>
<div class="dashboard-num">{{ user.videoChannels.length || 0 }}</div>
<div class="dashboard-label" i18n>{user.videoChannels.length, plural, =1 {Channel} other {Channels}}</div>
</a>
</div>
<div>
<a>
<div class="dashboard-num">{{ subscribersCount }}</div>
<div class="dashboard-label" i18n>{subscribersCount, plural, =1 {Subscriber} other {Subscribers}}</div>
</a>
</div>
<div>
<a>
<div class="dashboard-num">{{ user.videoAbusesCount }}</div>
<div class="dashboard-label" i18n>Incriminated in reports</div>
</a>
</div>
<div>
<a>
<div class="dashboard-num">{{ user.videoAbusesAcceptedCount }} / {{ user.videoAbusesCreatedCount }}</div>
<div class="dashboard-label" i18n>Authored reports accepted</div>
</a>
</div>
<div>
<a>
<div class="dashboard-num">{{ user.videoCommentsCount }}</div>
<div class="dashboard-label" i18n>{user.videoCommentsCount, plural, =1 {Comment} other {Comments}}</div>
</a>
</div>
</div>
</ng-template>
<div class="form-row" *ngIf="!isInBigView()"> <!-- hidden on large screens, as it is then displayed on the right side of the form -->
<div class="col-12 col-xl-3"></div>
<div class="form-group-right col-12 col-xl-9">
<ng-template *ngTemplateOutlet="dashboard"></ng-template>
</div>
</div>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
<div class="form-group" *ngIf="isCreation()">
<label i18n for="username">Username</label>
<input
type="text" id="username" i18n-placeholder placeholder="john"
formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }"
>
<div *ngIf="formErrors.username" class="form-error">
{{ formErrors.username }}
<div class="form-row mt-4"> <!-- user grid -->
<div class="form-group col-12 col-lg-4 col-xl-3">
<div class="anchor" id="user"></div> <!-- user anchor -->
<div *ngIf="isCreation()" class="account-title" i18n>NEW USER</div>
<div *ngIf="!isCreation() && user" class="account-title">
<my-actor-avatar-info [actor]="user.account"></my-actor-avatar-info>
</div>
</div>
<div class="form-group">
<label i18n for="email">Email</label>
<input
type="text" id="email" i18n-placeholder placeholder="mail@example.com"
formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }"
autocomplete="off"
>
<div *ngIf="formErrors.email" class="form-error">
{{ formErrors.email }}
</div>
</div>
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9" [ngClass]="{ 'form-row': isInBigView() }">
<div class="form-group" *ngIf="isCreation()">
<label i18n for="password">Password</label>
<my-help *ngIf="isPasswordOptional()">
<ng-template ptTemplate="customHtml">
<ng-container i18n>
If you leave the password empty, an email will be sent to the user.
</ng-container>
</ng-template>
</my-help>
<input
type="password" id="password" autocomplete="new-password"
formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
>
<div *ngIf="formErrors.password" class="form-error">
{{ formErrors.password }}
</div>
</div>
<form role="form" (ngSubmit)="formValidated()" [formGroup]="form" [ngClass]="{ 'col-5': isInBigView() }">
<div class="form-group" *ngIf="isCreation()">
<label i18n for="username">Username</label>
<input
type="text" id="username" i18n-placeholder placeholder="john"
formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }"
>
<div *ngIf="formErrors.username" class="form-error">
{{ formErrors.username }}
</div>
</div>
<div class="form-group">
<label i18n for="email">Email</label>
<input
type="text" id="email" i18n-placeholder placeholder="mail@example.com"
formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }"
autocomplete="off"
>
<div *ngIf="formErrors.email" class="form-error">
{{ formErrors.email }}
</div>
</div>
<div class="form-group" *ngIf="isCreation()">
<label i18n for="password">Password</label>
<my-help *ngIf="isPasswordOptional()">
<ng-template ptTemplate="customHtml">
<ng-container i18n>
If you leave the password empty, an email will be sent to the user.
</ng-container>
</ng-template>
</my-help>
<input
type="password" id="password" autocomplete="new-password"
formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
>
<div *ngIf="formErrors.password" class="form-error">
{{ formErrors.password }}
</div>
</div>
<div class="form-group">
<label i18n for="role">Role</label>
<div class="peertube-select-container">
<select id="role" formControlName="role">
<option *ngFor="let role of roles" [value]="role.value">
{{ role.label }}
</option>
</select>
</div>
<div *ngIf="formErrors.role" class="form-error">
{{ formErrors.role }}
</div>
</div>
<div class="form-group">
<label i18n for="videoQuota">Video quota</label>
<div class="peertube-select-container">
<select id="videoQuota" formControlName="videoQuota">
<option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value">
{{ videoQuotaOption.label }}
</option>
</select>
</div>
<div i18n class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()">
Transcoding is enabled. The video quota only takes into account <strong>original</strong> video size. <br />
At most, this user could upload ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}.
</div>
</div>
<div class="form-group">
<label i18n for="videoQuotaDaily">Daily video quota</label>
<div class="peertube-select-container">
<select id="videoQuotaDaily" formControlName="videoQuotaDaily">
<option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value">
{{ videoQuotaDailyOption.label }}
</option>
</select>
</div>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="byPassAutoBlacklist" formControlName="byPassAutoBlacklist"
i18n-labelText labelText="Doesn't need review before a video goes public"
></my-peertube-checkbox>
</div>
<input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
</form>
<div class="form-group">
<label i18n for="role">Role</label>
<div class="peertube-select-container">
<select id="role" formControlName="role">
<option *ngFor="let role of roles" [value]="role.value">
{{ role.label }}
</option>
</select>
<div *ngIf="isInBigView()" class="col-7">
<ng-template *ngTemplateOutlet="dashboard"></ng-template>
</div>
</div>
</div>
<div *ngIf="!isCreation() && user" class="form-row mt-4"> <!-- danger zone grid -->
<div class="form-group col-12 col-lg-4 col-xl-3">
<div class="anchor" id="danger"></div> <!-- danger zone anchor -->
<div i18n class="account-title">DANGER ZONE</div>
</div>
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9" [ngClass]="{ 'form-row': isInBigView() }">
<div class="danger-zone">
<div class="form-group reset-password-email">
<label i18n>Send a link to reset the password by email to the user</label>
<button (click)="resetPassword()" i18n>Ask for new password</button>
</div>
<div class="form-group">
<label i18n>Manually set the user password</label>
<my-user-password [userId]="user.id"></my-user-password>
</div>
</div>
<div *ngIf="formErrors.role" class="form-error">
{{ formErrors.role }}
</div>
</div>
<div class="form-group">
<label i18n for="videoQuota">Video quota</label>
<div class="peertube-select-container">
<select id="videoQuota" formControlName="videoQuota">
<option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value">
{{ videoQuotaOption.label }}
</option>
</select>
</div>
<div i18n class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()">
Transcoding is enabled on server. The video quota only take in account <strong>original</strong> video. <br />
At most, this user could use ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}.
</div>
</div>
<div class="form-group">
<label i18n for="videoQuotaDaily">Daily video quota</label>
<div class="peertube-select-container">
<select id="videoQuotaDaily" formControlName="videoQuotaDaily">
<option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value">
{{ videoQuotaDailyOption.label }}
</option>
</select>
</div>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="byPassAutoBlacklist" formControlName="byPassAutoBlacklist"
i18n-labelText labelText="Bypass video auto blacklist"
></my-peertube-checkbox>
</div>
<input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
</form>
<div *ngIf="!isCreation()" class="danger-zone">
<div class="account-title" i18n>Danger Zone</div>
<div class="form-group reset-password-email">
<label i18n>Send a link to reset the password by email to the user</label>
<button (click)="resetPassword()" i18n>Ask for new password</button>
</div>
<div class="form-group">
<label i18n>Manually set the user password</label>
<my-user-password [userId]="userId"></my-user-password>
</div>
</div>

View File

@ -1,8 +1,13 @@
@import '_variables';
@import '_mixins';
.form-sub-title {
margin-bottom: 30px;
label {
font-weight: $font-regular;
font-size: 100%;
}
.account-title {
@include settings-big-title;
}
input:not([type=submit]) {
@ -26,18 +31,9 @@ input[type=submit], button {
font-size: 11px;
}
.account-title {
@include in-content-small-title;
margin-top: 55px;
margin-bottom: 30px;
}
.danger-zone {
.reset-password-email {
margin-bottom: 30px;
padding-bottom: 30px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
button {
display: block;
@ -45,3 +41,20 @@ input[type=submit], button {
}
}
}
.breadcrumb {
@include breadcrumb;
}
.dashboard {
@include dashboard;
max-width: 900px;
}
my-actor-avatar-info ::ng-deep {
.actor-img-edit-container,
.actor-info-followers,
.actor-info-username {
display: none;
}
}

View File

@ -4,12 +4,14 @@ import { ServerConfig, USER_ROLE_LABELS, UserRole, VideoResolution } from '../..
import { ConfigService } from '@app/+admin/config/shared/config.service'
import { UserAdminFlag } from '@shared/models/users/user-flag.model'
import { OnInit } from '@angular/core'
import { User } from '@app/shared/users/user.model'
import { ScreenService } from '@app/shared/misc/screen.service'
export abstract class UserEdit extends FormReactive implements OnInit {
videoQuotaOptions: { value: string, label: string }[] = []
videoQuotaDailyOptions: { value: string, label: string }[] = []
username: string
userId: number
user: User
roles: { value: string, label: string }[] = []
@ -17,6 +19,7 @@ export abstract class UserEdit extends FormReactive implements OnInit {
protected abstract serverService: ServerService
protected abstract configService: ConfigService
protected abstract screenService: ScreenService
protected abstract auth: AuthService
abstract isCreation (): boolean
abstract getFormButtonTitle (): string
@ -29,6 +32,20 @@ export abstract class UserEdit extends FormReactive implements OnInit {
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
}
isInBigView () {
return this.screenService.getWindowInnerWidth() > 1600
}
buildRoles () {
const authUser = this.auth.getUser()

View File

@ -23,8 +23,6 @@ export class UserPasswordComponent extends FormReactive implements OnInit {
constructor (
protected formValidatorService: FormValidatorService,
private userValidatorsService: UserValidatorsService,
private route: ActivatedRoute,
private router: Router,
private notifier: Notifier,
private userService: UserService,
private i18n: I18n

View File

@ -4,13 +4,15 @@ import { Subscription } from 'rxjs'
import { AuthService, Notifier } from '@app/core'
import { ServerService } from '../../../core'
import { UserEdit } from './user-edit'
import { User, UserUpdate } from '../../../../../../shared'
import { User as UserType, UserUpdate, UserRole } from '../../../../../../shared'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
import { ConfigService } from '@app/+admin/config/shared/config.service'
import { UserService } from '@app/shared'
import { UserAdminFlag } from '@shared/models/users/user-flag.model'
import { User } from '@app/shared/users/user.model'
import { ScreenService } from '@app/shared/misc/screen.service'
@Component({
selector: 'my-user-update',
@ -19,9 +21,6 @@ import { UserAdminFlag } from '@shared/models/users/user-flag.model'
})
export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
error: string
userId: number
userEmail: string
username: string
private paramsSub: Subscription
@ -29,6 +28,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
protected formValidatorService: FormValidatorService,
protected serverService: ServerService,
protected configService: ConfigService,
protected screenService: ScreenService,
protected auth: AuthService,
private userValidatorsService: UserValidatorsService,
private route: ActivatedRoute,
@ -45,7 +45,12 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
ngOnInit () {
super.ngOnInit()
const defaultValues = { videoQuota: '-1', videoQuotaDaily: '-1' }
const defaultValues = {
role: UserRole.USER.toString(),
videoQuota: '-1',
videoQuotaDaily: '-1'
}
this.buildForm({
email: this.userValidatorsService.USER_EMAIL,
role: this.userValidatorsService.USER_ROLE,
@ -56,7 +61,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
this.paramsSub = this.route.params.subscribe(routeParams => {
const userId = routeParams['id']
this.userService.getUser(userId).subscribe(
this.userService.getUser(userId, true).subscribe(
user => this.onUserFetched(user),
err => this.error = err.message
@ -78,9 +83,9 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10)
userUpdate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10)
this.userService.updateUser(this.userId, userUpdate).subscribe(
this.userService.updateUser(this.user.id, userUpdate).subscribe(
() => {
this.notifier.success(this.i18n('User {{username}} updated.', { username: this.username }))
this.notifier.success(this.i18n('User {{user.username}} updated.', { username: this.user.username }))
this.router.navigate([ '/admin/users/list' ])
},
@ -101,10 +106,10 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
}
resetPassword () {
this.userService.askResetPassword(this.userEmail).subscribe(
this.userService.askResetPassword(this.user.email).subscribe(
() => {
this.notifier.success(
this.i18n('An email asking for password reset has been sent to {{username}}.', { username: this.username })
this.i18n('An email asking for password reset has been sent to {{username}}.', { username: this.user.username })
)
},
@ -112,14 +117,12 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
)
}
private onUserFetched (userJson: User) {
this.userId = userJson.id
this.username = userJson.username
this.userEmail = userJson.email
private onUserFetched (userJson: UserType) {
this.user = new User(userJson)
this.form.patchValue({
email: userJson.email,
role: userJson.role,
role: userJson.role.toString(),
videoQuota: userJson.videoQuota,
videoQuotaDaily: userJson.videoQuotaDaily,
byPassAutoBlacklist: userJson.adminFlags & UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST

View File

@ -15,7 +15,6 @@ import { MyAccountProfileComponent } from '@app/+my-account/my-account-settings/
import { MyAccountVideoChannelsComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channels.component'
import { MyAccountVideoChannelCreateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-create.component'
import { MyAccountVideoChannelUpdateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-update.component'
import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component'
import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component'
import { MyAccountDangerZoneComponent } from '@app/+my-account/my-account-settings/my-account-danger-zone'
import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component'
@ -63,7 +62,6 @@ import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-setti
MyAccountVideoChannelsComponent,
MyAccountVideoChannelCreateComponent,
MyAccountVideoChannelUpdateComponent,
ActorAvatarInfoComponent,
MyAccountVideoImportsComponent,
MyAccountDangerZoneComponent,
MyAccountSubscriptionsComponent,

View File

@ -1,14 +1,17 @@
<ng-container *ngIf="actor">
<div class="actor">
<img [src]="actor.avatarUrl" alt="Avatar" />
<div class="d-flex">
<img [src]="actor.avatarUrl" alt="Avatar" />
<div class="actor-img-edit-container">
<div class="actor-img-edit-button" [ngbTooltip]="'(extensions: '+ avatarExtensions +', '+ maxSizeText +': '+ maxAvatarSizeInBytes +')'" placement="right" container="body">
<my-global-icon iconName="edit"></my-global-icon>
<input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/>
<div class="actor-img-edit-container">
<div class="actor-img-edit-button" [ngbTooltip]="'(extensions: '+ avatarExtensions +', '+ maxSizeText +': '+ maxAvatarSizeInBytes +')'" placement="right" container="body">
<my-global-icon iconName="edit"></my-global-icon>
<input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/>
</div>
</div>
</div>
<div class="actor-info">
<div class="actor-info-names">
<div class="actor-info-display-name">{{ actor.displayName }}</div>

View File

@ -106,6 +106,7 @@ import { InputSwitchModule } from 'primeng/inputswitch'
import { MyAccountVideoSettingsComponent } from '@app/+my-account/my-account-settings/my-account-video-settings'
import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface'
import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component'
@NgModule({
imports: [
@ -189,7 +190,8 @@ import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account
PreviewUploadComponent,
MyAccountVideoSettingsComponent,
MyAccountInterfaceSettingsComponent
MyAccountInterfaceSettingsComponent,
ActorAvatarInfoComponent
],
exports: [
@ -270,7 +272,8 @@ import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account
VideoDurationPipe,
MyAccountVideoSettingsComponent,
MyAccountInterfaceSettingsComponent
MyAccountInterfaceSettingsComponent,
ActorAvatarInfoComponent
],
providers: [

View File

@ -51,6 +51,11 @@ export class User implements UserServerModel {
videoQuotaDaily: number
videoQuotaUsed?: number
videoQuotaUsedDaily?: number
videosCount?: number
videoAbusesCount?: number
videoAbusesAcceptedCount?: number
videoAbusesCreatedCount?: number
videoCommentsCount?: number
theme: string
@ -79,6 +84,11 @@ export class User implements UserServerModel {
this.videoQuotaDaily = hash.videoQuotaDaily
this.videoQuotaUsed = hash.videoQuotaUsed
this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily
this.videosCount = hash.videosCount
this.videoAbusesCount = hash.videoAbusesCount
this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount
this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount
this.videoCommentsCount = hash.videoCommentsCount
this.nsfwPolicy = hash.nsfwPolicy
this.webTorrentEnabled = hash.webTorrentEnabled

View File

@ -234,8 +234,9 @@ export class UserService {
return this.userCache[userId]
}
getUser (userId: number) {
return this.authHttp.get<UserServerModel>(UserService.BASE_USERS_URL + userId)
getUser (userId: number, withStats = false) {
const params = new HttpParams().append('withStats', withStats + '')
return this.authHttp.get<UserServerModel>(UserService.BASE_USERS_URL + userId, { params })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}

View File

@ -7,8 +7,7 @@
<div class="modal-body">
<div i18n class="information">
Your report will be sent to moderators of {{ currentHost }}.
<ng-container *ngIf="isRemoteVideo()"> It will be forwarded to origin instance {{ originHost }} too.</ng-container>
Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>.
</div>
<form novalidate [formGroup]="form" (ngSubmit)="report()">

View File

@ -621,3 +621,85 @@
}
}
}
@mixin breadcrumb {
display: flex;
flex-wrap: wrap;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
list-style: none;
background-color: var(--submenuColor);
border-radius: 0.25rem;
.breadcrumb-item {
display: flex;
a {
color: var(--mainColor);
}
& + .breadcrumb-item {
padding-left: 0.5rem;
&::before {
display: inline-block;
padding-right: 0.5rem;
color: #6c757d;
content: "/";
}
}
&.active {
color: #6c757d;
}
}
}
@mixin dashboard {
display: flex;
flex-wrap: wrap;
margin: 0 -5px;
& > div {
box-sizing: border-box;
flex: 0 0 percentage(1/3);
padding: 0 5px;
margin-bottom: 10px;
& > a {
text-decoration: none;
color: inherit;
display: block;
font-size: 18px;
&:active,
&:focus,
&:hover {
opacity: .8;
}
}
& > a,
& > div {
padding: 20px;
background: var(--submenuColor);
border-radius: 4px;
box-sizing: border-box;
height: 100%;
}
}
.dashboard-num, .dashboard-text {
text-align: center;
font-size: 130%;
line-height: 21px;
color: var(--mainForegroundColor);
line-height: 30px;
margin-bottom: 20px;
}
.dashboard-label {
font-size: 90%;
color: var(--inputPlaceholderColor);
text-align: center;
}
}

View File

@ -1,6 +1,6 @@
import * as Bluebird from 'bluebird'
import * as express from 'express'
import { body, param } from 'express-validator'
import { body, param, query } from 'express-validator'
import { omit } from 'lodash'
import { isIdOrUUIDValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
import {
@ -256,12 +256,13 @@ const usersUpdateMeValidator = [
const usersGetValidator = [
param('id').isInt().not().isEmpty().withMessage('Should have a valid id'),
query('withStats').optional().isBoolean().withMessage('Should have a valid stats flag'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking usersGet parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
if (!await checkUserIdExist(req.params.id, res)) return
if (!await checkUserIdExist(req.params.id, res, req.query.withStats)) return
return next()
}
@ -460,9 +461,9 @@ export {
// ---------------------------------------------------------------------------
function checkUserIdExist (idArg: number | string, res: express.Response) {
function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
const id = parseInt(idArg + '', 10)
return checkUserExist(() => UserModel.loadById(id), res)
return checkUserExist(() => UserModel.loadById(id, withStats), res)
}
function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {

View File

@ -19,7 +19,7 @@ import {
Table,
UpdatedAt
} from 'sequelize-typescript'
import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, VideoPlaylistType, VideoPrivacy } from '../../../shared'
import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, VideoPlaylistType, VideoPrivacy, VideoAbuseState } from '../../../shared'
import { User, UserRole } from '../../../shared/models/users'
import {
isNoInstanceConfigWarningModal,
@ -70,8 +70,26 @@ import {
MVideoFullLight
} from '@server/typings/models'
const literalVideoQuotaUsed: any = [
literal(
'(' +
'SELECT COALESCE(SUM("size"), 0) ' +
'FROM (' +
'SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' +
'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
'WHERE "account"."userId" = "UserModel"."id" GROUP BY "video"."id"' +
') t' +
')'
),
'videoQuotaUsed'
]
enum ScopeNames {
FOR_ME_API = 'FOR_ME_API'
FOR_ME_API = 'FOR_ME_API',
WITH_VIDEOCHANNELS = 'WITH_VIDEOCHANNELS',
WITH_STATS = 'WITH_STATS'
}
@DefaultScope(() => ({
@ -112,6 +130,86 @@ enum ScopeNames {
required: true
}
]
},
[ScopeNames.WITH_VIDEOCHANNELS]: {
include: [
{
model: AccountModel,
include: [
{
model: VideoChannelModel
},
{
attributes: [ 'id', 'name', 'type' ],
model: VideoPlaylistModel.unscoped(),
required: true,
where: {
type: {
[Op.ne]: VideoPlaylistType.REGULAR
}
}
}
]
}
]
},
[ScopeNames.WITH_STATS]: {
attributes: {
include: [
literalVideoQuotaUsed,
[
literal(
'(' +
'SELECT COUNT("video"."id") ' +
'FROM "video" ' +
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
'WHERE "account"."userId" = "UserModel"."id"' +
')'
),
'videosCount'
],
[
literal(
'(' +
`SELECT concat_ws(':', "abuses", "acceptedAbuses") ` +
'FROM (' +
'SELECT COUNT("videoAbuse"."id") AS "abuses", ' +
`COUNT("videoAbuse"."id") FILTER (WHERE "videoAbuse"."state" = ${VideoAbuseState.ACCEPTED}) AS "acceptedAbuses" ` +
'FROM "videoAbuse" ' +
'INNER JOIN "video" ON "videoAbuse"."videoId" = "video"."id" ' +
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
'WHERE "account"."userId" = "UserModel"."id"' +
') t' +
')'
),
'videoAbusesCount'
],
[
literal(
'(' +
'SELECT COUNT("videoAbuse"."id") ' +
'FROM "videoAbuse" ' +
'INNER JOIN "account" ON "account"."id" = "videoAbuse"."reporterAccountId" ' +
'WHERE "account"."userId" = "UserModel"."id"' +
')'
),
'videoAbusesCreatedCount'
],
[
literal(
'(' +
'SELECT COUNT("videoComment"."id") ' +
'FROM "videoComment" ' +
'INNER JOIN "account" ON "account"."id" = "videoComment"."accountId" ' +
'WHERE "account"."userId" = "UserModel"."id"' +
')'
),
'videoCommentsCount'
]
]
}
}
}))
@Table({
@ -332,23 +430,7 @@ export class UserModel extends Model<UserModel> {
const query: FindOptions = {
attributes: {
include: [
[
literal(
'(' +
'SELECT COALESCE(SUM("size"), 0) ' +
'FROM (' +
'SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' +
'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
'WHERE "account"."userId" = "UserModel"."id" GROUP BY "video"."id"' +
') t' +
')'
),
'videoQuotaUsed'
]
]
include: [ literalVideoQuotaUsed ]
},
offset: start,
limit: count,
@ -430,8 +512,14 @@ export class UserModel extends Model<UserModel> {
return UserModel.findAll(query)
}
static loadById (id: number): Bluebird<MUserDefault> {
return UserModel.findByPk(id)
static loadById (id: number, withStats = false): Bluebird<MUserDefault> {
const scopes = [
ScopeNames.WITH_VIDEOCHANNELS
]
if (withStats) scopes.push(ScopeNames.WITH_STATS)
return UserModel.scope(scopes).findByPk(id)
}
static loadByUsername (username: string): Bluebird<MUserDefault> {
@ -637,6 +725,10 @@ export class UserModel extends Model<UserModel> {
toFormattedJSON (this: MUserFormattable, parameters: { withAdminFlags?: boolean } = {}): User {
const videoQuotaUsed = this.get('videoQuotaUsed')
const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily')
const videosCount = this.get('videosCount')
const [ videoAbusesCount, videoAbusesAcceptedCount ] = (this.get('videoAbusesCount') as string || ':').split(':')
const videoAbusesCreatedCount = this.get('videoAbusesCreatedCount')
const videoCommentsCount = this.get('videoCommentsCount')
const json: User = {
id: this.id,
@ -666,6 +758,21 @@ export class UserModel extends Model<UserModel> {
videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined
? parseInt(videoQuotaUsedDaily + '', 10)
: undefined,
videosCount: videosCount !== undefined
? parseInt(videosCount + '', 10)
: undefined,
videoAbusesCount: videoAbusesCount
? parseInt(videoAbusesCount, 10)
: undefined,
videoAbusesAcceptedCount: videoAbusesAcceptedCount
? parseInt(videoAbusesAcceptedCount, 10)
: undefined,
videoAbusesCreatedCount: videoAbusesCreatedCount !== undefined
? parseInt(videoAbusesCreatedCount + '', 10)
: undefined,
videoCommentsCount: videoCommentsCount !== undefined
? parseInt(videoCommentsCount + '', 10)
: undefined,
noInstanceConfigWarningModal: this.noInstanceConfigWarningModal,
noWelcomeModal: this.noWelcomeModal,

View File

@ -2,7 +2,7 @@
import * as chai from 'chai'
import 'mocha'
import { MyUser, User, UserRole, Video, VideoPlaylistType } from '../../../../shared/index'
import { MyUser, User, UserRole, Video, VideoPlaylistType, VideoAbuseState, VideoAbuseUpdate } from '../../../../shared/index'
import {
blockUser,
cleanupTests,
@ -33,7 +33,11 @@ import {
updateMyUser,
updateUser,
uploadVideo,
userLogin
userLogin,
reportVideoAbuse,
addVideoCommentThread,
updateVideoAbuse,
getVideoAbusesList
} from '../../../../shared/extra-utils'
import { follow } from '../../../../shared/extra-utils/server/follows'
import { setAccessTokensToServers } from '../../../../shared/extra-utils/users/login'
@ -254,7 +258,7 @@ describe('Test users', function () {
const res1 = await getMyUserInformation(server.url, accessTokenUser)
const userMe: MyUser = res1.body
const res2 = await getUserInformation(server.url, server.accessToken, userMe.id)
const res2 = await getUserInformation(server.url, server.accessToken, userMe.id, true)
const userGet: User = res2.body
for (const user of [ userMe, userGet ]) {
@ -273,6 +277,16 @@ describe('Test users', function () {
expect(userMe.specialPlaylists).to.have.lengthOf(1)
expect(userMe.specialPlaylists[0].type).to.equal(VideoPlaylistType.WATCH_LATER)
// Check stats are included with withStats
expect(userGet.videosCount).to.be.a('number')
expect(userGet.videosCount).to.equal(0)
expect(userGet.videoCommentsCount).to.be.a('number')
expect(userGet.videoCommentsCount).to.equal(0)
expect(userGet.videoAbusesCount).to.be.a('number')
expect(userGet.videoAbusesCount).to.equal(0)
expect(userGet.videoAbusesAcceptedCount).to.be.a('number')
expect(userGet.videoAbusesAcceptedCount).to.equal(0)
})
})
@ -623,7 +637,6 @@ describe('Test users', function () {
})
describe('Updating another user', function () {
it('Should be able to update another user', async function () {
await updateUser({
url: server.url,
@ -698,6 +711,8 @@ describe('Test users', function () {
})
describe('Registering a new user', function () {
let user15AccessToken
it('Should register a new user', async function () {
const user = { displayName: 'super user 15', username: 'user_15', password: 'my super password' }
const channel = { name: 'my_user_15_channel', displayName: 'my channel rocks' }
@ -711,18 +726,18 @@ describe('Test users', function () {
password: 'my super password'
}
accessToken = await userLogin(server, user15)
user15AccessToken = await userLogin(server, user15)
})
it('Should have the correct display name', async function () {
const res = await getMyUserInformation(server.url, accessToken)
const res = await getMyUserInformation(server.url, user15AccessToken)
const user: User = res.body
expect(user.account.displayName).to.equal('super user 15')
})
it('Should have the correct video quota', async function () {
const res = await getMyUserInformation(server.url, accessToken)
const res = await getMyUserInformation(server.url, user15AccessToken)
const user = res.body
expect(user.videoQuota).to.equal(5 * 1024 * 1024)
@ -740,7 +755,7 @@ describe('Test users', function () {
expect(res.body.data.find(u => u.username === 'user_15')).to.not.be.undefined
}
await deleteMe(server.url, accessToken)
await deleteMe(server.url, user15AccessToken)
{
const res = await getUsersList(server.url, server.accessToken)
@ -750,6 +765,9 @@ describe('Test users', function () {
})
describe('User blocking', function () {
let user16Id
let user16AccessToken
it('Should block and unblock a user', async function () {
const user16 = {
username: 'user_16',
@ -761,19 +779,95 @@ describe('Test users', function () {
username: user16.username,
password: user16.password
})
const user16Id = resUser.body.user.id
user16Id = resUser.body.user.id
accessToken = await userLogin(server, user16)
user16AccessToken = await userLogin(server, user16)
await getMyUserInformation(server.url, accessToken, 200)
await getMyUserInformation(server.url, user16AccessToken, 200)
await blockUser(server.url, user16Id, server.accessToken)
await getMyUserInformation(server.url, accessToken, 401)
await getMyUserInformation(server.url, user16AccessToken, 401)
await userLogin(server, user16, 400)
await unblockUser(server.url, user16Id, server.accessToken)
accessToken = await userLogin(server, user16)
await getMyUserInformation(server.url, accessToken, 200)
user16AccessToken = await userLogin(server, user16)
await getMyUserInformation(server.url, user16AccessToken, 200)
})
})
describe('User stats', function () {
let user17Id
let user17AccessToken
it('Should report correct initial statistics about a user', async function () {
const user17 = {
username: 'user_17',
password: 'my super password'
}
const resUser = await createUser({
url: server.url,
accessToken: server.accessToken,
username: user17.username,
password: user17.password
})
user17Id = resUser.body.user.id
user17AccessToken = await userLogin(server, user17)
const res = await getUserInformation(server.url, server.accessToken, user17Id, true)
const user: User = res.body
expect(user.videosCount).to.equal(0)
expect(user.videoCommentsCount).to.equal(0)
expect(user.videoAbusesCount).to.equal(0)
expect(user.videoAbusesCreatedCount).to.equal(0)
expect(user.videoAbusesAcceptedCount).to.equal(0)
})
it('Should report correct videos count', async function () {
const videoAttributes = {
name: 'video to test user stats'
}
await uploadVideo(server.url, user17AccessToken, videoAttributes)
const res1 = await getVideosList(server.url)
videoId = res1.body.data.find(video => video.name === videoAttributes.name).id
const res2 = await getUserInformation(server.url, server.accessToken, user17Id, true)
const user: User = res2.body
expect(user.videosCount).to.equal(1)
})
it('Should report correct video comments for user', async function () {
const text = 'super comment'
await addVideoCommentThread(server.url, user17AccessToken, videoId, text)
const res = await getUserInformation(server.url, server.accessToken, user17Id, true)
const user: User = res.body
expect(user.videoCommentsCount).to.equal(1)
})
it('Should report correct video abuses counts', async function () {
const reason = 'my super bad reason'
await reportVideoAbuse(server.url, user17AccessToken, videoId, reason)
const res1 = await getVideoAbusesList(server.url, server.accessToken)
const abuseId = res1.body.data[0].id
const res2 = await getUserInformation(server.url, server.accessToken, user17Id, true)
const user2: User = res2.body
expect(user2.videoAbusesCount).to.equal(1) // number of incriminations
expect(user2.videoAbusesCreatedCount).to.equal(1) // number of reports created
const body: VideoAbuseUpdate = { state: VideoAbuseState.ACCEPTED }
await updateVideoAbuse(server.url, server.accessToken, videoId, abuseId, body)
const res3 = await getUserInformation(server.url, server.accessToken, user17Id, true)
const user3: User = res3.body
expect(user3.videoAbusesAcceptedCount).to.equal(1) // number of reports created accepted
})
})

View File

@ -130,11 +130,12 @@ function getMyUserVideoQuotaUsed (url: string, accessToken: string, specialStatu
.expect('Content-Type', /json/)
}
function getUserInformation (url: string, accessToken: string, userId: number) {
function getUserInformation (url: string, accessToken: string, userId: number, withStats = false) {
const path = '/api/v1/users/' + userId
return request(url)
.get(path)
.query({ withStats })
.set('Accept', 'application/json')
.set('Authorization', 'Bearer ' + accessToken)
.expect(200)

View File

@ -31,6 +31,11 @@ export interface User {
videoQuotaDaily: number
videoQuotaUsed?: number
videoQuotaUsedDaily?: number
videosCount?: number
videoAbusesCount?: number
videoAbusesAcceptedCount?: number
videoAbusesCreatedCount?: number
videoCommentsCount? : number
theme: string