mirror of
https://github.com/bitwarden/browser
synced 2025-12-17 16:53:34 +00:00
[PM-6825] Browser Refresh - Initial List Items (#9199)
* [PM-6825] Add temporary vault page header * [PM-6825] Expose cipherViews$ observable * [PM-6825] Refactor getAllDecryptedForUrl to expose filter functionality for reuse * [PM-6825] Introduce VaultPopupItemsService * [PM-6825] Introduce initial VaultListItem and VaultListItemsContainer components * [PM-6825] Add VaultListItems to VaultV2 component * [PM-6825] Introduce autofill-vault-list-items.component to encapsulate autofill logic * [PM-6825] Add temporary Vault icon * [PM-6825] Add empty and no results states to Vault tab * [PM-6825] Add unit tests for vault popup items service * [PM-6825] Negate noFilteredResults placeholder * [PM-6825] Cleanup new Vault components * [PM-6825] Move new components into its own module * [PM-6825] Fix missing button type * [PM-6825] Add booleanAttribute to showAutofill input * [PM-6825] Replace empty refresh BehaviorSubject with Subject * [PM-6825] Combine *ngIfs for vault list items container * [PM-6825] Use popup-section-header component * [PM-6825] Use small variant for icon buttons * [PM-6825] Use anchor tag for vault items * [PM-6825] Consolidate vault-list-items-container to include list item component functionality directly * [PM-6825] Add Tailwind classes to new Vault icon * [PM-6825] Remove temporary header comment * [PM-6825] Fix auto fill suggestion font size and padding * [PM-6825] Use tailwind for vault icon styling * [PM-6825] Add libs/angular to tailwind.config content * [PM-6825] Cleanup missing i18n * [PM-6825] Make VaultV2 standalone and cleanup Browser App module * [PM-6825] Use explicit type annotation * [PM-6825] Use property binding instead of interpolation
This commit is contained in:
committed by
Cesar Gonzalez
parent
c94ba3cfb0
commit
4f9277a72d
@@ -3137,6 +3137,41 @@
|
||||
"message": "to make them visible.",
|
||||
"description": "This will be part of a larger sentence, which will read like so: Assign these items to a collection from the Admin Console to make them visible."
|
||||
},
|
||||
"autofillSuggestions": {
|
||||
"message": "Auto-fill suggestions"
|
||||
},
|
||||
"autofillSuggestionsTip": {
|
||||
"message": "Save a login item for this site to auto-fill"
|
||||
},
|
||||
"yourVaultIsEmpty": {
|
||||
"message": "Your vault is empty"
|
||||
},
|
||||
"noItemsMatchSearch": {
|
||||
"message": "No items match your search"
|
||||
},
|
||||
"clearFiltersOrTryAnother": {
|
||||
"message": "Clear filters or try another search term"
|
||||
},
|
||||
"copyInfo": {
|
||||
"message": "Copy info, $ITEMNAME$",
|
||||
"description": "Aria label for a button that opens a menu with options to copy information from an item.",
|
||||
"placeholders": {
|
||||
"itemname": {
|
||||
"content": "$1",
|
||||
"example": "Secret Item"
|
||||
}
|
||||
}
|
||||
},
|
||||
"moreOptions": {
|
||||
"message": "More options, $ITEMNAME$",
|
||||
"description": "Aria label for a button that opens a menu with more options for an item.",
|
||||
"placeholders": {
|
||||
"itemname": {
|
||||
"content": "$1",
|
||||
"example": "Secret Item"
|
||||
}
|
||||
}
|
||||
},
|
||||
"adminConsole": {
|
||||
"message": "Admin Console"
|
||||
},
|
||||
|
||||
@@ -72,7 +72,6 @@ import { ShareComponent } from "../vault/popup/components/vault/share.component"
|
||||
import { VaultFilterComponent } from "../vault/popup/components/vault/vault-filter.component";
|
||||
import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items.component";
|
||||
import { VaultSelectComponent } from "../vault/popup/components/vault/vault-select.component";
|
||||
import { VaultV2Component } from "../vault/popup/components/vault/vault-v2.component";
|
||||
import { ViewCustomFieldsComponent } from "../vault/popup/components/vault/view-custom-fields.component";
|
||||
import { ViewComponent } from "../vault/popup/components/vault/view.component";
|
||||
import { AppearanceComponent } from "../vault/popup/settings/appearance.component";
|
||||
@@ -190,7 +189,6 @@ import "../platform/popup/locales";
|
||||
AutofillComponent,
|
||||
EnvironmentSelectorComponent,
|
||||
AccountSwitcherComponent,
|
||||
VaultV2Component,
|
||||
],
|
||||
providers: [CurrencyPipe, DatePipe],
|
||||
bootstrap: [AppComponent],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<app-vault-list-items-container
|
||||
*ngIf="autofillCiphers$ | async as ciphers"
|
||||
[ciphers]="ciphers"
|
||||
[title]="'autofillSuggestions' | i18n"
|
||||
showAutoFill
|
||||
></app-vault-list-items-container>
|
||||
<ng-container *ngIf="showEmptyAutofillTip$ | async">
|
||||
<bit-section>
|
||||
<popup-section-header [title]="'autofillSuggestions' | i18n"></popup-section-header>
|
||||
<span class="tw-text-muted tw-px-1" bitTypography="body2">{{
|
||||
"autofillSuggestionsTip" | i18n
|
||||
}}</span>
|
||||
</bit-section>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
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 { SectionComponent, TypographyModule } from "@bitwarden/components";
|
||||
|
||||
import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component";
|
||||
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
|
||||
import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
SectionComponent,
|
||||
TypographyModule,
|
||||
VaultListItemsContainerComponent,
|
||||
JslibModule,
|
||||
PopupSectionHeaderComponent,
|
||||
],
|
||||
selector: "app-autofill-vault-list-items",
|
||||
templateUrl: "autofill-vault-list-items.component.html",
|
||||
})
|
||||
export class AutofillVaultListItemsComponent {
|
||||
/**
|
||||
* The list of ciphers that can be used to autofill the current page.
|
||||
* @protected
|
||||
*/
|
||||
protected autofillCiphers$: Observable<CipherView[]> =
|
||||
this.vaultPopupItemsService.autoFillCiphers$;
|
||||
|
||||
/**
|
||||
* Observable that determines whether the empty autofill tip should be shown.
|
||||
* The tip is shown when there are no ciphers to autofill, no filter is applied, and autofill is allowed in
|
||||
* the current context (e.g. not in a popout).
|
||||
* @protected
|
||||
*/
|
||||
protected showEmptyAutofillTip$: Observable<boolean> = combineLatest([
|
||||
this.vaultPopupItemsService.hasFilterApplied$,
|
||||
this.autofillCiphers$,
|
||||
this.vaultPopupItemsService.autofillAllowed$,
|
||||
]).pipe(
|
||||
map(([hasFilter, ciphers, canAutoFill]) => !hasFilter && canAutoFill && ciphers.length === 0),
|
||||
);
|
||||
|
||||
constructor(private vaultPopupItemsService: VaultPopupItemsService) {
|
||||
// TODO: Migrate logic to show Autofill policy toast PM-8144
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./vault-list-items-container/vault-list-items-container.component";
|
||||
export * from "./autofill-vault-list-items/autofill-vault-list-items.component";
|
||||
@@ -0,0 +1,35 @@
|
||||
<bit-section *ngIf="ciphers?.length > 0">
|
||||
<popup-section-header [title]="title">
|
||||
<span bitTypography="body2" slot="end">{{ ciphers.length }}</span>
|
||||
</popup-section-header>
|
||||
<bit-item-group>
|
||||
<bit-item *ngFor="let cipher of ciphers">
|
||||
<a bit-item-content [routerLink]="['/view-cipher']" [queryParams]="{ cipherId: cipher.id }">
|
||||
<app-vault-icon slot="start" [cipher]="cipher"></app-vault-icon>
|
||||
{{ cipher.name }}
|
||||
<span slot="secondary">{{ cipher.subTitle }}</span>
|
||||
</a>
|
||||
<ng-container slot="end">
|
||||
<bit-item-action *ngIf="showAutoFill">
|
||||
<button type="button" bitBadge variant="primary">{{ "autoFill" | i18n }}</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-clone"
|
||||
size="small"
|
||||
[attr.aria-label]="'copyInfo' | i18n: cipher.name"
|
||||
></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
[attr.aria-label]="'moreOptions' | i18n: cipher.name"
|
||||
></button>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
</bit-item-group>
|
||||
</bit-section>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { booleanAttribute, Component, Input } from "@angular/core";
|
||||
import { RouterLink } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
ItemModule,
|
||||
SectionComponent,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component";
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
CommonModule,
|
||||
ItemModule,
|
||||
ButtonModule,
|
||||
BadgeModule,
|
||||
IconButtonModule,
|
||||
SectionComponent,
|
||||
TypographyModule,
|
||||
JslibModule,
|
||||
PopupSectionHeaderComponent,
|
||||
RouterLink,
|
||||
],
|
||||
selector: "app-vault-list-items-container",
|
||||
templateUrl: "vault-list-items-container.component.html",
|
||||
standalone: true,
|
||||
})
|
||||
export class VaultListItemsContainerComponent {
|
||||
@Input()
|
||||
ciphers: CipherView[];
|
||||
|
||||
@Input()
|
||||
title: string;
|
||||
|
||||
@Input({ transform: booleanAttribute })
|
||||
showAutoFill: boolean;
|
||||
}
|
||||
@@ -10,4 +10,40 @@
|
||||
<app-current-account></app-current-account>
|
||||
</ng-container>
|
||||
</popup-header>
|
||||
|
||||
<div *ngIf="showEmptyState$ | async" class="tw-flex tw-flex-col tw-h-full tw-justify-center">
|
||||
<bit-no-items [icon]="vaultIcon">
|
||||
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "autofillSuggestionsTip" | i18n }}</ng-container>
|
||||
<button slot="button" type="button" bitButton buttonType="primary" (click)="addCipher()">
|
||||
{{ "new" | i18n }}
|
||||
</button>
|
||||
</bit-no-items>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!(showEmptyState$ | async)">
|
||||
<!-- TODO: Filter/search Section in PM-6824 and PM-6826.-->
|
||||
|
||||
<div
|
||||
*ngIf="showNoResultsState$ | async"
|
||||
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
|
||||
>
|
||||
<bit-no-items>
|
||||
<ng-container slot="title">{{ "noItemsMatchSearch" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "clearFiltersOrTryAnother" | i18n }}</ng-container>
|
||||
</bit-no-items>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!(showNoResultsState$ | async)">
|
||||
<app-autofill-vault-list-items></app-autofill-vault-list-items>
|
||||
<app-vault-list-items-container
|
||||
[title]="'favorites' | i18n"
|
||||
[ciphers]="favoriteCiphers$ | async"
|
||||
></app-vault-list-items-container>
|
||||
<app-vault-list-items-container
|
||||
[title]="'allItems' | i18n"
|
||||
[ciphers]="remainingCiphers$ | async"
|
||||
></app-vault-list-items-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</popup-page>
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Router, RouterLink } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components";
|
||||
|
||||
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
|
||||
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
|
||||
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
|
||||
import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2";
|
||||
|
||||
@Component({
|
||||
selector: "app-vault",
|
||||
templateUrl: "vault-v2.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
PopupPageComponent,
|
||||
PopupHeaderComponent,
|
||||
PopOutComponent,
|
||||
CurrentAccountComponent,
|
||||
NoItemsModule,
|
||||
JslibModule,
|
||||
CommonModule,
|
||||
AutofillVaultListItemsComponent,
|
||||
VaultListItemsContainerComponent,
|
||||
ButtonModule,
|
||||
RouterLink,
|
||||
],
|
||||
})
|
||||
export class VaultV2Component implements OnInit, OnDestroy {
|
||||
constructor() {}
|
||||
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
|
||||
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
|
||||
|
||||
protected showEmptyState$ = this.vaultPopupItemsService.emptyVault$;
|
||||
protected showNoResultsState$ = this.vaultPopupItemsService.noFilteredResults$;
|
||||
|
||||
protected vaultIcon = Icons.Vault;
|
||||
|
||||
constructor(
|
||||
private vaultPopupItemsService: VaultPopupItemsService,
|
||||
private router: Router,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
|
||||
addCipher() {
|
||||
// TODO: Add currently filtered organization to query params if available
|
||||
void this.router.navigate(["/add-cipher"], {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
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 BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
|
||||
import { VaultPopupItemsService } from "./vault-popup-items.service";
|
||||
|
||||
describe("VaultPopupItemsService", () => {
|
||||
let service: VaultPopupItemsService;
|
||||
let allCiphers: Record<CipherId, CipherView>;
|
||||
let autoFillCiphers: CipherView[];
|
||||
|
||||
const cipherServiceMock = mock<CipherService>();
|
||||
const vaultSettingsServiceMock = mock<VaultSettingsService>();
|
||||
|
||||
beforeEach(() => {
|
||||
allCiphers = cipherFactory(10);
|
||||
const cipherList = Object.values(allCiphers);
|
||||
// First 2 ciphers are autofill
|
||||
autoFillCiphers = cipherList.slice(0, 2);
|
||||
|
||||
// First autofill cipher is also favorite
|
||||
autoFillCiphers[0].favorite = true;
|
||||
|
||||
// 3rd and 4th ciphers are favorite
|
||||
cipherList[2].favorite = true;
|
||||
cipherList[3].favorite = true;
|
||||
|
||||
cipherServiceMock.cipherViews$ = new BehaviorSubject(allCiphers).asObservable();
|
||||
cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers);
|
||||
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable();
|
||||
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable();
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
|
||||
jest
|
||||
.spyOn(BrowserApi, "getTabFromCurrentWindow")
|
||||
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab);
|
||||
service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("autoFillCiphers$", () => {
|
||||
it("should return empty array if there is no current tab", (done) => {
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(ciphers).toEqual([]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array if in Popout window", (done) => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(ciphers).toEqual([]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab);
|
||||
|
||||
service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
|
||||
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1);
|
||||
expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
currentTab.url,
|
||||
[CipherType.Card, CipherType.Identity],
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return ciphers sorted by type, then by last used date, then by name", (done) => {
|
||||
const expectedTypeOrder: Record<CipherType, number> = {
|
||||
[CipherType.Login]: 1,
|
||||
[CipherType.Card]: 2,
|
||||
[CipherType.Identity]: 3,
|
||||
[CipherType.SecureNote]: 4,
|
||||
};
|
||||
|
||||
// Assume all ciphers are autofill ciphers to test sorting
|
||||
cipherServiceMock.filterCiphersForUrl.mockImplementation(async () =>
|
||||
Object.values(allCiphers),
|
||||
);
|
||||
|
||||
service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
|
||||
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(ciphers.length).toBe(10);
|
||||
|
||||
for (let i = 0; i < ciphers.length - 1; i++) {
|
||||
const current = ciphers[i];
|
||||
const next = ciphers[i + 1];
|
||||
|
||||
expect(expectedTypeOrder[current.type]).toBeLessThanOrEqual(expectedTypeOrder[next.type]);
|
||||
}
|
||||
expect(cipherServiceMock.sortCiphersByLastUsedThenName).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("favoriteCiphers$", () => {
|
||||
it("should exclude autofill ciphers", (done) => {
|
||||
service.favoriteCiphers$.subscribe((ciphers) => {
|
||||
// 2 autofill ciphers, 3 favorite ciphers, 1 favorite cipher is also autofill = 2 favorite ciphers to show
|
||||
expect(ciphers.length).toBe(2);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should sort by last used then by name", (done) => {
|
||||
service.favoriteCiphers$.subscribe((ciphers) => {
|
||||
expect(cipherServiceMock.sortCiphersByLastUsedThenName).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("remainingCiphers$", () => {
|
||||
it("should exclude autofill and favorite ciphers", (done) => {
|
||||
service.remainingCiphers$.subscribe((ciphers) => {
|
||||
// 2 autofill ciphers, 2 favorite ciphers = 6 remaining ciphers to show
|
||||
expect(ciphers.length).toBe(6);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should sort by last used then by name", (done) => {
|
||||
service.remainingCiphers$.subscribe((ciphers) => {
|
||||
expect(cipherServiceMock.getLocaleSortingFunction).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("emptyVault$", () => {
|
||||
it("should return true if there are no ciphers", (done) => {
|
||||
cipherServiceMock.cipherViews$ = new BehaviorSubject({}).asObservable();
|
||||
service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
|
||||
service.emptyVault$.subscribe((empty) => {
|
||||
expect(empty).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if there are ciphers", (done) => {
|
||||
service.emptyVault$.subscribe((empty) => {
|
||||
expect(empty).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoFillAllowed$", () => {
|
||||
it("should return true if there is a current tab", (done) => {
|
||||
service.autofillAllowed$.subscribe((allowed) => {
|
||||
expect(allowed).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if there is no current tab", (done) => {
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
|
||||
service.autofillAllowed$.subscribe((allowed) => {
|
||||
expect(allowed).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if in a Popout", (done) => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
|
||||
service.autofillAllowed$.subscribe((allowed) => {
|
||||
expect(allowed).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// A function to generate a list of ciphers of different types
|
||||
function cipherFactory(count: number): Record<CipherId, CipherView> {
|
||||
const ciphers: CipherView[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const type = ((i % 4) + 1) as CipherType;
|
||||
switch (type) {
|
||||
case CipherType.Login:
|
||||
ciphers.push({
|
||||
id: `${i}`,
|
||||
type: CipherType.Login,
|
||||
name: `Login ${i}`,
|
||||
login: {
|
||||
username: `username${i}`,
|
||||
password: `password${i}`,
|
||||
},
|
||||
} as CipherView);
|
||||
break;
|
||||
case CipherType.SecureNote:
|
||||
ciphers.push({
|
||||
id: `${i}`,
|
||||
type: CipherType.SecureNote,
|
||||
name: `SecureNote ${i}`,
|
||||
notes: `notes${i}`,
|
||||
} as CipherView);
|
||||
break;
|
||||
case CipherType.Card:
|
||||
ciphers.push({
|
||||
id: `${i}`,
|
||||
type: CipherType.Card,
|
||||
name: `Card ${i}`,
|
||||
card: {
|
||||
cardholderName: `cardholderName${i}`,
|
||||
number: `number${i}`,
|
||||
brand: `brand${i}`,
|
||||
},
|
||||
} as CipherView);
|
||||
break;
|
||||
case CipherType.Identity:
|
||||
ciphers.push({
|
||||
id: `${i}`,
|
||||
type: CipherType.Identity,
|
||||
name: `Identity ${i}`,
|
||||
identity: {
|
||||
firstName: `firstName${i}`,
|
||||
lastName: `lastName${i}`,
|
||||
},
|
||||
} as CipherView);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(ciphers.map((c) => [c.id, c]));
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import {
|
||||
combineLatest,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
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 BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
|
||||
/**
|
||||
* Service for managing the various item lists on the new Vault tab in the browser popup.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class VaultPopupItemsService {
|
||||
private _refreshCurrentTab$ = new Subject<void>();
|
||||
|
||||
/**
|
||||
* Observable that contains the list of other cipher types that should be shown
|
||||
* in the autofill section of the Vault tab. Depends on vault settings.
|
||||
* @private
|
||||
*/
|
||||
private _otherAutoFillTypes$: Observable<CipherType[]> = combineLatest([
|
||||
this.vaultSettingsService.showCardsCurrentTab$,
|
||||
this.vaultSettingsService.showIdentitiesCurrentTab$,
|
||||
]).pipe(
|
||||
map(([showCards, showIdentities]) => {
|
||||
return [
|
||||
...(showCards ? [CipherType.Card] : []),
|
||||
...(showIdentities ? [CipherType.Identity] : []),
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable that contains the current tab to be considered for autofill. If there is no current tab
|
||||
* or the popup is in a popout window, this will be null.
|
||||
* @private
|
||||
*/
|
||||
private _currentAutofillTab$: Observable<chrome.tabs.Tab | null> = this._refreshCurrentTab$.pipe(
|
||||
startWith(null),
|
||||
switchMap(async () => {
|
||||
if (BrowserPopupUtils.inPopout(window)) {
|
||||
return null;
|
||||
}
|
||||
return await BrowserApi.getTabFromCurrentWindow();
|
||||
}),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable that contains the list of all decrypted ciphers.
|
||||
* @private
|
||||
*/
|
||||
private _cipherList$: Observable<CipherView[]> = this.cipherService.cipherViews$.pipe(
|
||||
map((ciphers) => Object.values(ciphers)),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
/**
|
||||
* List of ciphers that can be used for autofill on the current tab. Includes cards and/or identities
|
||||
* if enabled in the vault settings. Ciphers are sorted by type, then by last used date, then by name.
|
||||
*
|
||||
* See {@link refreshCurrentTab} to trigger re-evaluation of the current tab.
|
||||
*/
|
||||
autoFillCiphers$: Observable<CipherView[]> = combineLatest([
|
||||
this._cipherList$,
|
||||
this._otherAutoFillTypes$,
|
||||
this._currentAutofillTab$,
|
||||
]).pipe(
|
||||
switchMap(([ciphers, otherTypes, tab]) => {
|
||||
if (!tab) {
|
||||
return of([]);
|
||||
}
|
||||
return this.cipherService.filterCiphersForUrl(ciphers, tab.url, otherTypes);
|
||||
}),
|
||||
map((ciphers) => ciphers.sort(this.sortCiphersForAutofill.bind(this))),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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([
|
||||
this.autoFillCiphers$,
|
||||
this._cipherList$,
|
||||
]).pipe(
|
||||
map(([autoFillCiphers, ciphers]) =>
|
||||
ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)),
|
||||
),
|
||||
map((ciphers) =>
|
||||
ciphers.sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)),
|
||||
),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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([
|
||||
this.autoFillCiphers$,
|
||||
this.favoriteCiphers$,
|
||||
this._cipherList$,
|
||||
]).pipe(
|
||||
map(([autoFillCiphers, favoriteCiphers, ciphers]) =>
|
||||
ciphers.filter(
|
||||
(cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher),
|
||||
),
|
||||
),
|
||||
map((ciphers) => ciphers.sort(this.cipherService.getLocaleSortingFunction())),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable that indicates whether a filter is currently applied to the ciphers.
|
||||
* @todo Implement filter/search functionality in PM-6824 and PM-6826.
|
||||
*/
|
||||
hasFilterApplied$: Observable<boolean> = of(false);
|
||||
|
||||
/**
|
||||
* Observable that indicates whether autofill is allowed in the current context.
|
||||
* Autofill is allowed when there is a current tab and the popup is not in a popout window.
|
||||
*/
|
||||
autofillAllowed$: Observable<boolean> = this._currentAutofillTab$.pipe(map((tab) => !!tab));
|
||||
|
||||
/**
|
||||
* Observable that indicates whether the user's vault is empty.
|
||||
*/
|
||||
emptyVault$: Observable<boolean> = this._cipherList$.pipe(map((ciphers) => !ciphers.length));
|
||||
|
||||
/**
|
||||
* Observable that indicates whether there are no ciphers to show with the current filter.
|
||||
* @todo Implement filter/search functionality in PM-6824 and PM-6826.
|
||||
*/
|
||||
noFilteredResults$: Observable<boolean> = of(false);
|
||||
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private vaultSettingsService: VaultSettingsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Re-fetch the current tab to trigger a re-evaluation of the autofill ciphers.
|
||||
*/
|
||||
refreshCurrentTab() {
|
||||
this._refreshCurrentTab$.next(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort function for ciphers to be used in the autofill section of the Vault tab.
|
||||
* Sorts by type, then by last used date, and finally by name.
|
||||
* @private
|
||||
*/
|
||||
private sortCiphersForAutofill(a: CipherView, b: CipherView): number {
|
||||
const typeOrder: Record<CipherType, number> = {
|
||||
[CipherType.Login]: 1,
|
||||
[CipherType.Card]: 2,
|
||||
[CipherType.Identity]: 3,
|
||||
[CipherType.SecureNote]: 4,
|
||||
};
|
||||
|
||||
// Compare types first
|
||||
if (typeOrder[a.type] < typeOrder[b.type]) {
|
||||
return -1;
|
||||
} else if (typeOrder[a.type] > typeOrder[b.type]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If types are the same, then sort by last used then name
|
||||
return this.cipherService.sortCiphersByLastUsedThenName(a, b);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
/* eslint-disable no-undef, @typescript-eslint/no-var-requires */
|
||||
const config = require("../../libs/components/tailwind.config.base");
|
||||
|
||||
config.content = ["./src/**/*.{html,ts}", "../../libs/components/src/**/*.{html,ts}"];
|
||||
config.content = [
|
||||
"./src/**/*.{html,ts}",
|
||||
"../../libs/components/src/**/*.{html,ts}",
|
||||
"../../libs/angular/src/**/*.{html,ts}",
|
||||
];
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -577,6 +577,17 @@ app-vault-view .box-footer {
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
/* override for vault icon in desktop */
|
||||
app-vault-icon > div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
float: left;
|
||||
height: 36px;
|
||||
width: 34px;
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
/* tweak for inconsistent line heights in cipher view */
|
||||
.box-footer button,
|
||||
.box-footer a {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/* eslint-disable no-undef, @typescript-eslint/no-var-requires */
|
||||
const config = require("../../libs/components/tailwind.config.base");
|
||||
|
||||
config.content = ["./src/**/*.{html,ts}", "../../libs/components/src/**/*.{html,ts}"];
|
||||
config.content = [
|
||||
"./src/**/*.{html,ts}",
|
||||
"../../libs/components/src/**/*.{html,ts}",
|
||||
"../../libs/angular/src/**/*.{html,ts}",
|
||||
];
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -5,6 +5,7 @@ config.content = [
|
||||
"./src/**/*.{html,ts}",
|
||||
"../../libs/components/src/**/*.{html,ts}",
|
||||
"../../libs/auth/src/**/*.{html,ts}",
|
||||
"../../libs/angular/src/**/*.{html,ts}",
|
||||
"../../bitwarden_license/bit-web/src/**/*.{html,ts}",
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user