aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-10-07 11:06:28 +0200
committerChocobozzz <me@florianbigard.com>2022-10-07 11:06:28 +0200
commitd12b40fb96d56786a96c06a621f3d8e0a0d24f4a (patch)
tree7047fa5cd7e778eb377c897eccb539c52b2e59bc
parent56f47830758ff8e92abcfcc5f35d474ab12fe215 (diff)
downloadPeerTube-d12b40fb96d56786a96c06a621f3d8e0a0d24f4a.tar.gz
PeerTube-d12b40fb96d56786a96c06a621f3d8e0a0d24f4a.tar.zst
PeerTube-d12b40fb96d56786a96c06a621f3d8e0a0d24f4a.zip
Implement two factor in client
-rw-r--r--client/src/app/+login/login.component.html46
-rw-r--r--client/src/app/+login/login.component.ts38
-rw-r--r--client/src/app/+my-account/my-account-routing.module.ts11
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts4
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts4
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.html10
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts3
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html12
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts49
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html54
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss16
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts105
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/two-factor.service.ts52
-rw-r--r--client/src/app/+my-account/my-account.module.ts14
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts2
-rw-r--r--client/src/app/+signup/+register/register.component.ts2
-rw-r--r--client/src/app/core/auth/auth.service.ts23
-rw-r--r--client/src/app/core/confirm/confirm.service.ts47
-rw-r--r--client/src/app/core/rest/rest-extractor.service.ts6
-rw-r--r--client/src/app/core/users/user.model.ts4
-rw-r--r--client/src/app/modal/confirm.component.html7
-rw-r--r--client/src/app/modal/confirm.component.ts30
-rw-r--r--client/src/app/shared/form-validators/user-validators.ts9
-rw-r--r--client/src/app/shared/shared-forms/form-reactive.service.ts101
-rw-r--r--client/src/app/shared/shared-forms/form-reactive.ts7
-rw-r--r--client/src/app/shared/shared-forms/form-validator.service.ts2
-rw-r--r--client/src/app/shared/shared-forms/index.ts1
-rw-r--r--client/src/app/shared/shared-forms/input-text.component.ts10
-rw-r--r--client/src/app/shared/shared-forms/shared-form.module.ts5
-rw-r--r--client/src/app/shared/shared-main/auth/auth-interceptor.service.ts13
31 files changed, 621 insertions, 68 deletions
diff --git a/client/src/app/+login/login.component.html b/client/src/app/+login/login.component.html
index f3a2476f9..49b443a20 100644
--- a/client/src/app/+login/login.component.html
+++ b/client/src/app/+login/login.component.html
@@ -39,34 +39,48 @@
39 <div class="login-form-and-externals"> 39 <div class="login-form-and-externals">
40 40
41 <form myPluginSelector pluginSelectorId="login-form" role="form" (ngSubmit)="login()" [formGroup]="form"> 41 <form myPluginSelector pluginSelectorId="login-form" role="form" (ngSubmit)="login()" [formGroup]="form">
42 <div class="form-group"> 42 <ng-container *ngIf="!otpStep">
43 <div> 43 <div class="form-group">
44 <label i18n for="username">Username or email address</label> 44 <div>
45 <input 45 <label i18n for="username">Username or email address</label>
46 type="text" id="username" i18n-placeholder placeholder="Example: john@example.com" required tabindex="1" 46 <input
47 formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" myAutofocus 47 type="text" id="username" i18n-placeholder placeholder="Example: john@example.com" required tabindex="1"
48 > 48 formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" myAutofocus
49 >
50 </div>
51
52 <div *ngIf="formErrors.username" class="form-error">{{ formErrors.username }}</div>
53
54 <div *ngIf="hasUsernameUppercase()" i18n class="form-warning">
55 ⚠️ Most email addresses do not include capital letters.
56 </div>
49 </div> 57 </div>
50 58
51 <div *ngIf="formErrors.username" class="form-error">{{ formErrors.username }}</div> 59 <div class="form-group">
60 <label i18n for="password">Password</label>
52 61
53 <div *ngIf="hasUsernameUppercase()" i18n class="form-warning"> 62 <my-input-text
54 ⚠️ Most email addresses do not include capital letters. 63 formControlName="password" inputId="password" i18n-placeholder placeholder="Password"
64 [formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2"
65 ></my-input-text>
55 </div> 66 </div>
56 </div> 67 </ng-container>
68
69 <div *ngIf="otpStep" class="form-group">
70 <p i18n>Enter the two-factor code generated by your phone app:</p>
57 71
58 <div class="form-group"> 72 <label i18n for="otp-token">Two factor authentication token</label>
59 <label i18n for="password">Password</label>
60 73
61 <my-input-text 74 <my-input-text
62 formControlName="password" inputId="password" i18n-placeholder placeholder="Password" 75 #otpTokenInput
63 [formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2" 76 [show]="true" formControlName="otp-token" inputId="otp-token"
77 [formError]="formErrors['otp-token']" autocomplete="otp-token"
64 ></my-input-text> 78 ></my-input-text>
65 </div> 79 </div>
66 80
67 <input type="submit" class="peertube-button orange-button" i18n-value value="Login" [disabled]="!form.valid"> 81 <input type="submit" class="peertube-button orange-button" i18n-value value="Login" [disabled]="!form.valid">
68 82
69 <div class="additional-links"> 83 <div *ngIf="!otpStep" class="additional-links">
70 <a i18n role="button" class="link-orange" (click)="openForgotPasswordModal()" i18n-title title="Click here to reset your password">I forgot my password</a> 84 <a i18n role="button" class="link-orange" (click)="openForgotPasswordModal()" i18n-title title="Click here to reset your password">I forgot my password</a>
71 85
72 <ng-container *ngIf="signupAllowed"> 86 <ng-container *ngIf="signupAllowed">
diff --git a/client/src/app/+login/login.component.ts b/client/src/app/+login/login.component.ts
index 2ed9be16c..9095e43a7 100644
--- a/client/src/app/+login/login.component.ts
+++ b/client/src/app/+login/login.component.ts
@@ -4,7 +4,8 @@ import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core' 4import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
5import { HooksService } from '@app/core/plugins/hooks.service' 5import { HooksService } from '@app/core/plugins/hooks.service'
6import { LOGIN_PASSWORD_VALIDATOR, LOGIN_USERNAME_VALIDATOR } from '@app/shared/form-validators/login-validators' 6import { LOGIN_PASSWORD_VALIDATOR, LOGIN_USERNAME_VALIDATOR } from '@app/shared/form-validators/login-validators'
7import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 7import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators'
8import { FormReactive, FormValidatorService, InputTextComponent } from '@app/shared/shared-forms'
8import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' 9import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
9import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' 10import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
10import { PluginsManager } from '@root-helpers/plugins-manager' 11import { PluginsManager } from '@root-helpers/plugins-manager'
@@ -20,6 +21,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
20 private static SESSION_STORAGE_REDIRECT_URL_KEY = 'login-previous-url' 21 private static SESSION_STORAGE_REDIRECT_URL_KEY = 'login-previous-url'
21 22
22 @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef 23 @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef
24 @ViewChild('otpTokenInput') otpTokenInput: InputTextComponent
23 25
24 accordion: NgbAccordion 26 accordion: NgbAccordion
25 error: string = null 27 error: string = null
@@ -37,6 +39,8 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
37 codeOfConduct: false 39 codeOfConduct: false
38 } 40 }
39 41
42 otpStep = false
43
40 private openedForgotPasswordModal: NgbModalRef 44 private openedForgotPasswordModal: NgbModalRef
41 private serverConfig: ServerConfig 45 private serverConfig: ServerConfig
42 46
@@ -82,7 +86,11 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
82 // Avoid undefined errors when accessing form error properties 86 // Avoid undefined errors when accessing form error properties
83 this.buildForm({ 87 this.buildForm({
84 username: LOGIN_USERNAME_VALIDATOR, 88 username: LOGIN_USERNAME_VALIDATOR,
85 password: LOGIN_PASSWORD_VALIDATOR 89 password: LOGIN_PASSWORD_VALIDATOR,
90 'otp-token': {
91 VALIDATORS: [], // Will be set dynamically
92 MESSAGES: USER_OTP_TOKEN_VALIDATOR.MESSAGES
93 }
86 }) 94 })
87 95
88 this.serverConfig = snapshot.data.serverConfig 96 this.serverConfig = snapshot.data.serverConfig
@@ -118,13 +126,20 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
118 login () { 126 login () {
119 this.error = null 127 this.error = null
120 128
121 const { username, password } = this.form.value 129 const options = {
130 username: this.form.value['username'],
131 password: this.form.value['password'],
132 otpToken: this.form.value['otp-token']
133 }
122 134
123 this.authService.login(username, password) 135 this.authService.login(options)
136 .pipe()
124 .subscribe({ 137 .subscribe({
125 next: () => this.redirectService.redirectToPreviousRoute(), 138 next: () => this.redirectService.redirectToPreviousRoute(),
126 139
127 error: err => this.handleError(err) 140 error: err => {
141 this.handleError(err)
142 }
128 }) 143 })
129 } 144 }
130 145
@@ -162,7 +177,7 @@ The link will expire within 1 hour.`
162 private loadExternalAuthToken (username: string, token: string) { 177 private loadExternalAuthToken (username: string, token: string) {
163 this.isAuthenticatedWithExternalAuth = true 178 this.isAuthenticatedWithExternalAuth = true
164 179
165 this.authService.login(username, null, token) 180 this.authService.login({ username, password: null, token })
166 .subscribe({ 181 .subscribe({
167 next: () => { 182 next: () => {
168 const redirectUrl = this.storage.getItem(LoginComponent.SESSION_STORAGE_REDIRECT_URL_KEY) 183 const redirectUrl = this.storage.getItem(LoginComponent.SESSION_STORAGE_REDIRECT_URL_KEY)
@@ -182,6 +197,17 @@ The link will expire within 1 hour.`
182 } 197 }
183 198
184 private handleError (err: any) { 199 private handleError (err: any) {
200 if (this.authService.isOTPMissingError(err)) {
201 this.otpStep = true
202
203 setTimeout(() => {
204 this.form.get('otp-token').setValidators(USER_OTP_TOKEN_VALIDATOR.VALIDATORS)
205 this.otpTokenInput.focus()
206 })
207
208 return
209 }
210
185 if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.` 211 if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.`
186 else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.` 212 else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.`
187 else this.error = err.message 213 else this.error = err.message
diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts
index ef39c1a36..b39b1f6b4 100644
--- a/client/src/app/+my-account/my-account-routing.module.ts
+++ b/client/src/app/+my-account/my-account-routing.module.ts
@@ -7,6 +7,7 @@ import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-b
7import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component' 7import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
8import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component' 8import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
9import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' 9import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
10import { MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor'
10import { MyAccountComponent } from './my-account.component' 11import { MyAccountComponent } from './my-account.component'
11 12
12const myAccountRoutes: Routes = [ 13const myAccountRoutes: Routes = [
@@ -31,6 +32,16 @@ const myAccountRoutes: Routes = [
31 }, 32 },
32 33
33 { 34 {
35 path: 'two-factor-auth',
36 component: MyAccountTwoFactorComponent,
37 data: {
38 meta: {
39 title: $localize`Two factor authentication`
40 }
41 }
42 },
43
44 {
34 path: 'video-channels', 45 path: 'video-channels',
35 redirectTo: '/my-library/video-channels', 46 redirectTo: '/my-library/video-channels',
36 pathMatch: 'full' 47 pathMatch: 'full'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
index 9b87daa40..9e6b8e21d 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
@@ -4,7 +4,7 @@ import { Component, OnInit } from '@angular/core'
4import { AuthService, ServerService, UserService } from '@app/core' 4import { AuthService, ServerService, UserService } from '@app/core'
5import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' 5import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
7import { User } from '@shared/models' 7import { HttpStatusCode, User } from '@shared/models'
8 8
9@Component({ 9@Component({
10 selector: 'my-account-change-email', 10 selector: 'my-account-change-email',
@@ -57,7 +57,7 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
57 }, 57 },
58 58
59 error: err => { 59 error: err => {
60 if (err.status === 401) { 60 if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
61 this.error = $localize`You current password is invalid.` 61 this.error = $localize`You current password is invalid.`
62 return 62 return
63 } 63 }
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts
index 47e54dc23..dd405de33 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts
@@ -7,7 +7,7 @@ import {
7 USER_PASSWORD_VALIDATOR 7 USER_PASSWORD_VALIDATOR
8} from '@app/shared/form-validators/user-validators' 8} from '@app/shared/form-validators/user-validators'
9import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 9import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
10import { User } from '@shared/models' 10import { HttpStatusCode, User } from '@shared/models'
11 11
12@Component({ 12@Component({
13 selector: 'my-account-change-password', 13 selector: 'my-account-change-password',
@@ -57,7 +57,7 @@ export class MyAccountChangePasswordComponent extends FormReactive implements On
57 }, 57 },
58 58
59 error: err => { 59 error: err => {
60 if (err.status === 401) { 60 if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
61 this.error = $localize`You current password is invalid.` 61 this.error = $localize`You current password is invalid.`
62 return 62 return
63 } 63 }
diff --git a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
index 2bae3499e..9619623ee 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
@@ -18,7 +18,7 @@ export class MyAccountDangerZoneComponent {
18 ) { } 18 ) { }
19 19
20 async deleteMe () { 20 async deleteMe () {
21 const res = await this.confirmService.confirmWithInput( 21 const res = await this.confirmService.confirmWithExpectedInput(
22 $localize`Are you sure you want to delete your account?` + 22 $localize`Are you sure you want to delete your account?` +
23 '<br /><br />' + 23 '<br /><br />' +
24 // eslint-disable-next-line max-len 24 // eslint-disable-next-line max-len
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
index 42a8d0856..666205de6 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
@@ -62,6 +62,16 @@
62 </div> 62 </div>
63</div> 63</div>
64 64
65<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- two factor auth grid -->
66 <div class="col-12 col-lg-4 col-xl-3">
67 <h2 i18n class="account-title">Two-factor authentication</h2>
68 </div>
69
70 <div class="col-12 col-lg-8 col-xl-9">
71 <my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
72 </div>
73</div>
74
65<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid --> 75<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid -->
66 <div class="col-12 col-lg-4 col-xl-3"> 76 <div class="col-12 col-lg-4 col-xl-3">
67 <h2 i18n class="account-title">EMAIL</h2> 77 <h2 i18n class="account-title">EMAIL</h2>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts
new file mode 100644
index 000000000..ef83009a5
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts
@@ -0,0 +1,3 @@
1export * from './my-account-two-factor-button.component'
2export * from './my-account-two-factor.component'
3export * from './two-factor.service'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html
new file mode 100644
index 000000000..2fcfffbf3
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html
@@ -0,0 +1,12 @@
1<div class="two-factor">
2 <ng-container *ngIf="!twoFactorEnabled">
3 <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>
4
5 <my-button [routerLink]="[ '/my-account/two-factor-auth' ]" className="orange-button-link" i18n>Enable two-factor authentication</my-button>
6 </ng-container>
7
8 <ng-container *ngIf="twoFactorEnabled">
9 <my-button className="orange-button" (click)="disableTwoFactor()" i18n>Disable two-factor authentication</my-button>
10 </ng-container>
11
12</div>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
new file mode 100644
index 000000000..03b00e933
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
@@ -0,0 +1,49 @@
1import { Subject } from 'rxjs'
2import { Component, Input, OnInit } from '@angular/core'
3import { AuthService, ConfirmService, Notifier, User } from '@app/core'
4import { TwoFactorService } from './two-factor.service'
5
6@Component({
7 selector: 'my-account-two-factor-button',
8 templateUrl: './my-account-two-factor-button.component.html'
9})
10export class MyAccountTwoFactorButtonComponent implements OnInit {
11 @Input() user: User = null
12 @Input() userInformationLoaded: Subject<any>
13
14 twoFactorEnabled = false
15
16 constructor (
17 private notifier: Notifier,
18 private twoFactorService: TwoFactorService,
19 private confirmService: ConfirmService,
20 private auth: AuthService
21 ) {
22 }
23
24 ngOnInit () {
25 this.userInformationLoaded.subscribe(() => {
26 this.twoFactorEnabled = this.user.twoFactorEnabled
27 })
28 }
29
30 async disableTwoFactor () {
31 const message = $localize`Are you sure you want to disable two factor authentication of your account?`
32
33 const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`)
34 if (confirmed === false) return
35
36 this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })
37 .subscribe({
38 next: () => {
39 this.twoFactorEnabled = false
40
41 this.auth.refreshUserInformation()
42
43 this.notifier.success($localize`Two factor authentication disabled`)
44 },
45
46 error: err => this.notifier.error(err.message)
47 })
48 }
49}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html
new file mode 100644
index 000000000..16c344e3b
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html
@@ -0,0 +1,54 @@
1<h1>
2 <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
3 <ng-container i18n>Two factor authentication</ng-container>
4</h1>
5
6<div i18n *ngIf="twoFactorAlreadyEnabled === true" class="root already-enabled">
7 Two factor authentication is already enabled.
8</div>
9
10<div class="root" *ngIf="twoFactorAlreadyEnabled === false">
11 <ng-container *ngIf="step === 'request'">
12 <form role="form" (ngSubmit)="requestTwoFactor()" [formGroup]="formPassword">
13
14 <label i18n for="current-password">Your password</label>
15 <div class="form-group-description" i18n>Confirm your password to enable two factor authentication</div>
16
17 <my-input-text
18 formControlName="current-password" inputId="current-password" i18n-placeholder placeholder="Current password"
19 [formError]="formErrorsPassword['current-password']" autocomplete="current-password"
20 ></my-input-text>
21
22 <input class="peertube-button orange-button mt-3" type="submit" i18n-value value="Confirm" [disabled]="!formPassword.valid">
23 </form>
24 </ng-container>
25
26 <ng-container *ngIf="step === 'confirm'">
27
28 <p i18n>
29 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.
30 </p>
31
32 <qrcode [qrdata]="twoFactorURI" [width]="256" level="Q"></qrcode>
33
34 <div i18n>
35 If you can't scan the QR code and need to enter it manually, here is the plain-text secret:
36 </div>
37
38 <div class="secret-plain-text">{{ twoFactorSecret }}</div>
39
40 <form class="mt-3" role="form" (ngSubmit)="confirmTwoFactor()" [formGroup]="formOTP">
41
42 <label i18n for="otp-token">Two-factor code</label>
43 <div class="form-group-description" i18n>Enter the code generated by your authenticator app to confirm</div>
44
45 <my-input-text
46 [show]="true" formControlName="otp-token" inputId="otp-token"
47 [formError]="formErrorsOTP['otp-token']" autocomplete="otp-token"
48 ></my-input-text>
49
50 <input class="peertube-button orange-button mt-3" type="submit" i18n-value value="Confirm" [disabled]="!formOTP.valid">
51 </form>
52 </ng-container>
53
54</div>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss
new file mode 100644
index 000000000..cee016bb8
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss
@@ -0,0 +1,16 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4.root {
5 max-width: 600px;
6}
7
8.secret-plain-text {
9 font-family: monospace;
10 font-size: 0.9rem;
11}
12
13qrcode {
14 display: inline-block;
15 margin: auto;
16}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts
new file mode 100644
index 000000000..e4d4188f7
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts
@@ -0,0 +1,105 @@
1import { Component, OnInit } from '@angular/core'
2import { FormGroup } from '@angular/forms'
3import { Router } from '@angular/router'
4import { AuthService, Notifier, User } from '@app/core'
5import { USER_EXISTING_PASSWORD_VALIDATOR, USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators'
6import { FormReactiveService } from '@app/shared/shared-forms'
7import { TwoFactorService } from './two-factor.service'
8
9@Component({
10 selector: 'my-account-two-factor',
11 templateUrl: './my-account-two-factor.component.html',
12 styleUrls: [ './my-account-two-factor.component.scss' ]
13})
14export class MyAccountTwoFactorComponent implements OnInit {
15 twoFactorAlreadyEnabled: boolean
16
17 step: 'request' | 'confirm' | 'confirmed' = 'request'
18
19 twoFactorSecret: string
20 twoFactorURI: string
21
22 inPasswordStep = true
23
24 formPassword: FormGroup
25 formErrorsPassword: any
26
27 formOTP: FormGroup
28 formErrorsOTP: any
29
30 private user: User
31 private requestToken: string
32
33 constructor (
34 private notifier: Notifier,
35 private twoFactorService: TwoFactorService,
36 private formReactiveService: FormReactiveService,
37 private auth: AuthService,
38 private router: Router
39 ) {
40 }
41
42 ngOnInit () {
43 this.buildPasswordForm()
44 this.buildOTPForm()
45
46 this.auth.userInformationLoaded.subscribe(() => {
47 this.user = this.auth.getUser()
48
49 this.twoFactorAlreadyEnabled = this.user.twoFactorEnabled
50 })
51 }
52
53 requestTwoFactor () {
54 this.twoFactorService.requestTwoFactor({
55 userId: this.user.id,
56 currentPassword: this.formPassword.value['current-password']
57 }).subscribe({
58 next: ({ otpRequest }) => {
59 this.requestToken = otpRequest.requestToken
60 this.twoFactorURI = otpRequest.uri
61 this.twoFactorSecret = otpRequest.secret.replace(/(.{4})/g, '$1 ').trim()
62
63 this.step = 'confirm'
64 },
65
66 error: err => this.notifier.error(err.message)
67 })
68 }
69
70 confirmTwoFactor () {
71 this.twoFactorService.confirmTwoFactorRequest({
72 userId: this.user.id,
73 requestToken: this.requestToken,
74 otpToken: this.formOTP.value['otp-token']
75 }).subscribe({
76 next: () => {
77 this.notifier.success($localize`Two factor authentication has been enabled.`)
78
79 this.auth.refreshUserInformation()
80
81 this.router.navigateByUrl('/my-account/settings')
82 },
83
84 error: err => this.notifier.error(err.message)
85 })
86 }
87
88 private buildPasswordForm () {
89 const { form, formErrors } = this.formReactiveService.buildForm({
90 'current-password': USER_EXISTING_PASSWORD_VALIDATOR
91 })
92
93 this.formPassword = form
94 this.formErrorsPassword = formErrors
95 }
96
97 private buildOTPForm () {
98 const { form, formErrors } = this.formReactiveService.buildForm({
99 'otp-token': USER_OTP_TOKEN_VALIDATOR
100 })
101
102 this.formOTP = form
103 this.formErrorsOTP = formErrors
104 }
105}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/two-factor.service.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/two-factor.service.ts
new file mode 100644
index 000000000..c0e5ac492
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/two-factor.service.ts
@@ -0,0 +1,52 @@
1import { catchError } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor, UserService } from '@app/core'
5import { TwoFactorEnableResult } from '@shared/models'
6
7@Injectable()
8export class TwoFactorService {
9 constructor (
10 private authHttp: HttpClient,
11 private restExtractor: RestExtractor
12 ) { }
13
14 // ---------------------------------------------------------------------------
15
16 requestTwoFactor (options: {
17 userId: number
18 currentPassword: string
19 }) {
20 const { userId, currentPassword } = options
21
22 const url = UserService.BASE_USERS_URL + userId + '/two-factor/request'
23
24 return this.authHttp.post<TwoFactorEnableResult>(url, { currentPassword })
25 .pipe(catchError(err => this.restExtractor.handleError(err)))
26 }
27
28 confirmTwoFactorRequest (options: {
29 userId: number
30 requestToken: string
31 otpToken: string
32 }) {
33 const { userId, requestToken, otpToken } = options
34
35 const url = UserService.BASE_USERS_URL + userId + '/two-factor/confirm-request'
36
37 return this.authHttp.post(url, { requestToken, otpToken })
38 .pipe(catchError(err => this.restExtractor.handleError(err)))
39 }
40
41 disableTwoFactor (options: {
42 userId: number
43 currentPassword: string
44 }) {
45 const { userId, currentPassword } = options
46
47 const url = UserService.BASE_USERS_URL + userId + '/two-factor/disable'
48
49 return this.authHttp.post(url, { currentPassword })
50 .pipe(catchError(err => this.restExtractor.handleError(err)))
51 }
52}
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 4081e4f01..f5beaa4db 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -1,3 +1,4 @@
1import { QRCodeModule } from 'angularx-qrcode'
1import { AutoCompleteModule } from 'primeng/autocomplete' 2import { AutoCompleteModule } from 'primeng/autocomplete'
2import { TableModule } from 'primeng/table' 3import { TableModule } from 'primeng/table'
3import { DragDropModule } from '@angular/cdk/drag-drop' 4import { DragDropModule } from '@angular/cdk/drag-drop'
@@ -23,12 +24,18 @@ import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-d
23import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences' 24import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
24import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' 25import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
25import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' 26import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
27import {
28 MyAccountTwoFactorButtonComponent,
29 MyAccountTwoFactorComponent,
30 TwoFactorService
31} from './my-account-settings/my-account-two-factor'
26import { MyAccountComponent } from './my-account.component' 32import { MyAccountComponent } from './my-account.component'
27 33
28@NgModule({ 34@NgModule({
29 imports: [ 35 imports: [
30 MyAccountRoutingModule, 36 MyAccountRoutingModule,
31 37
38 QRCodeModule,
32 AutoCompleteModule, 39 AutoCompleteModule,
33 TableModule, 40 TableModule,
34 DragDropModule, 41 DragDropModule,
@@ -52,6 +59,9 @@ import { MyAccountComponent } from './my-account.component'
52 MyAccountChangeEmailComponent, 59 MyAccountChangeEmailComponent,
53 MyAccountApplicationsComponent, 60 MyAccountApplicationsComponent,
54 61
62 MyAccountTwoFactorButtonComponent,
63 MyAccountTwoFactorComponent,
64
55 MyAccountDangerZoneComponent, 65 MyAccountDangerZoneComponent,
56 MyAccountBlocklistComponent, 66 MyAccountBlocklistComponent,
57 MyAccountAbusesListComponent, 67 MyAccountAbusesListComponent,
@@ -64,7 +74,9 @@ import { MyAccountComponent } from './my-account.component'
64 MyAccountComponent 74 MyAccountComponent
65 ], 75 ],
66 76
67 providers: [] 77 providers: [
78 TwoFactorService
79 ]
68}) 80})
69export class MyAccountModule { 81export class MyAccountModule {
70} 82}
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
index 205ad7a89..ece59c2ff 100644
--- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
@@ -40,7 +40,7 @@ export class MyVideoChannelsComponent {
40 } 40 }
41 41
42 async deleteVideoChannel (videoChannel: VideoChannel) { 42 async deleteVideoChannel (videoChannel: VideoChannel) {
43 const res = await this.confirmService.confirmWithInput( 43 const res = await this.confirmService.confirmWithExpectedInput(
44 $localize`Do you really want to delete ${videoChannel.displayName}? 44 $localize`Do you really want to delete ${videoChannel.displayName}?
45It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another 45It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another
46channel with the same name (${videoChannel.name})!`, 46channel with the same name (${videoChannel.name})!`,
diff --git a/client/src/app/+signup/+register/register.component.ts b/client/src/app/+signup/+register/register.component.ts
index 4ab327b1b..958770ebf 100644
--- a/client/src/app/+signup/+register/register.component.ts
+++ b/client/src/app/+signup/+register/register.component.ts
@@ -158,7 +158,7 @@ export class RegisterComponent implements OnInit {
158 } 158 }
159 159
160 // Auto login 160 // Auto login
161 this.authService.login(body.username, body.password) 161 this.authService.login({ username: body.username, password: body.password })
162 .subscribe({ 162 .subscribe({
163 next: () => { 163 next: () => {
164 this.signupSuccess = true 164 this.signupSuccess = true
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts
index ca46866f5..7f4fae4aa 100644
--- a/client/src/app/core/auth/auth.service.ts
+++ b/client/src/app/core/auth/auth.service.ts
@@ -1,7 +1,7 @@
1import { Hotkey, HotkeysService } from 'angular2-hotkeys' 1import { Hotkey, HotkeysService } from 'angular2-hotkeys'
2import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs' 2import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs'
3import { catchError, map, mergeMap, share, tap } from 'rxjs/operators' 3import { catchError, map, mergeMap, share, tap } from 'rxjs/operators'
4import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http' 4import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'
5import { Injectable } from '@angular/core' 5import { Injectable } from '@angular/core'
6import { Router } from '@angular/router' 6import { Router } from '@angular/router'
7import { Notifier } from '@app/core/notification/notifier.service' 7import { Notifier } from '@app/core/notification/notifier.service'
@@ -141,7 +141,14 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
141 return !!this.getAccessToken() 141 return !!this.getAccessToken()
142 } 142 }
143 143
144 login (username: string, password: string, token?: string) { 144 login (options: {
145 username: string
146 password: string
147 otpToken?: string
148 token?: string
149 }) {
150 const { username, password, token, otpToken } = options
151
145 // Form url encoded 152 // Form url encoded
146 const body = { 153 const body = {
147 client_id: this.clientId, 154 client_id: this.clientId,
@@ -155,7 +162,9 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
155 162
156 if (token) Object.assign(body, { externalAuthToken: token }) 163 if (token) Object.assign(body, { externalAuthToken: token })
157 164
158 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') 165 let headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
166 if (otpToken) headers = headers.set('x-peertube-otp', otpToken)
167
159 return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers }) 168 return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers })
160 .pipe( 169 .pipe(
161 map(res => Object.assign(res, { username })), 170 map(res => Object.assign(res, { username })),
@@ -245,6 +254,14 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
245 }) 254 })
246 } 255 }
247 256
257 isOTPMissingError (err: HttpErrorResponse) {
258 if (err.status !== HttpStatusCode.UNAUTHORIZED_401) return false
259
260 if (err.headers.get('x-peertube-otp') !== 'required; app') return false
261
262 return true
263 }
264
248 private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> { 265 private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> {
249 // User is not loaded yet, set manually auth header 266 // User is not loaded yet, set manually auth header
250 const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`) 267 const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`)
diff --git a/client/src/app/core/confirm/confirm.service.ts b/client/src/app/core/confirm/confirm.service.ts
index 338b8762c..89a25f0a5 100644
--- a/client/src/app/core/confirm/confirm.service.ts
+++ b/client/src/app/core/confirm/confirm.service.ts
@@ -1,28 +1,53 @@
1import { firstValueFrom, Subject } from 'rxjs' 1import { firstValueFrom, map, Observable, Subject } from 'rxjs'
2import { Injectable } from '@angular/core' 2import { Injectable } from '@angular/core'
3 3
4type ConfirmOptions = { 4type ConfirmOptions = {
5 title: string 5 title: string
6 message: string 6 message: string
7 inputLabel?: string 7} & (
8 expectedInputValue?: string 8 {
9 confirmButtonText?: string 9 type: 'confirm'
10} 10 confirmButtonText?: string
11 } |
12 {
13 type: 'confirm-password'
14 confirmButtonText?: string
15 } |
16 {
17 type: 'confirm-expected-input'
18 inputLabel?: string
19 expectedInputValue?: string
20 confirmButtonText?: string
21 }
22)
11 23
12@Injectable() 24@Injectable()
13export class ConfirmService { 25export class ConfirmService {
14 showConfirm = new Subject<ConfirmOptions>() 26 showConfirm = new Subject<ConfirmOptions>()
15 confirmResponse = new Subject<boolean>() 27 confirmResponse = new Subject<{ confirmed: boolean, value?: string }>()
16 28
17 confirm (message: string, title = '', confirmButtonText?: string) { 29 confirm (message: string, title = '', confirmButtonText?: string) {
18 this.showConfirm.next({ title, message, confirmButtonText }) 30 this.showConfirm.next({ type: 'confirm', title, message, confirmButtonText })
31
32 return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
33 }
19 34
20 return firstValueFrom(this.confirmResponse.asObservable()) 35 confirmWithPassword (message: string, title = '', confirmButtonText?: string) {
36 this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText })
37
38 const obs = this.confirmResponse.asObservable()
39 .pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
40
41 return firstValueFrom(obs)
21 } 42 }
22 43
23 confirmWithInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) { 44 confirmWithExpectedInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) {
24 this.showConfirm.next({ title, message, inputLabel, expectedInputValue, confirmButtonText }) 45 this.showConfirm.next({ type: 'confirm-expected-input', title, message, inputLabel, expectedInputValue, confirmButtonText })
46
47 return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
48 }
25 49
26 return firstValueFrom(this.confirmResponse.asObservable()) 50 private extractConfirmed (obs: Observable<{ confirmed: boolean }>) {
51 return obs.pipe(map(({ confirmed }) => confirmed))
27 } 52 }
28} 53}
diff --git a/client/src/app/core/rest/rest-extractor.service.ts b/client/src/app/core/rest/rest-extractor.service.ts
index 7eec2eca6..57dd9ae26 100644
--- a/client/src/app/core/rest/rest-extractor.service.ts
+++ b/client/src/app/core/rest/rest-extractor.service.ts
@@ -4,6 +4,7 @@ import { Router } from '@angular/router'
4import { DateFormat, dateToHuman } from '@app/helpers' 4import { DateFormat, dateToHuman } from '@app/helpers'
5import { logger } from '@root-helpers/logger' 5import { logger } from '@root-helpers/logger'
6import { HttpStatusCode, ResultList } from '@shared/models' 6import { HttpStatusCode, ResultList } from '@shared/models'
7import { HttpHeaderResponse } from '@angular/common/http'
7 8
8@Injectable() 9@Injectable()
9export class RestExtractor { 10export class RestExtractor {
@@ -54,10 +55,11 @@ export class RestExtractor {
54 handleError (err: any) { 55 handleError (err: any) {
55 const errorMessage = this.buildErrorMessage(err) 56 const errorMessage = this.buildErrorMessage(err)
56 57
57 const errorObj: { message: string, status: string, body: string } = { 58 const errorObj: { message: string, status: string, body: string, headers: HttpHeaderResponse } = {
58 message: errorMessage, 59 message: errorMessage,
59 status: undefined, 60 status: undefined,
60 body: undefined 61 body: undefined,
62 headers: err.headers
61 } 63 }
62 64
63 if (err.status) { 65 if (err.status) {
diff --git a/client/src/app/core/users/user.model.ts b/client/src/app/core/users/user.model.ts
index 6ba30e4b8..8385a4012 100644
--- a/client/src/app/core/users/user.model.ts
+++ b/client/src/app/core/users/user.model.ts
@@ -66,6 +66,8 @@ export class User implements UserServerModel {
66 66
67 lastLoginDate: Date | null 67 lastLoginDate: Date | null
68 68
69 twoFactorEnabled: boolean
70
69 createdAt: Date 71 createdAt: Date
70 72
71 constructor (hash: Partial<UserServerModel>) { 73 constructor (hash: Partial<UserServerModel>) {
@@ -108,6 +110,8 @@ export class User implements UserServerModel {
108 110
109 this.notificationSettings = hash.notificationSettings 111 this.notificationSettings = hash.notificationSettings
110 112
113 this.twoFactorEnabled = hash.twoFactorEnabled
114
111 this.createdAt = hash.createdAt 115 this.createdAt = hash.createdAt
112 116
113 this.pluginAuth = hash.pluginAuth 117 this.pluginAuth = hash.pluginAuth
diff --git a/client/src/app/modal/confirm.component.html b/client/src/app/modal/confirm.component.html
index c59c25770..f364165c4 100644
--- a/client/src/app/modal/confirm.component.html
+++ b/client/src/app/modal/confirm.component.html
@@ -9,9 +9,12 @@
9 <div class="modal-body" > 9 <div class="modal-body" >
10 <div [innerHtml]="message"></div> 10 <div [innerHtml]="message"></div>
11 11
12 <div *ngIf="inputLabel && expectedInputValue" class="form-group mt-3"> 12 <div *ngIf="inputLabel" class="form-group mt-3">
13 <label for="confirmInput">{{ inputLabel }}</label> 13 <label for="confirmInput">{{ inputLabel }}</label>
14 <input type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" /> 14
15 <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
16
17 <my-input-text inputId="confirmInput" [(ngModel)]="inputValue"></my-input-text>
15 </div> 18 </div>
16 </div> 19 </div>
17 20
diff --git a/client/src/app/modal/confirm.component.ts b/client/src/app/modal/confirm.component.ts
index ec4e1d60f..3bb8b9b21 100644
--- a/client/src/app/modal/confirm.component.ts
+++ b/client/src/app/modal/confirm.component.ts
@@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit {
21 inputValue = '' 21 inputValue = ''
22 confirmButtonText = '' 22 confirmButtonText = ''
23 23
24 isPasswordInput = false
25
24 private openedModal: NgbModalRef 26 private openedModal: NgbModalRef
25 27
26 constructor ( 28 constructor (
@@ -31,11 +33,27 @@ export class ConfirmComponent implements OnInit {
31 33
32 ngOnInit () { 34 ngOnInit () {
33 this.confirmService.showConfirm.subscribe( 35 this.confirmService.showConfirm.subscribe(
34 ({ title, message, expectedInputValue, inputLabel, confirmButtonText }) => { 36 payload => {
37 // Reinit fields
38 this.title = ''
39 this.message = ''
40 this.expectedInputValue = ''
41 this.inputLabel = ''
42 this.inputValue = ''
43 this.confirmButtonText = ''
44 this.isPasswordInput = false
45
46 const { type, title, message, confirmButtonText } = payload
47
35 this.title = title 48 this.title = title
36 49
37 this.inputLabel = inputLabel 50 if (type === 'confirm-expected-input') {
38 this.expectedInputValue = expectedInputValue 51 this.inputLabel = payload.inputLabel
52 this.expectedInputValue = payload.expectedInputValue
53 } else if (type === 'confirm-password') {
54 this.inputLabel = $localize`Confirm your password`
55 this.isPasswordInput = true
56 }
39 57
40 this.confirmButtonText = confirmButtonText || $localize`Confirm` 58 this.confirmButtonText = confirmButtonText || $localize`Confirm`
41 59
@@ -66,11 +84,13 @@ export class ConfirmComponent implements OnInit {
66 this.openedModal = this.modalService.open(this.confirmModal, { centered: true }) 84 this.openedModal = this.modalService.open(this.confirmModal, { centered: true })
67 85
68 this.openedModal.result 86 this.openedModal.result
69 .then(() => this.confirmService.confirmResponse.next(true)) 87 .then(() => {
88 this.confirmService.confirmResponse.next({ confirmed: true, value: this.inputValue })
89 })
70 .catch((reason: string) => { 90 .catch((reason: string) => {
71 // If the reason was that the user used the back button, we don't care about the confirm dialog result 91 // If the reason was that the user used the back button, we don't care about the confirm dialog result
72 if (!reason || reason !== POP_STATE_MODAL_DISMISS) { 92 if (!reason || reason !== POP_STATE_MODAL_DISMISS) {
73 this.confirmService.confirmResponse.next(false) 93 this.confirmService.confirmResponse.next({ confirmed: false, value: this.inputValue })
74 } 94 }
75 }) 95 })
76 } 96 }
diff --git a/client/src/app/shared/form-validators/user-validators.ts b/client/src/app/shared/form-validators/user-validators.ts
index 3262853d8..b93de75ea 100644
--- a/client/src/app/shared/form-validators/user-validators.ts
+++ b/client/src/app/shared/form-validators/user-validators.ts
@@ -61,6 +61,15 @@ export const USER_EXISTING_PASSWORD_VALIDATOR: BuildFormValidator = {
61 } 61 }
62} 62}
63 63
64export const USER_OTP_TOKEN_VALIDATOR: BuildFormValidator = {
65 VALIDATORS: [
66 Validators.required
67 ],
68 MESSAGES: {
69 required: $localize`OTP token is required.`
70 }
71}
72
64export const USER_PASSWORD_VALIDATOR = { 73export const USER_PASSWORD_VALIDATOR = {
65 VALIDATORS: [ 74 VALIDATORS: [
66 Validators.required, 75 Validators.required,
diff --git a/client/src/app/shared/shared-forms/form-reactive.service.ts b/client/src/app/shared/shared-forms/form-reactive.service.ts
new file mode 100644
index 000000000..69077eb07
--- /dev/null
+++ b/client/src/app/shared/shared-forms/form-reactive.service.ts
@@ -0,0 +1,101 @@
1import { Injectable } from '@angular/core'
2import { AbstractControl, FormGroup } from '@angular/forms'
3import { wait } from '@root-helpers/utils'
4import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
5import { FormValidatorService } from './form-validator.service'
6
7export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
8export type FormReactiveValidationMessages = {
9 [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
10}
11
12@Injectable()
13export class FormReactiveService {
14
15 constructor (private formValidatorService: FormValidatorService) {
16
17 }
18
19 buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
20 const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues)
21
22 form.statusChanges.subscribe(async () => {
23 // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
24 await this.waitPendingCheck(form)
25
26 this.onStatusChanged({ form, formErrors, validationMessages })
27 })
28
29 return { form, formErrors, validationMessages }
30 }
31
32 async waitPendingCheck (form: FormGroup) {
33 if (form.status !== 'PENDING') return
34
35 // FIXME: the following line does not work: https://github.com/angular/angular/issues/41519
36 // return firstValueFrom(form.statusChanges.pipe(filter(status => status !== 'PENDING')))
37 // So we have to fallback to active wait :/
38
39 do {
40 await wait(10)
41 } while (form.status === 'PENDING')
42 }
43
44 markAllAsDirty (controlsArg: { [ key: string ]: AbstractControl }) {
45 const controls = controlsArg
46
47 for (const key of Object.keys(controls)) {
48 const control = controls[key]
49
50 if (control instanceof FormGroup) {
51 this.markAllAsDirty(control.controls)
52 continue
53 }
54
55 control.markAsDirty()
56 }
57 }
58
59 protected forceCheck (form: FormGroup, formErrors: any, validationMessages: FormReactiveValidationMessages) {
60 this.onStatusChanged({ form, formErrors, validationMessages, onlyDirty: false })
61 }
62
63 private onStatusChanged (options: {
64 form: FormGroup
65 formErrors: FormReactiveErrors
66 validationMessages: FormReactiveValidationMessages
67 onlyDirty?: boolean // default true
68 }) {
69 const { form, formErrors, validationMessages, onlyDirty = true } = options
70
71 for (const field of Object.keys(formErrors)) {
72 if (formErrors[field] && typeof formErrors[field] === 'object') {
73 this.onStatusChanged({
74 form: form.controls[field] as FormGroup,
75 formErrors: formErrors[field] as FormReactiveErrors,
76 validationMessages: validationMessages[field] as FormReactiveValidationMessages,
77 onlyDirty
78 })
79
80 continue
81 }
82
83 // clear previous error message (if any)
84 formErrors[field] = ''
85 const control = form.get(field)
86
87 if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
88
89 const staticMessages = validationMessages[field]
90 for (const key of Object.keys(control.errors)) {
91 const formErrorValue = control.errors[key]
92
93 // Try to find error message in static validation messages first
94 // Then check if the validator returns a string that is the error
95 if (staticMessages[key]) formErrors[field] += staticMessages[key] + ' '
96 else if (typeof formErrorValue === 'string') formErrors[field] += control.errors[key]
97 else throw new Error('Form error value of ' + field + ' is invalid')
98 }
99 }
100 }
101}
diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts
index a19ffdd82..acaeaba33 100644
--- a/client/src/app/shared/shared-forms/form-reactive.ts
+++ b/client/src/app/shared/shared-forms/form-reactive.ts
@@ -1,14 +1,9 @@
1
2import { AbstractControl, FormGroup } from '@angular/forms' 1import { AbstractControl, FormGroup } from '@angular/forms'
3import { wait } from '@root-helpers/utils' 2import { wait } from '@root-helpers/utils'
4import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' 3import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
4import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service'
5import { FormValidatorService } from './form-validator.service' 5import { FormValidatorService } from './form-validator.service'
6 6
7export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
8export type FormReactiveValidationMessages = {
9 [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
10}
11
12export abstract class FormReactive { 7export abstract class FormReactive {
13 protected abstract formValidatorService: FormValidatorService 8 protected abstract formValidatorService: FormValidatorService
14 protected formChanged = false 9 protected formChanged = false
diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts
index f67d5bb33..897008242 100644
--- a/client/src/app/shared/shared-forms/form-validator.service.ts
+++ b/client/src/app/shared/shared-forms/form-validator.service.ts
@@ -1,7 +1,7 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms' 2import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms'
3import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' 3import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
4import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive' 4import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service'
5 5
6@Injectable() 6@Injectable()
7export class FormValidatorService { 7export class FormValidatorService {
diff --git a/client/src/app/shared/shared-forms/index.ts b/client/src/app/shared/shared-forms/index.ts
index 495785e7b..bff9862f2 100644
--- a/client/src/app/shared/shared-forms/index.ts
+++ b/client/src/app/shared/shared-forms/index.ts
@@ -1,4 +1,5 @@
1export * from './advanced-input-filter.component' 1export * from './advanced-input-filter.component'
2export * from './form-reactive.service'
2export * from './form-reactive' 3export * from './form-reactive'
3export * from './form-validator.service' 4export * from './form-validator.service'
4export * from './form-validator.service' 5export * from './form-validator.service'
diff --git a/client/src/app/shared/shared-forms/input-text.component.ts b/client/src/app/shared/shared-forms/input-text.component.ts
index d667ed663..aa4a1cba8 100644
--- a/client/src/app/shared/shared-forms/input-text.component.ts
+++ b/client/src/app/shared/shared-forms/input-text.component.ts
@@ -1,4 +1,4 @@
1import { Component, forwardRef, Input } from '@angular/core' 1import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4 4
@@ -15,6 +15,8 @@ import { Notifier } from '@app/core'
15 ] 15 ]
16}) 16})
17export class InputTextComponent implements ControlValueAccessor { 17export class InputTextComponent implements ControlValueAccessor {
18 @ViewChild('input') inputElement: ElementRef
19
18 @Input() inputId = Math.random().toString(11).slice(2, 8) // id cannot be left empty or undefined 20 @Input() inputId = Math.random().toString(11).slice(2, 8) // id cannot be left empty or undefined
19 @Input() value = '' 21 @Input() value = ''
20 @Input() autocomplete = 'off' 22 @Input() autocomplete = 'off'
@@ -65,4 +67,10 @@ export class InputTextComponent implements ControlValueAccessor {
65 update () { 67 update () {
66 this.propagateChange(this.value) 68 this.propagateChange(this.value)
67 } 69 }
70
71 focus () {
72 const el: HTMLElement = this.inputElement.nativeElement
73
74 el.focus({ preventScroll: true })
75 }
68} 76}
diff --git a/client/src/app/shared/shared-forms/shared-form.module.ts b/client/src/app/shared/shared-forms/shared-form.module.ts
index 81f076db6..628affb56 100644
--- a/client/src/app/shared/shared-forms/shared-form.module.ts
+++ b/client/src/app/shared/shared-forms/shared-form.module.ts
@@ -1,4 +1,3 @@
1
2import { InputMaskModule } from 'primeng/inputmask' 1import { InputMaskModule } from 'primeng/inputmask'
3import { NgModule } from '@angular/core' 2import { NgModule } from '@angular/core'
4import { FormsModule, ReactiveFormsModule } from '@angular/forms' 3import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@@ -7,6 +6,7 @@ import { SharedGlobalIconModule } from '../shared-icons'
7import { SharedMainModule } from '../shared-main/shared-main.module' 6import { SharedMainModule } from '../shared-main/shared-main.module'
8import { AdvancedInputFilterComponent } from './advanced-input-filter.component' 7import { AdvancedInputFilterComponent } from './advanced-input-filter.component'
9import { DynamicFormFieldComponent } from './dynamic-form-field.component' 8import { DynamicFormFieldComponent } from './dynamic-form-field.component'
9import { FormReactiveService } from './form-reactive.service'
10import { FormValidatorService } from './form-validator.service' 10import { FormValidatorService } from './form-validator.service'
11import { InputSwitchComponent } from './input-switch.component' 11import { InputSwitchComponent } from './input-switch.component'
12import { InputTextComponent } from './input-text.component' 12import { InputTextComponent } from './input-text.component'
@@ -96,7 +96,8 @@ import { TimestampInputComponent } from './timestamp-input.component'
96 ], 96 ],
97 97
98 providers: [ 98 providers: [
99 FormValidatorService 99 FormValidatorService,
100 FormReactiveService
100 ] 101 ]
101}) 102})
102export class SharedFormModule { } 103export class SharedFormModule { }
diff --git a/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts b/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
index e4b74f3ad..93b3a93d6 100644
--- a/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
+++ b/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
@@ -27,13 +27,16 @@ export class AuthInterceptor implements HttpInterceptor {
27 .pipe( 27 .pipe(
28 catchError((err: HttpErrorResponse) => { 28 catchError((err: HttpErrorResponse) => {
29 const error = err.error as PeerTubeProblemDocument 29 const error = err.error as PeerTubeProblemDocument
30 const isOTPMissingError = this.authService.isOTPMissingError(err)
30 31
31 if (err.status === HttpStatusCode.UNAUTHORIZED_401 && error && error.code === OAuth2ErrorCode.INVALID_TOKEN) { 32 if (!isOTPMissingError) {
32 return this.handleTokenExpired(req, next) 33 if (err.status === HttpStatusCode.UNAUTHORIZED_401 && error && error.code === OAuth2ErrorCode.INVALID_TOKEN) {
33 } 34 return this.handleTokenExpired(req, next)
35 }
34 36
35 if (err.status === HttpStatusCode.UNAUTHORIZED_401) { 37 if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
36 return this.handleNotAuthenticated(err) 38 return this.handleNotAuthenticated(err)
39 }
37 } 40 }
38 41
39 return observableThrowError(() => err) 42 return observableThrowError(() => err)