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>
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>
textarea {
@include peertube-textarea(500px, 150px);
+ max-width: 100%;
display: block;
&.small {
@media screen and (max-width: 1400px) {
flex-direction: column !important;
}
+
+ textarea {
+ max-width: 100%;
+ }
}
}
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',
protected serverService: ServerService,
protected formValidatorService: FormValidatorService,
protected configService: ConfigService,
+ protected screenService: ScreenService,
protected auth: AuthService,
private userValidatorsService: UserValidatorsService,
private route: ActivatedRoute,
-<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>
-<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
+ <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>
-<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 }}
+<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>
-
- <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>
+ <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>
-
- <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>
+ <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-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 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 *ngIf="formErrors.role" class="form-error">
- {{ formErrors.role }}
- </div>
+ <div class="form-group-right col-12 col-xl-9">
+ <ng-template *ngTemplateOutlet="dashboard"></ng-template>
</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 *ngIf="error" class="alert alert-danger">{{ error }}</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 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="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 class="form-group form-group-right col-12 col-lg-8 col-xl-9" [ngClass]="{ 'form-row': isInBigView() }">
+
+ <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 *ngIf="isInBigView()" class="col-7">
+ <ng-template *ngTemplateOutlet="dashboard"></ng-template>
</div>
+
</div>
+</div>
+
- <div class="form-group">
- <my-peertube-checkbox
- inputName="byPassAutoBlacklist" formControlName="byPassAutoBlacklist"
- i18n-labelText labelText="Bypass video auto blacklist"
- ></my-peertube-checkbox>
+<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>
- <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
-</form>
+ <div class="form-group form-group-right col-12 col-lg-8 col-xl-9" [ngClass]="{ 'form-row': isInBigView() }">
-<div *ngIf="!isCreation()" class="danger-zone">
- <div class="account-title" i18n>Danger Zone</div>
+ <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 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 class="form-group">
- <label i18n>Manually set the user password</label>
- <my-user-password [userId]="userId"></my-user-password>
</div>
</div>
@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]) {
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;
}
}
}
+
+.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;
+ }
+}
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 }[] = []
protected abstract serverService: ServerService
protected abstract configService: ConfigService
+ protected abstract screenService: ScreenService
protected abstract auth: AuthService
abstract isCreation (): boolean
abstract getFormButtonTitle (): string
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()
constructor (
protected formValidatorService: FormValidatorService,
private userValidatorsService: UserValidatorsService,
- private route: ActivatedRoute,
- private router: Router,
private notifier: Notifier,
private userService: UserService,
private i18n: I18n
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',
})
export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
error: string
- userId: number
- userEmail: string
- username: string
private paramsSub: Subscription
protected formValidatorService: FormValidatorService,
protected serverService: ServerService,
protected configService: ConfigService,
+ protected screenService: ScreenService,
protected auth: AuthService,
private userValidatorsService: UserValidatorsService,
private route: ActivatedRoute,
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,
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
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' ])
},
}
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 })
)
},
)
}
- 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
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'
MyAccountVideoChannelsComponent,
MyAccountVideoChannelCreateComponent,
MyAccountVideoChannelUpdateComponent,
- ActorAvatarInfoComponent,
MyAccountVideoImportsComponent,
MyAccountDangerZoneComponent,
MyAccountSubscriptionsComponent,
<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>
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: [
PreviewUploadComponent,
MyAccountVideoSettingsComponent,
- MyAccountInterfaceSettingsComponent
+ MyAccountInterfaceSettingsComponent,
+ ActorAvatarInfoComponent
],
exports: [
VideoDurationPipe,
MyAccountVideoSettingsComponent,
- MyAccountInterfaceSettingsComponent
+ MyAccountInterfaceSettingsComponent,
+ ActorAvatarInfoComponent
],
providers: [
videoQuotaDaily: number
videoQuotaUsed?: number
videoQuotaUsedDaily?: number
+ videosCount?: number
+ videoAbusesCount?: number
+ videoAbusesAcceptedCount?: number
+ videoAbusesCreatedCount?: number
+ videoCommentsCount?: number
theme: string
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
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)))
}
<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()">
}
}
}
+
+@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;
+ }
+}
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 {
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()
}
// ---------------------------------------------------------------------------
-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) {
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,
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(() => ({
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({
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,
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> {
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,
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,
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,
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'
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 ]) {
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)
})
})
})
describe('Updating another user', function () {
-
it('Should be able to update another user', async function () {
await updateUser({
url: server.url,
})
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' }
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)
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)
})
describe('User blocking', function () {
+ let user16Id
+ let user16AccessToken
+
it('Should block and unblock a user', async function () {
const user16 = {
username: 'user_16',
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
})
})
.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)
videoQuotaDaily: number
videoQuotaUsed?: number
videoQuotaUsedDaily?: number
+ videosCount?: number
+ videoAbusesCount?: number
+ videoAbusesAcceptedCount?: number
+ videoAbusesCreatedCount?: number
+ videoCommentsCount? : number
theme: string