mirror of https://github.com/Chocobozzz/PeerTube
Implement two factor in client
parent
56f4783075
commit
d12b40fb96
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export * from './my-account-two-factor-button.component'
|
||||
export * from './my-account-two-factor.component'
|
||||
export * from './two-factor.service'
|
|
@ -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>
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)))
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
}
|
||||
|
|
|
@ -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})!`,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}`)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 { }
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue