Implement contact form in the client

This commit is contained in:
Chocobozzz 2019-01-10 11:12:41 +01:00
parent 3866f1a02f
commit d3e56c0c4b
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
18 changed files with 281 additions and 24 deletions

View File

@ -1,7 +1,9 @@
<div class="row"> <div class="row">
<div class="col-md-12 col-xl-6"> <div class="col-md-12 col-xl-6">
<div i18n class="about-instance-title"> <div class="about-instance-title">
About {{ instanceName }} instance <div i18n>About {{ instanceName }} instance</div>
<div *ngIf="isContactFormEnabled" (click)="openContactModal()" i18n role="button" class="contact-admin">Contact administrator</div>
</div> </div>
<div class="short-description"> <div class="short-description">
@ -46,3 +48,5 @@
<my-instance-features-table></my-instance-features-table> <my-instance-features-table></my-instance-features-table>
</div> </div>
</div> </div>
<my-contact-admin-modal #contactAdminModal></my-contact-admin-modal>

View File

@ -2,9 +2,19 @@
@import '_mixins'; @import '_mixins';
.about-instance-title { .about-instance-title {
display: flex;
justify-content: space-between;
& > div {
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
margin-bottom: 15px; margin-bottom: 15px;
}
& > .contact-admin {
@include peertube-button;
@include orange-button;
}
} }
.section-title { .section-title {

View File

@ -1,7 +1,9 @@
import { Component, OnInit } from '@angular/core' import { Component, OnInit, ViewChild } from '@angular/core'
import { Notifier, ServerService } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { MarkdownService } from '@app/videos/shared' import { MarkdownService } from '@app/videos/shared'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
import { InstanceService } from '@app/shared/instance/instance.service'
@Component({ @Component({
selector: 'my-about-instance', selector: 'my-about-instance',
@ -9,6 +11,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
styleUrls: [ './about-instance.component.scss' ] styleUrls: [ './about-instance.component.scss' ]
}) })
export class AboutInstanceComponent implements OnInit { export class AboutInstanceComponent implements OnInit {
@ViewChild('contactAdminModal') contactAdminModal: ContactAdminModalComponent
shortDescription = '' shortDescription = ''
descriptionHTML = '' descriptionHTML = ''
termsHTML = '' termsHTML = ''
@ -16,6 +20,7 @@ export class AboutInstanceComponent implements OnInit {
constructor ( constructor (
private notifier: Notifier, private notifier: Notifier,
private serverService: ServerService, private serverService: ServerService,
private instanceService: InstanceService,
private markdownService: MarkdownService, private markdownService: MarkdownService,
private i18n: I18n private i18n: I18n
) {} ) {}
@ -32,8 +37,12 @@ export class AboutInstanceComponent implements OnInit {
return this.serverService.getConfig().signup.allowed return this.serverService.getConfig().signup.allowed
} }
get isContactFormEnabled () {
return this.serverService.getConfig().email.enabled && this.serverService.getConfig().contactForm.enabled
}
ngOnInit () { ngOnInit () {
this.serverService.getAbout() this.instanceService.getAbout()
.subscribe( .subscribe(
res => { res => {
this.shortDescription = res.instance.shortDescription this.shortDescription = res.instance.shortDescription
@ -45,4 +54,8 @@ export class AboutInstanceComponent implements OnInit {
) )
} }
openContactModal () {
return this.contactAdminModal.show()
}
} }

View File

@ -0,0 +1,50 @@
<ng-template #modal>
<div class="modal-header">
<h4 i18n class="modal-title">Contact {{ instanceName }} administrator</h4>
<span class="close" aria-label="Close" role="button" (click)="hide()"></span>
</div>
<div class="modal-body">
<form novalidate [formGroup]="form" (ngSubmit)="sendForm()">
<div class="form-group">
<label i18n for="fromName">Your name</label>
<input
type="text" id="fromName"
formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }"
>
<div *ngIf="formErrors.fromName" class="form-error">{{ formErrors.fromName }}</div>
</div>
<div class="form-group">
<label i18n for="fromEmail">Your email</label>
<input
type="text" id="fromEmail"
formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }"
>
<div *ngIf="formErrors.fromEmail" class="form-error">{{ formErrors.fromEmail }}</div>
</div>
<div class="form-group">
<label i18n for="body">Your message</label>
<textarea id="body" formControlName="body" [ngClass]="{ 'input-error': formErrors['body'] }">
</textarea>
<div *ngIf="formErrors.body" class="form-error">{{ formErrors.body }}</div>
</div>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<div class="form-group inputs">
<span i18n class="action-button action-button-cancel" (click)="hide()">
Cancel
</span>
<input
type="submit" i18n-value value="Submit" class="action-button-submit"
[disabled]="!form.valid"
>
</div>
</form>
</div>
</ng-template>

View File

@ -0,0 +1,11 @@
@import 'variables';
@import 'mixins';
input[type=text] {
@include peertube-input-text(340px);
display: block;
}
textarea {
@include peertube-textarea(100%, 200px);
}

View File

@ -0,0 +1,72 @@
import { Component, OnInit, ViewChild } from '@angular/core'
import { Notifier } from '@app/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { FormReactive, InstanceValidatorsService } from '@app/shared'
import { InstanceService } from '@app/shared/instance/instance.service'
@Component({
selector: 'my-contact-admin-modal',
templateUrl: './contact-admin-modal.component.html',
styleUrls: [ './contact-admin-modal.component.scss' ]
})
export class ContactAdminModalComponent extends FormReactive implements OnInit {
@ViewChild('modal') modal: NgbModal
error: string
private openedModal: NgbModalRef
constructor (
protected formValidatorService: FormValidatorService,
private modalService: NgbModal,
private instanceValidatorsService: InstanceValidatorsService,
private instanceService: InstanceService,
private notifier: Notifier,
private i18n: I18n
) {
super()
}
ngOnInit () {
this.buildForm({
fromName: this.instanceValidatorsService.FROM_NAME,
fromEmail: this.instanceValidatorsService.FROM_EMAIL,
body: this.instanceValidatorsService.BODY
})
}
show () {
this.openedModal = this.modalService.open(this.modal, { keyboard: false })
}
hide () {
this.form.reset()
this.error = undefined
this.openedModal.close()
this.openedModal = null
}
sendForm () {
const fromName = this.form.value['fromName']
const fromEmail = this.form.value[ 'fromEmail' ]
const body = this.form.value[ 'body' ]
this.instanceService.contactAdministrator(fromEmail, fromName, body)
.subscribe(
() => {
this.notifier.success(this.i18n('Your message has been sent.'))
this.hide()
},
err => {
this.error = err.status === 403
? this.i18n('You already sent this form recently')
: err.message
}
)
}
}

View File

@ -5,6 +5,7 @@ import { AboutComponent } from './about.component'
import { SharedModule } from '../shared' import { SharedModule } from '../shared'
import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component' import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component'
import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component' import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
@NgModule({ @NgModule({
imports: [ imports: [
@ -15,7 +16,8 @@ import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertub
declarations: [ declarations: [
AboutComponent, AboutComponent,
AboutInstanceComponent, AboutInstanceComponent,
AboutPeertubeComponent AboutPeertubeComponent,
ContactAdminModalComponent
], ],
exports: [ exports: [

View File

@ -13,6 +13,7 @@ import { sortBy } from '@app/shared/misc/utils'
@Injectable() @Injectable()
export class ServerService { export class ServerService {
private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server/'
private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config/' private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config/'
private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/' private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/'
@ -147,10 +148,6 @@ export class ServerService {
return this.videoPrivacies return this.videoPrivacies
} }
getAbout () {
return this.http.get<About>(ServerService.BASE_CONFIG_URL + '/about')
}
private loadVideoAttributeEnum ( private loadVideoAttributeEnum (
attributeName: 'categories' | 'licences' | 'languages' | 'privacies', attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
hashToPopulate: VideoConstant<string | number>[], hashToPopulate: VideoConstant<string | number>[],

View File

@ -1,6 +1,7 @@
export * from './custom-config-validators.service' export * from './custom-config-validators.service'
export * from './form-validator.service' export * from './form-validator.service'
export * from './host' export * from './host'
export * from './instance-validators.service'
export * from './login-validators.service' export * from './login-validators.service'
export * from './reset-password-validators.service' export * from './reset-password-validators.service'
export * from './user-validators.service' export * from './user-validators.service'

View File

@ -0,0 +1,48 @@
import { I18n } from '@ngx-translate/i18n-polyfill'
import { Validators } from '@angular/forms'
import { BuildFormValidator } from '@app/shared'
import { Injectable } from '@angular/core'
@Injectable()
export class InstanceValidatorsService {
readonly FROM_EMAIL: BuildFormValidator
readonly FROM_NAME: BuildFormValidator
readonly BODY: BuildFormValidator
constructor (private i18n: I18n) {
this.FROM_EMAIL = {
VALIDATORS: [ Validators.required, Validators.email ],
MESSAGES: {
'required': this.i18n('Email is required.'),
'email': this.i18n('Email must be valid.')
}
}
this.FROM_NAME = {
VALIDATORS: [
Validators.required,
Validators.minLength(1),
Validators.maxLength(120)
],
MESSAGES: {
'required': this.i18n('Your name is required.'),
'minlength': this.i18n('Your name must be at least 1 character long.'),
'maxlength': this.i18n('Your name cannot be more than 120 characters long.')
}
}
this.BODY = {
VALIDATORS: [
Validators.required,
Validators.minLength(3),
Validators.maxLength(5000)
],
MESSAGES: {
'required': this.i18n('A message is required.'),
'minlength': this.i18n('The message must be at least 3 characters long.'),
'maxlength': this.i18n('The message cannot be more than 5000 characters long.')
}
}
}
}

View File

@ -0,0 +1,36 @@
import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { environment } from '../../../environments/environment'
import { RestExtractor, RestService } from '../rest'
import { About } from '../../../../../shared/models/server'
@Injectable()
export class InstanceService {
private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server'
constructor (
private authHttp: HttpClient,
private restService: RestService,
private restExtractor: RestExtractor
) {
}
getAbout () {
return this.authHttp.get<About>(InstanceService.BASE_CONFIG_URL + '/about')
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
contactAdministrator (fromEmail: string, fromName: string, message: string) {
const body = {
fromEmail,
fromName,
body: message
}
return this.authHttp.post(InstanceService.BASE_SERVER_URL + '/contact', body)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
}

View File

@ -37,6 +37,7 @@ import {
LoginValidatorsService, LoginValidatorsService,
ReactiveFileComponent, ReactiveFileComponent,
ResetPasswordValidatorsService, ResetPasswordValidatorsService,
InstanceValidatorsService,
TextareaAutoResizeDirective, TextareaAutoResizeDirective,
UserValidatorsService, UserValidatorsService,
VideoAbuseValidatorsService, VideoAbuseValidatorsService,
@ -65,6 +66,7 @@ import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.com
import { UserHistoryService } from '@app/shared/users/user-history.service' import { UserHistoryService } from '@app/shared/users/user-history.service'
import { UserNotificationService } from '@app/shared/users/user-notification.service' import { UserNotificationService } from '@app/shared/users/user-notification.service'
import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component' import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component'
import { InstanceService } from '@app/shared/instance/instance.service'
@NgModule({ @NgModule({
imports: [ imports: [
@ -185,8 +187,10 @@ import { UserNotificationsComponent } from '@app/shared/users/user-notifications
OverviewService, OverviewService,
VideoChangeOwnershipValidatorsService, VideoChangeOwnershipValidatorsService,
VideoAcceptOwnershipValidatorsService, VideoAcceptOwnershipValidatorsService,
InstanceValidatorsService,
BlocklistService, BlocklistService,
UserHistoryService, UserHistoryService,
InstanceService,
I18nPrimengCalendarService, I18nPrimengCalendarService,
ScreenService, ScreenService,

View File

@ -13,7 +13,7 @@ recreateDB () {
} }
removeFiles () { removeFiles () {
rm -rf "./test$1" "./config/local-test-$1.json" rm -rf "./test$1" "./config/local-test.json" "./config/local-test-$1.json"
} }
dropRedis () { dropRedis () {

View File

@ -65,7 +65,7 @@ async function getConfig (req: express.Request, res: express.Response) {
} }
}, },
email: { email: {
enabled: Emailer.Instance.isEnabled() enabled: Emailer.isEnabled()
}, },
contactForm: { contactForm: {
enabled: CONFIG.CONTACT_FORM.ENABLED enabled: CONFIG.CONTACT_FORM.ENABLED

View File

@ -10,6 +10,7 @@ import { getServerActor } from '../helpers/utils'
import { RecentlyAddedStrategy } from '../../shared/models/redundancy' import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
import { isArray } from '../helpers/custom-validators/misc' import { isArray } from '../helpers/custom-validators/misc'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { Emailer } from '../lib/emailer'
async function checkActivityPubUrls () { async function checkActivityPubUrls () {
const actor = await getServerActor() const actor = await getServerActor()
@ -32,9 +33,19 @@ async function checkActivityPubUrls () {
// Some checks on configuration files // Some checks on configuration files
// Return an error message, or null if everything is okay // Return an error message, or null if everything is okay
function checkConfig () { function checkConfig () {
const defaultNSFWPolicy = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
if (!Emailer.isEnabled()) {
if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
return 'Emailer is disabled but you require signup email verification.'
}
if (CONFIG.CONTACT_FORM.ENABLED) {
logger.warn('Emailer is disabled so the contact form will not work.')
}
}
// NSFW policy // NSFW policy
const defaultNSFWPolicy = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
{ {
const available = [ 'do_not_list', 'blur', 'display' ] const available = [ 'do_not_list', 'blur', 'display' ]
if (available.indexOf(defaultNSFWPolicy) === -1) { if (available.indexOf(defaultNSFWPolicy) === -1) {
@ -68,6 +79,7 @@ function checkConfig () {
} }
} }
// Check storage directory locations
if (isProdInstance()) { if (isProdInstance()) {
const configStorage = config.get('storage') const configStorage = config.get('storage')
for (const key of Object.keys(configStorage)) { for (const key of Object.keys(configStorage)) {

View File

@ -15,7 +15,7 @@ function checkMissedConfig () {
'storage.redundancy', 'storage.tmp', 'storage.redundancy', 'storage.tmp',
'log.level', 'log.level',
'user.video_quota', 'user.video_quota_daily', 'user.video_quota', 'user.video_quota_daily',
'cache.previews.size', 'admin.email', 'cache.previews.size', 'admin.email', 'contact_form.enabled',
'signup.enabled', 'signup.limit', 'signup.requires_email_verification', 'signup.enabled', 'signup.limit', 'signup.requires_email_verification',
'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
'redundancy.videos.strategies', 'redundancy.videos.check_interval', 'redundancy.videos.strategies', 'redundancy.videos.check_interval',

View File

@ -18,7 +18,6 @@ class Emailer {
private static instance: Emailer private static instance: Emailer
private initialized = false private initialized = false
private transporter: Transporter private transporter: Transporter
private enabled = false
private constructor () {} private constructor () {}
@ -27,7 +26,7 @@ class Emailer {
if (this.initialized === true) return if (this.initialized === true) return
this.initialized = true this.initialized = true
if (CONFIG.SMTP.HOSTNAME && CONFIG.SMTP.PORT) { if (Emailer.isEnabled()) {
logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT) logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT)
let tls let tls
@ -55,8 +54,6 @@ class Emailer {
tls, tls,
auth auth
}) })
this.enabled = true
} else { } else {
if (!isTestInstance()) { if (!isTestInstance()) {
logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!') logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!')
@ -64,8 +61,8 @@ class Emailer {
} }
} }
isEnabled () { static isEnabled () {
return this.enabled return !!CONFIG.SMTP.HOSTNAME && !!CONFIG.SMTP.PORT
} }
async checkConnectionOrDie () { async checkConnectionOrDie () {
@ -374,7 +371,7 @@ class Emailer {
} }
sendMail (to: string[], subject: string, text: string, from?: string) { sendMail (to: string[], subject: string, text: string, from?: string) {
if (!this.enabled) { if (!Emailer.isEnabled()) {
throw new Error('Cannot send mail because SMTP is not configured.') throw new Error('Cannot send mail because SMTP is not configured.')
} }

View File

@ -50,7 +50,7 @@ const contactAdministratorValidator = [
.end() .end()
} }
if (Emailer.Instance.isEnabled() === false) { if (Emailer.isEnabled() === false) {
return res return res
.status(409) .status(409)
.send({ error: 'Emailer is not enabled on this instance.' }) .send({ error: 'Emailer is not enabled on this instance.' })