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 index d4ff0662fe..b1f01bb9cb 100644 --- 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 @@ -1,8 +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 index a375aba302..9ac17b4938 100644 --- 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 @@ -1,27 +1,40 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; import { ActivatedRoute } from "@angular/router"; import { mock } from "jest-mock-extended"; -import { Subject } from "rxjs"; +import { BehaviorSubject, Subject } from "rxjs"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, 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 { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; 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 mockCipherView = { + id: "111-222-333", + name: "cipher one", + } as CipherView; + + const mockCipher = { + decrypt: jest.fn().mockResolvedValue(mockCipherView), + } as unknown as Cipher; + const back = jest.fn().mockResolvedValue(undefined); + const getCipher = jest.fn().mockResolvedValue(mockCipher); beforeEach(async () => { back.mockClear(); + getCipher.mockClear(); await TestBed.configureTestingModule({ imports: [PasswordHistoryV2Component], @@ -29,8 +42,13 @@ describe("PasswordHistoryV2Component", () => { { provide: WINDOW, useValue: window }, { provide: PlatformUtilsService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, - { provide: CipherService, useValue: mock() }, - { provide: AccountService, useValue: mock() }, + { provide: CipherService, useValue: mock({ get: getCipher }) }, + { + provide: AccountService, + useValue: mock({ + activeAccount$: new BehaviorSubject({ id: "acct-1" } as Account), + }), + }, { provide: PopupRouterCacheService, useValue: { back } }, { provide: ActivatedRoute, useValue: { queryParams: params$ } }, { provide: I18nService, useValue: { t: (key: string) => key } }, @@ -38,19 +56,21 @@ describe("PasswordHistoryV2Component", () => { }).compileComponents(); fixture = TestBed.createComponent(PasswordHistoryV2Component); - component = fixture.componentInstance; fixture.detectChanges(); }); - it("sets the cipherId from the params", () => { - params$.next({ cipherId: "444-33-33-1111" }); + it("loads the cipher from params the cipherId from the params", fakeAsync(() => { + params$.next({ cipherId: mockCipherView.id }); - expect(component["cipherId"]).toBe("444-33-33-1111"); - }); + tick(100); + + expect(getCipher).toHaveBeenCalledWith(mockCipherView.id); + })); it("navigates back when a cipherId is not in the params", () => { params$.next({}); expect(back).toHaveBeenCalledTimes(1); + expect(getCipher).not.toHaveBeenCalled(); }); }); 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 index c70c83f40f..c8f590ced5 100644 --- 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 @@ -3,10 +3,14 @@ import { NgIf } from "@angular/common"; import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { first } from "rxjs/operators"; +import { firstValueFrom } from "rxjs"; +import { first, map } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordHistoryViewComponent } from "../../../../../../../../libs/vault/src/components/password-history-view/password-history-view.component"; import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component"; @@ -28,18 +32,20 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach ], }) export class PasswordHistoryV2Component implements OnInit { - protected cipherId: CipherId; + protected cipher: CipherView; constructor( private browserRouterHistory: PopupRouterCacheService, private route: ActivatedRoute, + private cipherService: CipherService, + private accountService: AccountService, ) {} ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil this.route.queryParams.pipe(first()).subscribe((params) => { if (params.cipherId) { - this.cipherId = params.cipherId; + void this.loadCipher(params.cipherId); } else { this.close(); } @@ -49,4 +55,22 @@ export class PasswordHistoryV2Component implements OnInit { close() { void this.browserRouterHistory.back(); } + + /** Load the cipher based on the given Id */ + private async loadCipher(cipherId: string) { + const cipher = await this.cipherService.get(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; + this.cipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), + ); + } } diff --git a/apps/browser/src/vault/popup/services/browser-view-password-history.service.spec.ts b/apps/browser/src/vault/popup/services/browser-view-password-history.service.spec.ts index ded4686477..5024b960d9 100644 --- a/apps/browser/src/vault/popup/services/browser-view-password-history.service.spec.ts +++ b/apps/browser/src/vault/popup/services/browser-view-password-history.service.spec.ts @@ -2,6 +2,8 @@ import { TestBed } from "@angular/core/testing"; import { Router } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + import { BrowserViewPasswordHistoryService } from "./browser-view-password-history.service"; describe("BrowserViewPasswordHistoryService", () => { @@ -19,9 +21,9 @@ describe("BrowserViewPasswordHistoryService", () => { describe("viewPasswordHistory", () => { it("navigates to the password history screen", async () => { - await service.viewPasswordHistory("test"); + await service.viewPasswordHistory({ id: "cipher-id" } as CipherView); expect(router.navigate).toHaveBeenCalledWith(["/cipher-password-history"], { - queryParams: { cipherId: "test" }, + queryParams: { cipherId: "cipher-id" }, }); }); }); diff --git a/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts b/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts index 453fe113eb..5e400da9de 100644 --- a/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts +++ b/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts @@ -4,6 +4,7 @@ import { inject } from "@angular/core"; import { Router } from "@angular/router"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; /** * This class handles the premium upgrade process for the browser extension. @@ -14,7 +15,9 @@ export class BrowserViewPasswordHistoryService implements ViewPasswordHistorySer /** * Navigates to the password history screen. */ - async viewPasswordHistory(cipherId: string) { - await this.router.navigate(["/cipher-password-history"], { queryParams: { cipherId } }); + async viewPasswordHistory(cipher: CipherView) { + await this.router.navigate(["/cipher-password-history"], { + queryParams: { cipherId: cipher.id }, + }); } } diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts index 6a72360cfa..7506f6c5d0 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts @@ -4,16 +4,22 @@ import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { ModalService } from "@bitwarden/angular/services/modal.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService } from "@bitwarden/components"; +import { CipherFormConfigService, DefaultCipherFormConfigService } from "@bitwarden/vault"; import { EmergencyAccessService } from "../../../emergency-access"; import { EmergencyAccessAttachmentsComponent } from "../attachments/emergency-access-attachments.component"; import { EmergencyAddEditCipherComponent } from "./emergency-add-edit-cipher.component"; +import { EmergencyViewDialogComponent } from "./emergency-view-dialog.component"; @Component({ selector: "emergency-access-view", templateUrl: "emergency-access-view.component.html", + providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }], }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class EmergencyAccessViewComponent implements OnInit { @@ -31,6 +37,8 @@ export class EmergencyAccessViewComponent implements OnInit { private router: Router, private route: ActivatedRoute, private emergencyAccessService: EmergencyAccessService, + private configService: ConfigService, + private dialogService: DialogService, ) {} ngOnInit() { @@ -49,6 +57,19 @@ export class EmergencyAccessViewComponent implements OnInit { } async selectCipher(cipher: CipherView) { + const browserRefreshEnabled = await this.configService.getFeatureFlag( + FeatureFlag.ExtensionRefresh, + ); + + if (browserRefreshEnabled) { + EmergencyViewDialogComponent.open(this.dialogService, { + cipher, + }); + return; + } + + // FIXME PM-15385: Remove below dialog service logic once extension refresh is live. + // eslint-disable-next-line const [_, childComponent] = await this.modalService.openViewRef( EmergencyAddEditCipherComponent, diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts index 2da8e06449..59228431e6 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { DatePipe } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { CollectionService } from "@bitwarden/admin-console/common"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; @@ -30,7 +30,7 @@ import { AddEditComponent as BaseAddEditComponent } from "../../../../vault/indi selector: "app-org-vault-add-edit", templateUrl: "../../../../vault/individual-vault/add-edit.component.html", }) -export class EmergencyAddEditCipherComponent extends BaseAddEditComponent { +export class EmergencyAddEditCipherComponent extends BaseAddEditComponent implements OnInit { originalCipher: Cipher = null; viewOnly = true; protected override componentName = "app-org-vault-add-edit"; @@ -85,6 +85,14 @@ export class EmergencyAddEditCipherComponent extends BaseAddEditComponent { this.title = this.i18nService.t("viewItem"); } + async ngOnInit(): Promise { + await super.ngOnInit(); + // The base component `ngOnInit` calculates the `viewOnly` property based on cipher properties + // In the case of emergency access, `viewOnly` should always be true, set it manually here after + // the base `ngOnInit` is complete. + this.viewOnly = true; + } + protected async loadCipher() { return Promise.resolve(this.originalCipher); } diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.html b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.html new file mode 100644 index 0000000000..be38e1d950 --- /dev/null +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.html @@ -0,0 +1,13 @@ + + + {{ title }} + +
+ +
+ + + +
diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts new file mode 100644 index 0000000000..341e44f643 --- /dev/null +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts @@ -0,0 +1,108 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService } from "@bitwarden/components"; + +import { EmergencyViewDialogComponent } from "./emergency-view-dialog.component"; + +describe("EmergencyViewDialogComponent", () => { + let component: EmergencyViewDialogComponent; + let fixture: ComponentFixture; + + const open = jest.fn(); + const close = jest.fn(); + + const mockCipher = { + id: "cipher1", + name: "Cipher", + type: CipherType.Login, + login: { uris: [] }, + card: {}, + } as CipherView; + + beforeEach(async () => { + open.mockClear(); + close.mockClear(); + + await TestBed.configureTestingModule({ + imports: [EmergencyViewDialogComponent, NoopAnimationsModule], + providers: [ + { provide: OrganizationService, useValue: mock() }, + { provide: CollectionService, useValue: mock() }, + { provide: FolderService, useValue: mock() }, + { provide: I18nService, useValue: { t: (...keys: string[]) => keys.join(" ") } }, + { provide: DialogService, useValue: { open } }, + { provide: DialogRef, useValue: { close } }, + { provide: DIALOG_DATA, useValue: { cipher: mockCipher } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EmergencyViewDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("creates", () => { + expect(component).toBeTruthy(); + }); + + it("opens dialog", () => { + EmergencyViewDialogComponent.open({ open } as unknown as DialogService, { cipher: mockCipher }); + + expect(open).toHaveBeenCalled(); + }); + + it("closes the dialog", () => { + EmergencyViewDialogComponent.open({ open } as unknown as DialogService, { cipher: mockCipher }); + fixture.detectChanges(); + + const cancelButton = fixture.debugElement.queryAll(By.css("button")).pop(); + + cancelButton.nativeElement.click(); + + expect(close).toHaveBeenCalled(); + }); + + describe("updateTitle", () => { + it("sets login title", () => { + mockCipher.type = CipherType.Login; + + component["updateTitle"](); + + expect(component["title"]).toBe("viewItemType typelogin"); + }); + + it("sets card title", () => { + mockCipher.type = CipherType.Card; + + component["updateTitle"](); + + expect(component["title"]).toBe("viewItemType typecard"); + }); + + it("sets identity title", () => { + mockCipher.type = CipherType.Identity; + + component["updateTitle"](); + + expect(component["title"]).toBe("viewItemType typeidentity"); + }); + + it("sets note title", () => { + mockCipher.type = CipherType.SecureNote; + + component["updateTitle"](); + + expect(component["title"]).toBe("viewItemType note"); + }); + }); +}); diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts new file mode 100644 index 0000000000..7da4ce3165 --- /dev/null +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts @@ -0,0 +1,90 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { CipherViewComponent } from "@bitwarden/vault"; + +import { WebViewPasswordHistoryService } from "../../../../vault/services/web-view-password-history.service"; + +export interface EmergencyViewDialogParams { + /** The cipher being viewed. */ + cipher: CipherView; +} + +/** Stubbed class, premium upgrade is not applicable for emergency viewing */ +class PremiumUpgradePromptNoop implements PremiumUpgradePromptService { + async promptForPremium() { + return Promise.resolve(); + } +} + +@Component({ + selector: "app-emergency-view-dialog", + templateUrl: "emergency-view-dialog.component.html", + standalone: true, + imports: [ButtonModule, CipherViewComponent, DialogModule, CommonModule, JslibModule], + providers: [ + { provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService }, + { provide: PremiumUpgradePromptService, useClass: PremiumUpgradePromptNoop }, + ], +}) +export class EmergencyViewDialogComponent { + /** + * The title of the dialog. Updates based on the cipher type. + * @protected + */ + protected title: string; + + constructor( + @Inject(DIALOG_DATA) protected params: EmergencyViewDialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + ) { + this.updateTitle(); + } + + get cipher(): CipherView { + return this.params.cipher; + } + + cancel = () => { + this.dialogRef.close(); + }; + + private updateTitle() { + const partOne = "viewItemType"; + + const type = this.cipher.type; + + switch (type) { + case CipherType.Login: + this.title = this.i18nService.t(partOne, this.i18nService.t("typeLogin").toLowerCase()); + break; + case CipherType.Card: + this.title = this.i18nService.t(partOne, this.i18nService.t("typeCard").toLowerCase()); + break; + case CipherType.Identity: + this.title = this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLowerCase()); + break; + case CipherType.SecureNote: + this.title = this.i18nService.t(partOne, this.i18nService.t("note").toLowerCase()); + break; + } + } + + /** + * Opens the EmergencyViewDialog. + */ + static open(dialogService: DialogService, params: EmergencyViewDialogParams) { + return dialogService.open(EmergencyViewDialogComponent, { + data: params, + }); + } +} 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 7127e7ca64..4eaca8f736 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,7 +3,7 @@ {{ "passwordHistory" | i18n }} - +