Commit 9589907c authored by Chocobozzz's avatar Chocobozzz Committed by Chocobozzz
Browse files

Implement signup approval in client

parent b379759f
......@@ -96,6 +96,14 @@ export class AdminComponent implements OnInit {
children: []
}
if (this.hasRegistrationsRight()) {
moderationItems.children.push({
label: $localize`Registrations`,
routerLink: '/admin/moderation/registrations/list',
iconName: 'user'
})
}
if (this.hasAbusesRight()) {
moderationItems.children.push({
label: $localize`Reports`,
......@@ -229,4 +237,8 @@ export class AdminComponent implements OnInit {
private hasVideosRight () {
return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS)
}
private hasRegistrationsRight () {
return this.auth.getUser().hasRight(UserRight.MANAGE_REGISTRATIONS)
}
}
......@@ -30,7 +30,13 @@ import { FollowersListComponent, FollowModalComponent, VideoRedundanciesListComp
import { FollowingListComponent } from './follows/following-list/following-list.component'
import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
import { AbuseListComponent, VideoBlockListComponent } from './moderation'
import {
AbuseListComponent,
AdminRegistrationService,
ProcessRegistrationModalComponent,
RegistrationListComponent,
VideoBlockListComponent
} from './moderation'
import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
import {
UserCreateComponent,
......@@ -116,7 +122,10 @@ import { JobsComponent } from './system/jobs/jobs.component'
EditLiveConfigurationComponent,
EditAdvancedConfigurationComponent,
EditInstanceInformationComponent,
EditHomepageComponent
EditHomepageComponent,
RegistrationListComponent,
ProcessRegistrationModalComponent
],
exports: [
......@@ -130,7 +139,8 @@ import { JobsComponent } from './system/jobs/jobs.component'
ConfigService,
PluginApiService,
EditConfigurationService,
VideoAdminService
VideoAdminService,
AdminRegistrationService
]
})
export class AdminModule { }
......@@ -171,12 +171,21 @@
</ng-container>
<ng-container ngProjectAs="extra">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
i18n-labelText labelText="Signup requires email verification"
></my-peertube-checkbox>
<div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
inputName="signupRequiresApproval" formControlName="requiresApproval"
i18n-labelText labelText="Signup requires approval by moderators"
></my-peertube-checkbox>
</div>
<div [ngClass]="getDisabledSignupClass()" class="mt-3">
<div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
i18n-labelText labelText="Signup requires email verification"
></my-peertube-checkbox>
</div>
<div [ngClass]="getDisabledSignupClass()">
<label i18n for="signupLimit">Signup limit</label>
<div class="number-with-unit">
......
......@@ -132,6 +132,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
signup: {
enabled: null,
limit: SIGNUP_LIMIT_VALIDATOR,
requiresApproval: null,
requiresEmailVerification: null,
minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR
},
......
export * from './abuse-list'
export * from './instance-blocklist'
export * from './video-block-list'
export * from './registration-list'
export * from './moderation.routes'
......@@ -4,6 +4,7 @@ import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } f
import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
import { UserRightGuard } from '@app/core'
import { UserRight } from '@shared/models'
import { RegistrationListComponent } from './registration-list'
export const ModerationRoutes: Routes = [
{
......@@ -68,7 +69,19 @@ export const ModerationRoutes: Routes = [
}
},
// We move this component in admin overview pages
{
path: 'registrations/list',
component: RegistrationListComponent,
canActivate: [ UserRightGuard ],
data: {
userRight: UserRight.MANAGE_REGISTRATIONS,
meta: {
title: $localize`User registrations`
}
}
},
// We moved this component in admin overview pages
{
path: 'video-comments',
redirectTo: 'video-comments/list',
......
import { SortMeta } from 'primeng/api'
import { catchError } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, RestPagination, RestService } from '@app/core'
import { ResultList, UserRegistration } from '@shared/models'
import { environment } from '../../../../environments/environment'
@Injectable()
export class AdminRegistrationService {
private static BASE_REGISTRATION_URL = environment.apiUrl + '/api/v1/users/registrations'
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor,
private restService: RestService
) { }
listRegistrations (options: {
pagination: RestPagination
sort: SortMeta
search?: string
}) {
const { pagination, sort, search } = options
const url = AdminRegistrationService.BASE_REGISTRATION_URL
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort)
if (search) {
params = params.append('search', search)
}
return this.authHttp.get<ResultList<UserRegistration>>(url, { params })
.pipe(
catchError(res => this.restExtractor.handleError(res))
)
}
acceptRegistration (registration: UserRegistration, moderationResponse: string) {
const url = AdminRegistrationService.BASE_REGISTRATION_URL + '/' + registration.id + '/accept'
const body = { moderationResponse }
return this.authHttp.post(url, body)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
rejectRegistration (registration: UserRegistration, moderationResponse: string) {
const url = AdminRegistrationService.BASE_REGISTRATION_URL + '/' + registration.id + '/reject'
const body = { moderationResponse }
return this.authHttp.post(url, body)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
removeRegistration (registration: UserRegistration) {
const url = AdminRegistrationService.BASE_REGISTRATION_URL + '/' + registration.id
return this.authHttp.delete(url)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
}
export * from './admin-registration.service'
export * from './process-registration-modal.component'
export * from './process-registration-validators'
export * from './registration-list.component'
<ng-template #modal>
<div class="modal-header">
<h4 i18n class="modal-title">
<ng-container *ngIf="isAccept()">Accept {{ registration.username }} registration</ng-container>
<ng-container *ngIf="isReject()">Reject {{ registration.username }} registration</ng-container>
</h4>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div>
<form novalidate [formGroup]="form" (ngSubmit)="processRegistration()">
<div class="modal-body mb-3">
<div i18n *ngIf="!registration.emailVerified" class="alert alert-warning">
Registration email has not been verified.
</div>
<div class="description">
<ng-container *ngIf="isAccept()">
<p i18n>
<strong>Accepting</strong>&nbsp;<em>{{ registration.username }}</em> registration will create the account and channel.
</p>
<p *ngIf="isEmailEnabled()" i18n>
An email will be sent to <em>{{ registration.email }}</em> explaining its account has been created with the moderation response you'll write below.
</p>
<div *ngIf="!isEmailEnabled()" class="alert alert-warning" i18n>
Emails are not enabled on this instance so PeerTube won't be able to send an email to <em>{{ registration.email }}</em> explaining its account has been created.
</div>
</ng-container>
<ng-container *ngIf="isReject()">
<p i18n>
An email will be sent to <em>{{ registration.email }}</em> explaining its registration request has been <strong>rejected</strong> with the moderation response you'll write below.
</p>
<div *ngIf="!isEmailEnabled()" class="alert alert-warning" i18n>
Emails are not enabled on this instance so PeerTube won't be able to send an email to <em>{{ registration.email }}</em> explaining its registration request has been rejected.
</div>
</ng-container>
</div>
<div class="form-group">
<label for="moderationResponse" i18n>Send a message to the user</label>
<textarea
formControlName="moderationResponse" ngbAutofocus name="moderationResponse" id="moderationResponse"
[ngClass]="{ 'input-error': formErrors['moderationResponse'] }" class="form-control"
></textarea>
<div *ngIf="formErrors.moderationResponse" class="form-error">
{{ formErrors.moderationResponse }}
</div>
</div>
</div>
<div class="modal-footer inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="hide()" (key.enter)="hide()"
>
<input type="submit" [value]="getSubmitValue()" class="peertube-button orange-button" [disabled]="!form.valid">
</div>
</form>
</ng-template>
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier, ServerService } from '@app/core'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { UserRegistration } from '@shared/models'
import { AdminRegistrationService } from './admin-registration.service'
import { REGISTRATION_MODERATION_RESPONSE_VALIDATOR } from './process-registration-validators'
@Component({
selector: 'my-process-registration-modal',
templateUrl: './process-registration-modal.component.html',
styleUrls: [ './process-registration-modal.component.scss' ]
})
export class ProcessRegistrationModalComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal
@Output() registrationProcessed = new EventEmitter()
registration: UserRegistration
private openedModal: NgbModalRef
private processMode: 'accept' | 'reject'
constructor (
protected formReactiveService: FormReactiveService,
private server: ServerService,
private modalService: NgbModal,
private notifier: Notifier,
private registrationService: AdminRegistrationService
) {
super()
}
ngOnInit () {
this.buildForm({
moderationResponse: REGISTRATION_MODERATION_RESPONSE_VALIDATOR
})
}
isAccept () {
return this.processMode === 'accept'
}
isReject () {
return this.processMode === 'reject'
}
openModal (registration: UserRegistration, mode: 'accept' | 'reject') {
this.processMode = mode
this.registration = registration
this.openedModal = this.modalService.open(this.modal, { centered: true })
}
hide () {
this.form.reset()
this.openedModal.close()
}
getSubmitValue () {
if (this.isAccept()) {
return $localize`Accept registration`
}
return $localize`Reject registration`
}
processRegistration () {
if (this.isAccept()) return this.acceptRegistration()
return this.rejectRegistration()
}
isEmailEnabled () {
return this.server.getHTMLConfig().email.enabled
}
private acceptRegistration () {
this.registrationService.acceptRegistration(this.registration, this.form.value.moderationResponse)
.subscribe({
next: () => {
this.notifier.success($localize`${this.registration.username} account created`)
this.registrationProcessed.emit()
this.hide()
},
error: err => this.notifier.error(err.message)
})
}
private rejectRegistration () {
this.registrationService.rejectRegistration(this.registration, this.form.value.moderationResponse)
.subscribe({
next: () => {
this.notifier.success($localize`${this.registration.username} registration rejected`)
this.registrationProcessed.emit()
this.hide()
},
error: err => this.notifier.error(err.message)
})
}
}
import { Validators } from '@angular/forms'
import { BuildFormValidator } from '@app/shared/form-validators'
export const REGISTRATION_MODERATION_RESPONSE_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
MESSAGES: {
required: $localize`Moderation response is required.`,
minlength: $localize`Moderation response must be at least 2 characters long.`,
maxlength: $localize`Moderation response cannot be more than 3000 characters long.`
}
}
<h1>
<my-global-icon iconName="user" aria-hidden="true"></my-global-icon>
<ng-container i18n>Registration requests</ng-container>
</h1>
<p-table
[value]="registrations" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id"
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} registrations"
[expandedRowKeys]="expandedRows"
>
<ng-template pTemplate="caption">
<div class="caption">
<div class="ms-auto">
<my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
</div>
</div>
</ng-template>
<ng-template pTemplate="header">
<tr> <!-- header -->
<th style="width: 40px;"></th>
<th style="width: 150px;"></th>
<th i18n>Account</th>
<th i18n>Email</th>
<th i18n>Channel</th>
<th i18n>Registration reason</th>
<th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
<th i18n>Moderation response</th>
<th style="width: 150px;" i18n pSortableColumn="createdAt">Requested on <p-sortIcon field="createdAt"></p-sortIcon></th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-expanded="expanded" let-registration>
<tr>
<td class="expand-cell" [pRowToggler]="registration">
<my-table-expander-icon [expanded]="expanded"></my-table-expander-icon>
</td>
<td class="action-cell">
<my-action-dropdown
[ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
i18n-label label="Actions" [actions]="registrationActions" [entry]="registration"
></my-action-dropdown>
</td>
<td>
<div class="chip two-lines">
<div>
<span>{{ registration.username }}</span>
<span class="muted">{{ registration.accountDisplayName }}</span>
</div>
</div>
</td>
<td>
<my-user-email-info [entry]="registration" [requiresEmailVerification]="requiresEmailVerification"></my-user-email-info>
</td>
<td>
<div class="chip two-lines">
<div>
<span>{{ registration.channelHandle }}</span>
<span class="muted">{{ registration.channelDisplayName }}</span>
</div>
</div>
</td>
<td container="body" placement="left auto" [ngbTooltip]="registration.registrationReason">
{{ registration.registrationReason }}
</td>
<td class="c-hand abuse-states" [pRowToggler]="registration">
<my-global-icon *ngIf="isRegistrationAccepted(registration)" [title]="registration.state.label" iconName="tick"></my-global-icon>
<my-global-icon *ngIf="isRegistrationRejected(registration)" [title]="registration.state.label" iconName="cross"></my-global-icon>
</td>
<td container="body" placement="left auto" [ngbTooltip]="registration.moderationResponse">
{{ registration.moderationResponse }}
</td>
<td class="c-hand" [pRowToggler]="registration">{{ registration.createdAt | date: 'short' }}</td>
</tr>
</ng-template>
<ng-template pTemplate="rowexpansion" let-registration>
<tr>
<td colspan="9">
<div class="moderation-expanded">
<div class="left">
<div class="d-flex">
<span class="moderation-expanded-label" i18n>Registration reason:</span>
<span class="moderation-expanded-text" [innerHTML]="registration.registrationReasonHTML"></span>
</div>
<div *ngIf="registration.moderationResponse">
<span class="moderation-expanded-label" i18n>Moderation response:</span>
<span class="moderation-expanded-text" [innerHTML]="registration.moderationResponseHTML"></span>
</div>
</div>
</div>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="9">
<div class="no-results">
<ng-container *ngIf="search" i18n>No registrations found matching current filters.</ng-container>
<ng-container *ngIf="!search" i18n>No registrations found.</ng-container>
</div>
</td>
</tr>
</ng-template>
</p-table>
<my-process-registration-modal #processRegistrationModal (registrationProcessed)="onRegistrationProcessed()"></my-process-registration-modal>
@use '_mixins' as *;
@use '_variables' as *;
my-global-icon {
width: 24px;
height: 24px;
}
import { SortMeta } from 'primeng/api'
import { Component, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
import { AdvancedInputFilter } from '@app/shared/shared-forms'
import { DropdownAction } from '@app/shared/shared-main'
import { UserRegistration, UserRegistrationState } from '@shared/models'
import { AdminRegistrationService } from './admin-registration.service'
import { ProcessRegistrationModalComponent } from './process-registration-modal.component'
@Component({
selector: 'my-registration-list',
templateUrl: './registration-list.component.html',
styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './registration-list.component.scss' ]
})
export class RegistrationListComponent extends RestTable implements OnInit {
@ViewChild('processRegistrationModal', { static: true }) processRegistrationModal: ProcessRegistrationModalComponent
registrations: (UserRegistration & { registrationReasonHTML?: string, moderationResponseHTML?: string })[] = []
totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: -1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
registrationActions: DropdownAction<UserRegistration>[][] = []
inputFilters: AdvancedInputFilter[] = []
requiresEmailVerification: boolean
constructor (
protected route: ActivatedRoute,
protected router: Router,
private server: ServerService,
private notifier: Notifier,
private markdownRenderer: MarkdownService,
private adminRegistrationService: AdminRegistrationService
) {
super()
this.registrationActions = [
[
{
label: $localize`Accept this registration`,
handle