1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 05:00:10 +00:00

Merge branch 'main' into ac/pm-19814/improved-warning-popup-ui

This commit is contained in:
Cy Okeke
2025-05-01 07:36:07 +01:00
39 changed files with 416 additions and 148 deletions

View File

@@ -5201,6 +5201,12 @@
"changeAtRiskPassword": {
"message": "Change at-risk password"
},
"settingsVaultOptions": {
"message": "Vault options"
},
"emptyVaultDescription": {
"message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here."
},
"introCarouselLabel": {
"message": "Welcome to Bitwarden"
},
@@ -5228,6 +5234,21 @@
"secureDevicesBody": {
"message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
},
"emptyVaultNudgeTitle": {
"message": "Import existing passwords"
},
"emptyVaultNudgeBody": {
"message": "Use the importer to quickly transfer logins to Bitwarden without manually adding them."
},
"emptyVaultNudgeButton": {
"message": "Import now"
},
"hasItemsVaultNudgeTitle": {
"message": "Welcome to your vault!"
},
"hasItemsVaultNudgeBody": {
"message": "Autofill items for the current page\nFavorite items for easy access\nSearch your vault for something else"
},
"phishingPageTitle":{
"message": "Phishing website"
},

View File

@@ -14,7 +14,7 @@ export function CipherInfo({ cipher, theme }: { cipher: NotificationCipherData;
return html`
<div>
<span class=${cipherInfoPrimaryTextStyles(theme)}>
<span title=${name} class=${cipherInfoPrimaryTextStyles(theme)}>
${[
name,
hasIndicatorIcons

View File

@@ -18,9 +18,9 @@ export class TabsV2Component {
protected navButtons$ = combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge),
this.hasNudgeService.shouldShowNudge$(),
this.hasNudgeService.nudgeStatus$(),
]).pipe(
map(([onboardingFeatureEnabled, showNudge]) => {
map(([onboardingFeatureEnabled, nudgeStatus]) => {
return [
{
label: "vault",
@@ -45,7 +45,7 @@ export class TabsV2Component {
page: "/tabs/settings",
iconKey: "cog",
iconKeyActive: "cog-f",
showBerry: onboardingFeatureEnabled && showNudge,
showBerry: onboardingFeatureEnabled && !nudgeStatus.hasSpotlightDismissed,
},
];
}),

View File

@@ -29,9 +29,26 @@
</a>
</bit-item>
<bit-item>
<a bit-item-content routerLink="/vault-settings">
<a
bit-item-content
routerLink="/vault-settings"
(click)="dismissBadge(VaultNudgeType.EmptyVaultNudge)"
>
<i slot="start" class="bwi bwi-vault" aria-hidden="true"></i>
{{ "vault" | i18n }}
<div class="tw-flex tw-items-center tw-justify-center">
<p class="tw-pr-2">{{ "settingsVaultOptions" | i18n }}</p>
<!--
Currently can be only 1 item for notification.
Will make this dynamic when more nudges are added
-->
<span
*ngIf="!(showVaultBadge$ | async)?.hasBadgeDismissed"
bitBadge
variant="notification"
>1</span
>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>

View File

@@ -1,9 +1,14 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { Component, OnInit } from "@angular/core";
import { RouterModule } from "@angular/router";
import { firstValueFrom, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ItemModule } from "@bitwarden/components";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { BadgeComponent, ItemModule } from "@bitwarden/components";
import { NudgeStatus, VaultNudgesService, VaultNudgeType } from "@bitwarden/vault";
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
@@ -22,6 +27,29 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
PopOutComponent,
ItemModule,
CurrentAccountComponent,
BadgeComponent,
],
})
export class SettingsV2Component {}
export class SettingsV2Component implements OnInit {
VaultNudgeType = VaultNudgeType;
showVaultBadge$: Observable<NudgeStatus> = new Observable();
activeUserId: UserId | null = null;
constructor(
private readonly vaultNudgesService: VaultNudgesService,
private readonly accountService: AccountService,
) {}
async ngOnInit() {
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.showVaultBadge$ = this.vaultNudgesService.showNudge$(
VaultNudgeType.EmptyVaultNudge,
this.activeUserId,
);
}
async dismissBadge(type: VaultNudgeType) {
if (!(await firstValueFrom(this.showVaultBadge$)).hasBadgeDismissed) {
await this.vaultNudgesService.dismissNudge(type, this.activeUserId as UserId, true);
}
}
}

View File

@@ -1,4 +1,4 @@
<button bitButton [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<button bitButton size="small" [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<i class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>

View File

@@ -14,7 +14,9 @@
>
<bit-no-items [icon]="vaultIcon">
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
<ng-container slot="description">{{ "autofillSuggestionsTip" | i18n }}</ng-container>
<ng-container slot="description">
<p bitTypography="body2" class="tw-mx-6">{{ "emptyVaultDescription" | i18n }}</p>
</ng-container>
<app-new-item-dropdown
slot="button"
[initialValues]="newItemItemValues$ | async"
@@ -28,11 +30,31 @@
></blocked-injection-banner>
<!-- Show search & filters outside of the scroll area of the page -->
<ng-container slot="above-scroll-area" *ngIf="vaultState !== VaultStateEnum.Empty">
<vault-at-risk-password-callout
*appIfFeature="FeatureFlag.SecurityTasks"
></vault-at-risk-password-callout>
<app-vault-header-v2></app-vault-header-v2>
<ng-container slot="above-scroll-area">
<ng-container *ngIf="vaultState === VaultStateEnum.Empty && showEmptyVaultSpotlight$ | async">
<bit-spotlight
[title]="'emptyVaultNudgeTitle' | i18n"
[subtitle]="'emptyVaultNudgeBody' | i18n"
[buttonText]="'emptyVaultNudgeButton' | i18n"
(onButtonClick)="navigateToImport()"
(onDismiss)="dismissVaultNudgeSpotlight(VaultNudgeType.EmptyVaultNudge)"
>
</bit-spotlight>
</ng-container>
<ng-container *ngIf="vaultState !== VaultStateEnum.Empty">
<div class="tw-mb-4" *ngIf="showHasItemsVaultSpotlight$ | async">
<bit-spotlight
[title]="'hasItemsVaultNudgeTitle' | i18n"
[subtitle]="'hasItemsVaultNudgeBody' | i18n"
(onDismiss)="dismissVaultNudgeSpotlight(VaultNudgeType.HasVaultItems)"
>
</bit-spotlight>
</div>
<vault-at-risk-password-callout
*appIfFeature="FeatureFlag.SecurityTasks"
></vault-at-risk-password-callout>
<app-vault-header-v2></app-vault-header-v2>
</ng-container>
</ng-container>
<ng-container *ngIf="vaultState !== VaultStateEnum.Empty">

View File

@@ -2,30 +2,38 @@ import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrol
import { CommonModule } from "@angular/common";
import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router, RouterModule } from "@angular/router";
import {
combineLatest,
filter,
map,
firstValueFrom,
map,
Observable,
shareReplay,
startWith,
switchMap,
take,
startWith,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, DialogService, Icons, NoItemsModule } from "@bitwarden/components";
import { DecryptionFailureDialogComponent, VaultIcons } from "@bitwarden/vault";
import {
DecryptionFailureDialogComponent,
SpotlightComponent,
VaultIcons,
VaultNudgesService,
VaultNudgeType,
} from "@bitwarden/vault";
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
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";
@@ -74,14 +82,29 @@ enum VaultState {
VaultHeaderV2Component,
AtRiskPasswordCalloutComponent,
NewSettingsCalloutComponent,
SpotlightComponent,
RouterModule,
],
providers: [VaultPageService],
})
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
@ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement;
VaultNudgeType = VaultNudgeType;
cipherType = CipherType;
private activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
showEmptyVaultSpotlight$: Observable<boolean> = this.activeUserId$.pipe(
switchMap((userId) =>
this.vaultNudgesService.showNudge$(VaultNudgeType.EmptyVaultNudge, userId),
),
map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed),
);
showHasItemsVaultSpotlight$: Observable<boolean> = this.activeUserId$.pipe(
switchMap((userId) => this.vaultNudgesService.showNudge$(VaultNudgeType.HasVaultItems, userId)),
map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed),
);
activeUserId: UserId | null = null;
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
protected allFilters$ = this.vaultPopupListFiltersService.allFilters$;
@@ -131,7 +154,8 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
private dialogService: DialogService,
private vaultCopyButtonsService: VaultPopupCopyButtonsService,
private introCarouselService: IntroCarouselService,
private configService: ConfigService,
private vaultNudgesService: VaultNudgesService,
private router: Router,
) {
combineLatest([
this.vaultPopupItemsService.emptyVault$,
@@ -169,16 +193,12 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
}
async ngOnInit() {
const hasVaultNudgeFlag = await this.configService.getFeatureFlag(
FeatureFlag.PM8851_BrowserOnboardingNudge,
);
if (hasVaultNudgeFlag) {
await this.introCarouselService.setIntroCarouselDismissed();
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.introCarouselService.setIntroCarouselDismissed();
this.cipherService
.failedToDecryptCiphers$(activeUserId)
.failedToDecryptCiphers$(this.activeUserId)
.pipe(
map((ciphers) => (ciphers ? ciphers.filter((c) => !c.isDeleted) : [])),
filter((ciphers) => ciphers.length > 0),
@@ -196,5 +216,16 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
this.vaultScrollPositionService.stop();
}
async navigateToImport() {
await this.router.navigate(["/import"]);
if (await BrowserApi.isPopupOpen()) {
await BrowserPopupUtils.openCurrentPagePopout(window);
}
}
async dismissVaultNudgeSpotlight(type: VaultNudgeType) {
await this.vaultNudgesService.dismissNudge(type, this.activeUserId as UserId);
}
protected readonly FeatureFlag = FeatureFlag;
}

View File

@@ -1,6 +1,8 @@
import { Injectable } from "@angular/core";
import { map, Observable } from "rxjs";
import { firstValueFrom, map, Observable } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
GlobalState,
KeyDefinition,
@@ -26,9 +28,17 @@ export class IntroCarouselService {
map((x) => x ?? false),
);
constructor(private stateProvider: StateProvider) {}
constructor(
private stateProvider: StateProvider,
private configService: ConfigService,
) {}
async setIntroCarouselDismissed(): Promise<void> {
await this.introCarouselState.update(() => true);
const hasVaultNudgeFlag = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge),
);
if (hasVaultNudgeFlag) {
await this.introCarouselState.update(() => true);
}
}
}

View File

@@ -1,7 +1,13 @@
<popup-page>
<popup-header slot="header" [pageTitle]="'folders' | i18n" showBackButton>
<ng-container slot="end">
<button bitButton buttonType="primary" type="button" (click)="openAddEditFolderDialog()">
<button
bitButton
size="small"
buttonType="primary"
type="button"
(click)="openAddEditFolderDialog()"
>
<i class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>

View File

@@ -59,7 +59,7 @@ describe("CriticalAppsService", () => {
{ id: "id2", organizationId: "org1", uri: "https://example.org" },
] as PasswordHealthReportApplicationsResponse[];
encryptService.encrypt.mockResolvedValue(new EncString("encryptedUrlName"));
encryptService.encryptString.mockResolvedValue(new EncString("encryptedUrlName"));
criticalAppsApiService.saveCriticalApps.mockReturnValue(of(response));
// act
@@ -67,7 +67,7 @@ describe("CriticalAppsService", () => {
// expectations
expect(keyService.getOrgKey).toHaveBeenCalledWith("org1");
expect(encryptService.encrypt).toHaveBeenCalledTimes(2);
expect(encryptService.encryptString).toHaveBeenCalledTimes(2);
expect(criticalAppsApiService.saveCriticalApps).toHaveBeenCalledWith(request);
});
@@ -95,7 +95,7 @@ describe("CriticalAppsService", () => {
{ id: "id1", organizationId: "org1", uri: "test" },
] as PasswordHealthReportApplicationsResponse[];
encryptService.encrypt.mockResolvedValue(new EncString("encryptedUrlName"));
encryptService.encryptString.mockResolvedValue(new EncString("encryptedUrlName"));
criticalAppsApiService.saveCriticalApps.mockReturnValue(of(response));
// act
@@ -103,7 +103,7 @@ describe("CriticalAppsService", () => {
// expectations
expect(keyService.getOrgKey).toHaveBeenCalledWith("org1");
expect(encryptService.encrypt).toHaveBeenCalledTimes(1);
expect(encryptService.encryptString).toHaveBeenCalledTimes(1);
expect(criticalAppsApiService.saveCriticalApps).toHaveBeenCalledWith(request);
});
@@ -114,7 +114,7 @@ describe("CriticalAppsService", () => {
{ id: "id2", organizationId: "org1", uri: "https://example.org" },
] as PasswordHealthReportApplicationsResponse[];
encryptService.decryptToUtf8.mockResolvedValue("https://example.com");
encryptService.decryptString.mockResolvedValue("https://example.com");
criticalAppsApiService.getCriticalApps.mockReturnValue(of(response));
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
@@ -125,7 +125,7 @@ describe("CriticalAppsService", () => {
flush();
expect(keyService.getOrgKey).toHaveBeenCalledWith(orgId.toString());
expect(encryptService.decryptToUtf8).toHaveBeenCalledTimes(2);
expect(encryptService.decryptString).toHaveBeenCalledTimes(2);
expect(criticalAppsApiService.getCriticalApps).toHaveBeenCalledWith(orgId);
}));

View File

@@ -81,7 +81,7 @@ export class CriticalAppsService {
// add the new entries to the criticalAppsList
const updatedList = [...this.criticalAppsList.value];
for (const responseItem of dbResponse) {
const decryptedUrl = await this.encryptService.decryptToUtf8(
const decryptedUrl = await this.encryptService.decryptString(
new EncString(responseItem.uri),
key,
);
@@ -138,7 +138,7 @@ export class CriticalAppsService {
const results = response.map(async (r: PasswordHealthReportApplicationsResponse) => {
const encrypted = new EncString(r.uri);
const uri = await this.encryptService.decryptToUtf8(encrypted, key);
const uri = await this.encryptService.decryptString(encrypted, key);
return { id: r.id, organizationId: r.organizationId, uri: uri };
});
return forkJoin(results);
@@ -164,7 +164,7 @@ export class CriticalAppsService {
newEntries: string[],
): Promise<PasswordHealthReportApplicationsRequest[]> {
const criticalAppsPromises = newEntries.map(async (url) => {
const encryptedUrlName = await this.encryptService.encrypt(url, key);
const encryptedUrlName = await this.encryptService.encryptString(url, key);
return {
organizationId: orgId,
url: encryptedUrlName?.encryptedString?.toString() ?? "",

View File

@@ -22,8 +22,10 @@ describe("OrgKeyEncryptor", () => {
// on this property--that the facade treats its data like a opaque objects--to trace
// the data through several function calls. Should the encryptor interact with the
// objects themselves, these mocks will break.
encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString));
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c as unknown as string));
encryptService.encryptString.mockImplementation((p) =>
Promise.resolve(p as unknown as EncString),
);
encryptService.decryptString.mockImplementation((c) => Promise.resolve(c as unknown as string));
dataPacker.pack.mockImplementation((v) => v as string);
dataPacker.unpack.mockImplementation(<T>(v: string) => v as T);
});
@@ -95,7 +97,7 @@ describe("OrgKeyEncryptor", () => {
// these are data flow expectations; the operations all all pass-through mocks
expect(dataPacker.pack).toHaveBeenCalledWith(value);
expect(encryptService.encrypt).toHaveBeenCalledWith(value, orgKey);
expect(encryptService.encryptString).toHaveBeenCalledWith(value, orgKey);
expect(result).toBe(value);
});
});
@@ -117,7 +119,7 @@ describe("OrgKeyEncryptor", () => {
const result = await encryptor.decrypt(secret);
// these are data flow expectations; the operations all all pass-through mocks
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, orgKey);
expect(encryptService.decryptString).toHaveBeenCalledWith(secret, orgKey);
expect(dataPacker.unpack).toHaveBeenCalledWith(secret);
expect(result).toBe(secret);
});

View File

@@ -37,7 +37,7 @@ export class OrganizationKeyEncryptor extends OrganizationEncryptor {
this.assertHasValue("secret", secret);
let packed = this.dataPacker.pack(secret);
const encrypted = await this.encryptService.encrypt(packed, this.key);
const encrypted = await this.encryptService.encryptString(packed, this.key);
packed = null;
return encrypted;
@@ -46,7 +46,7 @@ export class OrganizationKeyEncryptor extends OrganizationEncryptor {
async decrypt<Secret>(secret: EncString): Promise<Jsonify<Secret>> {
this.assertHasValue("secret", secret);
let decrypted = await this.encryptService.decryptToUtf8(secret, this.key);
let decrypted = await this.encryptService.decryptString(secret, this.key);
const unpacked = this.dataPacker.unpack<Secret>(decrypted);
decrypted = null;

View File

@@ -22,8 +22,10 @@ describe("UserKeyEncryptor", () => {
// on this property--that the facade treats its data like a opaque objects--to trace
// the data through several function calls. Should the encryptor interact with the
// objects themselves, these mocks will break.
encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString));
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c as unknown as string));
encryptService.encryptString.mockImplementation((p) =>
Promise.resolve(p as unknown as EncString),
);
encryptService.decryptString.mockImplementation((c) => Promise.resolve(c as unknown as string));
dataPacker.pack.mockImplementation((v) => v as string);
dataPacker.unpack.mockImplementation(<T>(v: string) => v as T);
});
@@ -95,7 +97,7 @@ describe("UserKeyEncryptor", () => {
// these are data flow expectations; the operations all all pass-through mocks
expect(dataPacker.pack).toHaveBeenCalledWith(value);
expect(encryptService.encrypt).toHaveBeenCalledWith(value, userKey);
expect(encryptService.encryptString).toHaveBeenCalledWith(value, userKey);
expect(result).toBe(value);
});
});
@@ -117,7 +119,7 @@ describe("UserKeyEncryptor", () => {
const result = await encryptor.decrypt(secret);
// these are data flow expectations; the operations all all pass-through mocks
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey);
expect(encryptService.decryptString).toHaveBeenCalledWith(secret, userKey);
expect(dataPacker.unpack).toHaveBeenCalledWith(secret);
expect(result).toBe(secret);
});

View File

@@ -37,7 +37,7 @@ export class UserKeyEncryptor extends UserEncryptor {
this.assertHasValue("secret", secret);
let packed = this.dataPacker.pack(secret);
const encrypted = await this.encryptService.encrypt(packed, this.key);
const encrypted = await this.encryptService.encryptString(packed, this.key);
packed = null;
return encrypted;
@@ -46,7 +46,7 @@ export class UserKeyEncryptor extends UserEncryptor {
async decrypt<Secret>(secret: EncString): Promise<Jsonify<Secret>> {
this.assertHasValue("secret", secret);
let decrypted = await this.encryptService.decryptToUtf8(secret, this.key);
let decrypted = await this.encryptService.decryptString(secret, this.key);
const unpacked = this.dataPacker.unpack<Secret>(decrypted);
decrypted = null;

View File

@@ -112,7 +112,7 @@ describe("Send", () => {
const encryptService = mock<EncryptService>();
const keyService = mock<KeyService>();
encryptService.decryptToBytes
encryptService.decryptBytes
.calledWith(send.key, userKey)
.mockResolvedValue(makeStaticByteArray(32));
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);

View File

@@ -79,7 +79,8 @@ export class Send extends Domain {
try {
const sendKeyEncryptionKey = await keyService.getUserKey();
model.key = await encryptService.decryptToBytes(this.key, sendKeyEncryptionKey);
// model.key is a seed used to derive a key, not a SymmetricCryptoKey
model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey);
model.cryptoKey = await keyService.makeSendKey(model.key);
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@@ -477,7 +477,9 @@ describe("SendService", () => {
let encryptedKey: EncString;
beforeEach(() => {
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(new Uint8Array(32)),
);
encryptedKey = new EncString("Re-encrypted Send Key");
encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey);
});

View File

@@ -86,12 +86,12 @@ export class SendService implements InternalSendServiceAbstraction {
userKey = await this.keyService.getUserKey();
}
// Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey
send.key = await this.encryptService.encrypt(model.key, userKey);
send.name = await this.encryptService.encrypt(model.name, model.cryptoKey);
send.notes = await this.encryptService.encrypt(model.notes, model.cryptoKey);
send.key = await this.encryptService.encryptBytes(model.key, userKey);
send.name = await this.encryptService.encryptString(model.name, model.cryptoKey);
send.notes = await this.encryptService.encryptString(model.notes, model.cryptoKey);
if (send.type === SendType.Text) {
send.text = new SendText();
send.text.text = await this.encryptService.encrypt(model.text.text, model.cryptoKey);
send.text.text = await this.encryptService.encryptString(model.text.text, model.cryptoKey);
send.text.hidden = model.text.hidden;
} else if (send.type === SendType.File) {
send.file = new SendFile();
@@ -292,9 +292,7 @@ export class SendService implements InternalSendServiceAbstraction {
) {
const requests = await Promise.all(
sends.map(async (send) => {
const sendKey = new SymmetricCryptoKey(
await this.encryptService.decryptToBytes(send.key, originalUserKey),
);
const sendKey = await this.encryptService.unwrapSymmetricKey(send.key, originalUserKey);
send.key = await this.encryptService.wrapSymmetricKey(sendKey, rotateUserKey);
return new SendWithIdRequest(send);
}),
@@ -333,8 +331,8 @@ export class SendService implements InternalSendServiceAbstraction {
if (key == null) {
key = await this.keyService.getUserKey();
}
const encFileName = await this.encryptService.encrypt(fileName, key);
const encFileData = await this.encryptService.encryptToBytes(new Uint8Array(data), key);
const encFileName = await this.encryptService.encryptString(fileName, key);
const encFileData = await this.encryptService.encryptFileData(new Uint8Array(data), key);
return [encFileName, encFileData];
}

View File

@@ -72,7 +72,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
keyForDecryption = await this.keyService.getUserKeyWithLegacySupport();
}
const encKeyValidation = new EncString(results.encKeyValidation_DO_NOT_EDIT);
const encKeyValidationDecrypt = await this.encryptService.decryptToUtf8(
const encKeyValidationDecrypt = await this.encryptService.decryptString(
encKeyValidation,
keyForDecryption,
);

View File

@@ -92,7 +92,7 @@ describe("BitwardenPasswordProtectedImporter", () => {
});
it("succeeds with default jdoc", async () => {
encryptService.decryptToUtf8.mockReturnValue(Promise.resolve(emptyUnencryptedExport));
encryptService.decryptString.mockReturnValue(Promise.resolve(emptyUnencryptedExport));
expect((await importer.parse(JSON.stringify(jDoc))).success).toEqual(true);
});

View File

@@ -69,7 +69,7 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im
}
const encData = new EncString(parsedData.data);
const clearTextData = await this.encryptService.decryptToUtf8(encData, this.key);
const clearTextData = await this.encryptService.decryptString(encData, this.key);
return await super.parse(clearTextData);
}
@@ -90,7 +90,7 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im
const encKeyValidation = new EncString(jdoc.encKeyValidation_DO_NOT_EDIT);
const encKeyValidationDecrypt = await this.encryptService.decryptToUtf8(
const encKeyValidationDecrypt = await this.encryptService.decryptString(
encKeyValidation,
this.key,
);

View File

@@ -28,8 +28,8 @@ export class BaseVaultExportService {
const salt = Utils.fromBufferToB64(await this.cryptoFunctionService.randomBytes(16));
const key = await this.pinService.makePinKey(password, salt, kdfConfig);
const encKeyValidation = await this.encryptService.encrypt(Utils.newGuid(), key);
const encText = await this.encryptService.encrypt(clearText, key);
const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), key);
const encText = await this.encryptService.encryptString(clearText, key);
const jsonDoc: BitwardenPasswordProtectedFileFormat = {
encrypted: true,

View File

@@ -209,7 +209,7 @@ describe("VaultExportService", () => {
folderService.folderViews$.mockReturnValue(of(UserFolderViews));
folderService.folders$.mockReturnValue(of(UserFolders));
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
encryptService.encrypt.mockResolvedValue(new EncString("encrypted"));
encryptService.encryptString.mockResolvedValue(new EncString("encrypted"));
apiService.getAttachmentData.mockResolvedValue(attachmentResponse);
exportService = new IndividualVaultExportService(
@@ -313,7 +313,7 @@ describe("VaultExportService", () => {
cipherService.getAllDecrypted.mockResolvedValue([cipherView]);
folderService.getAllDecryptedFromState.mockResolvedValue([]);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255));
encryptService.decryptFileData.mockResolvedValue(new Uint8Array(255));
global.fetch = jest.fn(() =>
Promise.resolve({
@@ -338,7 +338,7 @@ describe("VaultExportService", () => {
cipherService.getAllDecrypted.mockResolvedValue([cipherView]);
folderService.getAllDecryptedFromState.mockResolvedValue([]);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255));
encryptService.decryptFileData.mockResolvedValue(new Uint8Array(255));
global.fetch = jest.fn(() =>
Promise.resolve({
@@ -362,7 +362,7 @@ describe("VaultExportService", () => {
cipherView.attachments = [attachmentView];
cipherService.getAllDecrypted.mockResolvedValue([cipherView]);
folderService.getAllDecryptedFromState.mockResolvedValue([]);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255));
encryptService.decryptFileData.mockResolvedValue(new Uint8Array(255));
global.fetch = jest.fn(() =>
Promise.resolve({
status: 200,
@@ -427,7 +427,7 @@ describe("VaultExportService", () => {
});
it("has a mac property", async () => {
encryptService.encrypt.mockResolvedValue(mac);
encryptService.encryptString.mockResolvedValue(mac);
exportedVault = await exportService.getPasswordProtectedExport(password);
exportString = exportedVault.data;
exportObject = JSON.parse(exportString);
@@ -436,7 +436,7 @@ describe("VaultExportService", () => {
});
it("has data property", async () => {
encryptService.encrypt.mockResolvedValue(data);
encryptService.encryptString.mockResolvedValue(data);
exportedVault = await exportService.getPasswordProtectedExport(password);
exportString = exportedVault.data;
exportObject = JSON.parse(exportString);

View File

@@ -157,7 +157,7 @@ export class IndividualVaultExportService
attachment.key != null
? attachment.key
: await this.keyService.getOrgKey(cipher.organizationId);
return await this.encryptService.decryptToBytes(encBuf, key);
return await this.encryptService.decryptFileData(encBuf, key);
} catch {
throw new Error("Error decrypting attachment");
}
@@ -220,7 +220,7 @@ export class IndividualVaultExportService
await Promise.all(promises);
const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId);
const encKeyValidation = await this.encryptService.encrypt(Utils.newGuid(), userKey);
const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), userKey);
const jsonDoc: BitwardenEncryptedIndividualJsonExport = {
encrypted: true,

View File

@@ -286,7 +286,7 @@ export class OrganizationVaultExportService
ciphers: Cipher[],
): Promise<string> {
const orgKey = await this.keyService.getOrgKey(organizationId);
const encKeyValidation = await this.encryptService.encrypt(Utils.newGuid(), orgKey);
const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), orgKey);
const jsonDoc: BitwardenEncryptedOrgJsonExport = {
encrypted: true,

View File

@@ -175,7 +175,7 @@ describe("VaultExportService", () => {
folderService.folderViews$.mockReturnValue(of(UserFolderViews));
folderService.folders$.mockReturnValue(of(UserFolders));
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
encryptService.encrypt.mockResolvedValue(new EncString("encrypted"));
encryptService.encryptString.mockResolvedValue(new EncString("encrypted"));
keyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
const userId = "" as UserId;
const accountInfo: AccountInfo = {
@@ -282,7 +282,7 @@ describe("VaultExportService", () => {
});
it("has a mac property", async () => {
encryptService.encrypt.mockResolvedValue(mac);
encryptService.encryptString.mockResolvedValue(mac);
exportedVault = await exportService.getPasswordProtectedExport(password);
@@ -293,7 +293,7 @@ describe("VaultExportService", () => {
});
it("has data property", async () => {
encryptService.encrypt.mockResolvedValue(data);
encryptService.encryptString.mockResolvedValue(data);
exportedVault = await exportService.getPasswordProtectedExport(password);

View File

@@ -19,7 +19,7 @@ export class LegacyPasswordHistoryDecryptor {
const promises = (history ?? []).map(async (item) => {
const encrypted = new EncString(item.password);
const decrypted = await this.encryptService.decryptToUtf8(encrypted, key);
const decrypted = await this.encryptService.decryptString(encrypted, key);
return new GeneratedPasswordHistory(decrypted, item.date);
});

View File

@@ -22,8 +22,10 @@ describe("LocalGeneratorHistoryService", () => {
const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
beforeEach(() => {
encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString));
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString));
encryptService.encryptString.mockImplementation((p) =>
Promise.resolve(p as unknown as EncString),
);
encryptService.decryptString.mockImplementation((c) => Promise.resolve(c.encryptedString));
keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey));
keyService.userKey$.mockImplementation(() => of(true as unknown as UserKey));
});

View File

@@ -1,4 +1,4 @@
<button bitButton [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<button bitButton size="small" [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<i *ngIf="!hideIcon" class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ (hideIcon ? "createSend" : "new") | i18n }}
</button>

View File

@@ -32,3 +32,5 @@ export { SshImportPromptService } from "./services/ssh-import-prompt.service";
export * from "./abstractions/change-login-password.service";
export * from "./services/default-change-login-password.service";
export { SpotlightComponent } from "./components/spotlight/spotlight.component";

View File

@@ -0,0 +1,64 @@
import { inject, Injectable } from "@angular/core";
import { combineLatest, Observable, of, switchMap } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
/**
* Custom Nudge Service Checking Nudge Status For Empty Vault
*/
@Injectable({
providedIn: "root",
})
export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
cipherService = inject(CipherService);
organizationService = inject(OrganizationService);
collectionService = inject(CollectionService);
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
this.organizationService.organizations$(userId),
this.collectionService.decryptedCollections$,
]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const emptyVault = ciphers == null || ciphers.length === 0;
if (orgs == null || orgs.length === 0) {
return nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed
? of(nudgeStatus)
: of({
hasSpotlightDismissed: emptyVault,
hasBadgeDismissed: emptyVault,
});
}
const orgIds = new Set(orgs.map((org) => org.id));
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
const hasManageCollections = collections.some(
(c) => c.manage && orgIds.has(c.organizationId),
);
// Do not show nudge when
// user has previously dismissed nudge
// OR
// user belongs to an organization and cannot create collections || manage collections
if (
nudgeStatus.hasBadgeDismissed ||
nudgeStatus.hasSpotlightDismissed ||
hasManageCollections ||
canCreateCollections
) {
return of(nudgeStatus);
}
return of({
hasSpotlightDismissed: emptyVault,
hasBadgeDismissed: emptyVault,
});
}),
);
}
}

View File

@@ -1,30 +1,49 @@
import { inject, Injectable } from "@angular/core";
import { map, Observable, of, switchMap } from "rxjs";
import { combineLatest, Observable, switchMap } from "rxjs";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { VaultNudgeType } from "../vault-nudges.service";
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
/**
* Custom Nudge Service to use for the Onboarding Nudges in the Vault
* Custom Nudge Service Checking Nudge Status For Welcome Nudge With Populated Vault
*/
@Injectable({
providedIn: "root",
})
export class HasItemsNudgeService extends DefaultSingleNudgeService {
cipherService = inject(CipherService);
vaultProfileService = inject(VaultProfileService);
logService = inject(LogService);
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
return this.isDismissed$(nudgeType, userId).pipe(
switchMap((dismissed) =>
dismissed
? of(false)
: this.cipherService
.cipherViews$(userId)
.pipe(map((ciphers) => ciphers == null || ciphers.length === 0)),
),
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.cipherService.cipherViews$(userId),
this.getNudgeStatus$(nudgeType, userId),
]).pipe(
switchMap(async ([ciphers, nudgeStatus]) => {
try {
const creationDate = await this.vaultProfileService.getProfileCreationDate(userId);
const thirtyDays = new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000);
const isRecentAcct = creationDate >= thirtyDays;
if (!isRecentAcct || nudgeStatus.hasSpotlightDismissed) {
return nudgeStatus;
} else {
return {
hasBadgeDismissed: ciphers == null || ciphers.length === 0,
hasSpotlightDismissed: ciphers == null || ciphers.length === 0,
};
}
} catch (error) {
this.logService.error("Failed to fetch profile creation date: ", error);
return nudgeStatus;
}
}),
);
}
}

View File

@@ -5,7 +5,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { VaultNudgeType } from "../vault-nudges.service";
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
/**
* Custom Nudge Service used for showing if the user has any existing nudge in the Vault.
@@ -17,6 +17,7 @@ export class HasNudgeService extends DefaultSingleNudgeService {
private accountService = inject(AccountService);
private nudgeTypes: VaultNudgeType[] = [
VaultNudgeType.EmptyVaultNudge,
VaultNudgeType.HasVaultItems,
VaultNudgeType.IntroCarouselDismissal,
// add additional nudge types here as needed
@@ -25,20 +26,25 @@ export class HasNudgeService extends DefaultSingleNudgeService {
/**
* Returns an observable that emits true if any of the provided nudge types are present
*/
shouldShowNudge$(): Observable<boolean> {
nudgeStatus$(): Observable<NudgeStatus> {
return this.accountService.activeAccount$.pipe(
switchMap((activeAccount) => {
const userId: UserId | undefined = activeAccount?.id;
if (!userId) {
return of(false);
return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true });
}
const nudgeObservables: Observable<boolean>[] = this.nudgeTypes.map((nudge) =>
super.shouldShowNudge$(nudge, userId),
const nudgeObservables: Observable<NudgeStatus>[] = this.nudgeTypes.map((nudge) =>
super.nudgeStatus$(nudge, userId),
);
return combineLatest(nudgeObservables).pipe(
map((nudgeStates) => nudgeStates.some((state) => state)),
map((nudgeStates) => {
return {
hasBadgeDismissed: true,
hasSpotlightDismissed: nudgeStates.some((state) => state.hasSpotlightDismissed),
};
}),
distinctUntilChanged(),
);
}),

View File

@@ -1,2 +1,3 @@
export * from "./has-items-nudge.service";
export * from "./empty-vault-nudge.service";
export * from "./has-nudge.service";

View File

@@ -4,15 +4,19 @@ import { map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { VAULT_NUDGE_DISMISSED_DISK_KEY, VaultNudgeType } from "./vault-nudges.service";
import {
NudgeStatus,
VAULT_NUDGE_DISMISSED_DISK_KEY,
VaultNudgeType,
} from "./vault-nudges.service";
/**
* Base interface for handling a nudge's status
*/
export interface SingleNudgeService {
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean>;
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus>;
setNudgeStatus(nudgeType: VaultNudgeType, dismissed: boolean, userId: UserId): Promise<void>;
setNudgeStatus(nudgeType: VaultNudgeType, newStatus: NudgeStatus, userId: UserId): Promise<void>;
}
/**
@@ -24,28 +28,29 @@ export interface SingleNudgeService {
export class DefaultSingleNudgeService implements SingleNudgeService {
stateProvider = inject(StateProvider);
protected isDismissed$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
protected getNudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
return this.stateProvider
.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY)
.state$.pipe(map((nudges) => nudges?.includes(nudgeType) ?? false));
.state$.pipe(
map(
(nudges) =>
nudges?.[nudgeType] ?? { hasBadgeDismissed: false, hasSpotlightDismissed: false },
),
);
}
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
return this.isDismissed$(nudgeType, userId).pipe(map((dismissed) => !dismissed));
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
return this.getNudgeStatus$(nudgeType, userId);
}
async setNudgeStatus(
nudgeType: VaultNudgeType,
dismissed: boolean,
status: NudgeStatus,
userId: UserId,
): Promise<void> {
await this.stateProvider.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY).update((nudges) => {
nudges ??= [];
if (dismissed) {
nudges.push(nudgeType);
} else {
nudges = nudges.filter((n) => n !== nudgeType);
}
nudges ??= {};
nudges[nudgeType] = status;
return nudges;
});
}

View File

@@ -2,12 +2,13 @@ import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider, mockAccountServiceWith } from "../../../common/spec";
import { HasItemsNudgeService } from "./custom-nudges-services/has-items-nudge.service";
import { HasItemsNudgeService, EmptyVaultNudgeService } from "./custom-nudges-services";
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
import { VaultNudgesService, VaultNudgeType } from "./vault-nudges.service";
@@ -15,6 +16,10 @@ describe("Vault Nudges Service", () => {
let fakeStateProvider: FakeStateProvider;
let testBed: TestBed;
const mockConfigService = {
getFeatureFlag$: jest.fn().mockReturnValue(of(true)),
getFeatureFlag: jest.fn().mockReturnValue(true),
};
beforeEach(async () => {
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
@@ -32,50 +37,55 @@ describe("Vault Nudges Service", () => {
provide: StateProvider,
useValue: fakeStateProvider,
},
{ provide: ConfigService, useValue: mockConfigService },
{
provide: HasItemsNudgeService,
useValue: mock<HasItemsNudgeService>(),
},
{
provide: EmptyVaultNudgeService,
useValue: mock<EmptyVaultNudgeService>(),
},
],
});
});
describe("DefaultSingleNudgeService", () => {
it("should return shouldShowNudge === false when IntroCaourselDismissal dismissed is true", async () => {
it("should return hasSpotlightDismissed === true when EmptyVaultNudge dismissed is true", async () => {
const service = testBed.inject(DefaultSingleNudgeService);
await service.setNudgeStatus(
VaultNudgeType.IntroCarouselDismissal,
true,
VaultNudgeType.EmptyVaultNudge,
{ hasBadgeDismissed: true, hasSpotlightDismissed: true },
"user-id" as UserId,
);
const result = await firstValueFrom(
service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId),
service.nudgeStatus$(VaultNudgeType.EmptyVaultNudge, "user-id" as UserId),
);
expect(result).toBe(false);
expect(result).toEqual({ hasBadgeDismissed: true, hasSpotlightDismissed: true });
});
it("should return shouldShowNudge === true when IntroCaourselDismissal dismissed is false", async () => {
it("should return hasSpotlightDismissed === true when EmptyVaultNudge dismissed is false", async () => {
const service = testBed.inject(DefaultSingleNudgeService);
await service.setNudgeStatus(
VaultNudgeType.IntroCarouselDismissal,
false,
VaultNudgeType.EmptyVaultNudge,
{ hasBadgeDismissed: false, hasSpotlightDismissed: false },
"user-id" as UserId,
);
const result = await firstValueFrom(
service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId),
service.nudgeStatus$(VaultNudgeType.EmptyVaultNudge, "user-id" as UserId),
);
expect(result).toBe(true);
expect(result).toEqual({ hasBadgeDismissed: false, hasSpotlightDismissed: false });
});
});
describe("VaultNudgesService", () => {
it("should return true, the proper value from the custom nudge service shouldShowNudge$", async () => {
it("should return true, the proper value from the custom nudge service nudgeStatus$", async () => {
TestBed.overrideProvider(HasItemsNudgeService, {
useValue: { shouldShowNudge$: () => of(true) },
useValue: { nudgeStatus$: () => of(true) },
});
const service = testBed.inject(VaultNudgesService);
@@ -86,9 +96,9 @@ describe("Vault Nudges Service", () => {
expect(result).toBe(true);
});
it("should return false, the proper value for the custom nudge service shouldShowNudge$", async () => {
it("should return false, the proper value for the custom nudge service nudgeStatus$", async () => {
TestBed.overrideProvider(HasItemsNudgeService, {
useValue: { shouldShowNudge$: () => of(false) },
useValue: { nudgeStatus$: () => of(false) },
});
const service = testBed.inject(VaultNudgesService);

View File

@@ -1,11 +1,19 @@
import { inject, Injectable } from "@angular/core";
import { of, switchMap } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserKeyDefinition, VAULT_NUDGES_DISK } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { HasItemsNudgeService } from "./custom-nudges-services/has-items-nudge.service";
import { HasItemsNudgeService, EmptyVaultNudgeService } from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
export type NudgeStatus = {
hasBadgeDismissed: boolean;
hasSpotlightDismissed: boolean;
};
/**
* Enum to list the various nudge types, to be used by components/badges to show/hide the nudge
*/
@@ -13,18 +21,17 @@ export enum VaultNudgeType {
/** Nudge to show when user has no items in their vault
* Add future nudges here
*/
EmptyVaultNudge = "empty-vault-nudge",
HasVaultItems = "has-vault-items",
IntroCarouselDismissal = "intro-carousel-dismissal",
}
export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<VaultNudgeType[]>(
VAULT_NUDGES_DISK,
"vaultNudgeDismissed",
{
deserializer: (nudgeDismissed) => nudgeDismissed,
clearOn: [], // Do not clear dismissals
},
);
export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
Partial<Record<VaultNudgeType, NudgeStatus>>
>(VAULT_NUDGES_DISK, "vaultNudgeDismissed", {
deserializer: (nudge) => nudge,
clearOn: [], // Do not clear dismissals
});
@Injectable({
providedIn: "root",
@@ -37,6 +44,7 @@ export class VaultNudgesService {
*/
private customNudgeServices: any = {
[VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService),
[VaultNudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService),
};
/**
@@ -45,6 +53,7 @@ export class VaultNudgesService {
* @private
*/
private defaultNudgeService = inject(DefaultSingleNudgeService);
private configService = inject(ConfigService);
private getNudgeService(nudge: VaultNudgeType): SingleNudgeService {
return this.customNudgeServices[nudge] ?? this.defaultNudgeService;
@@ -56,7 +65,14 @@ export class VaultNudgesService {
* @param userId
*/
showNudge$(nudge: VaultNudgeType, userId: UserId) {
return this.getNudgeService(nudge).shouldShowNudge$(nudge, userId);
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
switchMap((hasVaultNudgeFlag) => {
if (!hasVaultNudgeFlag) {
return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true } as NudgeStatus);
}
return this.getNudgeService(nudge).nudgeStatus$(nudge, userId);
}),
);
}
/**
@@ -64,7 +80,10 @@ export class VaultNudgesService {
* @param nudge
* @param userId
*/
dismissNudge(nudge: VaultNudgeType, userId: UserId) {
return this.getNudgeService(nudge).setNudgeStatus(nudge, true, userId);
async dismissNudge(nudge: VaultNudgeType, userId: UserId, onlyBadge: boolean = false) {
const dismissedStatus = onlyBadge
? { hasBadgeDismissed: true, hasSpotlightDismissed: false }
: { hasBadgeDismissed: true, hasSpotlightDismissed: true };
await this.getNudgeService(nudge).setNudgeStatus(nudge, dismissedStatus, userId);
}
}