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:
parent
d0c1325f0c
commit
f059d136b2
@ -1437,6 +1437,15 @@
|
||||
"collections": {
|
||||
"message": "Collections"
|
||||
},
|
||||
"nCollections": {
|
||||
"message": "$COUNT$ collections",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"content": "$1",
|
||||
"example": "2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"message": "Favorites"
|
||||
},
|
||||
|
@ -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$;
|
||||
|
||||
/**
|
||||
|
@ -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">
|
||||
|
@ -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) {}
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
41
apps/browser/src/vault/popup/views/popup-cipher.view.ts
Normal file
41
apps/browser/src/vault/popup/views/popup-cipher.view.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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$;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user