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:
parent
d1a9d6f613
commit
6eb94078f6
@ -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"
|
||||
},
|
||||
|
@ -4,7 +4,7 @@
|
||||
[title]="'autofillSuggestions' | i18n"
|
||||
[showRefresh]="showRefresh"
|
||||
(onRefresh)="refreshCurrentTab()"
|
||||
showAutoFill
|
||||
showAutofillButton
|
||||
></app-vault-list-items-container>
|
||||
<ng-container *ngIf="showEmptyAutofillTip$ | async">
|
||||
<bit-section>
|
||||
|
@ -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>
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -24,18 +24,14 @@
|
||||
<span slot="secondary">{{ cipher.subTitle }}</span>
|
||||
</a>
|
||||
<ng-container slot="end">
|
||||
<bit-item-action *ngIf="showAutoFill">
|
||||
<bit-item-action *ngIf="showAutofillButton">
|
||||
<button type="button" bitBadge variant="primary">{{ "autoFill" | i18n }}</button>
|
||||
</bit-item-action>
|
||||
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
|
||||
<bit-item-action>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
[attr.aria-label]="'moreOptions' | i18n: cipher.name"
|
||||
></button>
|
||||
</bit-item-action>
|
||||
<app-item-more-options
|
||||
[cipher]="cipher"
|
||||
[hideLoginOptions]="showAutofillButton"
|
||||
></app-item-more-options>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
</bit-item-group>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -22,10 +22,12 @@
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!(showEmptyState$ | async)">
|
||||
<app-vault-v2-search (searchTextChanged)="handleSearchTextChange($event)">
|
||||
</app-vault-v2-search>
|
||||
<div class="tw-fixed">
|
||||
<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
|
||||
*ngIf="(showNoResultsState$ | async) && !(showDeactivatedOrg$ | async)"
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
@ -16,6 +17,7 @@ import { VaultPopupItemsService } from "./vault-popup-items.service";
|
||||
import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
|
||||
|
||||
describe("VaultPopupItemsService", () => {
|
||||
let testBed: TestBed;
|
||||
let service: VaultPopupItemsService;
|
||||
let allCiphers: Record<CipherId, CipherView>;
|
||||
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<boolean>).next(true);
|
||||
(vaultSettingsServiceMock.showIdentitiesCurrentTab$ as BehaviorSubject<boolean>).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();
|
||||
|
@ -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<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)),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
private _filteredCipherList$: Observable<CipherView[]> = combineLatest([
|
||||
|
@ -13,6 +13,7 @@ import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
|
||||
|
||||
export abstract class CipherService {
|
||||
cipherViews$: Observable<Record<CipherId, CipherView>>;
|
||||
ciphers$: Observable<Record<CipherId, CipherData>>;
|
||||
/**
|
||||
* An observable monitoring the add/edit cipher info saved to memory.
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user