1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-22 16:29:09 +01:00

[PM-8456] [PM-7683] Dynamic list items - 3 dot menu (#9422)

* [PM-7683] Add fullAddressForCopy helper to identity.view

* [PM-7683] Introduce CopyCipherFieldService to the Vault library

- A new CopyCipherFieldService that can be used to copy a cipher's field to the user clipboard
- A new appCopyField directive to make it easy to copy a cipher's fields in templates
- Tests for the CopyCipherFieldService

* [PM-7683] Introduce item-copy-actions.component

* [PM-7683] Fix username value in copy cipher directive

* [PM-7683] Add title to View item link

* [PM-8456] Introduce initial item-more-options.component

* [PM-8456] Add logic to show/hide login menu options

* [PM-8456] Implement favorite/unfavorite menu option

* [PM-8456] Implement clone menu option

* [PM-8456] Hide launch website instead of disabling it

* [PM-8456] Ensure cipherList observable updates on cipher changes

* [PM-7683] Move disabled logic into own method

* [PM-8456] Cleanup spec file to use Angular testbed

* [PM-8456] Fix more options tooltip
This commit is contained in:
Shane Melton 2024-06-04 14:21:14 -07:00 committed by GitHub
parent d1a9d6f613
commit 6eb94078f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 215 additions and 62 deletions

View File

@ -389,6 +389,9 @@
"favorite": { "favorite": {
"message": "Favorite" "message": "Favorite"
}, },
"unfavorite": {
"message": "Unfavorite"
},
"notes": { "notes": {
"message": "Notes" "message": "Notes"
}, },
@ -410,6 +413,9 @@
"launch": { "launch": {
"message": "Launch" "message": "Launch"
}, },
"launchWebsite": {
"message": "Launch website"
},
"website": { "website": {
"message": "Website" "message": "Website"
}, },
@ -822,7 +828,7 @@
}, },
"exportPasswordDescription": { "exportPasswordDescription": {
"message": "This password will be used to export and import this file" "message": "This password will be used to export and import this file"
}, },
"accountRestrictedOptionDescription": { "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." "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": { "autoFillAndSave": {
"message": "Auto-fill and save" "message": "Auto-fill and save"
}, },
"fillAndSave": {
"message": "Fill and save"
},
"autoFillSuccessAndSavedUri": { "autoFillSuccessAndSavedUri": {
"message": "Item auto-filled and URI saved" "message": "Item auto-filled and URI saved"
}, },
@ -3351,6 +3360,9 @@
} }
} }
}, },
"assignCollections": {
"message": "Assign collections"
},
"copyEmail": { "copyEmail": {
"message": "Copy email" "message": "Copy email"
}, },

View File

@ -4,7 +4,7 @@
[title]="'autofillSuggestions' | i18n" [title]="'autofillSuggestions' | i18n"
[showRefresh]="showRefresh" [showRefresh]="showRefresh"
(onRefresh)="refreshCurrentTab()" (onRefresh)="refreshCurrentTab()"
showAutoFill showAutofillButton
></app-vault-list-items-container> ></app-vault-list-items-container>
<ng-container *ngIf="showEmptyAutofillTip$ | async"> <ng-container *ngIf="showEmptyAutofillTip$ | async">
<bit-section> <bit-section>

View File

@ -0,0 +1,36 @@
<bit-item-action>
<button
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
[attr.aria-label]="'moreOptionsLabel' | i18n: cipher.name"
[title]="'moreOptionsTitle' | i18n: cipher.name"
[bitMenuTriggerFor]="moreOptions"
></button>
<bit-menu #moreOptions>
<ng-container *ngIf="isLogin && !hideLoginOptions">
<ng-container *ngIf="autofillAllowed$ | async">
<button type="button" bitMenuItem>
{{ "autofill" | i18n }}
</button>
<button type="button" bitMenuItem *ngIf="canEdit">
{{ "fillAndSave" | i18n }}
</button>
</ng-container>
<button type="button" bitMenuItem *ngIf="this.canLaunch" (click)="launchCipher()">
{{ "launchWebsite" | i18n }}
</button>
</ng-container>
<button type="button" bitMenuItem (click)="toggleFavorite()">
{{ favoriteText | i18n }}
</button>
<ng-container *ngIf="canEdit">
<a routerLink="" bitMenuItem (click)="clone()">
{{ "clone" | i18n }}
</a>
<button type="button" bitMenuItem>
{{ "assignCollections" | i18n }}
</button>
</ng-container>
</bit-menu>
</bit-item-action>

