[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:
Shane Melton 2024-05-21 14:05:02 -07:00 committed by GitHub
parent 3ba19d8c9d
commit 3d0e0d261e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 759 additions and 12 deletions

View File

@ -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"
},

View File

@ -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],

View File

@ -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>

View File

@ -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
}
}

View File

@ -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";

View File

@ -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>

View File

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

View File

@ -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>

View File

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

View File

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

View File

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

View File

@ -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;

View File

@ -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 {

View File

@ -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;

View File

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

View File

@ -1,9 +1,10 @@
<div class="icon" aria-hidden="true">
<div class="tw-flex tw-justify-center tw-items-center" aria-hidden="true">
<ng-container *ngIf="data$ | async as data">
<img
[src]="data.image"
[appFallbackSrc]="data.fallbackImage"
*ngIf="data.imageEnabled && data.image"
class="tw-max-h-6 tw-max-w-6 tw-rounded-md"
alt=""
decoding="async"
loading="lazy"

View File

@ -12,6 +12,7 @@ import { FieldView } from "../models/view/field.view";
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
export abstract class CipherService {
cipherViews$: Observable<Record<CipherId, CipherView>>;
/**
* An observable monitoring the add/edit cipher info saved to memory.
*/
@ -34,6 +35,12 @@ export abstract class CipherService {
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting,
) => Promise<CipherView[]>;
filterCiphersForUrl: (
ciphers: CipherView[],
url: string,
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting,
) => Promise<CipherView[]>;
getAllFromApiForOrganization: (organizationId: string) => Promise<CipherView[]>;
/**
* Gets ciphers belonging to the specified organization that the user has explicit collection level access to.

View File

@ -1,4 +1,4 @@
import { Observable, firstValueFrom, map, share, skipWhile, switchMap } from "rxjs";
import { firstValueFrom, map, Observable, share, skipWhile, switchMap } from "rxjs";
import { SemVer } from "semver";
import { ApiService } from "../../abstractions/api.service";
@ -29,7 +29,7 @@ import {
StateProvider,
} from "../../platform/state";
import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid";
import { UserKey, OrgKey } from "../../types/key";
import { OrgKey, UserKey } from "../../types/key";
import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { FieldType } from "../enums";
@ -65,10 +65,10 @@ import { PasswordHistoryView } from "../models/view/password-history.view";
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
import {
ENCRYPTED_CIPHERS,
LOCAL_DATA_KEY,
ADD_EDIT_CIPHER_INFO_KEY,
DECRYPTED_CIPHERS,
ENCRYPTED_CIPHERS,
LOCAL_DATA_KEY,
} from "./key-state/ciphers.state";
const CIPHER_KEY_ENC_MIN_SERVER_VER = new SemVer("2024.2.0");
@ -443,15 +443,24 @@ export class CipherService implements CipherServiceAbstraction {
url: string,
includeOtherTypes?: CipherType[],
defaultMatch: UriMatchStrategySetting = null,
): Promise<CipherView[]> {
const ciphers = await this.getAllDecrypted();
return await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch);
}
async filterCiphersForUrl(
ciphers: CipherView[],
url: string,
includeOtherTypes?: CipherType[],
defaultMatch: UriMatchStrategySetting = null,
): Promise<CipherView[]> {
if (url == null && includeOtherTypes == null) {
return Promise.resolve([]);
return [];
}
const equivalentDomains = await firstValueFrom(
this.domainSettingsService.getUrlEquivalentDomains(url),
);
const ciphers = await this.getAllDecrypted();
defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$);
return ciphers.filter((cipher) => {

View File

@ -1,2 +1,3 @@
export * from "./search";
export * from "./no-access";
export * from "./vault";

View File

@ -0,0 +1,17 @@
import { svgIcon } from "../icon";
export const Vault = svgIcon`
<svg fill="none" width="100" height="90" viewBox="0 0 100 90" xmlns="http://www.w3.org/2000/svg">
<g>
<path d="m73.446 81.044h17.001v3.4685c0 2.7615-2.2385 5-5 5h-7.0011c-2.7615 0-5-2.2385-5-5v-3.4685zm2 2v1.4685c0 1.6569 1.3431 3 3 3h7.0011c1.6569 0 3-1.3431 3-3v-1.4685h-13.001z" clip-rule="evenodd" class="tw-fill-info-600" fill-rule="evenodd"/>
<path d="m10.108 81.044h17.001v3.4685c0 2.7615-2.2385 5-5 5h-7.0011c-2.7614 0-5-2.2385-5-5v-3.4685zm2 2v1.4685c0 1.6569 1.3431 3 3 3h7.0011c1.6569 0 3-1.3431 3-3v-1.4685h-13.001z" clip-rule="evenodd" class="tw-fill-info-600" fill-rule="evenodd"/>
<path d="m4.2281 2.4304c-1.1655 0-2.1208 0.95235-2.1208 2.1402v74.318c0 1.1878 0.95534 2.1402 2.1208 2.1402h91.544c1.1655 0 2.1208-0.9524 2.1208-2.1402v-74.318c0-1.1878-0.9553-2.1402-2.1208-2.1402h-91.544zm-4.1208 2.1402c0-2.2807 1.8391-4.1402 4.1208-4.1402h91.544c2.2817 0 4.1208 1.8595 4.1208 4.1402v74.318c0 2.2807-1.8391 4.1402-4.1208 4.1402h-91.544c-2.2817 0-4.1208-1.8595-4.1208-4.1402v-74.318z" clip-rule="evenodd" class="tw-fill-text-headers" fill-rule="evenodd"/>
<path d="m89.258 21.816c-0.7304 0-1.3307 0.5963-1.3307 1.3421v9.3686c0 0.7459 0.6003 1.3422 1.3307 1.3422 0.7303 0 1.3307-0.5963 1.3307-1.3422v-9.3686c0-0.7458-0.6004-1.3421-1.3307-1.3421zm-3.3307 1.3421c0-1.8412 1.4866-3.3421 3.3307-3.3421s3.3307 1.5009 3.3307 3.3421v9.3686c0 1.8412-1.4866 3.3422-3.3307 3.3422s-3.3307-1.501-3.3307-3.3422v-9.3686z" clip-rule="evenodd" class="tw-fill-info-600" fill-rule="evenodd"/>
<path d="m89.258 45.237c-0.7304 0-1.3307 0.5962-1.3307 1.3421v9.3686c0 0.7459 0.6003 1.3422 1.3307 1.3422 0.7303 0 1.3307-0.5963 1.3307-1.3422v-9.3686c0-0.7459-0.6004-1.3421-1.3307-1.3421zm-3.3307 1.3421c0-1.8412 1.4866-3.3421 3.3307-3.3421s3.3307 1.5009 3.3307 3.3421v9.3686c0 1.8412-1.4866 3.3422-3.3307 3.3422s-3.3307-1.501-3.3307-3.3422v-9.3686z" clip-rule="evenodd" class="tw-fill-info-600" fill-rule="evenodd"/>
<path d="m33.443 25.468c0-0.5523 0.4477-1 1-1 1.4163 0 2.6668 1.0953 2.6668 2.5705v21.595c0 1.4752-1.2505 2.5705-2.6668 2.5705-0.5523 0-1-0.4477-1-1s0.4477-1 1-1c0.4255 0 0.6668-0.3103 0.6668-0.5705v-21.595c0-0.2602-0.2413-0.5705-0.6668-0.5705-0.5523 0-1-0.4477-1-1z" clip-rule="evenodd" class="tw-fill-info-600" fill-rule="evenodd"/>
<path d="m60.556 48.551c-3.2028 0-5.7978-3.1022-5.7978-6.9179 0-3.8156 2.595-6.9114 5.7978-6.9114 3.2029 0 5.7913 3.1022 5.7913 6.9114 0 3.8093-2.5949 6.9179-5.7913 6.9179zm0-14.791c-3.6408 0-6.6018 3.529-6.6018 7.8733 0 4.3444 2.961 7.8798 6.6018 7.8798s6.5953-3.529 6.5953-7.8798c0-4.3507-2.961-7.8733-6.5953-7.8733z" class="tw-fill-info-600"/>
<path d="m60.556 26.027c-0.4379 0-0.804 0.4267-0.804 0.9555l-0.0201 3.257c-2.0247 0.2075-3.8681 1.1748-5.3381 2.6521l-1.9561-2.2909c-0.156-0.1901-0.3638-0.2856-0.5654-0.2866h0.0033-0.0065 0.0032c-0.2015 1e-3 -0.4028 0.0965-0.5588 0.2866-0.3138 0.3695-0.3138 0.9746 0 1.3441l1.9348 2.3123 0.0034 0.0042c-1.2532 1.7574-2.0625 3.9789-2.2323 6.4166h0.7647c0.0488 0 0.0966 0.0053 0.143 0.0154-0.0465-0.01-0.0942-0.0152-0.143-0.0152h-3.497c-0.438 0-0.804 0.4268-0.804 0.9491 0 0.5224 0.366 0.9555 0.804 0.9555h2.7323c0.1698 2.4381 0.986 4.66 2.2331 6.4175l-0.0028 0.0034-1.9297 2.3187c-0.3138 0.3694-0.3138 0.9746 0 1.344 0.1568 0.1848 0.3595 0.2803 0.5621 0.2803s0.4118-0.0955 0.5687-0.2803l1.9282-2.3123 1e-4 -1e-4c1.4757 1.4954 3.3361 2.4695 5.3729 2.6684v3.2622c0 0.5287 0.3661 0.9555 0.804 0.9555 0.438 0 0.7975-0.4268 0.7975-0.9555l0.0212-3.263c2.0293-0.2066 3.8833-1.1701 5.3555-2.6581l0.0028 0.0033 1.9282 2.306c0.1569 0.1847 0.3661 0.2803 0.5687 0.2803s0.4118-0.0956 0.5687-0.2803c0.3137-0.3695 0.3137-0.9746 0-1.3441l-1.9269-2.3334c1.2466-1.7626 2.0628-3.9762 2.2337-6.4125h2.7195c0.438 0 0.804-0.4268 0.804-0.9555s-0.366-0.9491-0.804-0.9491l-2.7198-0.0166c-0.1709-2.4276-0.9825-4.634-2.2222-6.3932l1.9157-2.3235c0.3137-0.3695 0.3137-0.9746 0-1.3441-0.1569-0.1911-0.3661-0.2866-0.5687-0.2866s-0.4118 0.0955-0.5687 0.2866l-1.9222 2.2988c-1.4756-1.4884-3.3591-2.454-5.3855-2.665v-3.252c0-0.5288-0.353-0.9555-0.7975-0.9555zm6.72 8.9311c0.0201-0.02 0.0396-0.0413 0.0584-0.0642l0.0144-0.0173-0.021 0.0239c-0.0167 0.0203-0.034 0.0395-0.0518 0.0576zm1.2545 6.6691c-0.0028-0.0609-0.0032-0.1186-0.0013-0.1732-0.0775-5.1648-3.6205-9.3364-7.9594-9.3438l-0.0138 1e-4 -0.0114-1e-4c-4.3862 0.0089-7.9565 4.2734-7.9565 9.5168 0 5.2239 3.5479 9.4824 7.9117 9.5229 0.0178-9e-4 0.036-0.0014 0.0546-0.0014 0.0194 0 0.0385 5e-4 0.0572 0.0015 4.3591-0.0317 7.9116-4.2849 7.9189-9.5068v-0.016zm-13.411 7.6696c0.0205-0.0858 0.0307-0.174 0.0307-0.2615 0-0.242-0.0784-0.4904-0.2353-0.6752-0.1503-0.1911-0.3595-0.2803-0.5621-0.2803l-0.0114 1e-4h0.0113c0.2026 0 0.4118 0.0892 0.5621 0.2803 0.1569 0.1847 0.2353 0.4332 0.2353 0.6752 0 0.0874-0.0102 0.1757-0.0306 0.2614zm-2.5382-7.6696c0-0.0236-7e-4 -0.0471-0.0021-0.0702 0.0014 0.0231 0.0022 0.0465 0.0022 0.07 0 0.0175-4e-4 0.0349-0.0012 0.0521 7e-4 -0.0172 0.0011-0.0345 0.0011-0.0519z" clip-rule="evenodd" class="tw-fill-text-headers" fill-rule="evenodd"/>
<path d="m25.442 10.125c0-1.2133 1.0146-2.1704 2.2154-2.1008l58.262 3.4199c1.1054 0.0669 1.9723 0.9842 1.9723 2.1009v7.3296h2v-7.3296c0-2.1736-1.6899-3.9673-3.853-4.0974l-58.264-3.42c-2.1001-0.12216-3.8976 1.356-4.264 3.347h-8.7578c-2.2641 0-4.0891 1.845-4.0891 4.1081v55.945c0 2.2631 1.825 4.1081 4.0891 4.1081h8.7036c0.1798 2.1936 2.0771 3.8865 4.3187 3.7561l58.264-3.4201c2.1631-0.1301 3.853-1.9237 3.853-4.0973v-11.184h-2v11.184c0 1.117-0.8674 2.0344-1.9731 2.1009l-58.261 3.4199c-1.2008 0.0696-2.2155-0.8875-2.2155-2.1009v-63.07zm-2 61.411v-60.162h-8.6897c-1.148 0-2.0891 0.9381-2.0891 2.1081v55.945c0 1.1701 0.9411 2.1081 2.0891 2.1081h8.6897zm64.449-36.67v9.4289h2v-9.4289h-2z" clip-rule="evenodd" class="tw-fill-text-headers" fill-rule="evenodd"/>
</g>
</svg>
`;

View File

@ -60,6 +60,7 @@ module.exports = {
contrast: rgba("--color-text-contrast"),
alt2: rgba("--color-text-alt2"),
code: rgba("--color-text-code"),
headers: rgba("--color-text-headers"),
},
background: {
DEFAULT: rgba("--color-background"),