From 33de685b403c2c459df36c753ca5770221a3f2b7 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Wed, 10 Jul 2024 16:59:17 +0200 Subject: [PATCH] [PM-5165][PM-8645] Migrate password strength component (#9912) * Create standalone password-strength-v2 component * Add deprecation notice to old component * PM-8645: Use new password-strength component on export * Remove unneccessary variable * Remove setPasswordScoreText method * Rename passwordStrengthResult to passwordStrengthScore and assign proper type * Add missing types * Document component Inputs/Outputs * Add unit tests --------- Co-authored-by: Daniel James Smith --- .../password-strength-v2.component.html | 7 ++ .../password-strength-v2.component.spec.ts | 80 ++++++++++++ .../password-strength-v2.component.ts | 119 ++++++++++++++++++ .../password-strength.component.ts | 3 + .../src/components/export.component.html | 3 +- .../src/components/export.component.ts | 5 +- 6 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 libs/angular/src/tools/password-strength/password-strength-v2.component.html create mode 100644 libs/angular/src/tools/password-strength/password-strength-v2.component.spec.ts create mode 100644 libs/angular/src/tools/password-strength/password-strength-v2.component.ts diff --git a/libs/angular/src/tools/password-strength/password-strength-v2.component.html b/libs/angular/src/tools/password-strength/password-strength-v2.component.html new file mode 100644 index 0000000000..b613f0f274 --- /dev/null +++ b/libs/angular/src/tools/password-strength/password-strength-v2.component.html @@ -0,0 +1,7 @@ + diff --git a/libs/angular/src/tools/password-strength/password-strength-v2.component.spec.ts b/libs/angular/src/tools/password-strength/password-strength-v2.component.spec.ts new file mode 100644 index 0000000000..2a67d3b9ed --- /dev/null +++ b/libs/angular/src/tools/password-strength/password-strength-v2.component.spec.ts @@ -0,0 +1,80 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; + +import { + PasswordColorText, + PasswordStrengthScore, + PasswordStrengthV2Component, +} from "./password-strength-v2.component"; + +describe("PasswordStrengthV2Component", () => { + let component: PasswordStrengthV2Component; + let fixture: ComponentFixture; + + const mockPasswordStrengthService = mock(); + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: PasswordStrengthServiceAbstraction, useValue: mockPasswordStrengthService }, + ], + }); + fixture = TestBed.createComponent(PasswordStrengthV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should update password strength when password changes", () => { + const password = "testPassword"; + jest.spyOn(component, "updatePasswordStrength"); + component.password = password; + expect(component.updatePasswordStrength).toHaveBeenCalledWith(password); + expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith( + password, + undefined, + undefined, + ); + }); + + it("should emit password strength result when password changes", () => { + const password = "testPassword"; + jest.spyOn(component.passwordStrengthScore, "emit"); + component.password = password; + expect(component.passwordStrengthScore.emit).toHaveBeenCalled(); + }); + + it("should emit password score text and color when ngOnChanges executes", () => { + jest.spyOn(component.passwordScoreTextWithColor, "emit"); + jest.useFakeTimers(); + component.ngOnChanges(); + jest.runAllTimers(); + expect(component.passwordScoreTextWithColor.emit).toHaveBeenCalled(); + }); + + const table = [ + [4, { color: "success", text: "strong" }], + [3, { color: "primary", text: "good" }], + [2, { color: "warning", text: "weak" }], + [1, { color: "danger", text: "weak" }], + [null, { color: "danger", text: null }], + ]; + + test.each(table)( + "should passwordScore be %d then emit passwordScoreTextWithColor = %s", + (score: PasswordStrengthScore, expected: PasswordColorText) => { + jest.useFakeTimers(); + jest.spyOn(component.passwordScoreTextWithColor, "emit"); + component.passwordScore = score; + component.ngOnChanges(); + jest.runAllTimers(); + expect(component.passwordScoreTextWithColor.emit).toHaveBeenCalledWith(expected); + }, + ); +}); diff --git a/libs/angular/src/tools/password-strength/password-strength-v2.component.ts b/libs/angular/src/tools/password-strength/password-strength-v2.component.ts new file mode 100644 index 0000000000..425adba7ba --- /dev/null +++ b/libs/angular/src/tools/password-strength/password-strength-v2.component.ts @@ -0,0 +1,119 @@ +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, Input, OnChanges, Output } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { ProgressModule } from "@bitwarden/components"; + +export interface PasswordColorText { + color: BackgroundTypes; + text: string; +} +export type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4; + +type SizeTypes = "small" | "default" | "large"; +type BackgroundTypes = "danger" | "primary" | "success" | "warning"; + +@Component({ + selector: "tools-password-strength", + templateUrl: "password-strength-v2.component.html", + standalone: true, + imports: [CommonModule, JslibModule, ProgressModule], +}) +export class PasswordStrengthV2Component implements OnChanges { + /** + * The size (height) of the password strength component. + * Possible values are "default", "small" and "large". + */ + @Input() size: SizeTypes = "default"; + /** + * Determines whether to show the password strength score text on the progress bar or not. + */ + @Input() showText = false; + /** + * Optional email address which can be used as input for the password strength calculation + */ + @Input() email: string; + /** + * Optional name which can be used as input for the password strength calculation + */ + @Input() name: string; + /** + * Sets the password value and updates the password strength. + * + * @param value - password provided by the hosting component + */ + @Input() set password(value: string) { + this.updatePasswordStrength(value); + } + /** + * Emits the password strength score. + * + * @remarks + * The password strength score represents the strength of a password. + * It is emitted as an event when the password strength changes. + */ + @Output() passwordStrengthScore = new EventEmitter(); + + /** + * Emits an event with the password score text and color. + */ + @Output() passwordScoreTextWithColor = new EventEmitter(); + + passwordScore: PasswordStrengthScore; + scoreWidth = 0; + color: BackgroundTypes = "danger"; + text: string; + + private passwordStrengthTimeout: number | NodeJS.Timeout; + + constructor( + private i18nService: I18nService, + private passwordStrengthService: PasswordStrengthServiceAbstraction, + ) {} + + ngOnChanges(): void { + this.passwordStrengthTimeout = setTimeout(() => { + this.scoreWidth = this.passwordScore == null ? 0 : (this.passwordScore + 1) * 20; + + switch (this.passwordScore) { + case 4: + this.color = "success"; + this.text = this.i18nService.t("strong"); + break; + case 3: + this.color = "primary"; + this.text = this.i18nService.t("good"); + break; + case 2: + this.color = "warning"; + this.text = this.i18nService.t("weak"); + break; + default: + this.color = "danger"; + this.text = this.passwordScore != null ? this.i18nService.t("weak") : null; + break; + } + + this.passwordScoreTextWithColor.emit({ + color: this.color, + text: this.text, + } as PasswordColorText); + }, 300); + } + + updatePasswordStrength(password: string) { + if (this.passwordStrengthTimeout != null) { + clearTimeout(this.passwordStrengthTimeout); + } + + const strengthResult = this.passwordStrengthService.getPasswordStrength( + password, + this.email, + this.name?.trim().toLowerCase().split(" "), + ); + this.passwordScore = strengthResult == null ? null : strengthResult.score; + this.passwordStrengthScore.emit(this.passwordScore); + } +} diff --git a/libs/angular/src/tools/password-strength/password-strength.component.ts b/libs/angular/src/tools/password-strength/password-strength.component.ts index 305feefbf2..5960bdb9d0 100644 --- a/libs/angular/src/tools/password-strength/password-strength.component.ts +++ b/libs/angular/src/tools/password-strength/password-strength.component.ts @@ -8,6 +8,9 @@ export interface PasswordColorText { text: string; } +/** + * @deprecated July 2024: Use new PasswordStrengthV2Component instead + */ @Component({ selector: "app-password-strength", templateUrl: "password-strength.component.html", diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html index 8abc0c7755..07606add8b 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -76,7 +76,8 @@ > {{ "exportPasswordDescription" | i18n }} - + + {{ "confirmFilePassword" | i18n }} diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index baa463d913..84d4c45934 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -12,7 +12,7 @@ import { ReactiveFormsModule, UntypedFormBuilder, Validators } from "@angular/fo import { map, merge, Observable, startWith, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { PasswordStrengthComponent } from "@bitwarden/angular/tools/password-strength/password-strength.component"; +import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -58,6 +58,7 @@ import { ExportScopeCalloutComponent } from "./export-scope-callout.component"; RadioButtonModule, ExportScopeCalloutComponent, UserVerificationDialogComponent, + PasswordStrengthV2Component, ], }) export class ExportComponent implements OnInit, OnDestroy { @@ -110,7 +111,7 @@ export class ExportComponent implements OnInit, OnDestroy { @Output() onSuccessfulExport = new EventEmitter(); - @ViewChild(PasswordStrengthComponent) passwordStrengthComponent: PasswordStrengthComponent; + @ViewChild(PasswordStrengthV2Component) passwordStrengthComponent: PasswordStrengthV2Component; encryptedExportType = EncryptedExportType; protected showFilePassword: boolean;