diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index c1b7689848c..703d706bfb6 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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" }, diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-info.ts b/apps/browser/src/autofill/content/components/cipher/cipher-info.ts index e3d237b9bc6..df3f2d7fa16 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-info.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-info.ts @@ -14,7 +14,7 @@ export function CipherInfo({ cipher, theme }: { cipher: NotificationCipherData; return html`
- + ${[ name, hasIndicatorIcons diff --git a/apps/browser/src/popup/tabs-v2.component.ts b/apps/browser/src/popup/tabs-v2.component.ts index 1392dc565ab..24ce9d8cb12 100644 --- a/apps/browser/src/popup/tabs-v2.component.ts +++ b/apps/browser/src/popup/tabs-v2.component.ts @@ -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, }, ]; }), diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index 26aeea4f20a..b6f98b649fe 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -29,9 +29,26 @@ - + - {{ "vault" | i18n }} +
+

{{ "settingsVaultOptions" | i18n }}

+ + 1 +
+
diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.ts index 5f3eb1c8f12..737d79ea4ca 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.ts @@ -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 = 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); + } + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html index c952260a9a9..af627e22ef2 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html @@ -1,4 +1,4 @@ - diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index 2a50eb43960..7d04f23795e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -14,7 +14,9 @@ > {{ "yourVaultIsEmpty" | i18n }} - {{ "autofillSuggestionsTip" | i18n }} + +

{{ "emptyVaultDescription" | i18n }}

