mirror of
https://github.com/bitwarden/browser
synced 2026-02-01 09:13:54 +00:00
Merge branch 'main' into dev/kreynolds/tunnel_proto_v2
This commit is contained in:
7
.github/workflows/build-desktop.yml
vendored
7
.github/workflows/build-desktop.yml
vendored
@@ -270,13 +270,6 @@ jobs:
|
||||
path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload .freebsd artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd
|
||||
path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload .snap artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
|
||||
1
.github/workflows/release-desktop.yml
vendored
1
.github/workflows/release-desktop.yml
vendored
@@ -107,7 +107,6 @@ jobs:
|
||||
with:
|
||||
artifacts: "apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-amd64.deb,
|
||||
apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-x86_64.rpm,
|
||||
apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-x64.freebsd,
|
||||
apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_amd64.snap,
|
||||
apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_arm64.snap,
|
||||
apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_arm64.tar.gz,
|
||||
|
||||
@@ -5821,6 +5821,15 @@
|
||||
"upgradeToPremium": {
|
||||
"message": "Upgrade to Premium"
|
||||
},
|
||||
"upgradeCompleteSecurity": {
|
||||
"message": "Upgrade for complete security"
|
||||
},
|
||||
"premiumGivesMoreTools": {
|
||||
"message": "Premium gives you more tools to stay secure, work efficiently, and stay in control."
|
||||
},
|
||||
"explorePremium": {
|
||||
"message": "Explore Premium"
|
||||
},
|
||||
"loadingVault": {
|
||||
"message": "Loading vault"
|
||||
},
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
|
||||
<!-- Show search & filters outside of the scroll area of the page -->
|
||||
<ng-container slot="above-scroll-area">
|
||||
<ng-container *ngIf="showPremiumSpotlight$ | async">
|
||||
<bit-spotlight
|
||||
[title]="'upgradeCompleteSecurity' | i18n"
|
||||
[subtitle]="'premiumGivesMoreTools' | i18n"
|
||||
[buttonText]="'explorePremium' | i18n"
|
||||
(onButtonClick)="showPremiumDialog()"
|
||||
(onDismiss)="dismissVaultNudgeSpotlight(NudgeType.PremiumUpgrade)"
|
||||
></bit-spotlight>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="vaultState === VaultStateEnum.Empty && showEmptyVaultSpotlight$ | async">
|
||||
<bit-spotlight
|
||||
[title]="'emptyVaultNudgeTitle' | i18n"
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
|
||||
import { ChangeDetectionStrategy, Component, input, NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { TestBed, fakeAsync, flush, tick } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, Observable, Subject, of } from "rxjs";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
|
||||
import { NudgeType, NudgesService } from "@bitwarden/angular/vault";
|
||||
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
|
||||
import { CurrentAccountComponent } from "@bitwarden/browser/auth/popup/account-switching/current-account.component";
|
||||
import AutofillService from "@bitwarden/browser/autofill/services/autofill.service";
|
||||
import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component";
|
||||
import { PopupHeaderComponent } from "@bitwarden/browser/platform/popup/layout/popup-header.component";
|
||||
import { PopupRouterCacheService } from "@bitwarden/browser/platform/popup/view-cache/popup-router-cache.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
import { TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
import { DecryptionFailureDialogComponent } from "@bitwarden/vault";
|
||||
|
||||
import { BrowserApi } from "../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils";
|
||||
import { IntroCarouselService } from "../../services/intro-carousel.service";
|
||||
import { VaultPopupAutofillService } from "../../services/vault-popup-autofill.service";
|
||||
import { VaultPopupCopyButtonsService } from "../../services/vault-popup-copy-buttons.service";
|
||||
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
|
||||
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
|
||||
import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
|
||||
import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component";
|
||||
|
||||
import { AutofillVaultListItemsComponent } from "./autofill-vault-list-items/autofill-vault-list-items.component";
|
||||
import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component";
|
||||
import { NewItemDropdownV2Component } from "./new-item-dropdown/new-item-dropdown-v2.component";
|
||||
import { VaultHeaderV2Component } from "./vault-header/vault-header-v2.component";
|
||||
import { VaultListItemsContainerComponent } from "./vault-list-items-container/vault-list-items-container.component";
|
||||
import { VaultV2Component } from "./vault-v2.component";
|
||||
|
||||
@Component({
|
||||
selector: "popup-header",
|
||||
standalone: true,
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PopupHeaderStubComponent {
|
||||
readonly pageTitle = input("");
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-vault-header-v2",
|
||||
standalone: true,
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VaultHeaderV2StubComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "app-current-account",
|
||||
standalone: true,
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class CurrentAccountStubComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "app-new-item-dropdown",
|
||||
standalone: true,
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class NewItemDropdownStubComponent {
|
||||
readonly initialValues = input();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-pop-out",
|
||||
standalone: true,
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class PopOutStubComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "blocked-injection-banner",
|
||||
standalone: true,
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class BlockedInjectionBannerStubComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "vault-at-risk-password-callout",
|
||||
standalone: true,
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class VaultAtRiskCalloutStubComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "app-autofill-vault-list-items",
|
||||
standalone: true,
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class AutofillVaultListItemsStubComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "app-vault-list-items-container",
|
||||
standalone: true,
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class VaultListItemsContainerStubComponent {
|
||||
readonly title = input<string>();
|
||||
readonly ciphers = input<any[]>();
|
||||
readonly id = input<string>();
|
||||
readonly disableSectionMargin = input<boolean>();
|
||||
readonly collapsibleKey = input<string>();
|
||||
}
|
||||
|
||||
const mockDialogRef = {
|
||||
close: jest.fn(),
|
||||
afterClosed: jest.fn().mockReturnValue(of(undefined)),
|
||||
} as unknown as import("@bitwarden/components").DialogRef<any, any>;
|
||||
|
||||
jest
|
||||
.spyOn(PremiumUpgradeDialogComponent, "open")
|
||||
.mockImplementation((_: DialogService) => mockDialogRef as any);
|
||||
|
||||
jest
|
||||
.spyOn(DecryptionFailureDialogComponent, "open")
|
||||
.mockImplementation((_: DialogService, _params: any) => mockDialogRef as any);
|
||||
jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(false);
|
||||
jest.spyOn(BrowserPopupUtils, "openCurrentPagePopout").mockResolvedValue();
|
||||
|
||||
describe("VaultV2Component", () => {
|
||||
let component: VaultV2Component;
|
||||
|
||||
interface FakeAccount {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function queryAllSpotlights(fixture: any): HTMLElement[] {
|
||||
return Array.from(fixture.nativeElement.querySelectorAll("bit-spotlight")) as HTMLElement[];
|
||||
}
|
||||
|
||||
const itemsSvc: any = {
|
||||
emptyVault$: new BehaviorSubject<boolean>(false),
|
||||
noFilteredResults$: new BehaviorSubject<boolean>(false),
|
||||
showDeactivatedOrg$: new BehaviorSubject<boolean>(false),
|
||||
favoriteCiphers$: new BehaviorSubject<any[]>([]),
|
||||
remainingCiphers$: new BehaviorSubject<any[]>([]),
|
||||
cipherCount$: new BehaviorSubject<number>(0),
|
||||
loading$: new BehaviorSubject<boolean>(true),
|
||||
} as Partial<VaultPopupItemsService>;
|
||||
|
||||
const filtersSvc = {
|
||||
allFilters$: new Subject<any>(),
|
||||
filters$: new BehaviorSubject<any>({}),
|
||||
filterVisibilityState$: new BehaviorSubject<any>({}),
|
||||
} as Partial<VaultPopupListFiltersService>;
|
||||
|
||||
const accountActive$ = new BehaviorSubject<FakeAccount | null>({ id: "user-1" });
|
||||
|
||||
const cipherSvc = {
|
||||
failedToDecryptCiphers$: jest.fn().mockReturnValue(of([])),
|
||||
} as Partial<CipherService>;
|
||||
|
||||
const nudgesSvc = {
|
||||
showNudgeSpotlight$: jest.fn().mockImplementation((_type: NudgeType) => of(false)),
|
||||
dismissNudge: jest.fn().mockResolvedValue(undefined),
|
||||
} as Partial<NudgesService>;
|
||||
|
||||
const dialogSvc = {} as Partial<DialogService>;
|
||||
|
||||
const introSvc = {
|
||||
setIntroCarouselDismissed: jest.fn().mockResolvedValue(undefined),
|
||||
} as Partial<IntroCarouselService>;
|
||||
|
||||
const scrollSvc = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
} as Partial<VaultPopupScrollPositionService>;
|
||||
|
||||
function getObs<T = unknown>(cmp: any, key: string): Observable<T> {
|
||||
return cmp[key] as Observable<T>;
|
||||
}
|
||||
|
||||
const hasPremiumFromAnySource$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
const billingSvc = {
|
||||
hasPremiumFromAnySource$: (_: string) => hasPremiumFromAnySource$,
|
||||
};
|
||||
|
||||
const vaultProfileSvc = {
|
||||
getProfileCreationDate: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)), // 8 days ago
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultV2Component, RouterTestingModule],
|
||||
providers: [
|
||||
{ provide: VaultPopupItemsService, useValue: itemsSvc },
|
||||
{ provide: VaultPopupListFiltersService, useValue: filtersSvc },
|
||||
{ provide: VaultPopupScrollPositionService, useValue: scrollSvc },
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: { activeAccount$: accountActive$ },
|
||||
},
|
||||
{ provide: CipherService, useValue: cipherSvc },
|
||||
{ provide: DialogService, useValue: dialogSvc },
|
||||
{ provide: IntroCarouselService, useValue: introSvc },
|
||||
{ provide: NudgesService, useValue: nudgesSvc },
|
||||
{
|
||||
provide: VaultProfileService,
|
||||
useValue: vaultProfileSvc,
|
||||
},
|
||||
{
|
||||
provide: VaultPopupCopyButtonsService,
|
||||
useValue: { showQuickCopyActions$: new BehaviorSubject<boolean>(false) },
|
||||
},
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: billingSvc,
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: { translate: (key: string) => key, t: (key: string) => key },
|
||||
},
|
||||
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
|
||||
{ provide: RestrictedItemTypesService, useValue: { restricted$: new BehaviorSubject([]) } },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
{ provide: AvatarService, useValue: mock<AvatarService>() },
|
||||
{ provide: ActivatedRoute, useValue: mock<ActivatedRoute>() },
|
||||
{ provide: AuthService, useValue: mock<AuthService>() },
|
||||
{ provide: AutofillService, useValue: mock<AutofillService>() },
|
||||
{
|
||||
provide: VaultPopupAutofillService,
|
||||
useValue: mock<VaultPopupAutofillService>(),
|
||||
},
|
||||
{ provide: TaskService, useValue: mock<TaskService>() },
|
||||
{ provide: StateProvider, useValue: mock<StateProvider>() },
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
getFeatureFlag$: (_: string) => of(false),
|
||||
},
|
||||
},
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
TestBed.overrideComponent(VaultV2Component, {
|
||||
remove: {
|
||||
imports: [
|
||||
PopupHeaderComponent,
|
||||
VaultHeaderV2Component,
|
||||
CurrentAccountComponent,
|
||||
NewItemDropdownV2Component,
|
||||
PopOutComponent,
|
||||
BlockedInjectionBanner,
|
||||
AtRiskPasswordCalloutComponent,
|
||||
AutofillVaultListItemsComponent,
|
||||
VaultListItemsContainerComponent,
|
||||
],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
PopupHeaderStubComponent,
|
||||
VaultHeaderV2StubComponent,
|
||||
CurrentAccountStubComponent,
|
||||
NewItemDropdownStubComponent,
|
||||
PopOutStubComponent,
|
||||
BlockedInjectionBannerStubComponent,
|
||||
VaultAtRiskCalloutStubComponent,
|
||||
AutofillVaultListItemsStubComponent,
|
||||
VaultListItemsContainerStubComponent,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(VaultV2Component);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe("vaultState", () => {
|
||||
type ExpectedKey = "Empty" | "DeactivatedOrg" | "NoResults" | null;
|
||||
|
||||
const cases: [string, boolean, boolean, boolean, ExpectedKey][] = [
|
||||
["null when none true", false, false, false, null],
|
||||
["Empty when empty true only", true, false, false, "Empty"],
|
||||
["DeactivatedOrg when only deactivated true", false, false, true, "DeactivatedOrg"],
|
||||
["NoResults when only noResults true", false, true, false, "NoResults"],
|
||||
];
|
||||
|
||||
it.each(cases)(
|
||||
"%s",
|
||||
fakeAsync(
|
||||
(
|
||||
_label: string,
|
||||
empty: boolean,
|
||||
noResults: boolean,
|
||||
deactivated: boolean,
|
||||
expectedKey: ExpectedKey,
|
||||
) => {
|
||||
const empty$ = itemsSvc.emptyVault$ as BehaviorSubject<boolean>;
|
||||
const noResults$ = itemsSvc.noFilteredResults$ as BehaviorSubject<boolean>;
|
||||
const deactivated$ = itemsSvc.showDeactivatedOrg$ as BehaviorSubject<boolean>;
|
||||
|
||||
empty$.next(empty);
|
||||
noResults$.next(noResults);
|
||||
deactivated$.next(deactivated);
|
||||
tick();
|
||||
|
||||
const expectedValue =
|
||||
expectedKey === null ? null : (component as any).VaultStateEnum[expectedKey];
|
||||
|
||||
expect((component as any).vaultState).toBe(expectedValue);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("loading$ is true when items loading or filters missing; false when both ready", () => {
|
||||
const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject<boolean>;
|
||||
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
|
||||
|
||||
const values: boolean[] = [];
|
||||
getObs<boolean>(component, "loading$").subscribe((v) => values.push(!!v));
|
||||
|
||||
itemsLoading$.next(true);
|
||||
|
||||
allFilters$.next({});
|
||||
|
||||
itemsLoading$.next(false);
|
||||
|
||||
expect(values[values.length - 1]).toBe(false);
|
||||
});
|
||||
|
||||
it("ngAfterViewInit waits for allFilters$ then starts scroll position service", fakeAsync(() => {
|
||||
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
|
||||
|
||||
(component as any).virtualScrollElement = {} as CdkVirtualScrollableElement;
|
||||
|
||||
component.ngAfterViewInit();
|
||||
expect(scrollSvc.start).not.toHaveBeenCalled();
|
||||
|
||||
allFilters$.next({ any: true });
|
||||
tick();
|
||||
|
||||
expect(scrollSvc.start).toHaveBeenCalledTimes(1);
|
||||
expect(scrollSvc.start).toHaveBeenCalledWith((component as any).virtualScrollElement);
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it("showPremiumDialog opens PremiumUpgradeDialogComponent", () => {
|
||||
component["showPremiumDialog"]();
|
||||
expect(PremiumUpgradeDialogComponent.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("navigateToImport navigates and opens popout if popup is open", fakeAsync(async () => {
|
||||
(BrowserApi.isPopupOpen as jest.Mock).mockResolvedValueOnce(true);
|
||||
|
||||
const ngRouter = TestBed.inject(Router);
|
||||
jest.spyOn(ngRouter, "navigate").mockResolvedValue(true as any);
|
||||
|
||||
await component["navigateToImport"]();
|
||||
|
||||
expect(ngRouter.navigate).toHaveBeenCalledWith(["/import"]);
|
||||
|
||||
expect(BrowserPopupUtils.openCurrentPagePopout).toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("navigateToImport does not popout when popup is not open", fakeAsync(async () => {
|
||||
(BrowserApi.isPopupOpen as jest.Mock).mockResolvedValueOnce(false);
|
||||
|
||||
const ngRouter = TestBed.inject(Router);
|
||||
jest.spyOn(ngRouter, "navigate").mockResolvedValue(true as any);
|
||||
|
||||
await component["navigateToImport"]();
|
||||
|
||||
expect(ngRouter.navigate).toHaveBeenCalledWith(["/import"]);
|
||||
expect(BrowserPopupUtils.openCurrentPagePopout).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("ngOnInit dismisses intro carousel and opens decryption dialog for non-deleted failures", fakeAsync(() => {
|
||||
(cipherSvc.failedToDecryptCiphers$ as any).mockReturnValue(
|
||||
of([
|
||||
{ id: "a", isDeleted: false },
|
||||
{ id: "b", isDeleted: true },
|
||||
{ id: "c", isDeleted: false },
|
||||
]),
|
||||
);
|
||||
|
||||
void component.ngOnInit();
|
||||
tick();
|
||||
|
||||
expect(introSvc.setIntroCarouselDismissed).toHaveBeenCalled();
|
||||
|
||||
expect(DecryptionFailureDialogComponent.open).toHaveBeenCalledWith(expect.any(Object), {
|
||||
cipherIds: ["a", "c"],
|
||||
});
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it("dismissVaultNudgeSpotlight forwards to NudgesService with active user id", fakeAsync(() => {
|
||||
const spy = jest.spyOn(nudgesSvc, "dismissNudge").mockResolvedValue(undefined);
|
||||
|
||||
accountActive$.next({ id: "user-xyz" });
|
||||
|
||||
void component.ngOnInit();
|
||||
tick();
|
||||
|
||||
void component["dismissVaultNudgeSpotlight"](NudgeType.HasVaultItems);
|
||||
tick();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(NudgeType.HasVaultItems, "user-xyz");
|
||||
}));
|
||||
|
||||
it("accountAgeInDays$ computes integer days since creation", (done) => {
|
||||
getObs<number | null>(component, "accountAgeInDays$").subscribe((days) => {
|
||||
if (days !== null) {
|
||||
expect(days).toBeGreaterThanOrEqual(7);
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
void component.ngOnInit();
|
||||
});
|
||||
|
||||
it("renders Premium spotlight when eligible and opens dialog on click", fakeAsync(() => {
|
||||
itemsSvc.cipherCount$.next(10);
|
||||
|
||||
hasPremiumFromAnySource$.next(false);
|
||||
|
||||
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) =>
|
||||
of(type === NudgeType.PremiumUpgrade),
|
||||
);
|
||||
|
||||
const fixture = TestBed.createComponent(VaultV2Component);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
void component.ngOnInit();
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const spotlights = Array.from(
|
||||
fixture.nativeElement.querySelectorAll("bit-spotlight"),
|
||||
) as HTMLElement[];
|
||||
expect(spotlights.length).toBe(1);
|
||||
|
||||
const spotDe = fixture.debugElement.query(By.css("bit-spotlight"));
|
||||
expect(spotDe).toBeTruthy();
|
||||
|
||||
spotDe.triggerEventHandler("onButtonClick", undefined);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(PremiumUpgradeDialogComponent.open).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
it("renders Empty-Vault spotlight when vaultState is Empty and nudge is on", fakeAsync(() => {
|
||||
itemsSvc.emptyVault$.next(true);
|
||||
|
||||
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => {
|
||||
return of(type === NudgeType.EmptyVaultNudge);
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(VaultV2Component);
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const spotlights = queryAllSpotlights(fixture);
|
||||
expect(spotlights.length).toBe(1);
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain("emptyVaultNudgeTitle");
|
||||
}));
|
||||
|
||||
it("renders Has-Items spotlight when vault has items and nudge is on", fakeAsync(() => {
|
||||
itemsSvc.emptyVault$.next(false);
|
||||
|
||||
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => {
|
||||
return of(type === NudgeType.HasVaultItems);
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(VaultV2Component);
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const spotlights = queryAllSpotlights(fixture);
|
||||
expect(spotlights.length).toBe(1);
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain("hasItemsVaultNudgeTitle");
|
||||
}));
|
||||
|
||||
it("does not render Premium spotlight when account is less than a week old", fakeAsync(() => {
|
||||
itemsSvc.cipherCount$.next(10);
|
||||
hasPremiumFromAnySource$.next(false);
|
||||
|
||||
vaultProfileSvc.getProfileCreationDate = jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)); // 3 days ago
|
||||
|
||||
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => {
|
||||
return of(type === NudgeType.PremiumUpgrade);
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(VaultV2Component);
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const spotlights = queryAllSpotlights(fixture);
|
||||
expect(spotlights.length).toBe(0);
|
||||
}));
|
||||
|
||||
it("does not render Premium spotlight when vault has less than 5 items", fakeAsync(() => {
|
||||
itemsSvc.cipherCount$.next(3);
|
||||
hasPremiumFromAnySource$.next(false);
|
||||
|
||||
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => {
|
||||
return of(type === NudgeType.PremiumUpgrade);
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(VaultV2Component);
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const spotlights = queryAllSpotlights(fixture);
|
||||
expect(spotlights.length).toBe(0);
|
||||
}));
|
||||
|
||||
it("does not render Premium spotlight when user already has premium", fakeAsync(() => {
|
||||
itemsSvc.cipherCount$.next(10);
|
||||
hasPremiumFromAnySource$.next(true);
|
||||
|
||||
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => {
|
||||
return of(type === NudgeType.PremiumUpgrade);
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(VaultV2Component);
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const spotlights = queryAllSpotlights(fixture);
|
||||
expect(spotlights.length).toBe(0);
|
||||
}));
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
shareReplay,
|
||||
@@ -17,12 +18,15 @@ import {
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
|
||||
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
|
||||
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
|
||||
import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -124,13 +128,55 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
void this.liveAnnouncer.announce(this.i18nService.translate(key), "polite");
|
||||
}),
|
||||
);
|
||||
|
||||
private skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.VaultLoadingSkeletons,
|
||||
);
|
||||
|
||||
private showPremiumNudgeSpotlight$ = this.activeUserId$.pipe(
|
||||
switchMap((userId) => this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId)),
|
||||
);
|
||||
|
||||
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
|
||||
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
|
||||
protected allFilters$ = this.vaultPopupListFiltersService.allFilters$;
|
||||
protected cipherCount$ = this.vaultPopupItemsService.cipherCount$;
|
||||
protected hasPremium$ = this.activeUserId$.pipe(
|
||||
switchMap((userId) => this.billingAccountService.hasPremiumFromAnySource$(userId)),
|
||||
);
|
||||
protected accountAgeInDays$ = this.activeUserId$.pipe(
|
||||
switchMap((userId) => {
|
||||
const creationDate$ = from(this.vaultProfileService.getProfileCreationDate(userId));
|
||||
return creationDate$.pipe(
|
||||
map((creationDate) => {
|
||||
if (!creationDate) {
|
||||
return 0;
|
||||
}
|
||||
const ageInMilliseconds = Date.now() - creationDate.getTime();
|
||||
return Math.floor(ageInMilliseconds / (1000 * 60 * 60 * 24));
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
protected showPremiumSpotlight$ = combineLatest([
|
||||
this.showPremiumNudgeSpotlight$,
|
||||
this.showEmptyVaultSpotlight$,
|
||||
this.showHasItemsVaultSpotlight$,
|
||||
this.hasPremium$,
|
||||
this.cipherCount$,
|
||||
this.accountAgeInDays$,
|
||||
]).pipe(
|
||||
map(
|
||||
([showNudge, emptyVault, hasItems, hasPremium, count, age]) =>
|
||||
showNudge && !emptyVault && !hasItems && !hasPremium && count >= 5 && age >= 7,
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
showPremiumDialog() {
|
||||
PremiumUpgradeDialogComponent.open(this.dialogService);
|
||||
}
|
||||
|
||||
/** When true, show spinner loading state */
|
||||
protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
|
||||
@@ -177,6 +223,8 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
private introCarouselService: IntroCarouselService,
|
||||
private nudgesService: NudgesService,
|
||||
private router: Router,
|
||||
private vaultProfileService: VaultProfileService,
|
||||
private billingAccountService: BillingAccountProfileStateService,
|
||||
private liveAnnouncer: LiveAnnouncer,
|
||||
private i18nService: I18nService,
|
||||
private configService: ConfigService,
|
||||
|
||||
@@ -288,6 +288,11 @@ export class VaultPopupItemsService {
|
||||
map((ciphers) => !ciphers.length),
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable that contains the count of ciphers in the active filtered list.
|
||||
*/
|
||||
cipherCount$: Observable<number> = this._activeCipherList$.pipe(map((ciphers) => ciphers.length));
|
||||
|
||||
/**
|
||||
* Observable that indicates whether there are no ciphers to show with the current filter.
|
||||
*/
|
||||
|
||||
@@ -78,12 +78,6 @@ switch (platform) {
|
||||
throw new Error(`Unsupported architecture on macOS: ${arch}`);
|
||||
}
|
||||
break;
|
||||
case "freebsd":
|
||||
nativeBinding = loadFirstAvailable(
|
||||
["desktop_napi.freebsd-x64.node"],
|
||||
"@bitwarden/desktop-napi-freebsd-x64",
|
||||
);
|
||||
break;
|
||||
case "linux":
|
||||
switch (arch) {
|
||||
case "x64":
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"to": "libprocess_isolation.so"
|
||||
}
|
||||
],
|
||||
"target": ["deb", "freebsd", "rpm", "AppImage", "snap"],
|
||||
"target": ["deb", "rpm", "AppImage", "snap"],
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"Name": "Bitwarden",
|
||||
@@ -252,9 +252,6 @@
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"fpm": ["--rpm-rpmbuild-define", "_build_id_links none"]
|
||||
},
|
||||
"freebsd": {
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}"
|
||||
},
|
||||
"snap": {
|
||||
"summary": "Bitwarden is a secure and free password manager for all of your devices.",
|
||||
"description": "Password Manager\n**Installation**\nBitwarden requires access to the `password-manager-service`. Please enable it through permissions or by running `sudo snap connect bitwarden:password-manager-service` after installation. See https://btwrdn.com/install-snap for details.",
|
||||
|
||||
@@ -37,6 +37,7 @@ export const NudgeType = {
|
||||
NewNoteItemStatus: "new-note-item-status",
|
||||
NewSshItemStatus: "new-ssh-item-status",
|
||||
GeneratorNudgeStatus: "generator-nudge-status",
|
||||
PremiumUpgrade: "premium-upgrade",
|
||||
} as const;
|
||||
|
||||
export type NudgeType = UnionOfValues<typeof NudgeType>;
|
||||
|
||||
@@ -94,16 +94,16 @@ export class IdentityView extends ItemView implements SdkIdentityView {
|
||||
this.lastName != null
|
||||
) {
|
||||
let name = "";
|
||||
if (this.title != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.title)) {
|
||||
name += this.title + " ";
|
||||
}
|
||||
if (this.firstName != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.firstName)) {
|
||||
name += this.firstName + " ";
|
||||
}
|
||||
if (this.middleName != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.middleName)) {
|
||||
name += this.middleName + " ";
|
||||
}
|
||||
if (this.lastName != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.lastName)) {
|
||||
name += this.lastName;
|
||||
}
|
||||
return name.trim();
|
||||
@@ -130,14 +130,20 @@ export class IdentityView extends ItemView implements SdkIdentityView {
|
||||
}
|
||||
|
||||
get fullAddressPart2(): string | undefined {
|
||||
if (this.city == null && this.state == null && this.postalCode == null) {
|
||||
const hasCity = !Utils.isNullOrWhitespace(this.city);
|
||||
const hasState = !Utils.isNullOrWhitespace(this.state);
|
||||
const hasPostalCode = !Utils.isNullOrWhitespace(this.postalCode);
|
||||
|
||||
if (!hasCity && !hasState && !hasPostalCode) {
|
||||
return undefined;
|
||||
}
|
||||
const city = this.city || "-";
|
||||
|
||||
const city = hasCity ? this.city : "-";
|
||||
const state = this.state;
|
||||
const postalCode = this.postalCode || "-";
|
||||
const postalCode = hasPostalCode ? this.postalCode : "-";
|
||||
|
||||
let addressPart2 = city;
|
||||
if (!Utils.isNullOrWhitespace(state)) {
|
||||
if (hasState) {
|
||||
addressPart2 += ", " + state;
|
||||
}
|
||||
addressPart2 += ", " + postalCode;
|
||||
|
||||
@@ -154,13 +154,13 @@ describe("CipherFormComponent", () => {
|
||||
expect(component["updatedCipherView"]?.login.fido2Credentials).toBeNull();
|
||||
});
|
||||
|
||||
it("clears archiveDate on updatedCipherView", async () => {
|
||||
it("does not clear archiveDate on updatedCipherView", async () => {
|
||||
cipherView.archivedDate = new Date();
|
||||
decryptCipher.mockResolvedValue(cipherView);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component["updatedCipherView"]?.archivedDate).toBeNull();
|
||||
expect(component["updatedCipherView"]?.archivedDate).toBe(cipherView.archivedDate);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -281,7 +281,6 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
|
||||
if (this.config.mode === "clone") {
|
||||
this.updatedCipherView.id = null;
|
||||
this.updatedCipherView.archivedDate = null;
|
||||
|
||||
if (this.updatedCipherView.login) {
|
||||
this.updatedCipherView.login.fido2Credentials = null;
|
||||
|
||||
Reference in New Issue
Block a user