<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 *ngIf="otpStep" class="form-group">
+ <p i18n>Enter the two-factor code generated by your phone app:</p>
- <div class="form-group">
- <label i18n for="password">Password</label>
+ <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">
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'
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
codeOfConduct: false
}
+ otpStep = false
+
private openedForgotPasswordModal: NgbModalRef
private serverConfig: ServerConfig
// 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
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)
+ }
})
}
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)
}
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
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 = [
}
},
+ {
+ path: 'two-factor-auth',
+ component: MyAccountTwoFactorComponent,
+ data: {
+ meta: {
+ title: $localize`Two factor authentication`
+ }
+ }
+ },
+
{
path: 'video-channels',
redirectTo: '/my-library/video-channels',
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',
},
error: err => {
- if (err.status === 401) {
+ if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
this.error = $localize`You current password is invalid.`
return
}
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',
},
error: err => {
- if (err.status === 401) {
+ if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
this.error = $localize`You current password is invalid.`
return
}
) { }
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
</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>
--- /dev/null
+export * from './my-account-two-factor-button.component'
+export * from './my-account-two-factor.component'
+export * from './two-factor.service'
--- /dev/null
+<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>
--- /dev/null
+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)
+ })
+ }
+}
--- /dev/null
+<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>
--- /dev/null
+@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;
+}
--- /dev/null
+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
+ }
+}
--- /dev/null
+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)))
+ }
+}
+import { QRCodeModule } from 'angularx-qrcode'
import { AutoCompleteModule } from 'primeng/autocomplete'
import { TableModule } from 'primeng/table'
import { DragDropModule } from '@angular/cdk/drag-drop'
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,
MyAccountChangeEmailComponent,
MyAccountApplicationsComponent,
+ MyAccountTwoFactorButtonComponent,
+ MyAccountTwoFactorComponent,
+
MyAccountDangerZoneComponent,
MyAccountBlocklistComponent,
MyAccountAbusesListComponent,
MyAccountComponent
],
- providers: []
+ providers: [
+ TwoFactorService
+ ]
})
export class MyAccountModule {
}
}
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})!`,
}
// Auto login
- this.authService.login(body.username, body.password)
+ this.authService.login({ username: body.username, password: body.password })
.subscribe({
next: () => {
this.signupSuccess = true
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'
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,
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 })),
})
}
+ 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}`)
-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.extractConfirmed(this.confirmResponse.asObservable()))
+ }
- return firstValueFrom(this.confirmResponse.asObservable())
+ confirmWithPassword (message: string, title = '', confirmButtonText?: string) {
+ this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText })
+
+ const obs = this.confirmResponse.asObservable()
+ .pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
+
+ return firstValueFrom(obs)
}
- confirmWithInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) {
- this.showConfirm.next({ title, message, inputLabel, expectedInputValue, confirmButtonText })
+ 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()))
+ }
- return firstValueFrom(this.confirmResponse.asObservable())
+ private extractConfirmed (obs: Observable<{ confirmed: boolean }>) {
+ return obs.pipe(map(({ confirmed }) => confirmed))
}
}
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 {
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) {
lastLoginDate: Date | null
+ twoFactorEnabled: boolean
+
createdAt: Date
constructor (hash: Partial<UserServerModel>) {
this.notificationSettings = hash.notificationSettings
+ this.twoFactorEnabled = hash.twoFactorEnabled
+
this.createdAt = hash.createdAt
this.pluginAuth = hash.pluginAuth
<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>
inputValue = ''
confirmButtonText = ''
+ isPasswordInput = false
+
private openedModal: NgbModalRef
constructor (
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`
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 })
}
})
}
}
}
+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,
--- /dev/null
+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')
+ }
+ }
+ }
+}
-
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
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 {
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'
-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'
]
})
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'
update () {
this.propagateChange(this.value)
}
+
+ focus () {
+ const el: HTMLElement = this.inputElement.nativeElement
+
+ el.focus({ preventScroll: true })
+ }
}
-
import { InputMaskModule } from 'primeng/inputmask'
import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
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'
],
providers: [
- FormValidatorService
+ FormValidatorService,
+ FormReactiveService
]
})
export class SharedFormModule { }
.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)