diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 3510ed10cb1..c18a645c110 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -389,6 +389,9 @@ "favorite": { "message": "Favorite" }, + "unfavorite": { + "message": "Unfavorite" + }, "notes": { "message": "Notes" }, @@ -410,6 +413,9 @@ "launch": { "message": "Launch" }, + "launchWebsite": { + "message": "Launch website" + }, "website": { "message": "Website" }, @@ -822,7 +828,7 @@ }, "exportPasswordDescription": { "message": "This password will be used to export and import this file" - }, + }, "accountRestrictedOptionDescription": { "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." }, @@ -1660,6 +1666,9 @@ "autoFillAndSave": { "message": "Auto-fill and save" }, + "fillAndSave": { + "message": "Fill and save" + }, "autoFillSuccessAndSavedUri": { "message": "Item auto-filled and URI saved" }, @@ -3351,6 +3360,9 @@ } } }, + "assignCollections": { + "message": "Assign collections" + }, "copyEmail": { "message": "Copy email" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html index 0b108e8b814..83b07fc14cb 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -4,7 +4,7 @@ [title]="'autofillSuggestions' | i18n" [showRefresh]="showRefresh" (onRefresh)="refreshCurrentTab()" - showAutoFill + showAutofillButton > diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html new file mode 100644 index 00000000000..1d7a2a8cd0c --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + {{ "clone" | i18n }} + + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts new file mode 100644 index 00000000000..9834dc553ec --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -0,0 +1,122 @@ +import { CommonModule } from "@angular/common"; +import { booleanAttribute, Component, Input } from "@angular/core"; +import { Router, RouterModule } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService, IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { BrowserApi } from "../../../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; + +@Component({ + standalone: true, + selector: "app-item-more-options", + templateUrl: "./item-more-options.component.html", + imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], +}) +export class ItemMoreOptionsComponent { + @Input({ + required: true, + }) + cipher: CipherView; + + /** + * Flag to hide the login specific menu options. Used for login items that are + * already in the autofill list suggestion. + */ + @Input({ transform: booleanAttribute }) + hideLoginOptions: boolean; + + protected autofillAllowed$ = this.vaultPopupItemsService.autofillAllowed$; + + constructor( + private cipherService: CipherService, + private vaultPopupItemsService: VaultPopupItemsService, + private passwordRepromptService: PasswordRepromptService, + private dialogService: DialogService, + private router: Router, + ) {} + + get canEdit() { + return this.cipher.edit; + } + + get isLogin() { + return this.cipher.type === CipherType.Login; + } + + get favoriteText() { + return this.cipher.favorite ? "unfavorite" : "favorite"; + } + + /** + * Determines if the login cipher can be launched in a new browser tab. + */ + get canLaunch() { + return this.isLogin && this.cipher.login.canLaunch; + } + + /** + * Launches the login cipher in a new browser tab. + */ + async launchCipher() { + if (!this.canLaunch) { + return; + } + + await this.cipherService.updateLastLaunchedDate(this.cipher.id); + + await BrowserApi.createNewTab(this.cipher.login.launchUri); + + if (BrowserPopupUtils.inPopup(window)) { + BrowserApi.closePopup(window); + } + } + + /** + * Toggles the favorite status of the cipher and updates it on the server. + */ + async toggleFavorite() { + this.cipher.favorite = !this.cipher.favorite; + const encryptedCipher = await this.cipherService.encrypt(this.cipher); + await this.cipherService.updateWithServer(encryptedCipher); + } + + /** + * Navigate to the clone cipher page with the current cipher as the source. + * A password reprompt is attempted if the cipher requires it. + * A confirmation dialog is shown if the cipher has FIDO2 credentials. + */ + async clone() { + if ( + this.cipher.reprompt === CipherRepromptType.Password && + !(await this.passwordRepromptService.showPasswordPrompt()) + ) { + return; + } + + if (this.cipher.login?.hasFido2Credentials) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "passkeyNotCopied" }, + content: { key: "passkeyNotCopiedAlert" }, + type: "info", + }); + + if (!confirmed) { + return; + } + } + + await this.router.navigate(["/clone-cipher"], { + queryParams: { + cloneMode: true, + cipherId: this.cipher.id, + }, + }); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index 47725cf9dce..91c1f521632 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -24,18 +24,14 @@ {{ cipher.subTitle }} - + - - - + diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index e8200cab79d..095d0155c67 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -15,6 +15,7 @@ import { import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component"; +import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component"; @Component({ imports: [ @@ -29,6 +30,7 @@ import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions. PopupSectionHeaderComponent, RouterLink, ItemCopyActionsComponent, + ItemMoreOptionsComponent, ], selector: "app-vault-list-items-container", templateUrl: "vault-list-items-container.component.html", @@ -63,5 +65,5 @@ export class VaultListItemsContainerComponent { * Option to show the autofill button for each item. */ @Input({ transform: booleanAttribute }) - showAutoFill: boolean; + showAutofillButton: boolean; } diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html index 4d75685f538..f99d3cbb303 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html @@ -22,10 +22,12 @@ - - +
+ + - + +
{ + let testBed: TestBed; let service: VaultPopupItemsService; let allCiphers: Record; let autoFillCiphers: CipherView[]; @@ -39,11 +41,12 @@ describe("VaultPopupItemsService", () => { cipherList[2].favorite = true; cipherList[3].favorite = true; - cipherServiceMock.cipherViews$ = new BehaviorSubject(allCiphers).asObservable(); + cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList); + cipherServiceMock.ciphers$ = new BehaviorSubject(null).asObservable(); searchService.searchCiphers.mockImplementation(async () => cipherList); cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers); - vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable(); - vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable(); + vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false); + vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false); vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({ organization: null, @@ -55,28 +58,26 @@ describe("VaultPopupItemsService", () => { vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject( (ciphers: CipherView[]) => ciphers, ); - jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); jest .spyOn(BrowserApi, "getTabFromCurrentWindow") .mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - vaultPopupListFiltersServiceMock, - organizationServiceMock, - searchService, - ); + + testBed = TestBed.configureTestingModule({ + providers: [ + { provide: CipherService, useValue: cipherServiceMock }, + { provide: VaultSettingsService, useValue: vaultSettingsServiceMock }, + { provide: SearchService, useValue: searchService }, + { provide: OrganizationService, useValue: organizationServiceMock }, + { provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock }, + ], + }); + + service = testBed.inject(VaultPopupItemsService); }); it("should be created", () => { - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - vaultPopupListFiltersServiceMock, - organizationServiceMock, - searchService, - ); + service = testBed.inject(VaultPopupItemsService); expect(service).toBeTruthy(); }); @@ -100,18 +101,10 @@ describe("VaultPopupItemsService", () => { it("should filter ciphers for the current tab and types", (done) => { const currentTab = { url: "https://example.com" } as chrome.tabs.Tab; - vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(true).asObservable(); - vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(true).asObservable(); + (vaultSettingsServiceMock.showCardsCurrentTab$ as BehaviorSubject).next(true); + (vaultSettingsServiceMock.showIdentitiesCurrentTab$ as BehaviorSubject).next(true); jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - vaultPopupListFiltersServiceMock, - organizationServiceMock, - searchService, - ); - service.autoFillCiphers$.subscribe((ciphers) => { expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1); expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith( @@ -136,14 +129,6 @@ describe("VaultPopupItemsService", () => { Object.values(allCiphers), ); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - vaultPopupListFiltersServiceMock, - organizationServiceMock, - searchService, - ); - service.autoFillCiphers$.subscribe((ciphers) => { expect(ciphers.length).toBe(10); @@ -248,14 +233,7 @@ describe("VaultPopupItemsService", () => { describe("emptyVault$", () => { it("should return true if there are no ciphers", (done) => { - cipherServiceMock.cipherViews$ = new BehaviorSubject({}).asObservable(); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - vaultPopupListFiltersServiceMock, - organizationServiceMock, - searchService, - ); + cipherServiceMock.getAllDecrypted.mockResolvedValue([]); service.emptyVault$.subscribe((empty) => { expect(empty).toBe(true); done(); diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index f9c37f6f7da..3fe509dc452 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@angular/core"; +import { inject, Injectable, NgZone } from "@angular/core"; import { BehaviorSubject, combineLatest, @@ -15,12 +15,14 @@ import { import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BrowserApi } from "../../../platform/browser/browser-api"; +import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; @@ -72,9 +74,11 @@ export class VaultPopupItemsService { * Observable that contains the list of all decrypted ciphers. * @private */ - private _cipherList$: Observable = this.cipherService.cipherViews$.pipe( + private _cipherList$: Observable = this.cipherService.ciphers$.pipe( + runInsideAngular(inject(NgZone)), // Workaround to ensure cipher$ state provider emissions are run inside Angular + switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())), map((ciphers) => Object.values(ciphers)), - shareReplay({ refCount: false, bufferSize: 1 }), + shareReplay({ refCount: true, bufferSize: 1 }), ); private _filteredCipherList$: Observable = combineLatest([ diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 0ea628cca11..34bc8193553 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -13,6 +13,7 @@ import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; export abstract class CipherService { cipherViews$: Observable>; + ciphers$: Observable>; /** * An observable monitoring the add/edit cipher info saved to memory. */