View File

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

View File

@ -24,18 +24,14 @@
<span slot="secondary">{{ cipher.subTitle }}</span> <span slot="secondary">{{ cipher.subTitle }}</span>
</a> </a>
<ng-container slot="end"> <ng-container slot="end">
<bit-item-action *ngIf="showAutoFill"> <bit-item-action *ngIf="showAutofillButton">
<button type="button" bitBadge variant="primary">{{ "autoFill" | i18n }}</button> <button type="button" bitBadge variant="primary">{{ "autoFill" | i18n }}</button>
</bit-item-action> </bit-item-action>
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions> <app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
<bit-item-action> <app-item-more-options
<button [cipher]="cipher"
type="button" [hideLoginOptions]="showAutofillButton"
bitIconButton="bwi-ellipsis-v" ></app-item-more-options>
size="small"
[attr.aria-label]="'moreOptions' | i18n: cipher.name"
></button>
</bit-item-action>
</ng-container> </ng-container>
</bit-item> </bit-item>
</bit-item-group> </bit-item-group>

View File

@ -15,6 +15,7 @@ import {
import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component";
import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component"; import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component";
import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component";
@Component({ @Component({
imports: [ imports: [
@ -29,6 +30,7 @@ import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.
PopupSectionHeaderComponent, PopupSectionHeaderComponent,
RouterLink, RouterLink,
ItemCopyActionsComponent, ItemCopyActionsComponent,
ItemMoreOptionsComponent,
], ],
selector: "app-vault-list-items-container", selector: "app-vault-list-items-container",
templateUrl: "vault-list-items-container.component.html", templateUrl: "vault-list-items-container.component.html",
@ -63,5 +65,5 @@ export class VaultListItemsContainerComponent {
* Option to show the autofill button for each item. * Option to show the autofill button for each item.
*/ */
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
showAutoFill: boolean; showAutofillButton: boolean;
} }

View File

@ -22,10 +22,12 @@
</div> </div>
<ng-container *ngIf="!(showEmptyState$ | async)"> <ng-container *ngIf="!(showEmptyState$ | async)">
<app-vault-v2-search (searchTextChanged)="handleSearchTextChange($event)"> <div class="tw-fixed">
</app-vault-v2-search> <app-vault-v2-search (searchTextChanged)="handleSearchTextChange($event)">
</app-vault-v2-search>
<app-vault-list-filters></app-vault-list-filters> <app-vault-list-filters></app-vault-list-filters>
</div>
<div <div
*ngIf="(showNoResultsState$ | async) && !(showDeactivatedOrg$ | async)" *ngIf="(showNoResultsState$ | async) && !(showDeactivatedOrg$ | async)"

View File

@ -1,3 +1,4 @@
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
@ -16,6 +17,7 @@ import { VaultPopupItemsService } from "./vault-popup-items.service";
import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
describe("VaultPopupItemsService", () => { describe("VaultPopupItemsService", () => {
let testBed: TestBed;
let service: VaultPopupItemsService; let service: VaultPopupItemsService;
let allCiphers: Record<CipherId, CipherView>; let allCiphers: Record<CipherId, CipherView>;
let autoFillCiphers: CipherView[]; let autoFillCiphers: CipherView[];
@ -39,11 +41,12 @@ describe("VaultPopupItemsService", () => {
cipherList[2].favorite = true; cipherList[2].favorite = true;
cipherList[3].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); searchService.searchCiphers.mockImplementation(async () => cipherList);
cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers); cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers);
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable(); vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false);
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable(); vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false);
vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({ vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({
organization: null, organization: null,
@ -55,28 +58,26 @@ describe("VaultPopupItemsService", () => {
vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject( vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject(
(ciphers: CipherView[]) => ciphers, (ciphers: CipherView[]) => ciphers,
); );
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
jest jest
.spyOn(BrowserApi, "getTabFromCurrentWindow") .spyOn(BrowserApi, "getTabFromCurrentWindow")
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); .mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab);
service = new VaultPopupItemsService(
cipherServiceMock, testBed = TestBed.configureTestingModule({
vaultSettingsServiceMock, providers: [
vaultPopupListFiltersServiceMock, { provide: CipherService, useValue: cipherServiceMock },
organizationServiceMock, { provide: VaultSettingsService, useValue: vaultSettingsServiceMock },
searchService, { provide: SearchService, useValue: searchService },
); { provide: OrganizationService, useValue: organizationServiceMock },
{ provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock },
],
});
service = testBed.inject(VaultPopupItemsService);
}); });
it("should be created", () => { it("should be created", () => {
service = new VaultPopupItemsService( service = testBed.inject(VaultPopupItemsService);
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
expect(service).toBeTruthy(); expect(service).toBeTruthy();
}); });
@ -100,18 +101,10 @@ describe("VaultPopupItemsService", () => {
it("should filter ciphers for the current tab and types", (done) => { it("should filter ciphers for the current tab and types", (done) => {
const currentTab = { url: "https://example.com" } as chrome.tabs.Tab; const currentTab = { url: "https://example.com" } as chrome.tabs.Tab;
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(true).asObservable(); (vaultSettingsServiceMock.showCardsCurrentTab$ as BehaviorSubject<boolean>).next(true);
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(true).asObservable(); (vaultSettingsServiceMock.showIdentitiesCurrentTab$ as BehaviorSubject<boolean>).next(true);
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab); jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab);
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
service.autoFillCiphers$.subscribe((ciphers) => { service.autoFillCiphers$.subscribe((ciphers) => {
expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1); expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1);
expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith( expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith(
@ -136,14 +129,6 @@ describe("VaultPopupItemsService", () => {
Object.values(allCiphers), Object.values(allCiphers),
); );
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
service.autoFillCiphers$.subscribe((ciphers) => { service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers.length).toBe(10); expect(ciphers.length).toBe(10);
@ -248,14 +233,7 @@ describe("VaultPopupItemsService", () => {
describe("emptyVault$", () => { describe("emptyVault$", () => {
it("should return true if there are no ciphers", (done) => { it("should return true if there are no ciphers", (done) => {
cipherServiceMock.cipherViews$ = new BehaviorSubject({}).asObservable(); cipherServiceMock.getAllDecrypted.mockResolvedValue([]);
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
service.emptyVault$.subscribe((empty) => { service.emptyVault$.subscribe((empty) => {
expect(empty).toBe(true); expect(empty).toBe(true);
done(); done();

View File

@ -1,4 +1,4 @@
import { Injectable } from "@angular/core"; import { inject, Injectable, NgZone } from "@angular/core";
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
@ -15,12 +15,14 @@ import {
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BrowserApi } from "../../../platform/browser/browser-api"; 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 BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; 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. * Observable that contains the list of all decrypted ciphers.
* @private * @private
*/ */
private _cipherList$: Observable<CipherView[]> = this.cipherService.cipherViews$.pipe( private _cipherList$: Observable<CipherView[]> = 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)), map((ciphers) => Object.values(ciphers)),
shareReplay({ refCount: false, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
); );
private _filteredCipherList$: Observable<CipherView[]> = combineLatest([ private _filteredCipherList$: Observable<CipherView[]> = combineLatest([

View File

@ -13,6 +13,7 @@ import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
export abstract class CipherService { export abstract class CipherService {
cipherViews$: Observable<Record<CipherId, CipherView>>; cipherViews$: Observable<Record<CipherId, CipherView>>;
ciphers$: Observable<Record<CipherId, CipherData>>;
/** /**
* An observable monitoring the add/edit cipher info saved to memory. * An observable monitoring the add/edit cipher info saved to memory.
*/ */