From 97bf4594244bef036469520045cec8a017624c51 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Fri, 18 Oct 2024 14:57:08 -0500 Subject: [PATCH] [PM-13251] Password History (#11618) * add password history view component in vault lib * integrate PasswordHistoryView into individual vault * add password history v2 to browser extension * update color of password history link * add check for `cipherId` before rendering password history --- apps/browser/src/popup/app-routing.module.ts | 6 +- .../vault-password-history-v2.component.html | 8 ++ ...ault-password-history-v2.component.spec.ts | 56 +++++++++++ .../vault-password-history-v2.component.ts | 50 ++++++++++ .../password-history.component.html | 29 +----- .../password-history.component.ts | 79 +++------------ apps/web/src/locales/en/messages.json | 3 + .../item-history-v2.component.html | 2 +- .../password-history-view.component.html | 28 ++++++ .../password-history-view.component.spec.ts | 97 +++++++++++++++++++ .../password-history-view.component.ts | 77 +++++++++++++++ libs/vault/src/index.ts | 1 + 12 files changed, 337 insertions(+), 99 deletions(-) create mode 100644 apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts create mode 100644 libs/vault/src/components/password-history-view/password-history-view.component.html create mode 100644 libs/vault/src/components/password-history-view/password-history-view.component.spec.ts create mode 100644 libs/vault/src/components/password-history-view/password-history-view.component.ts diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 1a95ad748398..a6d91d018784 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -100,6 +100,7 @@ import { ViewComponent } from "../vault/popup/components/vault/view.component"; import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component"; import { AssignCollections } from "../vault/popup/components/vault-v2/assign-collections/assign-collections.component"; import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attachments/attachments-v2.component"; +import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component"; import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component"; import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component"; import { AppearanceComponent } from "../vault/popup/settings/appearance.component"; @@ -259,12 +260,11 @@ const routes: Routes = [ canActivate: [authGuard], data: { state: "view-cipher" } satisfies RouteDataProperties, }), - { + ...extensionRefreshSwap(PasswordHistoryComponent, PasswordHistoryV2Component, { path: "cipher-password-history", - component: PasswordHistoryComponent, canActivate: [authGuard], data: { state: "cipher-password-history" } satisfies RouteDataProperties, - }, + }), ...extensionRefreshSwap(AddEditComponent, AddEditV2Component, { path: "add-cipher", canActivate: [authGuard, debounceNavigationGuard()], diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html new file mode 100644 index 000000000000..d4ff0662fe02 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts new file mode 100644 index 000000000000..a375aba302ec --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts @@ -0,0 +1,56 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { Subject } from "rxjs"; + +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; + +import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; + +import { PasswordHistoryV2Component } from "./vault-password-history-v2.component"; + +describe("PasswordHistoryV2Component", () => { + let component: PasswordHistoryV2Component; + let fixture: ComponentFixture; + const params$ = new Subject(); + const back = jest.fn().mockResolvedValue(undefined); + + beforeEach(async () => { + back.mockClear(); + + await TestBed.configureTestingModule({ + imports: [PasswordHistoryV2Component], + providers: [ + { provide: WINDOW, useValue: window }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: ConfigService, useValue: mock() }, + { provide: CipherService, useValue: mock() }, + { provide: AccountService, useValue: mock() }, + { provide: PopupRouterCacheService, useValue: { back } }, + { provide: ActivatedRoute, useValue: { queryParams: params$ } }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PasswordHistoryV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("sets the cipherId from the params", () => { + params$.next({ cipherId: "444-33-33-1111" }); + + expect(component["cipherId"]).toBe("444-33-33-1111"); + }); + + it("navigates back when a cipherId is not in the params", () => { + params$.next({}); + + expect(back).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts new file mode 100644 index 000000000000..bc677a91d647 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts @@ -0,0 +1,50 @@ +import { NgIf } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { first } from "rxjs/operators"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherId } from "@bitwarden/common/types/guid"; + +import { PasswordHistoryViewComponent } from "../../../../../../../../libs/vault/src/components/password-history-view/password-history-view.component"; +import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; +import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; + +@Component({ + standalone: true, + selector: "vault-password-history-v2", + templateUrl: "vault-password-history-v2.component.html", + imports: [ + JslibModule, + PopupPageComponent, + PopOutComponent, + PopupHeaderComponent, + PasswordHistoryViewComponent, + NgIf, + ], +}) +export class PasswordHistoryV2Component implements OnInit { + protected cipherId: CipherId; + + constructor( + private browserRouterHistory: PopupRouterCacheService, + private route: ActivatedRoute, + ) {} + + ngOnInit() { + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + this.route.queryParams.pipe(first()).subscribe((params) => { + if (params.cipherId) { + this.cipherId = params.cipherId; + } else { + this.close(); + } + }); + } + + close() { + void this.browserRouterHistory.back(); + } +} diff --git a/apps/web/src/app/vault/individual-vault/password-history.component.html b/apps/web/src/app/vault/individual-vault/password-history.component.html index bae10d85aa71..7127e7ca6496 100644 --- a/apps/web/src/app/vault/individual-vault/password-history.component.html +++ b/apps/web/src/app/vault/individual-vault/password-history.component.html @@ -3,34 +3,7 @@ {{ "passwordHistory" | i18n }} -
- -
- -
{{ h.lastUsedDate | date: "medium" }}
-
- - - - - -
-
-
-

{{ "noPasswordsInList" | i18n }}

-
+
+ + + + +
+

{{ "noPasswordsInList" | i18n }}

+
diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts new file mode 100644 index 000000000000..8772a245821d --- /dev/null +++ b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts @@ -0,0 +1,97 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { BehaviorSubject } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { ColorPasswordModule, ItemModule, ToastService } from "@bitwarden/components"; +import { ColorPasswordComponent } from "@bitwarden/components/src/color-password/color-password.component"; + +import { PasswordHistoryViewComponent } from "./password-history-view.component"; + +describe("PasswordHistoryViewComponent", () => { + let component: PasswordHistoryViewComponent; + let fixture: ComponentFixture; + + const mockCipher = { + id: "122-333-444", + type: CipherType.Login, + organizationId: "222-444-555", + } as CipherView; + + const copyToClipboard = jest.fn(); + const showToast = jest.fn(); + const activeAccount$ = new BehaviorSubject<{ id: string }>({ id: "666-444-444" }); + const mockCipherService = { + get: jest.fn().mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }), + getKeyForCipherKeyDecryption: jest.fn().mockResolvedValue({}), + }; + + beforeEach(async () => { + mockCipherService.get.mockClear(); + mockCipherService.getKeyForCipherKeyDecryption.mockClear(); + copyToClipboard.mockClear(); + showToast.mockClear(); + + await TestBed.configureTestingModule({ + imports: [ItemModule, ColorPasswordModule, JslibModule], + providers: [ + { provide: WINDOW, useValue: window }, + { provide: CipherService, useValue: mockCipherService }, + { provide: PlatformUtilsService, useValue: { copyToClipboard } }, + { provide: AccountService, useValue: { activeAccount$ } }, + { provide: ToastService, useValue: { showToast } }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PasswordHistoryViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("renders no history text when history does not exist", () => { + expect(fixture.debugElement.nativeElement.textContent).toBe("noPasswordsInList"); + }); + + describe("history", () => { + const password1 = { password: "bad-password-1", lastUsedDate: new Date("09/13/2004") }; + const password2 = { password: "bad-password-2", lastUsedDate: new Date("02/01/2004") }; + + beforeEach(async () => { + mockCipher.passwordHistory = [password1, password2]; + + mockCipherService.get.mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }); + await component.ngOnInit(); + fixture.detectChanges(); + }); + + it("renders all passwords", () => { + const passwords = fixture.debugElement.queryAll(By.directive(ColorPasswordComponent)); + + expect(passwords.map((password) => password.componentInstance.password)).toEqual([ + "bad-password-1", + "bad-password-2", + ]); + }); + + it("copies a password", () => { + const copyButton = fixture.debugElement.query(By.css("button")); + + copyButton.nativeElement.click(); + + expect(copyToClipboard).toHaveBeenCalledWith("bad-password-1", { window: window }); + expect(showToast).toHaveBeenCalledWith({ + message: "passwordCopied", + title: "", + variant: "info", + }); + }); + }); +}); diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.ts b/libs/vault/src/components/password-history-view/password-history-view.component.ts new file mode 100644 index 000000000000..5e858af72751 --- /dev/null +++ b/libs/vault/src/components/password-history-view/password-history-view.component.ts @@ -0,0 +1,77 @@ +import { CommonModule } from "@angular/common"; +import { OnInit, Inject, Component, Input } from "@angular/core"; +import { firstValueFrom, map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view"; +import { + ToastService, + ItemModule, + ColorPasswordModule, + IconButtonModule, +} from "@bitwarden/components"; + +@Component({ + selector: "vault-password-history-view", + templateUrl: "./password-history-view.component.html", + standalone: true, + imports: [CommonModule, ItemModule, ColorPasswordModule, IconButtonModule, JslibModule], +}) +export class PasswordHistoryViewComponent implements OnInit { + /** + * The ID of the cipher to display the password history for. + */ + @Input({ required: true }) cipherId: CipherId; + + /** The password history for the cipher. */ + history: PasswordHistoryView[] = []; + + constructor( + @Inject(WINDOW) private win: Window, + protected cipherService: CipherService, + protected platformUtilsService: PlatformUtilsService, + protected i18nService: I18nService, + protected accountService: AccountService, + protected toastService: ToastService, + ) {} + + async ngOnInit() { + await this.init(); + } + + /** Copies a password to the clipboard. */ + copy(password: string) { + const copyOptions = this.win != null ? { window: this.win } : undefined; + this.platformUtilsService.copyToClipboard(password, copyOptions); + this.toastService.showToast({ + variant: "info", + title: "", + message: this.i18nService.t("passwordCopied"), + }); + } + + /** Retrieve the password history for the given cipher */ + protected async init() { + const cipher = await this.cipherService.get(this.cipherId); + const activeAccount = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a: { id: string | undefined }) => a)), + ); + + if (!activeAccount?.id) { + throw new Error("Active account is not available."); + } + + const activeUserId = activeAccount.id as UserId; + const decCipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), + ); + + this.history = decCipher.passwordHistory == null ? [] : decCipher.passwordHistory; + } +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index d5841c7db06f..f6a95281f81e 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -12,5 +12,6 @@ export { } from "./components/assign-collections.component"; export { DownloadAttachmentComponent } from "./components/download-attachment/download-attachment.component"; +export { PasswordHistoryViewComponent } from "./components/password-history-view/password-history-view.component"; export * as VaultIcons from "./icons";