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">
<form myPluginSelector pluginSelectorId="login-form" role="form" (ngSubmit)="login()" [formGroup]="form">
<div class="form-group">
<div>
<label i18n for="username">Username or email address</label>
<input
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
>
<ng-container *ngIf="!otpStep">
<div class="form-group">
<div>
<label i18n for="username">Username or email address</label>
<input
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 *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">
⚠️ Most email addresses do not include capital letters.
<my-input-text
formControlName="password" inputId="password" i18n-placeholder placeholder="Password"
[formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2"
></my-input-text>
</div>
</div>
</ng-container>
<div class="form-group">
<label i18n for="password">Password</label>
<div *ngIf="otpStep" class="form-group">
<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
formControlName="password" inputId="password" i18n-placeholder placeholder="Password"
[formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2"
#otpTokenInput
[show]="true" formControlName="otp-token" inputId="otp-token"
[formError]="formErrors['otp-token']" autocomplete="otp-token"
></my-input-text>
</div>
<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>
<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 { HooksService } from '@app/core/plugins/hooks.service'
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 { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
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'
@ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef
@ViewChild('otpTokenInput') otpTokenInput: InputTextComponent
accordion: NgbAccordion
error: string = null
@ -37,6 +39,8 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
codeOfConduct: false
}
otpStep = false
private openedForgotPasswordModal: NgbModalRef
private serverConfig: ServerConfig
@ -82,7 +86,11 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
// Avoid undefined errors when accessing form error properties
this.buildForm({
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
@ -118,13 +126,20 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
login () {
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({
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) {
this.isAuthenticatedWithExternalAuth = true
this.authService.login(username, null, token)
this.authService.login({ username, password: null, token })
.subscribe({
next: () => {
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) {
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.`
else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.`
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 { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.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'
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',
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 { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { User } from '@shared/models'
import { HttpStatusCode, User } from '@shared/models'
@Component({
selector: 'my-account-change-email',
@ -57,7 +57,7 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
},
error: err => {
if (err.status === 401) {
if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
this.error = $localize`You current password is invalid.`
return
}

View File

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

View File

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

View File

@ -62,6 +62,16 @@
</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="col-12 col-lg-4 col-xl-3">
<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 { TableModule } from 'primeng/table'
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 { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.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'
@NgModule({
imports: [
MyAccountRoutingModule,
QRCodeModule,
AutoCompleteModule,
TableModule,
DragDropModule,
@ -52,6 +59,9 @@ import { MyAccountComponent } from './my-account.component'
MyAccountChangeEmailComponent,
MyAccountApplicationsComponent,
MyAccountTwoFactorButtonComponent,
MyAccountTwoFactorComponent,
MyAccountDangerZoneComponent,
MyAccountBlocklistComponent,
MyAccountAbusesListComponent,
@ -64,7 +74,9 @@ import { MyAccountComponent } from './my-account.component'
MyAccountComponent
],
providers: []
providers: [
TwoFactorService
]
})
export class MyAccountModule {
}

View File

@ -40,7 +40,7 @@ export class MyVideoChannelsComponent {
}
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}?
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})!`,

View File

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

View File

@ -1,7 +1,7 @@
import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs'
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 { Router } from '@angular/router'
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()
}
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
const body = {
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 })
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 })
.pipe(
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> {
// User is not loaded yet, set manually auth header
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'
type ConfirmOptions = {
title: 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()
export class ConfirmService {
showConfirm = new Subject<ConfirmOptions>()
confirmResponse = new Subject<boolean>()
confirmResponse = new Subject<{ confirmed: boolean, value?: 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) {
this.showConfirm.next({ title, message, inputLabel, expectedInputValue, confirmButtonText })
confirmWithPassword (message: string, title = '', confirmButtonText?: string) {
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 { logger } from '@root-helpers/logger'
import { HttpStatusCode, ResultList } from '@shared/models'
import { HttpHeaderResponse } from '@angular/common/http'
@Injectable()
export class RestExtractor {
@ -54,10 +55,11 @@ export class RestExtractor {
handleError (err: any) {
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,
status: undefined,
body: undefined
body: undefined,
headers: err.headers
}
if (err.status) {

View File

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

View File

@ -9,9 +9,12 @@
<div class="modal-body" >
<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>
<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>

View File

@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit {
inputValue = ''
confirmButtonText = ''
isPasswordInput = false
private openedModal: NgbModalRef
constructor (
@ -31,11 +33,27 @@ export class ConfirmComponent implements OnInit {
ngOnInit () {
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.inputLabel = inputLabel
this.expectedInputValue = expectedInputValue
if (type === 'confirm-expected-input') {
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`
@ -66,11 +84,13 @@ export class ConfirmComponent implements OnInit {
this.openedModal = this.modalService.open(this.confirmModal, { centered: true })
this.openedModal.result
.then(() => this.confirmService.confirmResponse.next(true))
.then(() => {
this.confirmService.confirmResponse.next({ confirmed: true, value: this.inputValue })
})
.catch((reason: string) => {
// 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) {
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 = {
VALIDATORS: [
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 { wait } from '@root-helpers/utils'
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.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 {
protected abstract formValidatorService: FormValidatorService
protected formChanged = false

View File

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

View File

@ -1,4 +1,5 @@
export * from './advanced-input-filter.component'
export * from './form-reactive.service'
export * from './form-reactive'
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 { Notifier } from '@app/core'
@ -15,6 +15,8 @@ import { Notifier } from '@app/core'
]
})
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() value = ''
@Input() autocomplete = 'off'
@ -65,4 +67,10 @@ export class InputTextComponent implements ControlValueAccessor {
update () {
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 { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@ -7,6 +6,7 @@ import { SharedGlobalIconModule } from '../shared-icons'
import { SharedMainModule } from '../shared-main/shared-main.module'
import { AdvancedInputFilterComponent } from './advanced-input-filter.component'
import { DynamicFormFieldComponent } from './dynamic-form-field.component'
import { FormReactiveService } from './form-reactive.service'
import { FormValidatorService } from './form-validator.service'
import { InputSwitchComponent } from './input-switch.component'
import { InputTextComponent } from './input-text.component'
@ -96,7 +96,8 @@ import { TimestampInputComponent } from './timestamp-input.component'
],
providers: [
FormValidatorService
FormValidatorService,
FormReactiveService
]
})
export class SharedFormModule { }

View File

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