1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[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
This commit is contained in:
Nick Krantz
2024-10-18 14:57:08 -05:00
committed by GitHub
parent 496bc74b51
commit 97bf459424
12 changed files with 337 additions and 99 deletions

View File

@@ -27,7 +27,7 @@
</p>
<a
*ngIf="cipher.hasPasswordHistory && isLogin"
class="tw-font-bold tw-no-underline tw-cursor-pointer"
class="tw-font-bold tw-no-underline tw-cursor-pointer tw-text-primary-600"
(click)="viewPasswordHistory()"
bitTypography="body2"
>

View File

@@ -0,0 +1,28 @@
<div *ngIf="history && history.length">
<bit-item *ngFor="let h of history">
<div class="tw-pl-3 tw-py-2">
<bit-color-password
class="tw-text-base"
[password]="h.password"
[showCount]="false"
></bit-color-password>
<div class="tw-text-sm tw-text-muted">{{ h.lastUsedDate | date: "medium" }}</div>
</div>
<ng-container slot="end">
<bit-item-action>
<button
type="button"
bitIconButton="bwi-clone"
[appA11yTitle]="'copyPassword' | i18n"
appStopClick
(click)="copy(h.password)"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-item-action>
</ng-container>
</bit-item>
</div>
<div class="no-items" *ngIf="!history?.length">
<p>{{ "noPasswordsInList" | i18n }}</p>
</div>

View File

@@ -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<PasswordHistoryViewComponent>;
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",
});
});
});
});

View File

@@ -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;
}
}

View File

@@ -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";