+
- - - + + + + + + +
+ + +
+ + +
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 7f5242dcf18..64805a02394 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -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 = this.activeUserId$.pipe( + switchMap((userId) => + this.vaultNudgesService.showNudge$(VaultNudgeType.EmptyVaultNudge, userId), + ), + map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed), + ); + showHasItemsVaultSpotlight$: Observable = 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; } diff --git a/apps/browser/src/vault/popup/services/intro-carousel.service.ts b/apps/browser/src/vault/popup/services/intro-carousel.service.ts index 2c523c5a93c..7d2bb7dedb9 100644 --- a/apps/browser/src/vault/popup/services/intro-carousel.service.ts +++ b/apps/browser/src/vault/popup/services/intro-carousel.service.ts @@ -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 { - await this.introCarouselState.update(() => true); + const hasVaultNudgeFlag = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge), + ); + if (hasVaultNudgeFlag) { + await this.introCarouselState.update(() => true); + } } } diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.html b/apps/browser/src/vault/popup/settings/folders-v2.component.html index 35a0fbec0a9..552547c0230 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.html +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.html @@ -1,7 +1,13 @@ - diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.spec.ts index d2d48edf869..b3e8e11f4f7 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.spec.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.spec.ts @@ -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); })); diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.ts index bc8edc17360..b879ef94705 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.ts @@ -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 { 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() ?? "", diff --git a/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts b/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts index 3d93db81389..9f03d618cdc 100644 --- a/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts +++ b/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts @@ -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((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); }); diff --git a/libs/common/src/tools/cryptography/organization-key-encryptor.ts b/libs/common/src/tools/cryptography/organization-key-encryptor.ts index 31f3db91232..99b47c48670 100644 --- a/libs/common/src/tools/cryptography/organization-key-encryptor.ts +++ b/libs/common/src/tools/cryptography/organization-key-encryptor.ts @@ -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: EncString): Promise> { 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(decrypted); decrypted = null; diff --git a/libs/common/src/tools/cryptography/user-key-encryptor.spec.ts b/libs/common/src/tools/cryptography/user-key-encryptor.spec.ts index e52190500b0..5bcb57ec563 100644 --- a/libs/common/src/tools/cryptography/user-key-encryptor.spec.ts +++ b/libs/common/src/tools/cryptography/user-key-encryptor.spec.ts @@ -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((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); }); diff --git a/libs/common/src/tools/cryptography/user-key-encryptor.ts b/libs/common/src/tools/cryptography/user-key-encryptor.ts index 4b7cd1516a0..74e41f7af6d 100644 --- a/libs/common/src/tools/cryptography/user-key-encryptor.ts +++ b/libs/common/src/tools/cryptography/user-key-encryptor.ts @@ -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: EncString): Promise> { 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(decrypted); decrypted = null; diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index 79f6c03adc8..7112ad7f751 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -112,7 +112,7 @@ describe("Send", () => { const encryptService = mock(); const keyService = mock(); - encryptService.decryptToBytes + encryptService.decryptBytes .calledWith(send.key, userKey) .mockResolvedValue(makeStaticByteArray(32)); keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index f12a0010fab..78d7966bb63 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -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 diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index cd8d52fe373..65fd53edd75 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -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); }); diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index cefd9942d29..db3834789c8 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -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]; } diff --git a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts index f01e6571439..9284718a063 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts @@ -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, ); diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts index 66deabf0634..8d0f5dfcc1c 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts @@ -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); }); diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts index 02a417c2169..878f9cf5819 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts @@ -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, ); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts index c1526ba0f4b..9a64298ffba 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts @@ -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, diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index 15791ae04fb..ae408af421b 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -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); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index 96b19acd963..8b66580d4cd 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -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, diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts index 86edf67bf03..fc46915c15d 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts @@ -286,7 +286,7 @@ export class OrganizationVaultExportService ciphers: Cipher[], ): Promise { 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, diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts index a90f0a3ed7b..4e0dbfcc330 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts @@ -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); diff --git a/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts b/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts index 06113ee9b99..4278f7e0e08 100644 --- a/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts +++ b/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts @@ -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); }); diff --git a/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts b/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts index b3dee69bdbf..3621b2c24a9 100644 --- a/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts +++ b/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts @@ -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)); }); diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html index 344348f6a90..c39d95616f6 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html @@ -1,4 +1,4 @@ - diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 6e5a452ec8c..87e15b18676 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -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"; diff --git a/libs/vault/src/services/custom-nudges-services/empty-vault-nudge.service.ts b/libs/vault/src/services/custom-nudges-services/empty-vault-nudge.service.ts new file mode 100644 index 00000000000..556e389b288 --- /dev/null +++ b/libs/vault/src/services/custom-nudges-services/empty-vault-nudge.service.ts @@ -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 { + 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, + }); + }), + ); + } +} diff --git a/libs/vault/src/services/custom-nudges-services/has-items-nudge.service.ts b/libs/vault/src/services/custom-nudges-services/has-items-nudge.service.ts index 144b15d61f4..6b5ac7eba00 100644 --- a/libs/vault/src/services/custom-nudges-services/has-items-nudge.service.ts +++ b/libs/vault/src/services/custom-nudges-services/has-items-nudge.service.ts @@ -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 { - 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 { + 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; + } + }), ); } } diff --git a/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts b/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts index b1f319451e6..0c14cff002f 100644 --- a/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts +++ b/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts @@ -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 { + nudgeStatus$(): Observable { 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[] = this.nudgeTypes.map((nudge) => - super.shouldShowNudge$(nudge, userId), + const nudgeObservables: Observable[] = 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(), ); }), diff --git a/libs/vault/src/services/custom-nudges-services/index.ts b/libs/vault/src/services/custom-nudges-services/index.ts index dd343e47d75..9a1f0acd420 100644 --- a/libs/vault/src/services/custom-nudges-services/index.ts +++ b/libs/vault/src/services/custom-nudges-services/index.ts @@ -1,2 +1,3 @@ export * from "./has-items-nudge.service"; +export * from "./empty-vault-nudge.service"; export * from "./has-nudge.service"; diff --git a/libs/vault/src/services/default-single-nudge.service.ts b/libs/vault/src/services/default-single-nudge.service.ts index 0fd48b63c8d..9a1759cab38 100644 --- a/libs/vault/src/services/default-single-nudge.service.ts +++ b/libs/vault/src/services/default-single-nudge.service.ts @@ -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; + nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable; - setNudgeStatus(nudgeType: VaultNudgeType, dismissed: boolean, userId: UserId): Promise; + setNudgeStatus(nudgeType: VaultNudgeType, newStatus: NudgeStatus, userId: UserId): Promise; } /** @@ -24,28 +28,29 @@ export interface SingleNudgeService { export class DefaultSingleNudgeService implements SingleNudgeService { stateProvider = inject(StateProvider); - protected isDismissed$(nudgeType: VaultNudgeType, userId: UserId): Observable { + protected getNudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable { 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 { - return this.isDismissed$(nudgeType, userId).pipe(map((dismissed) => !dismissed)); + nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable { + return this.getNudgeStatus$(nudgeType, userId); } async setNudgeStatus( nudgeType: VaultNudgeType, - dismissed: boolean, + status: NudgeStatus, userId: UserId, ): Promise { 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; }); } diff --git a/libs/vault/src/services/vault-nudges.service.spec.ts b/libs/vault/src/services/vault-nudges.service.spec.ts index 0d376f37cf9..a01cac94fb1 100644 --- a/libs/vault/src/services/vault-nudges.service.spec.ts +++ b/libs/vault/src/services/vault-nudges.service.spec.ts @@ -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(), }, + { + provide: EmptyVaultNudgeService, + useValue: mock(), + }, ], }); }); 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); diff --git a/libs/vault/src/services/vault-nudges.service.ts b/libs/vault/src/services/vault-nudges.service.ts index 0a031f8c092..28198d17068 100644 --- a/libs/vault/src/services/vault-nudges.service.ts +++ b/libs/vault/src/services/vault-nudges.service.ts @@ -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( - VAULT_NUDGES_DISK, - "vaultNudgeDismissed", - { - deserializer: (nudgeDismissed) => nudgeDismissed, - clearOn: [], // Do not clear dismissals - }, -); +export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition< + Partial> +>(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); } }