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 1a95ad7483..a6d91d0187 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 0000000000..d4ff0662fe
--- /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 0000000000..a375aba302
--- /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 0000000000..bc677a91d6
--- /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 bae10d85aa..7127e7ca64 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 }}
-
+
diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.html b/libs/vault/src/components/password-history-view/password-history-view.component.html
new file mode 100644
index 0000000000..44b7fea5f7
--- /dev/null
+++ b/libs/vault/src/components/password-history-view/password-history-view.component.html
@@ -0,0 +1,28 @@
+
+
+
+
+
{{ h.lastUsedDate | date: "medium" }}
+
+
+
+
+
+
+
+
+
+
{{ "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 0000000000..8772a24582
--- /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 0000000000..5e858af727
--- /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 d5841c7db0..f6a95281f8 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";