diff --git a/src/app/accounts/two-factor-options.component.html b/src/app/accounts/two-factor-options.component.html new file mode 100644 index 0000000000..740365d1ea --- /dev/null +++ b/src/app/accounts/two-factor-options.component.html @@ -0,0 +1,28 @@ + diff --git a/src/app/accounts/two-factor-options.component.ts b/src/app/accounts/two-factor-options.component.ts new file mode 100644 index 0000000000..acdb4a10d3 --- /dev/null +++ b/src/app/accounts/two-factor-options.component.ts @@ -0,0 +1,70 @@ +import * as template from './two-factor-options.component.html'; + +import { + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; + +import { Router } from '@angular/router'; + +import { Angulartics2 } from 'angulartics2'; +import { ToasterService } from 'angular2-toaster'; + +import { TwoFactorProviderType } from 'jslib/enums/twoFactorProviderType'; + +import { AuthService } from 'jslib/abstractions/auth.service'; +import { I18nService } from 'jslib/abstractions/i18n.service'; +import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service'; + +import { TwoFactorProviders } from 'jslib/services/auth.service'; + +@Component({ + selector: 'app-two-factor-options', + template: template, +}) +export class TwoFactorOptionsComponent implements OnInit { + @Output() onProviderSelected = new EventEmitter(); + @Output() onRecoverSelected = new EventEmitter(); + + providers: any[] = []; + + constructor(private authService: AuthService, private router: Router, private analytics: Angulartics2, + private toasterService: ToasterService, private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService) { } + + ngOnInit() { + if (this.authService.twoFactorProviders.has(TwoFactorProviderType.Authenticator)) { + this.providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]); + } + + if (this.authService.twoFactorProviders.has(TwoFactorProviderType.Yubikey)) { + this.providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]); + } + + if (this.authService.twoFactorProviders.has(TwoFactorProviderType.Duo)) { + this.providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]); + } + + if (this.authService.twoFactorProviders.has(TwoFactorProviderType.U2f) && + this.platformUtilsService.supportsU2f(window)) { + this.providers.push(TwoFactorProviders[TwoFactorProviderType.U2f]); + } + + if (this.authService.twoFactorProviders.has(TwoFactorProviderType.Email)) { + this.providers.push(TwoFactorProviders[TwoFactorProviderType.Email]); + } + } + + choose(p: any) { + this.onProviderSelected.emit(p.type); + } + + recover() { + this.analytics.eventTrack.next({ action: 'Selected Recover' }); + this.platformUtilsService.launchUri('https://help.bitwarden.com/article/lost-two-step-device/'); + this.onRecoverSelected.emit(); + } +} diff --git a/src/app/accounts/two-factor.component.html b/src/app/accounts/two-factor.component.html index b70909afc8..6adfd9a471 100644 --- a/src/app/accounts/two-factor.component.html +++ b/src/app/accounts/two-factor.component.html @@ -2,7 +2,9 @@

{{title}}

{{'enterVerificationCodeApp' | i18n}}

-

{{'enterVerificationCodeEmail' | i18n}}

+

+ {{'enterVerificationCodeEmail' | i18n : twoFactorEmail}} +

@@ -43,7 +45,7 @@
-
+

{{'noTwoStepProviders' | i18n}}

@@ -52,7 +54,8 @@
- @@ -60,6 +63,11 @@
+ diff --git a/src/app/accounts/two-factor.component.ts b/src/app/accounts/two-factor.component.ts index 4782cdce54..e595c5de17 100644 --- a/src/app/accounts/two-factor.component.ts +++ b/src/app/accounts/two-factor.component.ts @@ -2,7 +2,10 @@ import * as template from './two-factor.component.html'; import { Component, + ComponentFactoryResolver, OnInit, + ViewChild, + ViewContainerRef, } from '@angular/core'; import { Router } from '@angular/router'; @@ -10,6 +13,9 @@ import { Router } from '@angular/router'; import { Angulartics2 } from 'angulartics2'; import { ToasterService } from 'angular2-toaster'; +import { TwoFactorOptionsComponent } from './two-factor-options.component'; +import { ModalComponent } from '../modal.component'; + import { TwoFactorProviderType } from 'jslib/enums/twoFactorProviderType'; import { TwoFactorEmailRequest } from 'jslib/models/request/twoFactorEmailRequest'; @@ -26,6 +32,8 @@ import { TwoFactorProviders } from 'jslib/services/auth.service'; template: template, }) export class TwoFactorComponent implements OnInit { + @ViewChild('twoFactorOptions', { read: ViewContainerRef }) twoFactorOptionsModal: ViewContainerRef; + token: string = ''; remember: boolean = false; u2fReady: boolean = false; @@ -35,14 +43,14 @@ export class TwoFactorComponent implements OnInit { u2fSupported: boolean = false; u2f: any = null; title: string = ''; - useVerificationCode: boolean = false; twoFactorEmail: string = null; formPromise: Promise; emailPromise: Promise; constructor(private authService: AuthService, private router: Router, private analytics: Angulartics2, private toasterService: ToasterService, private i18nService: I18nService, private apiService: ApiService, - private platformUtilsService: PlatformUtilsService) { + private platformUtilsService: PlatformUtilsService, + private componentFactoryResolver: ComponentFactoryResolver) { this.u2fSupported = this.platformUtilsService.supportsU2f(window); } @@ -58,10 +66,6 @@ export class TwoFactorComponent implements OnInit { } async init() { - this.useVerificationCode = this.selectedProviderType === TwoFactorProviderType.Email || - this.selectedProviderType === TwoFactorProviderType.Authenticator || - this.selectedProviderType === TwoFactorProviderType.Yubikey; - if (this.selectedProviderType == null) { this.title = this.i18nService.t('loginUnavailable'); return; @@ -135,17 +139,36 @@ export class TwoFactorComponent implements OnInit { return; } + if (this.emailPromise != null) { + return; + } + try { const request = new TwoFactorEmailRequest(this.authService.email, this.authService.masterPasswordHash); this.emailPromise = this.apiService.postTwoFactorEmail(request); await this.emailPromise; if (doToast) { - // TODO toast + this.toasterService.popAsync('success', null, + this.i18nService.t('verificationCodeEmailSent', this.twoFactorEmail)); } } catch { } + + this.emailPromise = null; } anotherMethod() { - // TODO + const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent); + const modal = this.twoFactorOptionsModal.createComponent(factory).instance; + const childComponent = modal.show(TwoFactorOptionsComponent, + this.twoFactorOptionsModal); + + childComponent.onProviderSelected.subscribe(async (provider: TwoFactorProviderType) => { + modal.close(); + this.selectedProviderType = provider; + await this.init(); + }); + childComponent.onRecoverSelected.subscribe(() => { + modal.close(); + }); } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bec35815a2..ebc3e39828 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -32,6 +32,7 @@ import { SearchCiphersPipe } from './pipes/search-ciphers.pipe'; import { StopClickDirective } from './directives/stop-click.directive'; import { StopPropDirective } from './directives/stop-prop.directive'; import { TwoFactorComponent } from './accounts/two-factor.component'; +import { TwoFactorOptionsComponent } from './accounts/two-factor-options.component'; import { VaultComponent } from './vault/vault.component'; import { ViewComponent } from './vault/view.component'; @@ -71,15 +72,16 @@ import { ViewComponent } from './vault/view.component'; StopClickDirective, StopPropDirective, TwoFactorComponent, + TwoFactorOptionsComponent, VaultComponent, ViewComponent, - WebviewDirective, ], entryComponents: [ AttachmentsComponent, FolderAddEditComponent, ModalComponent, PasswordGeneratorComponent, + TwoFactorOptionsComponent, ], providers: [], bootstrap: [AppComponent], diff --git a/src/app/pipes/i18n.pipe.ts b/src/app/pipes/i18n.pipe.ts index 2a6cef7cc0..d542af9347 100644 --- a/src/app/pipes/i18n.pipe.ts +++ b/src/app/pipes/i18n.pipe.ts @@ -9,10 +9,9 @@ import { I18nService } from 'jslib/abstractions/i18n.service'; name: 'i18n', }) export class I18nPipe implements PipeTransform { - constructor(private i18nService: I18nService) { - } + constructor(private i18nService: I18nService) { } - transform(id: string): string { - return this.i18nService.t(id); + transform(id: string, p1?: string, p2?: string, p3?: string): string { + return this.i18nService.t(id, p1, p2, p3); } } diff --git a/src/images/two-factor/0.png b/src/images/two-factor/0.png new file mode 100644 index 0000000000..f37e3f17b4 Binary files /dev/null and b/src/images/two-factor/0.png differ diff --git a/src/images/two-factor/1.png b/src/images/two-factor/1.png new file mode 100644 index 0000000000..b47a12b1db Binary files /dev/null and b/src/images/two-factor/1.png differ diff --git a/src/images/two-factor/2.png b/src/images/two-factor/2.png new file mode 100644 index 0000000000..ab2e434036 Binary files /dev/null and b/src/images/two-factor/2.png differ diff --git a/src/images/two-factor/3.png b/src/images/two-factor/3.png new file mode 100644 index 0000000000..21aac2da67 Binary files /dev/null and b/src/images/two-factor/3.png differ diff --git a/src/images/two-factor/4.png b/src/images/two-factor/4.png new file mode 100644 index 0000000000..ae7d7b55e4 Binary files /dev/null and b/src/images/two-factor/4.png differ diff --git a/src/locales/en/messages.json b/src/locales/en/messages.json index 738f4cc9e1..bdc2b77b32 100644 --- a/src/locales/en/messages.json +++ b/src/locales/en/messages.json @@ -459,7 +459,22 @@ "message": "Enter the 6 digit verification code from your authenticator app." }, "enterVerificationCodeEmail": { - "message": "Enter the 6 digit verification code that was emailed to" + "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", + "placeholders": { + "email": { + "content": "$1", + "example": "example@gmail.com" + } + } + }, + "verificationCodeEmailSent": { + "message": "Verification email sent to $EMAIL$.", + "placeholders": { + "email": { + "content": "$1", + "example": "example@gmail.com" + } + } }, "rememberMe": { "message": "Remember me" @@ -510,5 +525,17 @@ }, "emailDesc": { "message": "Verification codes will be emailed to you." + }, + "loginUnavailable": { + "message": "Login Unavailable" + }, + "noTwoStepProviders": { + "message": "This account has two-step login enabled, however, none of the configured two-step providers are supported by this device." + }, + "noTwoStepProviders2": { + "message": "Please add additional providers that are better supported across devices (such as an authenticator app)." + }, + "twoStepOptions": { + "message": "Two-step Login Options" } } diff --git a/src/scss/box.scss b/src/scss/box.scss index 94cf6a1f1d..6b33725a0b 100644 --- a/src/scss/box.scss +++ b/src/scss/box.scss @@ -26,8 +26,6 @@ padding: 10px 15px; position: relative; z-index: 1; - overflow-wrap: break-word; - word-break: break-all; &:before { content: ""; @@ -73,6 +71,21 @@ margin-bottom: 5px; } + .text, .detail { + display: block; + color: $text-color; + } + + .detail { + font-size: $font-size-small; + color: $text-muted; + } + + .img-right { + float: right; + margin-left: 10px; + } + .row-main { flex-grow: 1; } diff --git a/src/scss/misc.scss b/src/scss/misc.scss index 1d900075cd..754c045880 100644 --- a/src/scss/misc.scss +++ b/src/scss/misc.scss @@ -106,8 +106,8 @@ #duo-frame { background: url('../images/loading.svg') 0 0 no-repeat; - width: 100%; - height: 300px; + height: 330px; + margin: 0 -150px 15px -150px; iframe { width: 100%; @@ -115,3 +115,13 @@ border: none; } } + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/src/scss/pages.scss b/src/scss/pages.scss index 40ad264317..b6abec1fb1 100644 --- a/src/scss/pages.scss +++ b/src/scss/pages.scss @@ -78,6 +78,15 @@ .sub-options { text-align: center; margin-bottom: 20px; + + a { + display: block; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + } } a.settings-icon { diff --git a/src/services/i18n.service.ts b/src/services/i18n.service.ts index 5b9d3dad71..fadc58faa5 100644 --- a/src/services/i18n.service.ts +++ b/src/services/i18n.service.ts @@ -41,17 +41,33 @@ export class I18nService implements I18nServiceAbstraction { } } - t(id: string): string { - return this.translate(id); + t(id: string, p1?: string, p2?: string, p3?: string): string { + return this.translate(id, p1, p2, p3); } - translate(id: string): string { + translate(id: string, p1?: string, p2?: string, p3?: string): string { + let result: string; if (this.localeMessages.hasOwnProperty(id) && this.localeMessages[id]) { - return this.localeMessages[id]; + result = this.localeMessages[id]; } else if (this.defaultMessages.hasOwnProperty(id) && this.defaultMessages[id]) { - return this.defaultMessages[id]; + result = this.defaultMessages[id]; + } else { + result = ''; } - return ''; + + if (result !== '') { + if (p1 != null) { + result = result.split('__$1__').join(p1); + } + if (p2 != null) { + result = result.split('__$2__').join(p2); + } + if (p3 != null) { + result = result.split('__$3__').join(p3); + } + } + + return result; } private loadMessages(locale: string, messagesObj: any): Promise { @@ -59,8 +75,25 @@ export class I18nService implements I18nServiceAbstraction { const filePath = path.join(__dirname, this.localesDirectory + '/' + formattedLocale + '/messages.json'); const locales = (window as any).require(filePath); for (const prop in locales) { - if (locales.hasOwnProperty(prop)) { - messagesObj[prop] = locales[prop].message; + if (!locales.hasOwnProperty(prop)) { + continue; + } + messagesObj[prop] = locales[prop].message; + + if (locales[prop].placeholders) { + for (const placeProp in locales[prop].placeholders) { + if (!locales[prop].placeholders.hasOwnProperty(placeProp) || + !locales[prop].placeholders[placeProp].content) { + continue; + } + + const replaceToken = '\\$' + placeProp.toUpperCase() + '\\$'; + let replaceContent = locales[prop].placeholders[placeProp].content; + if (replaceContent === '$1' || replaceContent === '$2' || replaceContent === '$3') { + replaceContent = '__' + replaceContent + '__'; + } + messagesObj[prop] = messagesObj[prop].replace(new RegExp(replaceToken, 'g'), replaceContent); + } } } diff --git a/webpack.renderer.js b/webpack.renderer.js index 66d2912d52..e9aa03df88 100644 --- a/webpack.renderer.js +++ b/webpack.renderer.js @@ -76,6 +76,7 @@ const renderer = { }, { test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, + exclude: /loading.svg/, use: [{ loader: 'file-loader', options: { @@ -95,7 +96,8 @@ const renderer = { { loader: 'sass-loader', } - ] + ], + publicPath: '../' }) }, ]