Multi step registration

pull/1856/head
Chocobozzz 2019-05-29 11:03:01 +02:00
parent e590b4a512
commit 1d5342abc4
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
19 changed files with 502 additions and 93 deletions

View File

@ -14,9 +14,6 @@
input {
@include peertube-checkbox(1px);
width: 10px;
margin-right: 10px;
}
}

View File

@ -9,6 +9,7 @@ import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
import { SortMeta } from 'primeng/api'
import { BytesPipe } from 'ngx-pipes'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { UserRegister } from '@shared/models/users/user-register.model'
@Injectable()
export class UserService {
@ -64,7 +65,7 @@ export class UserService {
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
signup (userCreate: UserCreate) {
signup (userCreate: UserRegister) {
return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
.pipe(
map(this.restExtractor.extractDataBool),

View File

@ -0,0 +1,25 @@
<section class="container">
<header>
<ng-container *ngFor="let step of steps; let i = index; let isLast = last;">
<div
class="step-info" [ngClass]="{ active: selectedIndex === i, completed: isCompleted(step) }"
(click)="onClick(i)"
>
<div class="step-index">
<ng-container *ngIf="!isCompleted(step)">{{ i + 1 }}</ng-container>
<my-global-icon *ngIf="isCompleted(step)" iconName="tick"></my-global-icon>
</div>
<div class="step-label">{{ step.label }}</div>
</div>
<!-- Do no display if this is the last child -->
<div *ngIf="!isLast" class="connector"></div>
</ng-container>
</header>
<div [style.display]="selected ? 'block' : 'none'">
<ng-container [ngTemplateOutlet]="selected.content"></ng-container>
</div>
</section>

View File

@ -0,0 +1,66 @@
@import '_variables';
@import '_mixins';
$grey-color: #9CA3AB;
$index-block-height: 32px;
header {
display: flex;
justify-content: space-between;
font-size: 15px;
margin-bottom: 30px;
.step-info {
color: $grey-color;
display: flex;
flex-direction: column;
align-items: center;
width: $index-block-height;
.step-index {
display: flex;
justify-content: center;
align-items: center;
width: $index-block-height;
height: $index-block-height;
border-radius: 100px;
border: 2px solid $grey-color;
margin-bottom: 10px;
my-global-icon {
@include apply-svg-color(var(--mainBackgroundColor));
width: 22px;
height: 22px;
}
}
.step-label {
width: max-content;
}
&.active,
&.completed {
.step-index {
border-color: var(--mainColor);
background-color: var(--mainColor);
color: var(--mainBackgroundColor);
}
.step-label {
color: var(--mainColor);
}
}
&.completed {
cursor: pointer;
}
}
.connector {
flex: auto;
margin: $index-block-height/2 10px 0 10px;
height: 2px;
background-color: $grey-color;
}
}

View File

@ -0,0 +1,19 @@
import { Component } from '@angular/core'
import { CdkStep, CdkStepper } from '@angular/cdk/stepper'
@Component({
selector: 'my-custom-stepper',
templateUrl: './custom-stepper.component.html',
styleUrls: [ './custom-stepper.component.scss' ],
providers: [ { provide: CdkStepper, useExisting: CustomStepperComponent } ]
})
export class CustomStepperComponent extends CdkStepper {
onClick (index: number): void {
this.selectedIndex = index
}
isCompleted (step: CdkStep) {
return step.stepControl && step.stepControl.dirty && step.stepControl.valid
}
}

View File

@ -0,0 +1,50 @@
<form role="form" [formGroup]="form">
<div class="channel-explanations">
<p i18n>
A channel is an entity in which you upload your videos. Creating several of them helps you to organize and separate your content.<br />
For example, you could decide to have a channel to publish your piano concerts, and another channel in which you publish your videos talking about ecology.
</p>
<p>
Other users can decide to subscribe any channel they want, to be notified when you publish a new video.
</p>
</div>
<div class="form-group">
<label for="name" i18n>Channel name</label>
<div class="input-group">
<input
type="text" id="name" i18n-placeholder placeholder="Example: my_super_channel"
formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }"
>
<div class="input-group-append">
<span class="input-group-text">@{{ instanceHost }}</span>
</div>
</div>
<div *ngIf="formErrors.name" class="form-error">
{{ formErrors.name }}
</div>
<div *ngIf="isSameThanUsername()" class="form-error" i18n>
Channel name cannot be the same than your account name. You can click on the first step to update your account name.
</div>
</div>
<div class="form-group">
<label for="displayName" i18n>Channel display name</label>
<div class="input-group">
<input
type="text" id="displayName"
formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
>
</div>
<div *ngIf="formErrors.displayName" class="form-error">
{{ formErrors.displayName }}
</div>
</div>
</form>

View File

@ -0,0 +1,40 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { AuthService } from '@app/core'
import { FormReactive, VideoChannelValidatorsService } from '../shared'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { FormGroup } from '@angular/forms'
@Component({
selector: 'my-signup-step-channel',
templateUrl: './signup-step-channel.component.html',
styleUrls: [ './signup.component.scss' ]
})
export class SignupStepChannelComponent extends FormReactive implements OnInit {
@Input() username: string
@Output() formBuilt = new EventEmitter<FormGroup>()
constructor (
protected formValidatorService: FormValidatorService,
private authService: AuthService,
private videoChannelValidatorsService: VideoChannelValidatorsService
) {
super()
}
get instanceHost () {
return window.location.host
}
isSameThanUsername () {
return this.username && this.username === this.form.value['name']
}
ngOnInit () {
this.buildForm({
name: this.videoChannelValidatorsService.VIDEO_CHANNEL_NAME,
displayName: this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME
})
setTimeout(() => this.formBuilt.emit(this.form))
}
}

View File

@ -0,0 +1,54 @@
<form role="form" [formGroup]="form">
<div class="form-group">
<label for="username" i18n>Username</label>
<div class="input-group">
<input
type="text" id="username" i18n-placeholder placeholder="Example: jane_doe"
formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }"
>
<div class="input-group-append">
<span class="input-group-text">@{{ instanceHost }}</span>
</div>
</div>
<div *ngIf="formErrors.username" class="form-error">
{{ formErrors.username }}
</div>
</div>
<div class="form-group">
<label for="email" i18n>Email</label>
<input
type="text" id="email" i18n-placeholder placeholder="Email"
formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }"
>
<div *ngIf="formErrors.email" class="form-error">
{{ formErrors.email }}
</div>
</div>
<div class="form-group">
<label for="password" i18n>Password</label>
<input
type="password" id="password" i18n-placeholder placeholder="Password"
formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
>
<div *ngIf="formErrors.password" class="form-error">
{{ formErrors.password }}
</div>
</div>
<div class="form-group form-group-terms">
<my-peertube-checkbox
inputName="terms" formControlName="terms"
i18n-labelHtml
labelHtml="I am at least 16 years old and agree to the <a href='/about/instance#terms-section' target='_blank'rel='noopener noreferrer'>Terms</a> of this instance"
></my-peertube-checkbox>
<div *ngIf="formErrors.terms" class="form-error">
{{ formErrors.terms }}
</div>
</div>
</form>

View File

@ -0,0 +1,37 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core'
import { AuthService } from '@app/core'
import { FormReactive, UserValidatorsService } from '../shared'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { FormGroup } from '@angular/forms'
@Component({
selector: 'my-signup-step-user',
templateUrl: './signup-step-user.component.html',
styleUrls: [ './signup.component.scss' ]
})
export class SignupStepUserComponent extends FormReactive implements OnInit {
@Output() formBuilt = new EventEmitter<FormGroup>()
constructor (
protected formValidatorService: FormValidatorService,
private authService: AuthService,
private userValidatorsService: UserValidatorsService
) {
super()
}
get instanceHost () {
return window.location.host
}
ngOnInit () {
this.buildForm({
username: this.userValidatorsService.USER_USERNAME,
password: this.userValidatorsService.USER_PASSWORD,
email: this.userValidatorsService.USER_EMAIL,
terms: this.userValidatorsService.USER_TERMS
})
setTimeout(() => this.formBuilt.emit(this.form))
}
}

View File

@ -4,64 +4,35 @@
Create an account
</div>
<my-success *ngIf="signupDone"></my-success>
<div *ngIf="info" class="alert alert-info">{{ info }}</div>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<div *ngIf="success" class="alert alert-success">{{ success }}</div>
<div class="d-flex justify-content-left flex-wrap">
<form role="form" (ngSubmit)="signup()" [formGroup]="form">
<div class="form-group">
<label for="username" i18n>Username</label>
<div class="wrapper" *ngIf="!signupDone">
<div>
<my-custom-stepper linear *ngIf="!signupDone">
<cdk-step [stepControl]="formStepUser" i18n-label label="User information">
<my-signup-step-user (formBuilt)="onUserFormBuilt($event)"></my-signup-step-user>
<div class="input-group">
<input
type="text" id="username" i18n-placeholder placeholder="Example: jane_doe"
formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }"
<button i18n cdkStepperNext [disabled]="!formStepUser || !formStepUser.valid">Next</button>
</cdk-step>
<cdk-step [stepControl]="formStepChannel" i18n-label label="Channel information">
<my-signup-step-channel (formBuilt)="onChannelFormBuilt($event)" [username]="getUsername()"></my-signup-step-channel>
<button i18n cdkStepperNext (click)="signup()"
[disabled]="!formStepChannel || !formStepChannel.valid || hasSameChannelAndAccountNames()"
>
<div class="input-group-append">
<span class="input-group-text">@{{ instanceHost }}</span>
</div>
</div>
Create my account
</button>
</cdk-step>
<div *ngIf="formErrors.username" class="form-error">
{{ formErrors.username }}
</div>
</div>
<div class="form-group">
<label for="email" i18n>Email</label>
<input
type="text" id="email" i18n-placeholder placeholder="Email"
formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }"
>
<div *ngIf="formErrors.email" class="form-error">
{{ formErrors.email }}
</div>
</div>
<div class="form-group">
<label for="password" i18n>Password</label>
<input
type="password" id="password" i18n-placeholder placeholder="Password"
formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
>
<div *ngIf="formErrors.password" class="form-error">
{{ formErrors.password }}
</div>
</div>
<div class="form-group form-group-terms">
<my-peertube-checkbox
inputName="terms" formControlName="terms"
i18n-labelHtml labelHtml="I am at least 16 years old and agree to the <a href='/about/instance#terms-section' target='_blank'rel='noopener noreferrer'>Terms</a> of this instance"
></my-peertube-checkbox>
<div *ngIf="formErrors.terms" class="form-error">
{{ formErrors.terms }}
</div>
</div>
<input type="submit" i18n-value value="Signup" [disabled]="!form.valid || signupDone">
</form>
<cdk-step i18n-label label="Done" editable="false">
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
</cdk-step>
</my-custom-stepper>
</div>
<div>
<label i18n>Features found on this instance</label>

