Implement two factor in client

pull/5340/head
Chocobozzz 2022-10-07 11:06:28 +02:00
parent 56f4783075
commit d12b40fb96
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
31 changed files with 621 additions and 68 deletions

View File

@ -39,34 +39,48 @@
<div class="login-form-and-externals"> <div class="login-form-and-externals">
<form myPluginSelector pluginSelectorId="login-form" role="form" (ngSubmit)="login()" [formGroup]="form"> <form myPluginSelector pluginSelectorId="login-form" role="form" (ngSubmit)="login()" [formGroup]="form">
<div class="form-group"> <ng-container *ngIf="!otpStep">
<div> <div class="form-group">
<label i18n for="username">Username or email address</label> <div>
<input <label i18n for="username">Username or email address</label>
type="text" id="username" i18n-placeholder placeholder="Example: john@example.com" required tabindex="1" <input
formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" myAutofocus type="text" id="username" i18n-placeholder placeholder="Example: john@example.com" required tabindex="1"
> formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" myAutofocus
>
</div>
<div *ngIf="formErrors.username" class="form-error">{{ formErrors.username }}</div>
<div *ngIf="hasUsernameUppercase()" i18n class="form-warning">
⚠️ Most email addresses do not include capital letters.
</div>
</div> </div>
<div *ngIf="formErrors.username" class="form-error">{{ formErrors.username }}</div> <div class="form-group">
<label i18n for="password">Password</label>
<div *ngIf="hasUsernameUppercase()" i18n class="form-warning"> <my-input-text
⚠️ Most email addresses do not include capital letters. formControlName="password" inputId="password" i18n-placeholder placeholder="Password"
[formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2"
></my-input-text>
</div> </div>
</div> </ng-container>
<div class="form-group"> <div *ngIf="otpStep" class="form-group">
<label i18n for="password">Password</label> <p i18n>Enter the two-factor code generated by your phone app:</p>
<label i18n for="otp-token">Two factor authentication token</label>
<my-input-text <my-input-text
formControlName="password" inputId="password" i18n-placeholder placeholder="Password" #otpTokenInput
[formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2" [show]="true" formControlName="otp-token" inputId="otp-token"
[formError]="formErrors['otp-token']" autocomplete="otp-token"
></my-input-text> ></my-input-text>
</div> </div>
<input type="submit" class="peertube-button orange-button" i18n-value value="Login" [disabled]="!form.valid"> <input type="submit" class="peertube-button orange-button" i18n-value value="Login" [disabled]="!form.valid">
<div class="additional-links"> <div *ngIf="!otpStep" class="additional-links">
<a i18n role="button" class="link-orange" (click)="openForgotPasswordModal()" i18n-title title="Click here to reset your password">I forgot my password</a> <a i18n role="button" class="link-orange" (click)="openForgotPasswordModal()" i18n-title title="Click here to reset your password">I forgot my password</a>
<ng-container *ngIf="signupAllowed"> <ng-container *ngIf="signupAllowed">

View File

@ -4,7 +4,8 @@ import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core' import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service' import { HooksService } from '@app/core/plugins/hooks.service'
import { LOGIN_PASSWORD_VALIDATOR, LOGIN_USERNAME_VALIDATOR } from '@app/shared/form-validators/login-validators' import { LOGIN_PASSWORD_VALIDATOR, LOGIN_USERNAME_VALIDATOR } from '@app/shared/form-validators/login-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive, FormValidatorService, InputTextComponent } from '@app/shared/shared-forms'
import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { PluginsManager } from '@root-helpers/plugins-manager' import { PluginsManager } from '@root-helpers/plugins-manager'
@ -20,6 +21,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
private static SESSION_STORAGE_REDIRECT_URL_KEY = 'login-previous-url' private static SESSION_STORAGE_REDIRECT_URL_KEY = 'login-previous-url'
@ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef
@ViewChild('otpTokenInput') otpTokenInput: InputTextComponent
accordion: NgbAccordion accordion: NgbAccordion
error: string = null error: string = null
@ -37,6 +39,8 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
codeOfConduct: false codeOfConduct: false
} }
otpStep = false
private openedForgotPasswordModal: NgbModalRef private openedForgotPasswordModal: NgbModalRef
private serverConfig: ServerConfig private serverConfig: ServerConfig
@ -82,7 +86,11 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
// Avoid undefined errors when accessing form error properties // Avoid undefined errors when accessing form error properties
this.buildForm({ this.buildForm({
username: LOGIN_USERNAME_VALIDATOR, username: LOGIN_USERNAME_VALIDATOR,
password: LOGIN_PASSWORD_VALIDATOR password: LOGIN_PASSWORD_VALIDATOR,
'otp-token': {
VALIDATORS: [], // Will be set dynamically
MESSAGES: USER_OTP_TOKEN_VALIDATOR.MESSAGES
}
}) })
this.serverConfig = snapshot.data.serverConfig this.serverConfig = snapshot.data.serverConfig
@ -118,13 +126,20 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
login () { login () {
this.error = null this.error = null
const { username, password } = this.form.value const options = {
username: this.form.value['username'],
password: this.form.value['password'],
otpToken: this.form.value['otp-token']
}
this.authService.login(username, password) this.authService.login(options)
.pipe()
.subscribe({ .subscribe({
next: () => this.redirectService.redirectToPreviousRoute(), next: () => this.redirectService.redirectToPreviousRoute(),
error: err => this.handleError(err) error: err => {
this.handleError(err)
}
}) })
} }
@ -162,7 +177,7 @@ The link will expire within 1 hour.`
private loadExternalAuthToken (username: string, token: string) { private loadExternalAuthToken (username: string, token: string) {
this.isAuthenticatedWithExternalAuth = true this.isAuthenticatedWithExternalAuth = true
this.authService.login(username, null, token) this.authService.login({ username, password: null, token })
.subscribe({ .subscribe({
next: () => { next: () => {
const redirectUrl = this.storage.getItem(LoginComponent.SESSION_STORAGE_REDIRECT_URL_KEY) const redirectUrl = this.storage.getItem(LoginComponent.SESSION_STORAGE_REDIRECT_URL_KEY)
@ -182,6 +197,17 @@ The link will expire within 1 hour.`
} }
private handleError (err: any) { private handleError (err: any) {
if (this.authService.isOTPMissingError(err)) {
this.otpStep = true
setTimeout(() => {
this.form.get('otp-token').setValidators(USER_OTP_TOKEN_VALIDATOR.VALIDATORS)
this.otpTokenInput.focus()
})
return
}
if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.` if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.`
else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.` else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.`
else this.error = err.message else this.error = err.message

View File

@ -7,6 +7,7 @@ import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-b
import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component' import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component' import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
import { MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor'
import { MyAccountComponent } from './my-account.component' import { MyAccountComponent } from './my-account.component'
const myAccountRoutes: Routes = [ const myAccountRoutes: Routes = [
@ -30,6 +31,16 @@ const myAccountRoutes: Routes = [
} }
}, },
{
path: 'two-factor-auth',
component: MyAccountTwoFactorComponent,
data: {
meta: {
title: $localize`Two factor authentication`
}
}
},
{ {
path: 'video-channels', path: 'video-channels',
redirectTo: '/my-library/video-channels', redirectTo: '/my-library/video-channels',

View File

@ -4,7 +4,7 @@ import { Component, OnInit } from '@angular/core'
import { AuthService, ServerService, UserService } from '@app/core' import { AuthService, ServerService, UserService } from '@app/core'
import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { User } from '@shared/models' import { HttpStatusCode, User } from '@shared/models'
@Component({ @Component({
selector: 'my-account-change-email', selector: 'my-account-change-email',
@ -57,7 +57,7 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
}, },
error: err => { error: err => {
if (err.status === 401) { if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
this.error = $localize`You current password is invalid.` this.error = $localize`You current password is invalid.`
return return
} }

View File

@ -7,7 +7,7 @@ import {
USER_PASSWORD_VALIDATOR USER_PASSWORD_VALIDATOR
} from '@app/shared/form-validators/user-validators' } from '@app/shared/form-validators/user-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { User } from '@shared/models' import { HttpStatusCode, User } from '@shared/models'
@Component({ @Component({
selector: 'my-account-change-password', selector: 'my-account-change-password',
@ -57,7 +57,7 @@ export class MyAccountChangePasswordComponent extends FormReactive implements On
}, },
error: err => { error: err => {
if (err.status === 401) { if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
this.error = $localize`You current password is invalid.` this.error = $localize`You current password is invalid.`
return return
} }

View File

@ -18,7 +18,7 @@ export class MyAccountDangerZoneComponent {
) { } ) { }
async deleteMe () { async deleteMe () {
const res = await this.confirmService.confirmWithInput( const res = await this.confirmService.confirmWithExpectedInput(
$localize`Are you sure you want to delete your account?` + $localize`Are you sure you want to delete your account?` +
'<br /><br />' + '<br /><br />' +
// eslint-disable-next-line max-len // eslint-disable-next-line max-len

View File

@ -62,6 +62,16 @@
</div> </div>
</div> </div>
<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- two factor auth grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="account-title">Two-factor authentication</h2>
</div>
<div class="col-12 col-lg-8 col-xl-9">
<my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
</div>
</div>
<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid --> <div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid -->
<div class="col-12 col-lg-4 col-xl-3"> <div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="account-title">EMAIL</h2> <h2 i18n class="account-title">EMAIL</h2>

View File

@ -0,0 +1,3 @@
export * from './my-account-two-factor-button.component'
export * from './my-account-two-factor.component'
export * from './two-factor.service'

View File

@ -0,0 +1,12 @@
<div class="two-factor">
<ng-container *ngIf="!twoFactorEnabled">
<p i18n>Two factor authentication adds an additional layer of security to your account by requiring a numeric code from another device (most commonly mobile phones) when you log in.</p>
<my-button [routerLink]="[ '/my-account/two-factor-auth' ]" className="orange-button-link" i18n>Enable two-factor authentication</my-button>
</ng-container>
<ng-container *ngIf="twoFactorEnabled">
<my-button className="orange-button" (click)="disableTwoFactor()" i18n>Disable two-factor authentication</my-button>
</ng-container>
</div>

View File

@ -0,0 +1,49 @@
import { Subject } from 'rxjs'
import { Component, Input, OnInit } from '@angular/core'
import { AuthService, ConfirmService, Notifier, User } from '@app/core'
import { TwoFactorService } from './two-factor.service'
@Component({
selector: 'my-account-two-factor-button',
templateUrl: './my-account-two-factor-button.component.html'
})
export class MyAccountTwoFactorButtonComponent implements OnInit {
@Input() user: User = null
@Input() userInformationLoaded: Subject<any>
twoFactorEnabled = false
constructor (
private notifier: Notifier,
private twoFactorService: TwoFactorService,
private confirmService: ConfirmService,
private auth: AuthService
) {
}
ngOnInit () {
this.userInformationLoaded.subscribe(() => {
this.twoFactorEnabled = this.user.twoFactorEnabled
})
}
async disableTwoFactor () {
const message = $localize`Are you sure you want to disable two factor authentication of your account?`
const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`)
if (confirmed === false) return
this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })
.subscribe({
next: () => {
this.twoFactorEnabled = false
this.auth.refreshUserInformation()
this.notifier.success($localize`Two factor authentication disabled`)
},
error: err => this.notifier.error(err.message)
})
}
}

View File

@ -0,0 +1,54 @@
<h1>
<my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
<ng-container i18n>Two factor authentication</ng-container>
</h1>
<div i18n *ngIf="twoFactorAlreadyEnabled === true" class="root already-enabled">
Two factor authentication is already enabled.
</div>
<div class="root" *ngIf="twoFactorAlreadyEnabled === false">
<ng-container *ngIf="step === 'request'">
<form role="form" (ngSubmit)="requestTwoFactor()" [formGroup]="formPassword">
<label i18n for="current-password">Your password</label>
<div class="form-group-description" i18n>Confirm your password to enable two factor authentication</div>
<my-input-text
formControlName="current-password" inputId="current-password" i18n-placeholder placeholder="Current password"
[formError]="formErrorsPassword['current-password']" autocomplete="current-password"
></my-input-text>
<input class="peertube-button orange-button mt-3" type="submit" i18n-value value="Confirm" [disabled]="!formPassword.valid">
</form>
</ng-container>
<ng-container *ngIf="step === 'confirm'">
<p i18n>
Scan this QR code into a TOTP app on your phone. This app will generate tokens that you will have to enter when logging in.
</p>
<qrcode [qrdata]="twoFactorURI" [width]="256" level="Q"></qrcode>
<div i18n>
If you can't scan the QR code and need to enter it manually, here is the plain-text secret:
</div>
<div class="secret-plain-text">{{ twoFactorSecret }}</div>
<form class="mt-3" role="form" (ngSubmit)="confirmTwoFactor()" [formGroup]="formOTP">
<label i18n for="otp-token">Two-factor code</label>
<div class="form-group-description" i18n>Enter the code generated by your authenticator app to confirm</div>
<my-input-text
[show]="true" formControlName="otp-token" inputId="otp-token"
[formError]="formErrorsOTP['otp-token']" autocomplete="otp-token"
></my-input-text>
<input class="peertube-button orange-button mt-3" type="submit" i18n-value value="Confirm" [disabled]="!formOTP.valid">
</form>
</ng-container>
</div>

View File

@ -0,0 +1,16 @@
@use '_variables' as *;
@use '_mixins' as *;
.root {
max-width: 600px;
}
.secret-plain-text {
font-family: monospace;
font-size: 0.9rem;
}
qrcode {
display: inline-block;
margin: auto;
}

View File

@ -0,0 +1,105 @@
import { Component, OnInit } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { Router } from '@angular/router'
import { AuthService, Notifier, User } from '@app/core'
import { USER_EXISTING_PASSWORD_VALIDATOR, USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactiveService } from '@app/shared/shared-forms'
import { TwoFactorService } from './two-factor.service'
@Component({
selector: 'my-account-two-factor',
templateUrl: './my-account-two-factor.component.html',
styleUrls: [ './my-account-two-factor.component.scss' ]
})
export class MyAccountTwoFactorComponent implements OnInit {
twoFactorAlreadyEnabled: boolean
step: 'request' | 'confirm' | 'confirmed' = 'request'
twoFactorSecret: string
twoFactorURI: string
inPasswordStep = true
formPassword: FormGroup
formErrorsPassword: any
formOTP: FormGroup
formErrorsOTP: any
private user: User
private requestToken: string
constructor (
private notifier: Notifier,
private twoFactorService: TwoFactorService,
private formReactiveService: FormReactiveService,
private auth: AuthService,
private router: Router
) {
}
ngOnInit () {
this.buildPasswordForm()
this.buildOTPForm()
this.auth.userInformationLoaded.subscribe(() => {
this.user = this.auth.getUser()
this.twoFactorAlreadyEnabled = this.user.twoFactorEnabled
})
}
requestTwoFactor () {
this.twoFactorService.requestTwoFactor({
userId: this.user.id,
currentPassword: this.formPassword.value['current-password']
}).subscribe({
next: ({ otpRequest }) => {
this.requestToken = otpRequest.requestToken
this.twoFactorURI = otpRequest.uri
this.twoFactorSecret = otpRequest.secret.replace(/(.{4})/g, '$1 ').trim()
this.step = 'confirm'
},
error: err => this.notifier.error(err.message)
})
}
confirmTwoFactor () {
this.twoFactorService.confirmTwoFactorRequest({
userId: this.user.id,
requestToken: this.requestToken,
otpToken: this.formOTP.value['otp-token']
}).subscribe({
next: () => {
this.notifier.success($localize`Two factor authentication has been enabled.`)
this.auth.refreshUserInformation()
this.router.navigateByUrl('/my-account/settings')
},
error: err => this.notifier.error(err.message)
})
}
private buildPasswordForm () {
const { form, formErrors } = this.formReactiveService.buildForm({
'current-password': USER_EXISTING_PASSWORD_VALIDATOR
})
this.formPassword = form
this.formErrorsPassword = formErrors
}
private buildOTPForm () {
const { form, formErrors } = this.formReactiveService.buildForm({
'otp-token': USER_OTP_TOKEN_VALIDATOR
})
this.formOTP = form
this.formErrorsOTP = formErrors
}
}

View File

@ -0,0 +1,52 @@
import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, UserService } from '@app/core'
import { TwoFactorEnableResult } from '@shared/models'
@Injectable()
export class TwoFactorService {
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor
) { }
// ---------------------------------------------------------------------------
requestTwoFactor (options: {
userId: number
currentPassword: string
}) {
const { userId, currentPassword } = options
const url = UserService.BASE_USERS_URL + userId + '/two-factor/request'
return this.authHttp.post<TwoFactorEnableResult>(url, { currentPassword })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
confirmTwoFactorRequest (options: {
userId: number
requestToken: string
otpToken: string
}) {
const { userId, requestToken, otpToken } = options
const url = UserService.BASE_USERS_URL + userId + '/two-factor/confirm-request'
return this.authHttp.post(url, { requestToken, otpToken })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
disableTwoFactor (options: {
userId: number
currentPassword: string
}) {
const { userId, currentPassword } = options
const url = UserService.BASE_USERS_URL + userId + '/two-factor/disable'
return this.authHttp.post(url, { currentPassword })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}

View File

@ -1,3 +1,4 @@
import { QRCodeModule } from 'angularx-qrcode'
import { AutoCompleteModule } from 'primeng/autocomplete' import { AutoCompleteModule } from 'primeng/autocomplete'
import { TableModule } from 'primeng/table' import { TableModule } from 'primeng/table'
import { DragDropModule } from '@angular/cdk/drag-drop' import { DragDropModule } from '@angular/cdk/drag-drop'
@ -23,12 +24,18 @@ import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-d
import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences' import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
import {
MyAccountTwoFactorButtonComponent,
MyAccountTwoFactorComponent,
TwoFactorService
} from './my-account-settings/my-account-two-factor'
import { MyAccountComponent } from './my-account.component' import { MyAccountComponent } from './my-account.component'
@NgModule({ @NgModule({
imports: [ imports: [
MyAccountRoutingModule, MyAccountRoutingModule,
QRCodeModule,
AutoCompleteModule, AutoCompleteModule,
TableModule, TableModule,
DragDropModule, DragDropModule,
@ -52,6 +59,9 @@ import { MyAccountComponent } from './my-account.component'
MyAccountChangeEmailComponent, MyAccountChangeEmailComponent,
MyAccountApplicationsComponent, MyAccountApplicationsComponent,
MyAccountTwoFactorButtonComponent,
MyAccountTwoFactorComponent,
MyAccountDangerZoneComponent, MyAccountDangerZoneComponent,
MyAccountBlocklistComponent, MyAccountBlocklistComponent,
MyAccountAbusesListComponent, MyAccountAbusesListComponent,
@ -64,7 +74,9 @@ import { MyAccountComponent } from './my-account.component'
MyAccountComponent MyAccountComponent
], ],
providers: [] providers: [
TwoFactorService
]
}) })
export class MyAccountModule { export class MyAccountModule {
} }

View File

@ -40,7 +40,7 @@ export class MyVideoChannelsComponent {
} }
async deleteVideoChannel (videoChannel: VideoChannel) { async deleteVideoChannel (videoChannel: VideoChannel) {
const res = await this.confirmService.confirmWithInput( const res = await this.confirmService.confirmWithExpectedInput(
$localize`Do you really want to delete ${videoChannel.displayName}? $localize`Do you really want to delete ${videoChannel.displayName}?
It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another
channel with the same name (${videoChannel.name})!`, channel with the same name (${videoChannel.name})!`,

View File

@ -158,7 +158,7 @@ export class RegisterComponent implements OnInit {
} }
// Auto login // Auto login
this.authService.login(body.username, body.password) this.authService.login({ username: body.username, password: body.password })
.subscribe({ .subscribe({
next: () => { next: () => {
this.signupSuccess = true this.signupSuccess = true

View File

@ -1,7 +1,7 @@
import { Hotkey, HotkeysService } from 'angular2-hotkeys' import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs' import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs'
import { catchError, map, mergeMap, share, tap } from 'rxjs/operators' import { catchError, map, mergeMap, share, tap } from 'rxjs/operators'
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http' import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { Notifier } from '@app/core/notification/notifier.service' import { Notifier } from '@app/core/notification/notifier.service'
@ -141,7 +141,14 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
return !!this.getAccessToken() return !!this.getAccessToken()
} }
login (username: string, password: string, token?: string) { login (options: {
username: string
password: string
otpToken?: string
token?: string
}) {
const { username, password, token, otpToken } = options
// Form url encoded // Form url encoded
const body = { const body = {
client_id: this.clientId, client_id: this.clientId,
@ -155,7 +162,9 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
if (token) Object.assign(body, { externalAuthToken: token }) if (token) Object.assign(body, { externalAuthToken: token })
const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') let headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
if (otpToken) headers = headers.set('x-peertube-otp', otpToken)
return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers }) return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers })
.pipe( .pipe(
map(res => Object.assign(res, { username })), map(res => Object.assign(res, { username })),
@ -245,6 +254,14 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
}) })
} }
isOTPMissingError (err: HttpErrorResponse) {
if (err.status !== HttpStatusCode.UNAUTHORIZED_401) return false
if (err.headers.get('x-peertube-otp') !== 'required; app') return false
return true
}
private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> { private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> {
// User is not loaded yet, set manually auth header // User is not loaded yet, set manually auth header
const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`) const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`)

View File

@ -1,28 +1,53 @@
import { firstValueFrom, Subject } from 'rxjs' import { firstValueFrom, map, Observable, Subject } from 'rxjs'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
type ConfirmOptions = { type ConfirmOptions = {
title: string title: string
message: string message: string
inputLabel?: string } & (
expectedInputValue?: string {
confirmButtonText?: string type: 'confirm'
} confirmButtonText?: string
} |
{
type: 'confirm-password'
confirmButtonText?: string
} |
{
type: 'confirm-expected-input'
inputLabel?: string
expectedInputValue?: string
confirmButtonText?: string
}
)
@Injectable() @Injectable()
export class ConfirmService { export class ConfirmService {
showConfirm = new Subject<ConfirmOptions>() showConfirm = new Subject<ConfirmOptions>()
confirmResponse = new Subject<boolean>() confirmResponse = new Subject<{ confirmed: boolean, value?: string }>()
confirm (message: string, title = '', confirmButtonText?: string) { confirm (message: string, title = '', confirmButtonText?: string) {
this.showConfirm.next({ title, message, confirmButtonText }) this.showConfirm.next({ type: 'confirm', title, message, confirmButtonText })
return firstValueFrom(this.confirmResponse.asObservable()) return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
} }
confirmWithInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) { confirmWithPassword (message: string, title = '', confirmButtonText?: string) {
this.showConfirm.next({ title, message, inputLabel, expectedInputValue, confirmButtonText }) this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText })
return firstValueFrom(this.confirmResponse.asObservable()) const obs = this.confirmResponse.asObservable()
.pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
return firstValueFrom(obs)
}
confirmWithExpectedInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) {
this.showConfirm.next({ type: 'confirm-expected-input', title, message, inputLabel, expectedInputValue, confirmButtonText })
return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
}
private extractConfirmed (obs: Observable<{ confirmed: boolean }>) {
return obs.pipe(map(({ confirmed }) => confirmed))
} }
} }

View File

@ -4,6 +4,7 @@ import { Router } from '@angular/router'
import { DateFormat, dateToHuman } from '@app/helpers' import { DateFormat, dateToHuman } from '@app/helpers'
import { logger } from '@root-helpers/logger' import { logger } from '@root-helpers/logger'
import { HttpStatusCode, ResultList } from '@shared/models' import { HttpStatusCode, ResultList } from '@shared/models'
import { HttpHeaderResponse } from '@angular/common/http'
@Injectable() @Injectable()
export class RestExtractor { export class RestExtractor {
@ -54,10 +55,11 @@ export class RestExtractor {
handleError (err: any) { handleError (err: any) {
const errorMessage = this.buildErrorMessage(err) const errorMessage = this.buildErrorMessage(err)
const errorObj: { message: string, status: string, body: string } = { const errorObj: { message: string, status: string, body: string, headers: HttpHeaderResponse } = {
message: errorMessage, message: errorMessage,
status: undefined, status: undefined,
body: undefined body: undefined,
headers: err.headers
} }
if (err.status) { if (err.status) {

View File

@ -66,6 +66,8 @@ export class User implements UserServerModel {
lastLoginDate: Date | null lastLoginDate: Date | null
twoFactorEnabled: boolean
createdAt: Date createdAt: Date
constructor (hash: Partial<UserServerModel>) { constructor (hash: Partial<UserServerModel>) {
@ -108,6 +110,8 @@ export class User implements UserServerModel {
this.notificationSettings = hash.notificationSettings this.notificationSettings = hash.notificationSettings
this.twoFactorEnabled = hash.twoFactorEnabled
this.createdAt = hash.createdAt this.createdAt = hash.createdAt
this.pluginAuth = hash.pluginAuth this.pluginAuth = hash.pluginAuth

View File

@ -9,9 +9,12 @@
<div class="modal-body" > <div class="modal-body" >
<div [innerHtml]="message"></div> <div [innerHtml]="message"></div>
<div *ngIf="inputLabel && expectedInputValue" class="form-group mt-3"> <div *ngIf="inputLabel" class="form-group mt-3">
<label for="confirmInput">{{ inputLabel }}</label> <label for="confirmInput">{{ inputLabel }}</label>
<input type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
<input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
<my-input-text inputId="confirmInput" [(ngModel)]="inputValue"></my-input-text>
</div> </div>
</div> </div>

View File

@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit {
inputValue = '' inputValue = ''
confirmButtonText = '' confirmButtonText = ''
isPasswordInput = false
private openedModal: NgbModalRef private openedModal: NgbModalRef
constructor ( constructor (
@ -31,11 +33,27 @@ export class ConfirmComponent implements OnInit {
ngOnInit () { ngOnInit () {
this.confirmService.showConfirm.subscribe( this.confirmService.showConfirm.subscribe(
({ title, message, expectedInputValue, inputLabel, confirmButtonText }) => { payload => {
// Reinit fields
this.title = ''
this.message = ''
this.expectedInputValue = ''
this.inputLabel = ''
this.inputValue = ''
this.confirmButtonText = ''
this.isPasswordInput = false
const { type, title, message, confirmButtonText } = payload
this.title = title this.title = title
this.inputLabel = inputLabel if (type === 'confirm-expected-input') {
this.expectedInputValue = expectedInputValue this.inputLabel = payload.inputLabel
this.expectedInputValue = payload.expectedInputValue
} else if (type === 'confirm-password') {
this.inputLabel = $localize`Confirm your password`
this.isPasswordInput = true
}
this.confirmButtonText = confirmButtonText || $localize`Confirm` this.confirmButtonText = confirmButtonText || $localize`Confirm`
@ -66,11 +84,13 @@ export class ConfirmComponent implements OnInit {
this.openedModal = this.modalService.open(this.confirmModal, { centered: true }) this.openedModal = this.modalService.open(this.confirmModal, { centered: true })
this.openedModal.result this.openedModal.result
.then(() => this.confirmService.confirmResponse.next(true)) .then(() => {
this.confirmService.confirmResponse.next({ confirmed: true, value: this.inputValue })
})
.catch((reason: string) => { .catch((reason: string) => {
// If the reason was that the user used the back button, we don't care about the confirm dialog result // If the reason was that the user used the back button, we don't care about the confirm dialog result
if (!reason || reason !== POP_STATE_MODAL_DISMISS) { if (!reason || reason !== POP_STATE_MODAL_DISMISS) {
this.confirmService.confirmResponse.next(false) this.confirmService.confirmResponse.next({ confirmed: false, value: this.inputValue })
} }
}) })
} }

View File

@ -61,6 +61,15 @@ export const USER_EXISTING_PASSWORD_VALIDATOR: BuildFormValidator = {
} }
} }
export const USER_OTP_TOKEN_VALIDATOR: BuildFormValidator = {
VALIDATORS: [
Validators.required
],
MESSAGES: {
required: $localize`OTP token is required.`
}
}
export const USER_PASSWORD_VALIDATOR = { export const USER_PASSWORD_VALIDATOR = {
VALIDATORS: [ VALIDATORS: [
Validators.required, Validators.required,

View File

@ -0,0 +1,101 @@
import { Injectable } from '@angular/core'
import { AbstractControl, FormGroup } from '@angular/forms'
import { wait } from '@root-helpers/utils'
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
import { FormValidatorService } from './form-validator.service'
export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
export type FormReactiveValidationMessages = {
[ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
}
@Injectable()
export class FormReactiveService {
constructor (private formValidatorService: FormValidatorService) {
}
buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues)
form.statusChanges.subscribe(async () => {
// FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
await this.waitPendingCheck(form)
this.onStatusChanged({ form, formErrors, validationMessages })
})
return { form, formErrors, validationMessages }
}
async waitPendingCheck (form: FormGroup) {
if (form.status !== 'PENDING') return
// FIXME: the following line does not work: https://github.com/angular/angular/issues/41519
// return firstValueFrom(form.statusChanges.pipe(filter(status => status !== 'PENDING')))
// So we have to fallback to active wait :/
do {
await wait(10)
} while (form.status === 'PENDING')
}
markAllAsDirty (controlsArg: { [ key: string ]: AbstractControl }) {
const controls = controlsArg
for (const key of Object.keys(controls)) {
const control = controls[key]
if (control instanceof FormGroup) {
this.markAllAsDirty(control.controls)
continue
}
control.markAsDirty()
}
}
protected forceCheck (form: FormGroup, formErrors: any, validationMessages: FormReactiveValidationMessages) {
this.onStatusChanged({ form, formErrors, validationMessages, onlyDirty: false })
}
private onStatusChanged (options: {
form: FormGroup
formErrors: FormReactiveErrors
validationMessages: FormReactiveValidationMessages
onlyDirty?: boolean // default true
}) {
const { form, formErrors, validationMessages, onlyDirty = true } = options
for (const field of Object.keys(formErrors)) {
if (formErrors[field] && typeof formErrors[field] === 'object') {
this.onStatusChanged({
form: form.controls[field] as FormGroup,
formErrors: formErrors[field] as FormReactiveErrors,
validationMessages: validationMessages[field] as FormReactiveValidationMessages,
onlyDirty
})
continue
}
// clear previous error message (if any)
formErrors[field] = ''
const control = form.get(field)
if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
const staticMessages = validationMessages[field]
for (const key of Object.keys(control.errors)) {
const formErrorValue = control.errors[key]
// Try to find error message in static validation messages first
// Then check if the validator returns a string that is the error
if (staticMessages[key]) formErrors[field] += staticMessages[key] + ' '
else if (typeof formErrorValue === 'string') formErrors[field] += control.errors[key]
else throw new Error('Form error value of ' + field + ' is invalid')
}
}
}
}

View File

@ -1,14 +1,9 @@
import { AbstractControl, FormGroup } from '@angular/forms' import { AbstractControl, FormGroup } from '@angular/forms'
import { wait } from '@root-helpers/utils' import { wait } from '@root-helpers/utils'
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service'
import { FormValidatorService } from './form-validator.service' import { FormValidatorService } from './form-validator.service'
export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
export type FormReactiveValidationMessages = {
[ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
}
export abstract class FormReactive { export abstract class FormReactive {
protected abstract formValidatorService: FormValidatorService protected abstract formValidatorService: FormValidatorService
protected formChanged = false protected formChanged = false

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms' import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms'
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive' import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service'
@Injectable() @Injectable()
export class FormValidatorService { export class FormValidatorService {

View File

@ -1,4 +1,5 @@
export * from './advanced-input-filter.component' export * from './advanced-input-filter.component'
export * from './form-reactive.service'
export * from './form-reactive' export * from './form-reactive'
export * from './form-validator.service' export * from './form-validator.service'
export * from './form-validator.service' export * from './form-validator.service'

View File

@ -1,4 +1,4 @@
import { Component, forwardRef, Input } from '@angular/core' import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { Notifier } from '@app/core' import { Notifier } from '@app/core'
@ -15,6 +15,8 @@ import { Notifier } from '@app/core'
] ]
}) })
export class InputTextComponent implements ControlValueAccessor { export class InputTextComponent implements ControlValueAccessor {
@ViewChild('input') inputElement: ElementRef
@Input() inputId = Math.random().toString(11).slice(2, 8) // id cannot be left empty or undefined @Input() inputId = Math.random().toString(11).slice(2, 8) // id cannot be left empty or undefined
@Input() value = '' @Input() value = ''
@Input() autocomplete = 'off' @Input() autocomplete = 'off'
@ -65,4 +67,10 @@ export class InputTextComponent implements ControlValueAccessor {
update () { update () {
this.propagateChange(this.value) this.propagateChange(this.value)
} }
focus () {
const el: HTMLElement = this.inputElement.nativeElement
el.focus({ preventScroll: true })
}
} }

View File

@ -1,4 +1,3 @@
import { InputMaskModule } from 'primeng/inputmask' import { InputMaskModule } from 'primeng/inputmask'
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@ -7,6 +6,7 @@ import { SharedGlobalIconModule } from '../shared-icons'
import { SharedMainModule } from '../shared-main/shared-main.module' import { SharedMainModule } from '../shared-main/shared-main.module'
import { AdvancedInputFilterComponent } from './advanced-input-filter.component' import { AdvancedInputFilterComponent } from './advanced-input-filter.component'
import { DynamicFormFieldComponent } from './dynamic-form-field.component' import { DynamicFormFieldComponent } from './dynamic-form-field.component'
import { FormReactiveService } from './form-reactive.service'
import { FormValidatorService } from './form-validator.service' import { FormValidatorService } from './form-validator.service'
import { InputSwitchComponent } from './input-switch.component' import { InputSwitchComponent } from './input-switch.component'
import { InputTextComponent } from './input-text.component' import { InputTextComponent } from './input-text.component'
@ -96,7 +96,8 @@ import { TimestampInputComponent } from './timestamp-input.component'
], ],
providers: [ providers: [
FormValidatorService FormValidatorService,
FormReactiveService
] ]
}) })
export class SharedFormModule { } export class SharedFormModule { }

View File

@ -27,13 +27,16 @@ export class AuthInterceptor implements HttpInterceptor {
.pipe( .pipe(
catchError((err: HttpErrorResponse) => { catchError((err: HttpErrorResponse) => {
const error = err.error as PeerTubeProblemDocument const error = err.error as PeerTubeProblemDocument
const isOTPMissingError = this.authService.isOTPMissingError(err)
if (err.status === HttpStatusCode.UNAUTHORIZED_401 && error && error.code === OAuth2ErrorCode.INVALID_TOKEN) { if (!isOTPMissingError) {
return this.handleTokenExpired(req, next) if (err.status === HttpStatusCode.UNAUTHORIZED_401 && error && error.code === OAuth2ErrorCode.INVALID_TOKEN) {
} return this.handleTokenExpired(req, next)
}
if (err.status === HttpStatusCode.UNAUTHORIZED_401) { if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
return this.handleNotAuthenticated(err) return this.handleNotAuthenticated(err)
}
} }
return observableThrowError(() => err) return observableThrowError(() => err)