1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-10-06 05:28:51 +02:00

[PM-8485] [PM-7683] Dynamic list items - Org Details (#9466)

* [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

* [PM-8485] Introduce new PopupCipherView

* [PM-8485] Use new PopupCipherView in items service

* [PM-8485] Add org icon for items that belong to an organization

* [PM-8485] Fix tests

* [PM-8485] Remove share operator from cipherViews$
This commit is contained in:
Shane Melton 2024-06-04 14:34:48 -07:00 committed by GitHub
parent d0c1325f0c
commit f059d136b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 156 additions and 26 deletions

View File

@ -1437,6 +1437,15 @@
"collections": {
"message": "Collections"
},
"nCollections": {
"message": "$COUNT$ collections",
"placeholders": {
"count": {
"content": "$1",
"example": "2"
}
}
},
"favorites": {
"message": "Favorites"
},

View File

@ -3,12 +3,12 @@ import { Component } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { IconButtonModule, SectionComponent, TypographyModule } from "@bitwarden/components";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
import { PopupCipherView } from "../../../views/popup-cipher.view";
import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component";
@Component({
@ -30,7 +30,7 @@ export class AutofillVaultListItemsComponent {
* The list of ciphers that can be used to autofill the current page.
* @protected
*/
protected autofillCiphers$: Observable<CipherView[]> =
protected autofillCiphers$: Observable<PopupCipherView[]> =
this.vaultPopupItemsService.autoFillCiphers$;
/**

View File

@ -21,6 +21,12 @@
>
<app-vault-icon slot="start" [cipher]="cipher"></app-vault-icon>
{{ cipher.name }}
<i
class="bwi bwi-sm"
*ngIf="cipher.organizationId"
[ngClass]="cipher.orgIcon"
[appA11yTitle]="orgIconTooltip(cipher)"
></i>
<span slot="secondary">{{ cipher.subTitle }}</span>
</a>
<ng-container slot="end">

View File

@ -3,7 +3,7 @@ import { booleanAttribute, Component, EventEmitter, Input, Output } from "@angul
import { RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
BadgeModule,
ButtonModule,
@ -14,6 +14,7 @@ import {
} from "@bitwarden/components";
import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component";
import { PopupCipherView } from "../../../views/popup-cipher.view";
import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component";
import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component";
@ -41,7 +42,7 @@ export class VaultListItemsContainerComponent {
* The list of ciphers to display.
*/
@Input()
ciphers: CipherView[];
ciphers: PopupCipherView[] = [];
/**
* Title for the vault list item section.
@ -66,4 +67,18 @@ export class VaultListItemsContainerComponent {
*/
@Input({ transform: booleanAttribute })
showAutofillButton: boolean;
/**
* The tooltip text for the organization icon for ciphers that belong to an organization.
* @param cipher
*/
orgIconTooltip(cipher: PopupCipherView) {
if (cipher.collectionIds.length > 1) {
return this.i18nService.t("nCollections", cipher.collectionIds.length);
}
return cipher.collections[0]?.name;
}
constructor(private i18nService: I18nService) {}
}

View File

@ -4,11 +4,15 @@ import { BehaviorSubject } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.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 { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
@ -22,11 +26,15 @@ describe("VaultPopupItemsService", () => {
let allCiphers: Record<CipherId, CipherView>;
let autoFillCiphers: CipherView[];
let mockOrg: Organization;
let mockCollections: CollectionView[];
const cipherServiceMock = mock<CipherService>();
const vaultSettingsServiceMock = mock<VaultSettingsService>();
const organizationServiceMock = mock<OrganizationService>();
const vaultPopupListFiltersServiceMock = mock<VaultPopupListFiltersService>();
const searchService = mock<SearchService>();
const collectionService = mock<CollectionService>();
beforeEach(() => {
allCiphers = cipherFactory(10);
@ -43,8 +51,10 @@ describe("VaultPopupItemsService", () => {
cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList);
cipherServiceMock.ciphers$ = new BehaviorSubject(null).asObservable();
searchService.searchCiphers.mockImplementation(async () => cipherList);
cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers);
searchService.searchCiphers.mockImplementation(async (_, __, ciphers) => ciphers);
cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) =>
ciphers.filter((c) => ["0", "1"].includes(c.id)),
);
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false);
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false);
@ -63,6 +73,20 @@ describe("VaultPopupItemsService", () => {
.spyOn(BrowserApi, "getTabFromCurrentWindow")
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab);
mockOrg = {
id: "org1",
name: "Organization 1",
planProductType: ProductType.Enterprise,
} as Organization;
mockCollections = [
{ id: "col1", name: "Collection 1" } as CollectionView,
{ id: "col2", name: "Collection 2" } as CollectionView,
];
organizationServiceMock.organizations$ = new BehaviorSubject([mockOrg]);
collectionService.decryptedCollections$ = new BehaviorSubject(mockCollections);
testBed = TestBed.configureTestingModule({
providers: [
{ provide: CipherService, useValue: cipherServiceMock },
@ -70,17 +94,35 @@ describe("VaultPopupItemsService", () => {
{ provide: SearchService, useValue: searchService },
{ provide: OrganizationService, useValue: organizationServiceMock },
{ provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock },
{ provide: CollectionService, useValue: collectionService },
],
});
service = testBed.inject(VaultPopupItemsService);
});
afterEach(() => {
jest.clearAllMocks();
});
it("should be created", () => {
service = testBed.inject(VaultPopupItemsService);
expect(service).toBeTruthy();
});
it("should merge cipher views with collections and organization", (done) => {
const cipherList = Object.values(allCiphers);
cipherList[0].organizationId = "org1";
cipherList[0].collectionIds = ["col1", "col2"];
service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers[0].organization).toEqual(mockOrg);
expect(ciphers[0].collections).toContain(mockCollections[0]);
expect(ciphers[0].collections).toContain(mockCollections[1]);
done();
});
});
describe("autoFillCiphers$", () => {
it("should return empty array if there is no current tab", (done) => {
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
@ -144,19 +186,18 @@ describe("VaultPopupItemsService", () => {
});
it("should filter autoFillCiphers$ down to search term", (done) => {
const cipherList = Object.values(allCiphers);
const searchText = "Login";
searchService.searchCiphers.mockImplementation(async () => {
return cipherList.filter((cipher) => {
searchService.searchCiphers.mockImplementation(async (q, _, ciphers) => {
return ciphers.filter((cipher) => {
return cipher.name.includes(searchText);
});
});
// there is only 1 Login returned for filteredCiphers. but two results expected because of other autofill types
// there is only 1 Login returned for filteredCiphers.
service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers[0].name.includes(searchText)).toBe(true);
expect(ciphers.length).toBe(2);
expect(ciphers.length).toBe(1);
done();
});
});

View File

@ -16,7 +16,9 @@ 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 { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.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";
@ -24,6 +26,7 @@ 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 { PopupCipherView } from "../views/popup-cipher.view";
import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
@ -74,14 +77,33 @@ export class VaultPopupItemsService {
* Observable that contains the list of all decrypted ciphers.
* @private
*/
private _cipherList$: Observable<CipherView[]> = this.cipherService.ciphers$.pipe(
private _cipherList$: Observable<PopupCipherView[]> = 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)),
switchMap((ciphers) =>
combineLatest([
this.organizationService.organizations$,
this.collectionService.decryptedCollections$,
]).pipe(
map(([organizations, collections]) => {
const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org]));
const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col]));
return ciphers.map(
(cipher) =>
new PopupCipherView(
cipher,
cipher.collectionIds?.map((colId) => collectionMap[colId as CollectionId]),
orgMap[cipher.organizationId as OrganizationId],
),
);
}),
),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
private _filteredCipherList$: Observable<CipherView[]> = combineLatest([
private _filteredCipherList$: Observable<PopupCipherView[]> = combineLatest([
this._cipherList$,
this.searchText$,
this.vaultPopupListFiltersService.filterFunction$,
@ -90,8 +112,9 @@ export class VaultPopupItemsService {
filterFunction(ciphers),
searchText,
]),
switchMap(([ciphers, searchText]) =>
this.searchService.searchCiphers(searchText, null, ciphers),
switchMap(
([ciphers, searchText]) =>
this.searchService.searchCiphers(searchText, null, ciphers) as Promise<PopupCipherView[]>,
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
@ -102,7 +125,7 @@ export class VaultPopupItemsService {
*
* See {@link refreshCurrentTab} to trigger re-evaluation of the current tab.
*/
autoFillCiphers$: Observable<CipherView[]> = combineLatest([
autoFillCiphers$: Observable<PopupCipherView[]> = combineLatest([
this._filteredCipherList$,
this._otherAutoFillTypes$,
this._currentAutofillTab$,
@ -121,7 +144,7 @@ export class VaultPopupItemsService {
* List of favorite ciphers that are not currently suggested for autofill.
* Ciphers are sorted by last used date, then by name.
*/
favoriteCiphers$: Observable<CipherView[]> = combineLatest([
favoriteCiphers$: Observable<PopupCipherView[]> = combineLatest([
this.autoFillCiphers$,
this._filteredCipherList$,
]).pipe(
@ -138,7 +161,7 @@ export class VaultPopupItemsService {
* List of all remaining ciphers that are not currently suggested for autofill or marked as favorite.
* Ciphers are sorted by name.
*/
remainingCiphers$: Observable<CipherView[]> = combineLatest([
remainingCiphers$: Observable<PopupCipherView[]> = combineLatest([
this.autoFillCiphers$,
this.favoriteCiphers$,
this._filteredCipherList$,
@ -208,6 +231,7 @@ export class VaultPopupItemsService {
private vaultPopupListFiltersService: VaultPopupListFiltersService,
private organizationService: OrganizationService,
private searchService: SearchService,
private collectionService: CollectionService,
) {}
/**

View File

@ -0,0 +1,41 @@
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
/**
* Extended cipher view for the popup. Includes the associated collections and organization
* if applicable.
*/
export class PopupCipherView extends CipherView {
collections?: CollectionView[];
organization?: Organization;
constructor(
cipher: CipherView,
collections: CollectionView[] = null,
organization: Organization = null,
) {
super();
Object.assign(this, cipher);
this.collections = collections;
this.organization = organization;
}
/**
* Get the bwi icon for the cipher according to the organization type.
*/
get orgIcon(): "bwi-family" | "bwi-business" | null {
switch (this.organization?.planProductType) {
case ProductType.Free:
case ProductType.Families:
return "bwi-family";
case ProductType.Teams:
case ProductType.Enterprise:
case ProductType.TeamsStarter:
return "bwi-business";
default:
return null;
}
}
}

View File

@ -1,4 +1,4 @@
import { firstValueFrom, map, Observable, share, skipWhile, switchMap } from "rxjs";
import { firstValueFrom, map, Observable, skipWhile, switchMap } from "rxjs";
import { SemVer } from "semver";
import { ApiService } from "../../abstractions/api.service";
@ -125,13 +125,7 @@ export class CipherService implements CipherServiceAbstraction {
switchMap(() => this.encryptedCiphersState.state$),
map((ciphers) => ciphers ?? {}),
);
this.cipherViews$ = this.decryptedCiphersState.state$.pipe(
map((views) => views ?? {}),
share({
resetOnRefCountZero: true,
}),
);
this.cipherViews$ = this.decryptedCiphersState.state$.pipe(map((views) => views ?? {}));
this.addEditCipherInfo$ = this.addEditCipherInfoState.state$;
}