View File

@ -1,16 +1,32 @@
@import '_variables';
@import '_mixins';
.alert {
font-size: 15px;
text-align: center;
}
.wrapper {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
& > div {
margin-bottom: 40px;
width: 450px;
@media screen and (max-width: 500px) {
width: auto;
}
}
}
my-instance-features-table {
display: block;
margin-bottom: 40px;
}
form {
margin: 0 60px 40px 0;
}
.form-group-terms {
margin: 30px 0;
}
@ -25,15 +41,18 @@ form {
input:not([type=submit]) {
@include peertube-input-text(400px);
display: block;
&#username {
width: auto;
&#username,
&#name {
width: auto !important;
flex-grow: 1;
}
}
input[type=submit] {
input[type=submit],
button {
@include peertube-button;
@include orange-button;
}

View File

@ -1,22 +1,25 @@
import { Component, OnInit } from '@angular/core'
import { Component } from '@angular/core'
import { AuthService, Notifier, RedirectService, ServerService } from '@app/core'
import { UserCreate } from '../../../../shared'
import { FormReactive, UserService, UserValidatorsService } from '../shared'
import { UserService, UserValidatorsService } from '../shared'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { UserRegister } from '@shared/models/users/user-register.model'
import { FormGroup } from '@angular/forms'
@Component({
selector: 'my-signup',
templateUrl: './signup.component.html',
styleUrls: [ './signup.component.scss' ]
})
export class SignupComponent extends FormReactive implements OnInit {
export class SignupComponent {
info: string = null
error: string = null
success: string = null
signupDone = false
formStepUser: FormGroup
formStepChannel: FormGroup
constructor (
protected formValidatorService: FormValidatorService,
private authService: AuthService,
private userValidatorsService: UserValidatorsService,
private notifier: Notifier,
@ -25,47 +28,55 @@ export class SignupComponent extends FormReactive implements OnInit {
private redirectService: RedirectService,
private i18n: I18n
) {
super()
}
get instanceHost () {
return window.location.host
}
get requiresEmailVerification () {
return this.serverService.getConfig().signup.requiresEmailVerification
}
ngOnInit () {
this.buildForm({
username: this.userValidatorsService.USER_USERNAME,
password: this.userValidatorsService.USER_PASSWORD,
email: this.userValidatorsService.USER_EMAIL,
terms: this.userValidatorsService.USER_TERMS
})
hasSameChannelAndAccountNames () {
return this.getUsername() === this.getChannelName()
}
getUsername () {
if (!this.formStepUser) return undefined
return this.formStepUser.value['username']
}
getChannelName () {
if (!this.formStepChannel) return undefined
return this.formStepChannel.value['name']
}
onUserFormBuilt (form: FormGroup) {
this.formStepUser = form
}
onChannelFormBuilt (form: FormGroup) {
this.formStepChannel = form
}
signup () {
this.error = null
const userCreate: UserCreate = this.form.value
const body: UserRegister = Object.assign(this.formStepUser.value, this.formStepChannel.value)
this.userService.signup(userCreate).subscribe(
this.userService.signup(body).subscribe(
() => {
this.signupDone = true
if (this.requiresEmailVerification) {
this.info = this.i18n('Welcome! Now please check your emails to verify your account and complete signup.')
this.info = this.i18n('Now please check your emails to verify your account and complete signup.')
return
}
// Auto login
this.authService.login(userCreate.username, userCreate.password)
this.authService.login(body.username, body.password)
.subscribe(
() => {
this.notifier.success(this.i18n('You are now logged in as {{username}}!', { username: userCreate.username }))
this.redirectService.redirectToHomepage()
this.success = this.i18n('You are now logged in as {{username}}!', { username: body.username })
},
err => this.error = err.message

View File

@ -1,17 +1,26 @@
import { NgModule } from '@angular/core'
import { SignupRoutingModule } from './signup-routing.module'
import { SignupComponent } from './signup.component'
import { SharedModule } from '../shared'
import { CdkStepperModule } from '@angular/cdk/stepper'
import { SignupStepChannelComponent } from '@app/signup/signup-step-channel.component'
import { SignupStepUserComponent } from '@app/signup/signup-step-user.component'
import { CustomStepperComponent } from '@app/signup/custom-stepper.component'
import { SuccessComponent } from '@app/signup/success.component'
@NgModule({
imports: [
SignupRoutingModule,
SharedModule
SharedModule,
CdkStepperModule
],
declarations: [
SignupComponent
SignupComponent,
CustomStepperComponent,
SuccessComponent,
SignupStepChannelComponent,
SignupStepUserComponent
],
exports: [

View File

@ -0,0 +1,8 @@
<!-- Thanks: Amit Singh Sansoya from https://codepen.io/amit3200/pen/zWMJOO -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130.2 130.2">
<circle class="path circle" fill="none" stroke="#73AF55" stroke-width="6" stroke-miterlimit="10" cx="65.1" cy="65.1" r="62.1"/>
<polyline class="path check" fill="none" stroke="#73AF55" stroke-width="6" stroke-linecap="round" stroke-miterlimit="10" points="100.2,40.2 51.5,88.8 29.8,67.5 "/>
</svg>
<p class="success">Welcome on PeerTube!</p>

After

Width:  |  Height:  |  Size: 510 B

View File

@ -0,0 +1,74 @@
svg {
width: 100px;
display: block;
margin: 40px auto 0;
}
.path {
stroke-dasharray: 1000;
stroke-dashoffset: 0;
&.circle {
-webkit-animation: dash .9s ease-in-out;
animation: dash .9s ease-in-out;
}
&.line {
stroke-dashoffset: 1000;
-webkit-animation: dash .9s .35s ease-in-out forwards;
animation: dash .9s .35s ease-in-out forwards;
}
&.check {
stroke-dashoffset: -100;
-webkit-animation: dash-check .9s .35s ease-in-out forwards;
animation: dash-check .9s .35s ease-in-out forwards;
}
}
p {
text-align: center;
margin: 20px 0 60px;
font-size: 1.25em;
&.success {
color: #73AF55;
}
}
@-webkit-keyframes dash {
0% {
stroke-dashoffset: 1000;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes dash {
0% {
stroke-dashoffset: 1000;
}
100% {
stroke-dashoffset: 0;
}
}
@-webkit-keyframes dash-check {
0% {
stroke-dashoffset: -100;
}
100% {
stroke-dashoffset: 900;
}
}
@keyframes dash-check {
0% {
stroke-dashoffset: -100;
}
100% {
stroke-dashoffset: 900;
}
}

View File

@ -0,0 +1,10 @@
import { Component } from '@angular/core'
@Component({
selector: 'my-success',
templateUrl: './success.component.html',
styleUrls: [ './success.component.scss' ]
})
export class SuccessComponent {
}

View File

@ -331,7 +331,12 @@
}
@mixin peertube-checkbox ($border-width) {
display: none;
opacity: 0;
width: 0;
&:focus + span {
outline: auto;
}
& + span {
position: relative;

View File

@ -70,6 +70,12 @@ const usersRegisterValidator = [
.end()
}
if (body.channel.name === body.username) {
return res.status(400)
.send({ error: 'Channel name cannot be the same than user username.' })
.end()
}
const existing = await ActorModel.loadLocalByName(body.channel.name)
if (existing) {
return res.status(409)

View File

@ -737,6 +737,13 @@ describe('Test users API validators', function () {
await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
})
it('Should fail with a channel name that is the same than user username', async function () {
const source = { username: 'super_user', channel: { name: 'super_user', displayName: 'display name' } }
const fields = immutableAssign(baseCorrectParams, source)
await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
})
it('Should fail with an existing channel', async function () {
const videoChannelAttributesArg = { name: 'existing_channel', displayName: 'hello', description: 'super description' }
await addVideoChannel(server.url, server.accessToken, videoChannelAttributesArg)