diff options
author | Chocobozzz <me@florianbigard.com> | 2022-10-07 11:06:28 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2022-10-07 11:06:28 +0200 |
commit | d12b40fb96d56786a96c06a621f3d8e0a0d24f4a (patch) | |
tree | 7047fa5cd7e778eb377c897eccb539c52b2e59bc | |
parent | 56f47830758ff8e92abcfcc5f35d474ab12fe215 (diff) | |
download | PeerTube-d12b40fb96d56786a96c06a621f3d8e0a0d24f4a.tar.gz PeerTube-d12b40fb96d56786a96c06a621f3d8e0a0d24f4a.tar.zst PeerTube-d12b40fb96d56786a96c06a621f3d8e0a0d24f4a.zip |
Implement two factor in client
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' | |||
4 | import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core' | 4 | import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core' |
5 | import { HooksService } from '@app/core/plugins/hooks.service' | 5 | import { HooksService } from '@app/core/plugins/hooks.service' |
6 | import { LOGIN_PASSWORD_VALIDATOR, LOGIN_USERNAME_VALIDATOR } from '@app/shared/form-validators/login-validators' | 6 | import { LOGIN_PASSWORD_VALIDATOR, LOGIN_USERNAME_VALIDATOR } from '@app/shared/form-validators/login-validators' |
7 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' | 7 | import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators' |
8 | import { FormReactive, FormValidatorService, InputTextComponent } from '@app/shared/shared-forms' | ||
8 | import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' | 9 | import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' |
9 | import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' | 10 | import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' |
10 | import { PluginsManager } from '@root-helpers/plugins-manager' | 11 | import { 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 | |||
7 | import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component' | 7 | import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component' |
8 | import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component' | 8 | import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component' |
9 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' | 9 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' |
10 | import { MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor' | ||
10 | import { MyAccountComponent } from './my-account.component' | 11 | import { MyAccountComponent } from './my-account.component' |
11 | 12 | ||
12 | const myAccountRoutes: Routes = [ | 13 | const 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' | |||
4 | import { AuthService, ServerService, UserService } from '@app/core' | 4 | import { AuthService, ServerService, UserService } from '@app/core' |
5 | import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' | 5 | import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' |
6 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' | 6 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' |
7 | import { User } from '@shared/models' | 7 | import { 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' |
9 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' | 9 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' |
10 | import { User } from '@shared/models' | 10 | import { 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 @@ | |||
1 | export * from './my-account-two-factor-button.component' | ||
2 | export * from './my-account-two-factor.component' | ||
3 | export * 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 @@ | |||
1 | import { Subject } from 'rxjs' | ||
2 | import { Component, Input, OnInit } from '@angular/core' | ||
3 | import { AuthService, ConfirmService, Notifier, User } from '@app/core' | ||
4 | import { 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 | }) | ||
10 | export 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 | |||
13 | qrcode { | ||
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 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { FormGroup } from '@angular/forms' | ||
3 | import { Router } from '@angular/router' | ||
4 | import { AuthService, Notifier, User } from '@app/core' | ||
5 | import { USER_EXISTING_PASSWORD_VALIDATOR, USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators' | ||
6 | import { FormReactiveService } from '@app/shared/shared-forms' | ||
7 | import { 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 | }) | ||
14 | export 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 @@ | |||
1 | import { catchError } from 'rxjs/operators' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { RestExtractor, UserService } from '@app/core' | ||
5 | import { TwoFactorEnableResult } from '@shared/models' | ||
6 | |||
7 | @Injectable() | ||
8 | export 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 @@ | |||
1 | import { QRCodeModule } from 'angularx-qrcode' | ||
1 | import { AutoCompleteModule } from 'primeng/autocomplete' | 2 | import { AutoCompleteModule } from 'primeng/autocomplete' |
2 | import { TableModule } from 'primeng/table' | 3 | import { TableModule } from 'primeng/table' |
3 | import { DragDropModule } from '@angular/cdk/drag-drop' | 4 | import { DragDropModule } from '@angular/cdk/drag-drop' |
@@ -23,12 +24,18 @@ import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-d | |||
23 | import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences' | 24 | import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences' |
24 | import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' | 25 | import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' |
25 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' | 26 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' |
27 | import { | ||
28 | MyAccountTwoFactorButtonComponent, | ||
29 | MyAccountTwoFactorComponent, | ||
30 | TwoFactorService | ||
31 | } from './my-account-settings/my-account-two-factor' | ||
26 | import { MyAccountComponent } from './my-account.component' | 32 | import { 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 | }) |
69 | export class MyAccountModule { | 81 | export 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}? |
45 | It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another | 45 | It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another |
46 | channel with the same name (${videoChannel.name})!`, | 46 | channel 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 @@ | |||
1 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' | 1 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' |
2 | import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs' | 2 | import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs' |
3 | import { catchError, map, mergeMap, share, tap } from 'rxjs/operators' | 3 | import { catchError, map, mergeMap, share, tap } from 'rxjs/operators' |
4 | import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http' | 4 | import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http' |
5 | import { Injectable } from '@angular/core' | 5 | import { Injectable } from '@angular/core' |
6 | import { Router } from '@angular/router' | 6 | import { Router } from '@angular/router' |
7 | import { Notifier } from '@app/core/notification/notifier.service' | 7 | import { 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 @@ | |||
1 | import { firstValueFrom, Subject } from 'rxjs' | 1 | import { firstValueFrom, map, Observable, Subject } from 'rxjs' |
2 | import { Injectable } from '@angular/core' | 2 | import { Injectable } from '@angular/core' |
3 | 3 | ||
4 | type ConfirmOptions = { | 4 | type 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() |
13 | export class ConfirmService { | 25 | export 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' | |||
4 | import { DateFormat, dateToHuman } from '@app/helpers' | 4 | import { DateFormat, dateToHuman } from '@app/helpers' |
5 | import { logger } from '@root-helpers/logger' | 5 | import { logger } from '@root-helpers/logger' |
6 | import { HttpStatusCode, ResultList } from '@shared/models' | 6 | import { HttpStatusCode, ResultList } from '@shared/models' |
7 | import { HttpHeaderResponse } from '@angular/common/http' | ||
7 | 8 | ||
8 | @Injectable() | 9 | @Injectable() |
9 | export class RestExtractor { | 10 | export 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 | ||
64 | export const USER_OTP_TOKEN_VALIDATOR: BuildFormValidator = { | ||
65 | VALIDATORS: [ | ||
66 | Validators.required | ||
67 | ], | ||
68 | MESSAGES: { | ||
69 | required: $localize`OTP token is required.` | ||
70 | } | ||
71 | } | ||
72 | |||
64 | export const USER_PASSWORD_VALIDATOR = { | 73 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { AbstractControl, FormGroup } from '@angular/forms' | ||
3 | import { wait } from '@root-helpers/utils' | ||
4 | import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' | ||
5 | import { FormValidatorService } from './form-validator.service' | ||
6 | |||
7 | export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } | ||
8 | export type FormReactiveValidationMessages = { | ||
9 | [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages | ||
10 | } | ||
11 | |||
12 | @Injectable() | ||
13 | export 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 | |||
2 | import { AbstractControl, FormGroup } from '@angular/forms' | 1 | import { AbstractControl, FormGroup } from '@angular/forms' |
3 | import { wait } from '@root-helpers/utils' | 2 | import { wait } from '@root-helpers/utils' |
4 | import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' | 3 | import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' |
4 | import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service' | ||
5 | import { FormValidatorService } from './form-validator.service' | 5 | import { FormValidatorService } from './form-validator.service' |
6 | 6 | ||
7 | export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } | ||
8 | export type FormReactiveValidationMessages = { | ||
9 | [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages | ||
10 | } | ||
11 | |||
12 | export abstract class FormReactive { | 7 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms' | 2 | import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms' |
3 | import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' | 3 | import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' |
4 | import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive' | 4 | import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service' |
5 | 5 | ||
6 | @Injectable() | 6 | @Injectable() |
7 | export class FormValidatorService { | 7 | export 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 @@ | |||
1 | export * from './advanced-input-filter.component' | 1 | export * from './advanced-input-filter.component' |
2 | export * from './form-reactive.service' | ||
2 | export * from './form-reactive' | 3 | export * from './form-reactive' |
3 | export * from './form-validator.service' | 4 | export * from './form-validator.service' |
4 | export * from './form-validator.service' | 5 | export * 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 @@ | |||
1 | import { Component, forwardRef, Input } from '@angular/core' | 1 | import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core' |
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
3 | import { Notifier } from '@app/core' | 3 | import { Notifier } from '@app/core' |
4 | 4 | ||
@@ -15,6 +15,8 @@ import { Notifier } from '@app/core' | |||
15 | ] | 15 | ] |
16 | }) | 16 | }) |
17 | export class InputTextComponent implements ControlValueAccessor { | 17 | export 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 | |||
2 | import { InputMaskModule } from 'primeng/inputmask' | 1 | import { InputMaskModule } from 'primeng/inputmask' |
3 | import { NgModule } from '@angular/core' | 2 | import { NgModule } from '@angular/core' |
4 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' | 3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' |
@@ -7,6 +6,7 @@ import { SharedGlobalIconModule } from '../shared-icons' | |||
7 | import { SharedMainModule } from '../shared-main/shared-main.module' | 6 | import { SharedMainModule } from '../shared-main/shared-main.module' |
8 | import { AdvancedInputFilterComponent } from './advanced-input-filter.component' | 7 | import { AdvancedInputFilterComponent } from './advanced-input-filter.component' |
9 | import { DynamicFormFieldComponent } from './dynamic-form-field.component' | 8 | import { DynamicFormFieldComponent } from './dynamic-form-field.component' |
9 | import { FormReactiveService } from './form-reactive.service' | ||
10 | import { FormValidatorService } from './form-validator.service' | 10 | import { FormValidatorService } from './form-validator.service' |
11 | import { InputSwitchComponent } from './input-switch.component' | 11 | import { InputSwitchComponent } from './input-switch.component' |
12 | import { InputTextComponent } from './input-text.component' | 12 | import { 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 | }) |
102 | export class SharedFormModule { } | 103 | export 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) |