diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ee97f16b0a9..91b4ac86328 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -149,6 +149,8 @@ { matchPackageNames: [ "@angular-eslint/schematics", + "@typescript-eslint/rule-tester", + "@typescript-eslint/utils", "angular-eslint", "eslint-config-prettier", "eslint-import-resolver-typescript", @@ -313,8 +315,6 @@ "@storybook/angular", "@storybook/manager-api", "@storybook/theming", - "@typescript-eslint/utils", - "@typescript-eslint/rule-tester", "@types/react", "autoprefixer", "bootstrap", diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 4ca6dc25aab..630e1e55682 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -196,7 +196,7 @@ jobs: } - name: Set up QEMU emulators - uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 diff --git a/LICENSE.txt b/LICENSE.txt index 55bf3b3f736..8ad59f788b3 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -5,13 +5,13 @@ specifies another license. Bitwarden Licensed code is found only in the /bitwarden_license directory. GPL v3.0: -https://github.com/bitwarden/web/blob/master/LICENSE_GPL.txt +https://github.com/bitwarden/clients/blob/main/LICENSE_GPL.txt Bitwarden License v1.0: -https://github.com/bitwarden/web/blob/master/LICENSE_BITWARDEN.txt +https://github.com/bitwarden/clients/blob/main/LICENSE_BITWARDEN.txt No grant of any rights in the trademarks, service marks, or logos of Bitwarden is made (except as may be necessary to comply with the notice requirements as applicable), and use of any Bitwarden trademarks must comply with Bitwarden Trademark Guidelines -. +. diff --git a/LICENSE_BITWARDEN.txt b/LICENSE_BITWARDEN.txt index 08e09f28639..938946a09a1 100644 --- a/LICENSE_BITWARDEN.txt +++ b/LICENSE_BITWARDEN.txt @@ -56,7 +56,7 @@ such Open Source Software only. logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 2.3), and use of any Bitwarden trademarks must comply with Bitwarden Trademark Guidelines -. +. 3. TERMINATION diff --git a/apps/browser/README.md b/apps/browser/README.md index c99d0844a09..fdeb1307567 100644 --- a/apps/browser/README.md +++ b/apps/browser/README.md @@ -1,4 +1,4 @@ -[![Github Workflow build browser on master](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:master) +[![Github Workflow build browser on main](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:main) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/bitwarden-browser/localized.svg)](https://crowdin.com/project/bitwarden-browser) [![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby) @@ -15,7 +15,7 @@ The Bitwarden browser extension is written using the Web Extension API and Angular. -![My Vault](https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/browser-chrome.png) +![My Vault](https://raw.githubusercontent.com/bitwarden/brand/main/screenshots/web-browser-extension-generator.png) ## Documentation diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 4f83b07506b..6e1e2ef57ac 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" }, @@ -5227,5 +5233,50 @@ }, "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" + }, + "newLoginNudgeTitle": { + "message": "Save time with autofill" + }, + "newLoginNudgeBody": { + "message": "Include a Website so this login appears as an autofill suggestion." + }, + "newCardNudgeTitle": { + "message": "Seamless online checkout" + }, + "newCardNudgeBody": { + "message": "With cards, easily autofill payment forms securely and accurately." + }, + "newIdentityNudgeTitle": { + "message": "Simplify creating accounts" + }, + "newIdentityNudgeBody": { + "message": "With identities, quickly autofill long registration or contact forms." + }, + "newNoteNudgeTitle": { + "message": "Keep your sensitive data safe" + }, + "newNoteNudgeBody": { + "message": "With notes, securely store sensitive data like banking or insurance details." + }, + "newSshNudgeTitle": { + "message": "Developer-friendly SSH access" + }, + "newSshNudgeBody": { + "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication." } -} \ No newline at end of file +} 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/platform/sync/foreground-sync.service.spec.ts b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts index f5daff93815..34ee4fa0f77 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts @@ -8,6 +8,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; @@ -80,7 +81,72 @@ describe("ForegroundSyncService", () => { const fullSyncPromise = sut.fullSync(true, false); expect(sut.syncInProgress).toBe(true); - const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: false }); + const requestId = getAndAssertRequestId({ + forceSync: true, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + }); + + // Pretend the sync has finished + messages.next({ successfully: true, errorMessage: null, requestId: requestId }); + + const result = await fullSyncPromise; + + expect(sut.syncInProgress).toBe(false); + expect(result).toBe(true); + }); + + const testData: { + input: boolean | SyncOptions | undefined; + normalized: Required; + }[] = [ + { + input: undefined, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: true, + normalized: { allowThrowOnError: true, skipTokenRefresh: false }, + }, + { + input: false, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: false }, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: true }, + normalized: { allowThrowOnError: true, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: false, skipTokenRefresh: false }, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: true, skipTokenRefresh: false }, + normalized: { allowThrowOnError: true, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: true, skipTokenRefresh: true }, + normalized: { allowThrowOnError: true, skipTokenRefresh: true }, + }, + { + input: { allowThrowOnError: false, skipTokenRefresh: true }, + normalized: { allowThrowOnError: false, skipTokenRefresh: true }, + }, + ]; + + it.each(testData)("normalize input $input options correctly", async ({ input, normalized }) => { + const messages = new Subject(); + messageListener.messages$.mockReturnValue(messages); + const fullSyncPromise = sut.fullSync(true, input); + expect(sut.syncInProgress).toBe(true); + + const requestId = getAndAssertRequestId({ + forceSync: true, + options: normalized, + }); // Pretend the sync has finished messages.next({ successfully: true, errorMessage: null, requestId: requestId }); @@ -97,7 +163,10 @@ describe("ForegroundSyncService", () => { const fullSyncPromise = sut.fullSync(false, false); expect(sut.syncInProgress).toBe(true); - const requestId = getAndAssertRequestId({ forceSync: false, allowThrowOnError: false }); + const requestId = getAndAssertRequestId({ + forceSync: false, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + }); // Pretend the sync has finished messages.next({ @@ -118,7 +187,10 @@ describe("ForegroundSyncService", () => { const fullSyncPromise = sut.fullSync(true, true); expect(sut.syncInProgress).toBe(true); - const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: true }); + const requestId = getAndAssertRequestId({ + forceSync: true, + options: { allowThrowOnError: true, skipTokenRefresh: false }, + }); // Pretend the sync has finished messages.next({ diff --git a/apps/browser/src/platform/sync/foreground-sync.service.ts b/apps/browser/src/platform/sync/foreground-sync.service.ts index a6ed7281851..ce776f53685 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.ts @@ -14,6 +14,7 @@ import { import { Utils } from "@bitwarden/common/platform/misc/utils"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CoreSyncService } from "@bitwarden/common/platform/sync/internal"; +import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -22,7 +23,7 @@ import { InternalFolderService } from "@bitwarden/common/vault/abstractions/fold import { FULL_SYNC_FINISHED } from "./sync-service.listener"; -export type FullSyncMessage = { forceSync: boolean; allowThrowOnError: boolean; requestId: string }; +export type FullSyncMessage = { forceSync: boolean; options: SyncOptions; requestId: string }; export const DO_FULL_SYNC = new CommandDefinition("doFullSync"); @@ -60,9 +61,20 @@ export class ForegroundSyncService extends CoreSyncService { ); } - async fullSync(forceSync: boolean, allowThrowOnError: boolean = false): Promise { + async fullSync( + forceSync: boolean, + allowThrowOnErrorOrOptions?: boolean | SyncOptions, + ): Promise { this.syncInProgress = true; try { + // Normalize options + const options = + typeof allowThrowOnErrorOrOptions === "boolean" + ? { allowThrowOnError: allowThrowOnErrorOrOptions, skipTokenRefresh: false } + : { + allowThrowOnError: allowThrowOnErrorOrOptions?.allowThrowOnError ?? false, + skipTokenRefresh: allowThrowOnErrorOrOptions?.skipTokenRefresh ?? false, + }; const requestId = Utils.newGuid(); const syncCompletedPromise = firstValueFrom( this.messageListener.messages$(FULL_SYNC_FINISHED).pipe( @@ -79,10 +91,10 @@ export class ForegroundSyncService extends CoreSyncService { }), ), ); - this.messageSender.send(DO_FULL_SYNC, { forceSync, allowThrowOnError, requestId }); + this.messageSender.send(DO_FULL_SYNC, { forceSync, options, requestId }); const result = await syncCompletedPromise; - if (allowThrowOnError && result.errorMessage != null) { + if (options.allowThrowOnError && result.errorMessage != null) { throw new Error(result.errorMessage); } diff --git a/apps/browser/src/platform/sync/sync-service.listener.spec.ts b/apps/browser/src/platform/sync/sync-service.listener.spec.ts index 51f97e9f879..9682e2cdb57 100644 --- a/apps/browser/src/platform/sync/sync-service.listener.spec.ts +++ b/apps/browser/src/platform/sync/sync-service.listener.spec.ts @@ -27,11 +27,18 @@ describe("SyncServiceListener", () => { const emissionPromise = firstValueFrom(listener); syncService.fullSync.mockResolvedValueOnce(value); - messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" }); + messages.next({ + forceSync: true, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + requestId: "1", + }); await emissionPromise; - expect(syncService.fullSync).toHaveBeenCalledWith(true, false); + expect(syncService.fullSync).toHaveBeenCalledWith(true, { + allowThrowOnError: false, + skipTokenRefresh: false, + }); expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, { successfully: value, errorMessage: null, @@ -45,11 +52,18 @@ describe("SyncServiceListener", () => { const emissionPromise = firstValueFrom(listener); syncService.fullSync.mockRejectedValueOnce(new Error("SyncError")); - messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" }); + messages.next({ + forceSync: true, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + requestId: "1", + }); await emissionPromise; - expect(syncService.fullSync).toHaveBeenCalledWith(true, false); + expect(syncService.fullSync).toHaveBeenCalledWith(true, { + allowThrowOnError: false, + skipTokenRefresh: false, + }); expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, { successfully: false, errorMessage: "SyncError", diff --git a/apps/browser/src/platform/sync/sync-service.listener.ts b/apps/browser/src/platform/sync/sync-service.listener.ts index b7171528648..4274eafcf6a 100644 --- a/apps/browser/src/platform/sync/sync-service.listener.ts +++ b/apps/browser/src/platform/sync/sync-service.listener.ts @@ -9,6 +9,7 @@ import { MessageSender, isExternalMessage, } from "@bitwarden/common/platform/messaging"; +import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DO_FULL_SYNC } from "./foreground-sync.service"; @@ -34,15 +35,15 @@ export class SyncServiceListener { listener$(): Observable { return this.messageListener.messages$(DO_FULL_SYNC).pipe( filter((message) => isExternalMessage(message)), - concatMap(async ({ forceSync, allowThrowOnError, requestId }) => { - await this.doFullSync(forceSync, allowThrowOnError, requestId); + concatMap(async ({ forceSync, options, requestId }) => { + await this.doFullSync(forceSync, options, requestId); }), ); } - private async doFullSync(forceSync: boolean, allowThrowOnError: boolean, requestId: string) { + private async doFullSync(forceSync: boolean, options: SyncOptions, requestId: string) { try { - const result = await this.syncService.fullSync(forceSync, allowThrowOnError); + const result = await this.syncService.fullSync(forceSync, options); this.messageSender.send(FULL_SYNC_FINISHED, { successfully: result, errorMessage: null, 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..894f27245b2 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,11 +14,12 @@ > {{ "yourVaultIsEmpty" | i18n }} - {{ "autofillSuggestionsTip" | i18n }} - + +

{{ "emptyVaultDescription" | i18n }}

+
+ + {{ "newLogin" | i18n }} +
@@ -28,11 +29,31 @@ > - - - + + + + + + +
+ + +
+ + +
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/apps/cli/README.md b/apps/cli/README.md index d39c0e39c8f..2b13270cdba 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,4 +1,4 @@ -[![Github Workflow build on master](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:master) +[![Github Workflow build on main](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:main) [![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby) # Bitwarden Command-line Interface diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 8d66a566038..8a94cc4175a 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -5,7 +5,7 @@ import * as http from "http"; import { OptionValues } from "commander"; import * as inquirer from "inquirer"; import Separator from "inquirer/lib/objects/separator"; -import { firstValueFrom, map, switchMap } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; import { LoginStrategyServiceAbstraction, @@ -29,7 +29,6 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ClientType } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; @@ -40,6 +39,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { KdfConfigService, KeyService } from "@bitwarden/key-management"; @@ -367,9 +367,9 @@ export class LoginCommand { clientSecret == null ) { if (response.forcePasswordReset === ForceSetPasswordReason.AdminForcePasswordReset) { - return await this.updateTempPassword(); + return await this.updateTempPassword(response.userId); } else if (response.forcePasswordReset === ForceSetPasswordReason.WeakMasterPassword) { - return await this.updateWeakPassword(password); + return await this.updateWeakPassword(response.userId, password); } } @@ -431,7 +431,7 @@ export class LoginCommand { return Response.success(res); } - private async updateWeakPassword(currentPassword: string) { + private async updateWeakPassword(userId: UserId, currentPassword: string) { // If no interaction available, alert user to use web vault if (!this.canInteract) { await this.logoutCallback(); @@ -448,6 +448,7 @@ export class LoginCommand { try { const { newPasswordHash, newUserKey, hint } = await this.collectNewMasterPasswordDetails( + userId, "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now.", ); @@ -469,7 +470,7 @@ export class LoginCommand { } } - private async updateTempPassword() { + private async updateTempPassword(userId: UserId) { // If no interaction available, alert user to use web vault if (!this.canInteract) { await this.logoutCallback(); @@ -486,6 +487,7 @@ export class LoginCommand { try { const { newPasswordHash, newUserKey, hint } = await this.collectNewMasterPasswordDetails( + userId, "An organization administrator recently changed your master password. In order to access the vault, you must update your master password now.", ); @@ -510,10 +512,12 @@ export class LoginCommand { * Collect new master password and hint from the CLI. The collected password * is validated against any applicable master password policies, a new master * key is generated, and we use it to re-encrypt the user key + * @param userId - User ID of the account * @param prompt - Message that is displayed during the initial prompt * @param error */ private async collectNewMasterPasswordDetails( + userId: UserId, prompt: string, error?: string, ): Promise<{ @@ -539,11 +543,12 @@ export class LoginCommand { // Master Password Validation if (masterPassword == null || masterPassword === "") { - return this.collectNewMasterPasswordDetails(prompt, "Master password is required.\n"); + return this.collectNewMasterPasswordDetails(userId, prompt, "Master password is required.\n"); } if (masterPassword.length < Utils.minimumPasswordLength) { return this.collectNewMasterPasswordDetails( + userId, prompt, `Master password must be at least ${Utils.minimumPasswordLength} characters long.\n`, ); @@ -556,10 +561,7 @@ export class LoginCommand { ); const enforcedPolicyOptions = await firstValueFrom( - this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)), - ), + this.policyService.masterPasswordPolicyOptions$(userId), ); // Verify master password meets policy requirements @@ -572,6 +574,7 @@ export class LoginCommand { ) ) { return this.collectNewMasterPasswordDetails( + userId, prompt, "Your new master password does not meet the policy requirements.\n", ); @@ -589,6 +592,7 @@ export class LoginCommand { // Re-type Validation if (masterPassword !== masterPasswordRetype) { return this.collectNewMasterPasswordDetails( + userId, prompt, "Master password confirmation does not match.\n", ); @@ -601,7 +605,7 @@ export class LoginCommand { message: "Master Password Hint (optional):", }); const masterPasswordHint = hint.input; - const kdfConfig = await this.kdfConfigService.getKdfConfig(); + const kdfConfig = await this.kdfConfigService.getKdfConfig(userId); // Create new key and hash new password const newMasterKey = await this.keyService.makeMasterKey( diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6578699369b..ee13f451641 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,4 +1,4 @@ -[![Github Workflow build on master](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:master) +[![Github Workflow build on main](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:main) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/bitwarden-desktop/localized.svg)](https://crowdin.com/project/bitwarden-desktop) [![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e00df0b26df..21892cd1df8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.4.2", + "version": "2025.5.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 81e3a94ff4d..2350e0df4c7 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3712,5 +3712,35 @@ }, "move": { "message": "Move" + }, + "newLoginNudgeTitle": { + "message": "Save time with autofill" + }, + "newLoginNudgeBody": { + "message": "Include a Website so this login appears as an autofill suggestion." + }, + "newCardNudgeTitle": { + "message": "Seamless online checkout" + }, + "newCardNudgeBody": { + "message": "With cards, easily autofill payment forms securely and accurately." + }, + "newIdentityNudgeTitle": { + "message": "Simplify creating accounts" + }, + "newIdentityNudgeBody": { + "message": "With identities, quickly autofill long registration or contact forms." + }, + "newNoteNudgeTitle": { + "message": "Keep your sensitive data safe" + }, + "newNoteNudgeBody": { + "message": "With notes, securely store sensitive data like banking or insurance details." + }, + "newSshNudgeTitle": { + "message": "Developer-friendly SSH access" + }, + "newSshNudgeBody": { + "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication." } } diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index f6449bd9626..b3a33dc75e3 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.4.2", + "version": "2025.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.4.2", + "version": "2025.5.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 45a6f6b90af..c180ed8c744 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.4.2", + "version": "2025.5.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/web/README.md b/apps/web/README.md index f43a9dc1614..c5e03eebb59 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,12 +1,12 @@

- +

The Bitwarden web project is an Angular application that powers the web vault (https://vault.bitwarden.com/).

- - Github Workflow build on master + + Github Workflow build on main Crowdin diff --git a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.spec.ts b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.spec.ts index 0354a08c285..abd99d37355 100644 --- a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.spec.ts +++ b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.spec.ts @@ -1,6 +1,7 @@ import { CollectionView } from "@bitwarden/admin-console/common"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; -import { getNestedCollectionTree } from "./collection-utils"; +import { getNestedCollectionTree, getFlatCollectionTree } from "./collection-utils"; describe("CollectionUtils Service", () => { describe("getNestedCollectionTree", () => { @@ -36,4 +37,63 @@ describe("CollectionUtils Service", () => { expect(result).toEqual([]); }); }); + + describe("getFlatCollectionTree", () => { + it("should flatten a tree node with no children", () => { + // Arrange + const collection = new CollectionView(); + collection.name = "Test Collection"; + collection.id = "test-id"; + + const treeNodes: TreeNode[] = [ + new TreeNode(collection, null), + ]; + + // Act + const result = getFlatCollectionTree(treeNodes); + + // Assert + expect(result.length).toBe(1); + expect(result[0]).toBe(collection); + }); + + it("should flatten a tree node with children", () => { + // Arrange + const parentCollection = new CollectionView(); + parentCollection.name = "Parent"; + parentCollection.id = "parent-id"; + + const child1Collection = new CollectionView(); + child1Collection.name = "Child 1"; + child1Collection.id = "child1-id"; + + const child2Collection = new CollectionView(); + child2Collection.name = "Child 2"; + child2Collection.id = "child2-id"; + + const grandchildCollection = new CollectionView(); + grandchildCollection.name = "Grandchild"; + grandchildCollection.id = "grandchild-id"; + + const parentNode = new TreeNode(parentCollection, null); + const child1Node = new TreeNode(child1Collection, parentNode); + const child2Node = new TreeNode(child2Collection, parentNode); + const grandchildNode = new TreeNode(grandchildCollection, child1Node); + + parentNode.children = [child1Node, child2Node]; + child1Node.children = [grandchildNode]; + + const treeNodes: TreeNode[] = [parentNode]; + + // Act + const result = getFlatCollectionTree(treeNodes); + + // Assert + expect(result.length).toBe(4); + expect(result[0]).toBe(parentCollection); + expect(result).toContain(child1Collection); + expect(result).toContain(child2Collection); + expect(result).toContain(grandchildCollection); + }); + }); }); diff --git a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts index 2926ff3acee..95ae911bbf6 100644 --- a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts +++ b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts @@ -37,6 +37,27 @@ export function getNestedCollectionTree( return nodes; } +export function getFlatCollectionTree( + nodes: TreeNode[], +): CollectionAdminView[]; +export function getFlatCollectionTree(nodes: TreeNode[]): CollectionView[]; +export function getFlatCollectionTree( + nodes: TreeNode[], +): (CollectionView | CollectionAdminView)[] { + if (!nodes || nodes.length === 0) { + return []; + } + + return nodes.flatMap((node) => { + if (!node.children || node.children.length === 0) { + return [node.node]; + } + + const children = getFlatCollectionTree(node.children); + return [node.node, ...children]; + }); +} + function cloneCollection(collection: CollectionView): CollectionView; function cloneCollection(collection: CollectionAdminView): CollectionAdminView; function cloneCollection( diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index ccb97e2a703..7d159da917c 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -121,7 +121,7 @@ import { BulkCollectionsDialogResult, } from "./bulk-collections-dialog"; import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component"; -import { getNestedCollectionTree } from "./utils"; +import { getNestedCollectionTree, getFlatCollectionTree } from "./utils"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; @@ -432,23 +432,33 @@ export class VaultComponent implements OnInit, OnDestroy { } this.showAddAccessToggle = false; - let collectionsToReturn = []; + let searchableCollectionNodes: TreeNode[] = []; if (filter.collectionId === undefined || filter.collectionId === All) { - collectionsToReturn = collections.map((c) => c.node); + searchableCollectionNodes = collections; } else { const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( collections, filter.collectionId, ); - collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? []; + searchableCollectionNodes = selectedCollection?.children ?? []; } + let collectionsToReturn: CollectionAdminView[] = []; + if (await this.searchService.isSearchable(this.userId, searchText)) { + // Flatten the tree for searching through all levels + const flatCollectionTree: CollectionAdminView[] = + getFlatCollectionTree(searchableCollectionNodes); + collectionsToReturn = this.searchPipe.transform( - collectionsToReturn, + flatCollectionTree, searchText, - (collection: CollectionAdminView) => collection.name, - (collection: CollectionAdminView) => collection.id, + (collection) => collection.name, + (collection) => collection.id, + ); + } else { + collectionsToReturn = searchableCollectionNodes.map( + (treeNode: TreeNode): CollectionAdminView => treeNode.node, ); } diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts index d8e371fd36b..ffa5247ad08 100644 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ b/apps/web/src/app/auth/settings/change-password.component.ts @@ -310,13 +310,16 @@ export class ChangePasswordComponent newMasterKey: MasterKey, newUserKey: [UserKey, EncString], ) { - const masterKey = await this.keyService.makeMasterKey( - this.currentMasterPassword, - await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email))), - await this.kdfConfigService.getKdfConfig(), + const [userId, email] = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])), + ); + + const masterKey = await this.keyService.makeMasterKey( + this.currentMasterPassword, + email, + await this.kdfConfigService.getKdfConfig(userId), ); - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); const newLocalKeyHash = await this.keyService.hashMasterKey( this.masterPassword, newMasterKey, diff --git a/apps/web/src/app/auth/settings/security/change-kdf/change-kdf.component.ts b/apps/web/src/app/auth/settings/security/change-kdf/change-kdf.component.ts index 3c392795ef4..cbbef0e016b 100644 --- a/apps/web/src/app/auth/settings/security/change-kdf/change-kdf.component.ts +++ b/apps/web/src/app/auth/settings/security/change-kdf/change-kdf.component.ts @@ -2,8 +2,10 @@ // @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder, FormControl, ValidatorFn, Validators } from "@angular/forms"; -import { Subject, takeUntil } from "rxjs"; +import { Subject, firstValueFrom, takeUntil } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { DialogService } from "@bitwarden/components"; import { KdfConfigService, @@ -43,6 +45,7 @@ export class ChangeKdfComponent implements OnInit, OnDestroy { constructor( private dialogService: DialogService, private kdfConfigService: KdfConfigService, + private accountService: AccountService, private formBuilder: FormBuilder, ) { this.kdfOptions = [ @@ -52,7 +55,8 @@ export class ChangeKdfComponent implements OnInit, OnDestroy { } async ngOnInit() { - this.kdfConfig = await this.kdfConfigService.getKdfConfig(); + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId); this.formGroup.get("kdf").setValue(this.kdfConfig.kdfType); this.setFormControlValues(this.kdfConfig); diff --git a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html index 2dbcc577e54..405211d6ecb 100644 --- a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html +++ b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html @@ -34,7 +34,15 @@ - - - - - + + +

+ {{ "sponsorshipFreeBitwardenFamilies" | i18n }} +

+
+ {{ "sponsoredFamiliesIncludeMessage" | i18n }}: +
    +
  • {{ "sponsoredFamiliesPremiumAccess" | i18n }}
  • +
  • {{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}
  • +
+
- - - - +

{{ "sponsoredBitwardenFamilies" | i18n }}

-

{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}

+ @if (loading()) { + + + {{ "loading" | i18n }} + + } + + @if (!loading() && sponsoredFamilies?.length > 0) { + + + + + {{ "recipient" | i18n }} + {{ "status" | i18n }} + {{ "notes" | i18n }} + + + + + @for (o of sponsoredFamilies; let i = $index; track i) { + + + {{ o.friendlyName }} + {{ o.statusMessage }} + {{ o.notes }} + + + + + +
+ + +
+ + +
+ } +
+
+
+
+ } @else if (!loading()) { +
+ Search +

{{ "noSponsoredFamiliesMessage" | i18n }}

+

{{ "nosponsoredFamiliesDetails" | i18n }}

+
+ } + + @if (!loading() && sponsoredFamilies.length > 0) { +

{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}

+ } +
+ diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts index af43e5a4bc1..c141eaebd78 100644 --- a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts @@ -1,62 +1,259 @@ import { DialogRef } from "@angular/cdk/dialog"; -import { Component, OnInit } from "@angular/core"; -import { Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { formatDate } from "@angular/common"; +import { Component, OnInit, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute } from "@angular/router"; +import { firstValueFrom, map, Observable, switchMap } from "rxjs"; -import { DialogService } from "@bitwarden/components"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; +import { OrganizationSponsorshipInvitesResponse } from "@bitwarden/common/billing/models/response/organization-sponsorship-invites.response"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; -import { FreeFamiliesPolicyService } from "../services/free-families-policy.service"; - -import { - AddSponsorshipDialogComponent, - AddSponsorshipDialogResult, -} from "./add-sponsorship-dialog.component"; -import { SponsoredFamily } from "./types/sponsored-family"; +import { AddSponsorshipDialogComponent } from "./add-sponsorship-dialog.component"; @Component({ selector: "app-free-bitwarden-families", templateUrl: "free-bitwarden-families.component.html", }) export class FreeBitwardenFamiliesComponent implements OnInit { + loading = signal(true); tabIndex = 0; - sponsoredFamilies: SponsoredFamily[] = []; + sponsoredFamilies: OrganizationSponsorshipInvitesResponse[] = []; + + organizationId = ""; + organizationKey$: Observable; + + private locale: string = ""; constructor( - private router: Router, + private route: ActivatedRoute, private dialogService: DialogService, - private freeFamiliesPolicyService: FreeFamiliesPolicyService, - ) {} + private apiService: ApiService, + private encryptService: EncryptService, + private keyService: KeyService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private logService: LogService, + private toastService: ToastService, + private organizationSponsorshipApiService: OrganizationSponsorshipApiServiceAbstraction, + private stateProvider: StateProvider, + ) { + this.organizationId = this.route.snapshot.params.organizationId || ""; + this.organizationKey$ = this.stateProvider.activeUserId$.pipe( + switchMap( + (userId) => + this.keyService.orgKeys$(userId as UserId) as Observable>, + ), + map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]), + takeUntilDestroyed(), + ); + } async ngOnInit() { - await this.preventAccessToFreeFamiliesPage(); + this.locale = await firstValueFrom(this.i18nService.locale$); + await this.loadSponsorships(); + + this.loading.set(false); + } + + async loadSponsorships() { + if (!this.organizationId) { + return; + } + + const [response, orgKey] = await Promise.all([ + this.organizationSponsorshipApiService.getOrganizationSponsorship(this.organizationId), + firstValueFrom(this.organizationKey$), + ]); + + if (!orgKey) { + this.logService.error("Organization key not found"); + return; + } + + const organizationFamilies = response.data; + + this.sponsoredFamilies = await Promise.all( + organizationFamilies.map(async (family) => { + let decryptedNote = ""; + try { + decryptedNote = await this.encryptService.decryptString( + new EncString(family.notes), + orgKey, + ); + } catch (e) { + this.logService.error(e); + } + + const { statusMessage, statusClass } = this.getStatus( + this.isSelfHosted, + family.toDelete, + family.validUntil, + family.lastSyncDate, + this.locale, + ); + + const newFamily = { + ...family, + notes: decryptedNote, + statusMessage: statusMessage || "", + statusClass: statusClass || "tw-text-success", + status: statusMessage || "", + }; + + return new OrganizationSponsorshipInvitesResponse(newFamily); + }), + ); } async addSponsorship() { - const addSponsorshipDialogRef: DialogRef = - AddSponsorshipDialogComponent.open(this.dialogService); + const addSponsorshipDialogRef: DialogRef = AddSponsorshipDialogComponent.open( + this.dialogService, + { + data: { + organizationId: this.organizationId, + organizationKey: await firstValueFrom(this.organizationKey$), + }, + }, + ); - const dialogRef = await firstValueFrom(addSponsorshipDialogRef.closed); + await firstValueFrom(addSponsorshipDialogRef.closed); - if (dialogRef?.value) { - this.sponsoredFamilies = [dialogRef.value, ...this.sponsoredFamilies]; + await this.loadSponsorships(); + } + + async removeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) { + try { + await this.doRevokeSponsorship(sponsorship); + } catch (e) { + this.logService.error(e); } } - removeSponsorhip(sponsorship: any) { - const index = this.sponsoredFamilies.findIndex( - (e) => e.sponsorshipEmail == sponsorship.sponsorshipEmail, - ); - this.sponsoredFamilies.splice(index, 1); + get isSelfHosted(): boolean { + return this.platformUtilsService.isSelfHost(); } - private async preventAccessToFreeFamiliesPage() { - const showFreeFamiliesPage = await firstValueFrom( - this.freeFamiliesPolicyService.showFreeFamilies$, - ); + async resendEmail(sponsorship: OrganizationSponsorshipInvitesResponse) { + await this.apiService.postResendSponsorshipOffer(sponsorship.sponsoringOrganizationUserId); + this.toastService.showToast({ + variant: "success", + title: undefined, + message: this.i18nService.t("emailSent"), + }); + } - if (!showFreeFamiliesPage) { - await this.router.navigate(["/"]); + private async doRevokeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) { + const content = sponsorship.validUntil + ? this.i18nService.t( + "updatedRevokeSponsorshipConfirmationForAcceptedSponsorship", + sponsorship.friendlyName, + formatDate(sponsorship.validUntil, "MM/dd/yyyy", this.locale), + ) + : this.i18nService.t( + "updatedRevokeSponsorshipConfirmationForSentSponsorship", + sponsorship.friendlyName, + ); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: `${this.i18nService.t("removeSponsorship")}?`, + content, + acceptButtonText: { key: "remove" }, + type: "warning", + }); + + if (!confirmed) { return; } + + await this.apiService.deleteRevokeSponsorship(sponsorship.sponsoringOrganizationUserId); + + this.toastService.showToast({ + variant: "success", + title: undefined, + message: this.i18nService.t("reclaimedFreePlan"), + }); + + await this.loadSponsorships(); + } + + private getStatus( + selfHosted: boolean, + toDelete?: boolean, + validUntil?: Date, + lastSyncDate?: Date, + locale: string = "", + ): { statusMessage: string; statusClass: "tw-text-success" | "tw-text-danger" } { + /* + * Possible Statuses: + * Requested (self-hosted only) + * Sent + * Active + * RequestRevoke + * RevokeWhenExpired + */ + + if (toDelete && validUntil) { + // They want to delete but there is a valid until date which means there is an active sponsorship + return { + statusMessage: this.i18nService.t( + "revokeWhenExpired", + formatDate(validUntil, "MM/dd/yyyy", locale), + ), + statusClass: "tw-text-danger", + }; + } + + if (toDelete) { + // They want to delete and we don't have a valid until date so we can + // this should only happen on a self-hosted install + return { + statusMessage: this.i18nService.t("requestRemoved"), + statusClass: "tw-text-danger", + }; + } + + if (validUntil) { + // They don't want to delete and they have a valid until date + // that means they are actively sponsoring someone + return { + statusMessage: this.i18nService.t("active"), + statusClass: "tw-text-success", + }; + } + + if (selfHosted && lastSyncDate) { + // We are on a self-hosted install and it has been synced but we have not gotten + // a valid until date so we can't know if they are actively sponsoring someone + return { + statusMessage: this.i18nService.t("sent"), + statusClass: "tw-text-success", + }; + } + + if (!selfHosted) { + // We are in cloud and all other status checks have been false therefore we have + // sent the request but it hasn't been accepted yet + return { + statusMessage: this.i18nService.t("sent"), + statusClass: "tw-text-success", + }; + } + + // We are on a self-hosted install and we have not synced yet + return { + statusMessage: this.i18nService.t("requested"), + statusClass: "tw-text-success", + }; } } diff --git a/apps/web/src/app/billing/members/organization-member-families.component.html b/apps/web/src/app/billing/members/organization-member-families.component.html deleted file mode 100644 index c5b7283d9d9..00000000000 --- a/apps/web/src/app/billing/members/organization-member-families.component.html +++ /dev/null @@ -1,47 +0,0 @@ - - -

- {{ "membersWithSponsoredFamilies" | i18n }} -

- -

{{ "memberFamilies" | i18n }}

- - @if (loading) { - - - {{ "loading" | i18n }} - - } - - @if (!loading && memberFamilies?.length > 0) { - - - - - {{ "member" | i18n }} - {{ "status" | i18n }} - - - - - @for (o of memberFamilies; let i = $index; track i) { - - - {{ o.sponsorshipEmail }} - {{ o.status }} - - - } - - -
-
- } @else { -
- Search -

{{ "noMemberFamilies" | i18n }}

-

{{ "noMemberFamiliesDescription" | i18n }}

-
- } -
-
diff --git a/apps/web/src/app/billing/members/organization-member-families.component.ts b/apps/web/src/app/billing/members/organization-member-families.component.ts deleted file mode 100644 index 52c95646a11..00000000000 --- a/apps/web/src/app/billing/members/organization-member-families.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Component, Input, OnDestroy, OnInit } from "@angular/core"; -import { Subject } from "rxjs"; - -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; - -import { SponsoredFamily } from "./types/sponsored-family"; - -@Component({ - selector: "app-organization-member-families", - templateUrl: "organization-member-families.component.html", -}) -export class OrganizationMemberFamiliesComponent implements OnInit, OnDestroy { - tabIndex = 0; - loading = false; - - @Input() memberFamilies: SponsoredFamily[] = []; - - private _destroy = new Subject(); - - constructor(private platformUtilsService: PlatformUtilsService) {} - - async ngOnInit() { - this.loading = false; - } - - ngOnDestroy(): void { - this._destroy.next(); - this._destroy.complete(); - } - - get isSelfHosted(): boolean { - return this.platformUtilsService.isSelfHost(); - } -} diff --git a/apps/web/src/app/billing/members/organization-sponsored-families.component.html b/apps/web/src/app/billing/members/organization-sponsored-families.component.html deleted file mode 100644 index 7db96deb4ab..00000000000 --- a/apps/web/src/app/billing/members/organization-sponsored-families.component.html +++ /dev/null @@ -1,87 +0,0 @@ - - -

- {{ "sponsorFreeBitwardenFamilies" | i18n }} -

-
- {{ "sponsoredFamiliesInclude" | i18n }}: -
    -
  • {{ "sponsoredFamiliesPremiumAccess" | i18n }}
  • -
  • {{ "sponsoredFamiliesSharedCollections" | i18n }}
  • -
-
- -

{{ "sponsoredBitwardenFamilies" | i18n }}

- - @if (loading) { - - - {{ "loading" | i18n }} - - } - - @if (!loading && sponsoredFamilies?.length > 0) { - - - - - {{ "recipient" | i18n }} - {{ "status" | i18n }} - {{ "notes" | i18n }} - - - - - @for (o of sponsoredFamilies; let i = $index; track i) { - - - {{ o.sponsorshipEmail }} - {{ o.status }} - {{ o.sponsorshipNote }} - - - - - -
- - -
- - -
- } -
-
-
-
- } @else { -
- Search -

{{ "noSponsoredFamilies" | i18n }}

-

{{ "noSponsoredFamiliesDescription" | i18n }}

-
- } -
-
diff --git a/apps/web/src/app/billing/members/organization-sponsored-families.component.ts b/apps/web/src/app/billing/members/organization-sponsored-families.component.ts deleted file mode 100644 index 7cc46634a38..00000000000 --- a/apps/web/src/app/billing/members/organization-sponsored-families.component.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { Subject } from "rxjs"; - -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; - -import { SponsoredFamily } from "./types/sponsored-family"; - -@Component({ - selector: "app-organization-sponsored-families", - templateUrl: "organization-sponsored-families.component.html", -}) -export class OrganizationSponsoredFamiliesComponent implements OnInit, OnDestroy { - loading = false; - tabIndex = 0; - - @Input() sponsoredFamilies: SponsoredFamily[] = []; - @Output() removeSponsorshipEvent = new EventEmitter(); - - private _destroy = new Subject(); - - constructor(private platformUtilsService: PlatformUtilsService) {} - - async ngOnInit() { - this.loading = false; - } - - get isSelfHosted(): boolean { - return this.platformUtilsService.isSelfHost(); - } - - remove(sponsorship: SponsoredFamily) { - this.removeSponsorshipEvent.emit(sponsorship); - } - - ngOnDestroy(): void { - this._destroy.next(); - this._destroy.complete(); - } -} diff --git a/apps/web/src/app/billing/settings/sponsored-families.component.html b/apps/web/src/app/billing/settings/sponsored-families.component.html index 12e942aaf18..5a6957718a3 100644 --- a/apps/web/src/app/billing/settings/sponsored-families.component.html +++ b/apps/web/src/app/billing/settings/sponsored-families.component.html @@ -10,10 +10,10 @@ {{ "sponsoredFamiliesEligible" | i18n }}

- {{ "sponsoredFamiliesInclude" | i18n }}: + {{ "sponsoredFamiliesIncludeMessage" | i18n }}:
  • {{ "sponsoredFamiliesPremiumAccess" | i18n }}
  • -
  • {{ "sponsoredFamiliesSharedCollections" | i18n }}
  • +
  • {{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}
diff --git a/apps/web/src/app/billing/settings/sponsoring-org-row.component.html b/apps/web/src/app/billing/settings/sponsoring-org-row.component.html index eeeaa256049..1e5690cd85a 100644 --- a/apps/web/src/app/billing/settings/sponsoring-org-row.component.html +++ b/apps/web/src/app/billing/settings/sponsoring-org-row.component.html @@ -32,7 +32,7 @@ type="button" bitMenuItem (click)="revokeSponsorship()" - [attr.aria-label]="'revokeAccount' | i18n: sponsoringOrg.familySponsorshipFriendlyName" + [attr.aria-label]="'revokeAccountMessage' | i18n: sponsoringOrg.familySponsorshipFriendlyName" > {{ "remove" | i18n }} diff --git a/apps/web/src/app/billing/shared/payment/payment.component.html b/apps/web/src/app/billing/shared/payment/payment.component.html index c86975cd0e8..0d76d98e334 100644 --- a/apps/web/src/app/billing/shared/payment/payment.component.html +++ b/apps/web/src/app/billing/shared/payment/payment.component.html @@ -81,7 +81,7 @@ - {{ "verifyBankAccountWithStatementDescriptorWarning" | i18n }} + {{ bankAccountWarning }}
diff --git a/apps/web/src/app/billing/shared/payment/payment.component.ts b/apps/web/src/app/billing/shared/payment/payment.component.ts index c7c3e31c89f..5911e377869 100644 --- a/apps/web/src/app/billing/shared/payment/payment.component.ts +++ b/apps/web/src/app/billing/shared/payment/payment.component.ts @@ -8,6 +8,7 @@ import { takeUntil } from "rxjs/operators"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { TokenizedPaymentSourceRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-source.request"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SharedModule } from "../../../shared"; import { BillingServicesModule, BraintreeService, StripeService } from "../../services"; @@ -37,6 +38,8 @@ export class PaymentComponent implements OnInit, OnDestroy { /** If provided, will be invoked with the tokenized payment source during form submission. */ @Input() protected onSubmit?: (request: TokenizedPaymentSourceRequest) => Promise; + @Input() private bankAccountWarningOverride?: string; + @Output() submitted = new EventEmitter(); private destroy$ = new Subject(); @@ -56,6 +59,7 @@ export class PaymentComponent implements OnInit, OnDestroy { constructor( private billingApiService: BillingApiServiceAbstraction, private braintreeService: BraintreeService, + private i18nService: I18nService, private stripeService: StripeService, ) {} @@ -200,4 +204,12 @@ export class PaymentComponent implements OnInit, OnDestroy { private get usingStripe(): boolean { return this.usingBankAccount || this.usingCard; } + + get bankAccountWarning(): string { + if (this.bankAccountWarningOverride) { + return this.bankAccountWarningOverride; + } else { + return this.i18nService.t("verifyBankAccountWithStatementDescriptorWarning"); + } + } } diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index bec06888c57..90e4c6ba9c3 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -62,8 +62,6 @@ import { PipesModule } from "../vault/individual-vault/pipes/pipes.module"; import { PurgeVaultComponent } from "../vault/settings/purge-vault.component"; import { FreeBitwardenFamiliesComponent } from "./../billing/members/free-bitwarden-families.component"; -import { OrganizationMemberFamiliesComponent } from "./../billing/members/organization-member-families.component"; -import { OrganizationSponsoredFamiliesComponent } from "./../billing/members/organization-sponsored-families.component"; import { EnvironmentSelectorModule } from "./../components/environment-selector/environment-selector.module"; import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component"; import { SharedModule } from "./shared.module"; @@ -128,8 +126,6 @@ import { SharedModule } from "./shared.module"; SelectableAvatarComponent, SetPasswordComponent, SponsoredFamiliesComponent, - OrganizationSponsoredFamiliesComponent, - OrganizationMemberFamiliesComponent, FreeBitwardenFamiliesComponent, SponsoringOrgRowComponent, UpdatePasswordComponent, @@ -176,8 +172,6 @@ import { SharedModule } from "./shared.module"; SelectableAvatarComponent, SetPasswordComponent, SponsoredFamiliesComponent, - OrganizationSponsoredFamiliesComponent, - OrganizationMemberFamiliesComponent, FreeBitwardenFamiliesComponent, SponsoringOrgRowComponent, UpdateTempPasswordComponent, diff --git a/apps/web/src/app/tools/reports/pages/cipher-report.component.ts b/apps/web/src/app/tools/reports/pages/cipher-report.component.ts index 0ad8a0a519c..ceda7b1c480 100644 --- a/apps/web/src/app/tools/reports/pages/cipher-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/cipher-report.component.ts @@ -67,7 +67,7 @@ export class CipherReportComponent implements OnDestroy { protected i18nService: I18nService, private syncService: SyncService, private cipherFormConfigService: CipherFormConfigService, - private adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, + protected adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, ) { this.organizations$ = this.accountService.activeAccount$.pipe( getUserId, @@ -207,7 +207,7 @@ export class CipherReportComponent implements OnDestroy { // If the dialog was closed by deleting the cipher, refresh the report. if (result === VaultItemDialogResult.Deleted || result === VaultItemDialogResult.Saved) { - await this.load(); + await this.refresh(result, cipher); } } @@ -215,6 +215,10 @@ export class CipherReportComponent implements OnDestroy { this.allCiphers = []; } + protected async refresh(result: VaultItemDialogResult, cipher: CipherView) { + await this.load(); + } + protected async repromptCipher(c: CipherView) { return ( c.reprompt === CipherRepromptType.None || diff --git a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts index f7631e37a7d..4144c9ac20f 100644 --- a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts @@ -1,18 +1,22 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { CipherId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; +import { VaultItemDialogResult } from "@bitwarden/web-vault/app/vault/components/vault-item-dialog/vault-item-dialog.component"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; @@ -40,7 +44,7 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen i18nService: I18nService, syncService: SyncService, cipherFormConfigService: CipherFormConfigService, - adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, + protected adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, ) { super( cipherService, @@ -66,62 +70,112 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen this.findWeakPasswords(allCiphers); } - protected findWeakPasswords(ciphers: CipherView[]): void { - ciphers.forEach((ciph) => { - const { type, login, isDeleted, edit, viewPassword } = ciph; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { + protected async refresh(result: VaultItemDialogResult, cipher: CipherView) { + if (result === VaultItemDialogResult.Deleted) { + // remove the cipher from the list + this.weakPasswordCiphers = this.weakPasswordCiphers.filter((c) => c.id !== cipher.id); + this.filterCiphersByOrg(this.weakPasswordCiphers); + return; + } + + if (result == VaultItemDialogResult.Saved) { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + let updatedCipher = await this.cipherService.get(cipher.id, activeUserId); + + if (this.isAdminConsoleActive) { + updatedCipher = await this.adminConsoleCipherFormConfigService.getCipher( + cipher.id as CipherId, + this.organization, + ); + } + + const updatedCipherView = await updatedCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId), + ); + // update the cipher views + const updatedReportResult = this.determineWeakPasswordScore(updatedCipherView); + const index = this.weakPasswordCiphers.findIndex((c) => c.id === updatedCipherView.id); + + if (updatedReportResult == null) { + // the password is no longer weak + // remove the cipher from the list + this.weakPasswordCiphers.splice(index, 1); + this.filterCiphersByOrg(this.weakPasswordCiphers); return; } - const hasUserName = this.isUserNameNotEmpty(ciph); - let userInput: string[] = []; - if (hasUserName) { - const atPosition = login.username.indexOf("@"); - if (atPosition > -1) { - userInput = userInput - .concat( - login.username - .substr(0, atPosition) - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/), - ) - .filter((i) => i.length >= 3); - } else { - userInput = login.username - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/) - .filter((i) => i.length >= 3); - } + if (index > -1) { + // update the existing cipher + this.weakPasswordCiphers[index] = updatedReportResult; + this.filterCiphersByOrg(this.weakPasswordCiphers); } - const result = this.passwordStrengthService.getPasswordStrength( - login.password, - null, - userInput.length > 0 ? userInput : null, - ); + } + } - if (result.score != null && result.score <= 2) { - const scoreValue = this.scoreKey(result.score); - const row = { - ...ciph, - score: result.score, - reportValue: scoreValue, - scoreKey: scoreValue.sortOrder, - } as ReportResult; + protected findWeakPasswords(ciphers: CipherView[]): void { + ciphers.forEach((ciph) => { + const row = this.determineWeakPasswordScore(ciph); + if (row != null) { this.weakPasswordCiphers.push(row); } }); this.filterCiphersByOrg(this.weakPasswordCiphers); } + protected determineWeakPasswordScore(ciph: CipherView): ReportResult | null { + const { type, login, isDeleted, edit, viewPassword } = ciph; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + (!this.organization && !edit) || + !viewPassword + ) { + return; + } + + const hasUserName = this.isUserNameNotEmpty(ciph); + let userInput: string[] = []; + if (hasUserName) { + const atPosition = login.username.indexOf("@"); + if (atPosition > -1) { + userInput = userInput + .concat( + login.username + .substr(0, atPosition) + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/), + ) + .filter((i) => i.length >= 3); + } else { + userInput = login.username + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/) + .filter((i) => i.length >= 3); + } + } + const result = this.passwordStrengthService.getPasswordStrength( + login.password, + null, + userInput.length > 0 ? userInput : null, + ); + + if (result.score != null && result.score <= 2) { + const scoreValue = this.scoreKey(result.score); + return { + ...ciph, + score: result.score, + reportValue: scoreValue, + scoreKey: scoreValue.sortOrder, + } as ReportResult; + } + + return null; + } + protected canManageCipher(c: CipherView): boolean { // this will only ever be false from the org view; return true; diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 67bd6a6a526..55bbd0c0651 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -79,7 +79,10 @@ import { PasswordRepromptService, } from "@bitwarden/vault"; -import { getNestedCollectionTree } from "../../admin-console/organizations/collections"; +import { + getNestedCollectionTree, + getFlatCollectionTree, +} from "../../admin-console/organizations/collections"; import { CollectionDialogAction, CollectionDialogTabType, @@ -372,31 +375,35 @@ export class VaultComponent implements OnInit, OnDestroy { if (filter.collectionId === undefined || filter.collectionId === Unassigned) { return []; } - let collectionsToReturn = []; + let searchableCollectionNodes: TreeNode[] = []; if (filter.organizationId !== undefined && filter.collectionId === All) { - collectionsToReturn = collections - .filter((c) => c.node.organizationId === filter.organizationId) - .map((c) => c.node); + searchableCollectionNodes = collections.filter( + (c) => c.node.organizationId === filter.organizationId, + ); } else if (filter.collectionId === All) { - collectionsToReturn = collections.map((c) => c.node); + searchableCollectionNodes = collections; } else { const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( collections, filter.collectionId, ); - collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? []; + searchableCollectionNodes = selectedCollection?.children ?? []; } if (await this.searchService.isSearchable(activeUserId, searchText)) { - collectionsToReturn = this.searchPipe.transform( - collectionsToReturn, + // Flatten the tree for searching through all levels + const flatCollectionTree: CollectionView[] = + getFlatCollectionTree(searchableCollectionNodes); + + return this.searchPipe.transform( + flatCollectionTree, searchText, (collection) => collection.name, (collection) => collection.id, ); } - return collectionsToReturn; + return searchableCollectionNodes.map((treeNode: TreeNode) => treeNode.node); }), shareReplay({ refCount: true, bufferSize: 1 }), ); diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts index dd9cef91a54..15af27ba8d0 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts @@ -100,7 +100,7 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ }; } - private async getCipher(id: CipherId | null, organization: Organization): Promise { + async getCipher(id: CipherId | null, organization: Organization): Promise { if (id == null) { return null; } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4650ded54bb..59ba7961d82 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6303,13 +6303,13 @@ "sponsoredBitwardenFamilies": { "message": "Sponsored families" }, - "noSponsoredFamilies": { + "noSponsoredFamiliesMessage": { "message": "No sponsored families" }, - "noSponsoredFamiliesDescription": { + "nosponsoredFamiliesDetails": { "message": "Sponsored non-member families plans will display here" }, - "sponsorFreeBitwardenFamilies": { + "sponsorshipFreeBitwardenFamilies": { "message": "Members of your organization are eligible for Free Bitwarden Families. You can sponsor Free Bitwarden Families for employees who are not a member of your Bitwarden organization. Sponsoring a non-member requires an available seat within your organization." }, "sponsoredFamiliesRemoveActiveSponsorship": { @@ -6321,14 +6321,14 @@ "sponsoredFamiliesEligibleCard": { "message": "Redeem your Free Bitwarden for Families plan today to keep your data secure even when you are not at work." }, - "sponsoredFamiliesInclude": { - "message": "The Bitwarden for Families plan include" + "sponsoredFamiliesIncludeMessage": { + "message": "The Bitwarden for Families plan includes" }, "sponsoredFamiliesPremiumAccess": { "message": "Premium access for up to 6 users" }, - "sponsoredFamiliesSharedCollections": { - "message": "Shared collections for Family secrets" + "sponsoredFamiliesSharedCollectionsMessage": { + "message": "Shared collections for family members" }, "memberFamilies": { "message": "Member families" @@ -6342,6 +6342,15 @@ "membersWithSponsoredFamilies": { "message": "Members of your organization are eligible for Free Bitwarden Families. Here you can see members who have sponsored a Families organization." }, + "organizationHasMemberMessage": { + "message": "A sponsorship cannot be sent to $EMAIL$ because they are a member of your organization.", + "placeholders": { + "email": { + "content": "$1", + "example": "mail@example.com" + } + } + }, "badToken": { "message": "The link is no longer valid. Please have the sponsor resend the offer." }, @@ -6393,7 +6402,7 @@ "redeemedAccount": { "message": "Account redeemed" }, - "revokeAccount": { + "revokeAccountMessage": { "message": "Revoke account $NAME$", "placeholders": { "name": { @@ -10620,7 +10629,40 @@ "newBusinessUnit": { "message": "New business unit" }, + "newLoginNudgeTitle": { + "message": "Save time with autofill" + }, + "newLoginNudgeBody": { + "message": "Include a Website so this login appears as an autofill suggestion." + }, + "newCardNudgeTitle": { + "message": "Seamless online checkout" + }, + "newCardNudgeBody": { + "message": "With cards, easily autofill payment forms securely and accurately." + }, + "newIdentityNudgeTitle": { + "message": "Simplify creating accounts" + }, + "newIdentityNudgeBody": { + "message": "With identities, quickly autofill long registration or contact forms." + }, + "newNoteNudgeTitle": { + "message": "Keep your sensitive data safe" + }, + "newNoteNudgeBody": { + "message": "With notes, securely store sensitive data like banking or insurance details." + }, + "newSshNudgeTitle": { + "message": "Developer-friendly SSH access" + }, + "newSshNudgeBody": { + "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication." + }, "restart": { "message": "Restart" + }, + "verifyProviderBankAccountWithStatementDescriptorWarning": { + "message": "Payment with a bank account is only available to customers in the United States. You will be required to verify your bank account. We will make a micro-deposit within the next 1-2 business days. Enter the statement descriptor code from this deposit on the provider's subscription page to verify the bank account. Failure to verify the bank account will result in a missed payment and your subscription being suspended." } } 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/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index dd9baa99948..1c15812edc8 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -7,6 +7,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { CardComponent, SearchModule } from "@bitwarden/components"; import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component"; import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing"; +import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component"; import { VerifyBankAccountComponent } from "@bitwarden/web-vault/app/billing/shared/verify-bank-account/verify-bank-account.component"; import { OssModule } from "@bitwarden/web-vault/app/oss.module"; @@ -53,6 +54,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr ScrollingModule, VerifyBankAccountComponent, CardComponent, + PaymentComponent, ], declarations: [ AcceptProviderComponent, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html index 38b4c3bc9de..4c5a35ea58d 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html @@ -12,23 +12,50 @@

{{ "setupProviderDesc" | i18n }}

-

{{ "generalInformation" | i18n }}

-
-
- - {{ "providerName" | i18n }} - - + @if (!(requireProviderPaymentMethodDuringSetup$ | async)) { +

{{ "generalInformation" | i18n }}

+
+
+ + {{ "providerName" | i18n }} + + +
+
+ + {{ "billingEmail" | i18n }} + + {{ "providerBillingEmailHint" | i18n }} + +
-
- - {{ "billingEmail" | i18n }} - - {{ "providerBillingEmailHint" | i18n }} - + + } @else { +

{{ "billingInformation" | i18n }}

+
+
+ + {{ "providerName" | i18n }} + + +
+
+ + {{ "billingEmail" | i18n }} + + {{ "providerBillingEmailHint" | i18n }} + +
-
- +

{{ "paymentMethod" | i18n }}

+ + + } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts index ecf649b8f31..0b6483b9f48 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts @@ -3,13 +3,14 @@ import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, switchMap } from "rxjs"; +import { firstValueFrom, Subject, switchMap } from "rxjs"; import { first, takeUntil } from "rxjs/operators"; import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request"; import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -17,12 +18,14 @@ import { ProviderKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component"; @Component({ selector: "provider-setup", templateUrl: "setup.component.html", }) export class SetupComponent implements OnInit, OnDestroy { + @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; @ViewChild(ManageTaxInformationComponent) taxInformationComponent: ManageTaxInformationComponent; loading = true; @@ -36,6 +39,10 @@ export class SetupComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); + requireProviderPaymentMethodDuringSetup$ = this.configService.getFeatureFlag$( + FeatureFlag.PM19956_RequireProviderPaymentMethodDuringSetup, + ); + constructor( private router: Router, private i18nService: I18nService, @@ -134,6 +141,14 @@ export class SetupComponent implements OnInit, OnDestroy { request.taxInfo.city = taxInformation.city; request.taxInfo.state = taxInformation.state; + const requireProviderPaymentMethodDuringSetup = await firstValueFrom( + this.requireProviderPaymentMethodDuringSetup$, + ); + + if (requireProviderPaymentMethodDuringSetup) { + request.paymentSource = await this.paymentComponent.tokenize(); + } + const provider = await this.providerApiService.postProviderSetup(this.providerId, request); this.toastService.showToast({ diff --git a/libs/angular/src/auth/components/change-password.component.ts b/libs/angular/src/auth/components/change-password.component.ts index 3b186a7fd2e..ca81f741b23 100644 --- a/libs/angular/src/auth/components/change-password.component.ts +++ b/libs/angular/src/auth/components/change-password.component.ts @@ -83,11 +83,12 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { return; } - const email = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.email)), + const [userId, email] = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])), ); + if (this.kdfConfig == null) { - this.kdfConfig = await this.kdfConfigService.getKdfConfig(); + this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId); } // Create new master key diff --git a/libs/angular/src/auth/components/update-password.component.ts b/libs/angular/src/auth/components/update-password.component.ts index 77e854753d7..47affbecdf2 100644 --- a/libs/angular/src/auth/components/update-password.component.ts +++ b/libs/angular/src/auth/components/update-password.component.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Directive } from "@angular/core"; import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; @@ -10,6 +11,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { Verification } from "@bitwarden/common/auth/types/verification"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -96,8 +98,8 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent { }); return false; } - - this.kdfConfig = await this.kdfConfigService.getKdfConfig(); + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId); return true; } diff --git a/libs/angular/src/auth/components/update-temp-password.component.ts b/libs/angular/src/auth/components/update-temp-password.component.ts index 267beb2b822..db2f319998a 100644 --- a/libs/angular/src/auth/components/update-temp-password.component.ts +++ b/libs/angular/src/auth/components/update-temp-password.component.ts @@ -110,10 +110,11 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp } async setupSubmitActions(): Promise { - this.email = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.email)), + const [userId, email] = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])), ); - this.kdfConfig = await this.kdfConfigService.getKdfConfig(); + this.email = email; + this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId); return true; } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 1cc2b591412..0d59f4a6547 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -136,11 +136,13 @@ import { import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; +import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service"; +import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { TaxService } from "@bitwarden/common/billing/services/tax.service"; import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; @@ -1063,6 +1065,11 @@ const safeProviders: SafeProvider[] = [ // subscribes to sync notifications and will update itself based on that. deps: [ApiServiceAbstraction, SyncService], }), + safeProvider({ + provide: OrganizationSponsorshipApiServiceAbstraction, + useClass: OrganizationSponsorshipApiService, + deps: [ApiServiceAbstraction], + }), safeProvider({ provide: OrganizationBillingApiServiceAbstraction, useClass: OrganizationBillingApiService, diff --git a/libs/auth/src/common/services/pin/pin.service.implementation.ts b/libs/auth/src/common/services/pin/pin.service.implementation.ts index c0034020de8..4e363063f2f 100644 --- a/libs/auth/src/common/services/pin/pin.service.implementation.ts +++ b/libs/auth/src/common/services/pin/pin.service.implementation.ts @@ -172,7 +172,7 @@ export class PinService implements PinServiceAbstraction { const email = await firstValueFrom( this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)), ); - const kdfConfig = await this.kdfConfigService.getKdfConfig(); + const kdfConfig = await this.kdfConfigService.getKdfConfig(userId); const pinKey = await this.makePinKey(pin, email, kdfConfig); return await this.encryptService.wrapSymmetricKey(userKey, pinKey); @@ -293,7 +293,7 @@ export class PinService implements PinServiceAbstraction { const email = await firstValueFrom( this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)), ); - const kdfConfig = await this.kdfConfigService.getKdfConfig(); + const kdfConfig = await this.kdfConfigService.getKdfConfig(userId); const userKey: UserKey = await this.decryptUserKey( userId, diff --git a/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts b/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts index 19e993487c2..726bd6a85e1 100644 --- a/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts +++ b/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts @@ -6,5 +6,6 @@ export class OrganizationSponsorshipCreateRequest { sponsoredEmail: string; planSponsorshipType: PlanSponsorshipType; friendlyName: string; + isAdminInitiated?: boolean; notes?: string; } diff --git a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts index d4992e969dc..5c9ea5526a0 100644 --- a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts +++ b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { ExpandedTaxInfoUpdateRequest } from "../../../../billing/models/request/expanded-tax-info-update.request"; +import { TokenizedPaymentSourceRequest } from "../../../../billing/models/request/tokenized-payment-source.request"; export class ProviderSetupRequest { name: string; @@ -9,4 +10,5 @@ export class ProviderSetupRequest { token: string; key: string; taxInfo: ExpandedTaxInfoUpdateRequest; + paymentSource?: TokenizedPaymentSourceRequest; } diff --git a/libs/common/src/auth/services/user-verification/user-verification.service.ts b/libs/common/src/auth/services/user-verification/user-verification.service.ts index 1ff629114ab..cfa6800deed 100644 --- a/libs/common/src/auth/services/user-verification/user-verification.service.ts +++ b/libs/common/src/auth/services/user-verification/user-verification.service.ts @@ -117,7 +117,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti masterKey = await this.keyService.makeMasterKey( verification.secret, email, - await this.kdfConfigService.getKdfConfig(), + await this.kdfConfigService.getKdfConfig(userId), ); } request.masterPasswordHash = alreadyHashed @@ -186,7 +186,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti throw new Error("Email is required. Cannot verify user by master password."); } - const kdfConfig = await this.kdfConfigService.getKdfConfig(); + const kdfConfig = await this.kdfConfigService.getKdfConfig(userId); if (!kdfConfig) { throw new Error("KDF config is required. Cannot verify user by master password."); } diff --git a/libs/common/src/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction.ts b/libs/common/src/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction.ts new file mode 100644 index 00000000000..e6e395c69df --- /dev/null +++ b/libs/common/src/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction.ts @@ -0,0 +1,8 @@ +import { ListResponse } from "../../../models/response/list.response"; +import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response"; + +export abstract class OrganizationSponsorshipApiServiceAbstraction { + abstract getOrganizationSponsorship( + sponsoredOrgId: string, + ): Promise>; +} diff --git a/libs/common/src/billing/models/response/organization-sponsorship-invites.response.ts b/libs/common/src/billing/models/response/organization-sponsorship-invites.response.ts new file mode 100644 index 00000000000..87a2cae4699 --- /dev/null +++ b/libs/common/src/billing/models/response/organization-sponsorship-invites.response.ts @@ -0,0 +1,31 @@ +import { BaseResponse } from "../../../models/response/base.response"; +import { PlanSponsorshipType } from "../../enums"; + +export class OrganizationSponsorshipInvitesResponse extends BaseResponse { + sponsoringOrganizationUserId: string; + friendlyName: string; + offeredToEmail: string; + planSponsorshipType: PlanSponsorshipType; + lastSyncDate?: Date; + validUntil?: Date; + toDelete = false; + isAdminInitiated: boolean; + notes: string; + statusMessage?: string; + statusClass?: string; + + constructor(response: any) { + super(response); + this.sponsoringOrganizationUserId = this.getResponseProperty("SponsoringOrganizationUserId"); + this.friendlyName = this.getResponseProperty("FriendlyName"); + this.offeredToEmail = this.getResponseProperty("OfferedToEmail"); + this.planSponsorshipType = this.getResponseProperty("PlanSponsorshipType"); + this.lastSyncDate = this.getResponseProperty("LastSyncDate"); + this.validUntil = this.getResponseProperty("ValidUntil"); + this.toDelete = this.getResponseProperty("ToDelete") ?? false; + this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated"); + this.notes = this.getResponseProperty("Notes"); + this.statusMessage = this.getResponseProperty("StatusMessage"); + this.statusClass = this.getResponseProperty("StatusClass"); + } +} diff --git a/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts b/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts new file mode 100644 index 00000000000..bb420377439 --- /dev/null +++ b/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts @@ -0,0 +1,22 @@ +import { ApiService } from "../../../abstractions/api.service"; +import { ListResponse } from "../../../models/response/list.response"; +import { OrganizationSponsorshipApiServiceAbstraction } from "../../abstractions/organizations/organization-sponsorship-api.service.abstraction"; +import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response"; + +export class OrganizationSponsorshipApiService + implements OrganizationSponsorshipApiServiceAbstraction +{ + constructor(private apiService: ApiService) {} + async getOrganizationSponsorship( + sponsoredOrgId: string, + ): Promise> { + const r = await this.apiService.send( + "GET", + "/organization/sponsorship/" + sponsoredOrgId + "/sponsored", + null, + true, + true, + ); + return new ListResponse(r, OrganizationSponsorshipInvitesResponse); + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 0e0956b0460..3644ceefa9a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -34,6 +34,7 @@ export enum FeatureFlag { PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features", PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method", PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", + PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup", /* Data Insights and Reporting */ CriticalApps = "pm-14466-risk-insights-critical-application", @@ -121,6 +122,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE, [FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE, [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, + [FeatureFlag.PM19956_RequireProviderPaymentMethodDuringSetup]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 70e0c3998dd..587212299df 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -206,7 +206,7 @@ export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk"); export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk"); export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk"); export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk"); -export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk"); +export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk", { web: "disk-local" }); export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition( "vaultBrowserIntroCarousel", "disk", diff --git a/libs/common/src/platform/sync/core-sync.service.ts b/libs/common/src/platform/sync/core-sync.service.ts index 1865ffb852f..4020c75f764 100644 --- a/libs/common/src/platform/sync/core-sync.service.ts +++ b/libs/common/src/platform/sync/core-sync.service.ts @@ -28,6 +28,8 @@ import { StateService } from "../abstractions/state.service"; import { MessageSender } from "../messaging"; import { StateProvider, SYNC_DISK, UserKeyDefinition } from "../state"; +import { SyncOptions } from "./sync.service"; + const LAST_SYNC_DATE = new UserKeyDefinition(SYNC_DISK, "lastSync", { deserializer: (d) => (d != null ? new Date(d) : null), clearOn: ["logout"], @@ -55,6 +57,7 @@ export abstract class CoreSyncService implements SyncService { protected readonly stateProvider: StateProvider, ) {} + abstract fullSync(forceSync: boolean, syncOptions?: SyncOptions): Promise; abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise; async getLastSync(): Promise { diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts new file mode 100644 index 00000000000..ded06c8be6b --- /dev/null +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -0,0 +1,199 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { + LogoutReason, + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { KeyService } from "@bitwarden/key-management"; + +import { Matrix } from "../../../spec/matrix"; +import { ApiService } from "../../abstractions/api.service"; +import { InternalOrganizationServiceAbstraction } from "../../admin-console/abstractions/organization/organization.service.abstraction"; +import { InternalPolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; +import { ProviderService } from "../../admin-console/abstractions/provider.service"; +import { Account, AccountService } from "../../auth/abstractions/account.service"; +import { AuthService } from "../../auth/abstractions/auth.service"; +import { AvatarService } from "../../auth/abstractions/avatar.service"; +import { TokenService } from "../../auth/abstractions/token.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { DomainSettingsService } from "../../autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "../../billing/abstractions"; +import { KeyConnectorService } from "../../key-management/key-connector/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "../../key-management/master-password/abstractions/master-password.service.abstraction"; +import { SendApiService } from "../../tools/send/services/send-api.service.abstraction"; +import { InternalSendService } from "../../tools/send/services/send.service.abstraction"; +import { UserId } from "../../types/guid"; +import { CipherService } from "../../vault/abstractions/cipher.service"; +import { FolderApiServiceAbstraction } from "../../vault/abstractions/folder/folder-api.service.abstraction"; +import { InternalFolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; +import { LogService } from "../abstractions/log.service"; +import { StateService } from "../abstractions/state.service"; +import { MessageSender } from "../messaging"; +import { StateProvider } from "../state"; + +import { DefaultSyncService } from "./default-sync.service"; +import { SyncResponse } from "./sync.response"; + +describe("DefaultSyncService", () => { + let masterPasswordAbstraction: MockProxy; + let accountService: MockProxy; + let apiService: MockProxy; + let domainSettingsService: MockProxy; + let folderService: MockProxy; + let cipherService: MockProxy; + let keyService: MockProxy; + let collectionService: MockProxy; + let messageSender: MockProxy; + let policyService: MockProxy; + let sendService: MockProxy; + let logService: MockProxy; + let keyConnectorService: MockProxy; + let stateService: MockProxy; + let providerService: MockProxy; + let folderApiService: MockProxy; + let organizationService: MockProxy; + let sendApiService: MockProxy; + let userDecryptionOptionsService: MockProxy; + let avatarService: MockProxy; + let logoutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: UserId]>; + let billingAccountProfileStateService: MockProxy; + let tokenService: MockProxy; + let authService: MockProxy; + let stateProvider: MockProxy; + + let sut: DefaultSyncService; + + beforeEach(() => { + masterPasswordAbstraction = mock(); + accountService = mock(); + apiService = mock(); + domainSettingsService = mock(); + folderService = mock(); + cipherService = mock(); + keyService = mock(); + collectionService = mock(); + messageSender = mock(); + policyService = mock(); + sendService = mock(); + logService = mock(); + keyConnectorService = mock(); + stateService = mock(); + providerService = mock(); + folderApiService = mock(); + organizationService = mock(); + sendApiService = mock(); + userDecryptionOptionsService = mock(); + avatarService = mock(); + logoutCallback = jest.fn(); + billingAccountProfileStateService = mock(); + tokenService = mock(); + authService = mock(); + stateProvider = mock(); + + sut = new DefaultSyncService( + masterPasswordAbstraction, + accountService, + apiService, + domainSettingsService, + folderService, + cipherService, + keyService, + collectionService, + messageSender, + policyService, + sendService, + logService, + keyConnectorService, + stateService, + providerService, + folderApiService, + organizationService, + sendApiService, + userDecryptionOptionsService, + avatarService, + logoutCallback, + billingAccountProfileStateService, + tokenService, + authService, + stateProvider, + ); + }); + + const user1 = "user1" as UserId; + + describe("fullSync", () => { + beforeEach(() => { + accountService.activeAccount$ = of({ id: user1 } as Account); + Matrix.autoMockMethod(authService.authStatusFor$, () => of(AuthenticationStatus.Unlocked)); + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: { + id: user1, + }, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + Matrix.autoMockMethod(userDecryptionOptionsService.userDecryptionOptionsById$, () => + of({ hasMasterPassword: true } satisfies UserDecryptionOptions), + ); + stateProvider.getUser.mockReturnValue(mock()); + }); + + it("does a token refresh when option missing from options", async () => { + await sut.fullSync(true, { allowThrowOnError: false }); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when boolean passed in", async () => { + await sut.fullSync(true, false); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when skipTokenRefresh option passed in with false and allowThrowOnError also passed in", async () => { + await sut.fullSync(true, { allowThrowOnError: false, skipTokenRefresh: false }); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when skipTokenRefresh option passed in with false by itself", async () => { + await sut.fullSync(true, { skipTokenRefresh: false }); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does not do a token refresh when skipTokenRefresh passed in as true", async () => { + await sut.fullSync(true, { skipTokenRefresh: true }); + + expect(apiService.refreshIdentityToken).not.toHaveBeenCalled(); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does not do a token refresh when skipTokenRefresh passed in as true and allowThrowOnError also passed in", async () => { + await sut.fullSync(true, { allowThrowOnError: false, skipTokenRefresh: true }); + + expect(apiService.refreshIdentityToken).not.toHaveBeenCalled(); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when nothing passed in", async () => { + await sut.fullSync(true); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index a6b1b974645..faf54f11912 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -54,6 +54,7 @@ import { MessageSender } from "../messaging"; import { StateProvider } from "../state"; import { CoreSyncService } from "./core-sync.service"; +import { SyncOptions } from "./sync.service"; export class DefaultSyncService extends CoreSyncService { syncInProgress = false; @@ -102,7 +103,15 @@ export class DefaultSyncService extends CoreSyncService { ); } - override async fullSync(forceSync: boolean, allowThrowOnError = false): Promise { + override async fullSync( + forceSync: boolean, + allowThrowOnErrorOrOptions?: boolean | SyncOptions, + ): Promise { + const { allowThrowOnError = false, skipTokenRefresh = false } = + typeof allowThrowOnErrorOrOptions === "boolean" + ? { allowThrowOnError: allowThrowOnErrorOrOptions } + : (allowThrowOnErrorOrOptions ?? {}); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); this.syncStarted(); const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId)); @@ -127,7 +136,9 @@ export class DefaultSyncService extends CoreSyncService { } try { - await this.apiService.refreshIdentityToken(); + if (!skipTokenRefresh) { + await this.apiService.refreshIdentityToken(); + } const response = await this.apiService.getSync(); await this.syncProfile(response.profile); diff --git a/libs/common/src/platform/sync/sync.service.ts b/libs/common/src/platform/sync/sync.service.ts index 967e4db27a5..6ef62fc9cb8 100644 --- a/libs/common/src/platform/sync/sync.service.ts +++ b/libs/common/src/platform/sync/sync.service.ts @@ -7,6 +7,26 @@ import { } from "../../models/response/notification.response"; import { UserId } from "../../types/guid"; +/** + * A set of options for configuring how a {@link SyncService.fullSync} call should behave. + */ +export type SyncOptions = { + /** + * A boolean dictating whether or not caught errors should be rethrown. + * `true` if they can be rethrown, `false` if they should not be rethrown. + * @default false + */ + allowThrowOnError?: boolean; + /** + * A boolean dictating whether or not to do a token refresh before doing the sync. + * `true` if the refresh can be skipped, likely because one was done soon before the call to + * `fullSync`. `false` if the token refresh should be done before getting data. + * + * @default false + */ + skipTokenRefresh?: boolean; +}; + /** * A class encapsulating sync operations and data. */ @@ -47,9 +67,12 @@ export abstract class SyncService { * as long as the current user is authenticated. If `false` it will only sync if either a sync * has not happened before or the last sync date for the active user is before their account * revision date. Try to always use `false` if possible. - * - * @param allowThrowOnError A boolean dictating whether or not caught errors should be rethrown. - * `true` if they can be rethrown, `false` if they should not be rethrown. + * @param syncOptions Options for customizing how the sync call should behave. + */ + abstract fullSync(forceSync: boolean, syncOptions?: SyncOptions): Promise; + + /** + * @deprecated Use the overload taking {@link SyncOptions} instead. */ abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise; 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/components/src/icon-button/index.ts b/libs/components/src/icon-button/index.ts index 9da4a3162bf..cc52f263252 100644 --- a/libs/components/src/icon-button/index.ts +++ b/libs/components/src/icon-button/index.ts @@ -1 +1,2 @@ export * from "./icon-button.module"; +export { BitIconButtonComponent } from "./icon-button.component"; 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/key-management/src/abstractions/kdf-config.service.ts b/libs/key-management/src/abstractions/kdf-config.service.ts index 9cc39561aa8..c6c4e5d4fb0 100644 --- a/libs/key-management/src/abstractions/kdf-config.service.ts +++ b/libs/key-management/src/abstractions/kdf-config.service.ts @@ -6,6 +6,6 @@ import { KdfConfig } from "../models/kdf-config"; export abstract class KdfConfigService { abstract setKdfConfig(userId: UserId, KdfConfig: KdfConfig): Promise; - abstract getKdfConfig(): Promise; + abstract getKdfConfig(userId: UserId): Promise; abstract getKdfConfig$(userId: UserId): Observable; } diff --git a/libs/key-management/src/kdf-config.service.spec.ts b/libs/key-management/src/kdf-config.service.spec.ts index 986d7abac40..97684266f5d 100644 --- a/libs/key-management/src/kdf-config.service.spec.ts +++ b/libs/key-management/src/kdf-config.service.spec.ts @@ -26,90 +26,94 @@ describe("KdfConfigService", () => { sutKdfConfigService = new DefaultKdfConfigService(fakeStateProvider); }); - it("setKdfConfig(): should set the PBKDF2KdfConfig config", async () => { - const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000); - await sutKdfConfigService.setKdfConfig(mockUserId, kdfConfig); - expect(fakeStateProvider.mock.setUserState).toHaveBeenCalledWith( - KDF_CONFIG, - kdfConfig, - mockUserId, - ); + describe("setKdfConfig", () => { + it("sets the PBKDF2KdfConfig config", async () => { + const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000); + await sutKdfConfigService.setKdfConfig(mockUserId, kdfConfig); + expect(fakeStateProvider.mock.setUserState).toHaveBeenCalledWith( + KDF_CONFIG, + kdfConfig, + mockUserId, + ); + }); + + it("sets the Argon2KdfConfig config", async () => { + const kdfConfig: KdfConfig = new Argon2KdfConfig(2, 63, 3); + await sutKdfConfigService.setKdfConfig(mockUserId, kdfConfig); + expect(fakeStateProvider.mock.setUserState).toHaveBeenCalledWith( + KDF_CONFIG, + kdfConfig, + mockUserId, + ); + }); + + it("throws error KDF cannot be null", async () => { + try { + await sutKdfConfigService.setKdfConfig(mockUserId, null as unknown as KdfConfig); + } catch (e) { + expect(e).toEqual(new Error("kdfConfig cannot be null")); + } + }); + + it("throws error userId cannot be null", async () => { + const kdfConfig: KdfConfig = new Argon2KdfConfig(3, 64, 4); + try { + await sutKdfConfigService.setKdfConfig(null as unknown as UserId, kdfConfig); + } catch (e) { + expect(e).toEqual(new Error("userId cannot be null")); + } + }); }); - it("setKdfConfig(): should set the Argon2KdfConfig config", async () => { - const kdfConfig: KdfConfig = new Argon2KdfConfig(2, 63, 3); - await sutKdfConfigService.setKdfConfig(mockUserId, kdfConfig); - expect(fakeStateProvider.mock.setUserState).toHaveBeenCalledWith( - KDF_CONFIG, - kdfConfig, - mockUserId, - ); + describe("getKdfConfig", () => { + it("throws error if userId is null", async () => { + await expect(sutKdfConfigService.getKdfConfig(null as unknown as UserId)).rejects.toThrow( + "userId cannot be null", + ); + }); + + it("throws if target user doesn't have a KkfConfig", async () => { + const errorMessage = "KdfConfig for user " + mockUserId + " is null"; + await expect(sutKdfConfigService.getKdfConfig(mockUserId)).rejects.toThrow(errorMessage); + }); + + it("returns KdfConfig of target user", async () => { + const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000); + await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfig, mockUserId); + await expect(sutKdfConfigService.getKdfConfig(mockUserId)).resolves.toEqual(kdfConfig); + }); }); - it("setKdfConfig(): should throw error KDF cannot be null", async () => { - try { - await sutKdfConfigService.setKdfConfig(mockUserId, null as unknown as KdfConfig); - } catch (e) { - expect(e).toEqual(new Error("kdfConfig cannot be null")); - } - }); + describe("getKdfConfig$", () => { + it("gets KdfConfig of provided user", async () => { + await expect( + firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId)), + ).resolves.toBeNull(); + const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000); + await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfig, mockUserId); + await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toEqual( + kdfConfig, + ); + }); - it("setKdfConfig(): should throw error userId cannot be null", async () => { - const kdfConfig: KdfConfig = new Argon2KdfConfig(3, 64, 4); - try { - await sutKdfConfigService.setKdfConfig(null as unknown as UserId, kdfConfig); - } catch (e) { - expect(e).toEqual(new Error("userId cannot be null")); - } - }); + it("gets KdfConfig of provided user after changed", async () => { + await expect( + firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId)), + ).resolves.toBeNull(); + await fakeStateProvider.setUserState(KDF_CONFIG, new PBKDF2KdfConfig(500_000), mockUserId); + const kdfConfigChanged: KdfConfig = new PBKDF2KdfConfig(500_001); + await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfigChanged, mockUserId); + await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toEqual( + kdfConfigChanged, + ); + }); - it("getKdfConfig(): should get KdfConfig of active user", async () => { - const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000); - await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfig, mockUserId); - await expect(sutKdfConfigService.getKdfConfig()).resolves.toEqual(kdfConfig); - }); - - it("getKdfConfig(): should throw error KdfConfig can only be retrieved when there is active user", async () => { - fakeAccountService.activeAccountSubject.next(null); - try { - await sutKdfConfigService.getKdfConfig(); - } catch (e) { - expect(e).toEqual(new Error("KdfConfig can only be retrieved when there is active user")); - } - }); - - it("getKdfConfig(): should throw error KdfConfig for active user account state is null", async () => { - try { - await sutKdfConfigService.getKdfConfig(); - } catch (e) { - expect(e).toEqual(new Error("KdfConfig for active user account state is null")); - } - }); - - it("getKdfConfig$(UserId): should get KdfConfig of provided user", async () => { - await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toBeNull(); - const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000); - await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfig, mockUserId); - await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toEqual( - kdfConfig, - ); - }); - - it("getKdfConfig$(UserId): should get KdfConfig of provided user after changed", async () => { - await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toBeNull(); - await fakeStateProvider.setUserState(KDF_CONFIG, new PBKDF2KdfConfig(500_000), mockUserId); - const kdfConfigChanged: KdfConfig = new PBKDF2KdfConfig(500_001); - await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfigChanged, mockUserId); - await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toEqual( - kdfConfigChanged, - ); - }); - - it("getKdfConfig$(UserId): should throw error userId cannot be null", async () => { - try { - sutKdfConfigService.getKdfConfig$(null as unknown as UserId); - } catch (e) { - expect(e).toEqual(new Error("userId cannot be null")); - } + it("throws error userId cannot be null", async () => { + try { + sutKdfConfigService.getKdfConfig$(null as unknown as UserId); + } catch (e) { + expect(e).toEqual(new Error("userId cannot be null")); + } + }); }); }); diff --git a/libs/key-management/src/kdf-config.service.ts b/libs/key-management/src/kdf-config.service.ts index efc5310e5a8..24635e87580 100644 --- a/libs/key-management/src/kdf-config.service.ts +++ b/libs/key-management/src/kdf-config.service.ts @@ -37,14 +37,14 @@ export class DefaultKdfConfigService implements KdfConfigService { await this.stateProvider.setUserState(KDF_CONFIG, kdfConfig, userId); } - async getKdfConfig(): Promise { - const userId = await firstValueFrom(this.stateProvider.activeUserId$); + async getKdfConfig(userId: UserId): Promise { if (userId == null) { - throw new Error("KdfConfig can only be retrieved when there is active user"); + throw new Error("userId cannot be null"); } + const state = await firstValueFrom(this.stateProvider.getUser(userId, KDF_CONFIG).state$); if (state == null) { - throw new Error("KdfConfig for active user account state is null"); + throw new Error("KdfConfig for user " + userId + " is null"); } return state; } 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 0a92f4f02d7..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 @@ -4,6 +4,7 @@ import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { KdfConfig, KdfConfigService, KdfType } from "@bitwarden/key-management"; @@ -17,14 +18,18 @@ export class BaseVaultExportService { private kdfConfigService: KdfConfigService, ) {} - protected async buildPasswordExport(clearText: string, password: string): Promise { - const kdfConfig: KdfConfig = await this.kdfConfigService.getKdfConfig(); + protected async buildPasswordExport( + userId: UserId, + clearText: string, + password: string, + ): Promise { + const kdfConfig: KdfConfig = await this.kdfConfigService.getKdfConfig(userId); 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 d253ae8d0b1..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 @@ -13,6 +13,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { CipherWithIdExport, FolderWithIdExport } from "@bitwarden/common/models/export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -59,19 +60,21 @@ export class IndividualVaultExportService * @param format The format of the export */ async getExport(format: ExportFormat = "csv"): Promise { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); if (format === "encrypted_json") { - return this.getEncryptedExport(); + return this.getEncryptedExport(userId); } else if (format === "zip") { - return this.getDecryptedExportZip(); + return this.getDecryptedExportZip(userId); } - return this.getDecryptedExport(format); + return this.getDecryptedExport(userId, format); } - /** Creates a password protected export of an individiual vault (My Vault) as a JSON file + /** Creates a password protected export of an individual vault (My Vault) as a JSON file * @param password The password to encrypt the export with * @returns A password-protected encrypted individual vault export */ async getPasswordProtectedExport(password: string): Promise { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const exportVault = await this.getExport("json"); if (exportVault.type !== "text/plain") { @@ -80,19 +83,20 @@ export class IndividualVaultExportService return { type: "text/plain", - data: await this.buildPasswordExport(exportVault.data, password), + data: await this.buildPasswordExport(userId, exportVault.data, password), fileName: ExportHelper.getFileName("", "encrypted_json"), } as ExportedVaultAsString; } /** Creates a unencrypted export of an individual vault including attachments + * @param activeUserId The user ID of the user requesting the export * @returns A unencrypted export including attachments */ - async getDecryptedExportZip(): Promise { + async getDecryptedExportZip(activeUserId: UserId): Promise { const zip = new JSZip(); // ciphers - const exportedVault = await this.getDecryptedExport("json"); + const exportedVault = await this.getDecryptedExport(activeUserId, "json"); zip.file("data.json", exportedVault.data); const attachmentsFolder = zip.folder("attachments"); @@ -100,8 +104,6 @@ export class IndividualVaultExportService throw new Error("Error creating attachments folder"); } - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - // attachments for (const cipher of await this.cipherService.getAllDecrypted(activeUserId)) { if ( @@ -155,17 +157,19 @@ 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"); } } - private async getDecryptedExport(format: "json" | "csv"): Promise { + private async getDecryptedExport( + activeUserId: UserId, + format: "json" | "csv", + ): Promise { let decFolders: FolderView[] = []; let decCiphers: CipherView[] = []; const promises = []; - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); promises.push( firstValueFrom(this.folderService.folderViews$(activeUserId)).then((folders) => { @@ -196,11 +200,10 @@ export class IndividualVaultExportService } as ExportedVaultAsString; } - private async getEncryptedExport(): Promise { + private async getEncryptedExport(activeUserId: UserId): Promise { let folders: Folder[] = []; let ciphers: Cipher[] = []; const promises = []; - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); promises.push( firstValueFrom(this.folderService.folders$(activeUserId)).then((f) => { @@ -216,10 +219,8 @@ export class IndividualVaultExportService await Promise.all(promises); - const userKey = await this.keyService.getUserKeyWithLegacySupport( - await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)), - ); - const encKeyValidation = await this.encryptService.encrypt(Utils.newGuid(), userKey); + const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId); + 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 e4ed105d1ad..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 @@ -18,7 +18,7 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { CipherWithIdExport, CollectionWithIdExport } from "@bitwarden/common/models/export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; @@ -67,6 +67,7 @@ export class OrganizationVaultExportService password: string, onlyManagedCollections: boolean, ): Promise { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const exportVault = await this.getOrganizationExport( organizationId, "json", @@ -75,7 +76,7 @@ export class OrganizationVaultExportService return { type: "text/plain", - data: await this.buildPasswordExport(exportVault.data, password), + data: await this.buildPasswordExport(userId, exportVault.data, password), fileName: ExportHelper.getFileName("org", "encrypted_json"), } as ExportedVaultAsString; } @@ -102,12 +103,13 @@ export class OrganizationVaultExportService if (format === "zip") { throw new Error("Zip export not supported for organization"); } + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); if (format === "encrypted_json") { return { type: "text/plain", data: onlyManagedCollections - ? await this.getEncryptedManagedExport(organizationId) + ? await this.getEncryptedManagedExport(userId, organizationId) : await this.getOrganizationEncryptedExport(organizationId), fileName: ExportHelper.getFileName("org", "encrypted_json"), } as ExportedVaultAsString; @@ -116,20 +118,20 @@ export class OrganizationVaultExportService return { type: "text/plain", data: onlyManagedCollections - ? await this.getDecryptedManagedExport(organizationId, format) - : await this.getOrganizationDecryptedExport(organizationId, format), + ? await this.getDecryptedManagedExport(userId, organizationId, format) + : await this.getOrganizationDecryptedExport(userId, organizationId, format), fileName: ExportHelper.getFileName("org", format), } as ExportedVaultAsString; } private async getOrganizationDecryptedExport( + activeUserId: UserId, organizationId: string, format: "json" | "csv", ): Promise { const decCollections: CollectionView[] = []; const decCiphers: CipherView[] = []; const promises = []; - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); promises.push( this.apiService.getOrganizationExport(organizationId).then((exportData) => { @@ -210,6 +212,7 @@ export class OrganizationVaultExportService } private async getDecryptedManagedExport( + activeUserId: UserId, organizationId: string, format: "json" | "csv", ): Promise { @@ -217,7 +220,6 @@ export class OrganizationVaultExportService let allDecCiphers: CipherView[] = []; let decCollections: CollectionView[] = []; const promises = []; - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); promises.push( this.collectionService.getAllDecrypted().then(async (collections) => { @@ -245,12 +247,14 @@ export class OrganizationVaultExportService return this.buildJsonExport(decCollections, decCiphers); } - private async getEncryptedManagedExport(organizationId: string): Promise { + private async getEncryptedManagedExport( + activeUserId: UserId, + organizationId: string, + ): Promise { let encCiphers: Cipher[] = []; let allCiphers: Cipher[] = []; let encCollections: Collection[] = []; const promises = []; - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); promises.push( this.collectionService.getAll().then((collections) => { @@ -282,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 1e138220e07..caff4234386 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 @@ -23,9 +23,11 @@ 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)); - // tests always provide a value for c.encryptedString - encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString!)); + encryptService.encryptString.mockImplementation((p) => + Promise.resolve(p as unknown as EncString), + ); + // in the test environment `c.encryptedString` always has a value + 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/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index 50577472120..9943f07292d 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -34,7 +34,9 @@ import { AsyncActionsModule, ButtonModule, ItemModule, ToastService } from "@bit import { CipherFormConfig, CipherFormGenerationService, + NudgeStatus, PasswordRepromptService, + VaultNudgesService, } from "@bitwarden/vault"; // FIXME: remove `/apps` import from `/libs` // FIXME: remove `src` and fix import @@ -47,6 +49,7 @@ import { CipherFormService } from "./abstractions/cipher-form.service"; import { TotpCaptureService } from "./abstractions/totp-capture.service"; import { CipherFormModule } from "./cipher-form.module"; import { CipherFormComponent } from "./components/cipher-form.component"; +import { NewItemNudgeComponent } from "./components/new-item-nudge/new-item-nudge.component"; import { CipherFormCacheService } from "./services/default-cipher-form-cache.service"; const defaultConfig: CipherFormConfig = { @@ -132,8 +135,23 @@ export default { component: CipherFormComponent, decorators: [ moduleMetadata({ - imports: [CipherFormModule, AsyncActionsModule, ButtonModule, ItemModule], + imports: [ + CipherFormModule, + AsyncActionsModule, + ButtonModule, + ItemModule, + NewItemNudgeComponent, + ], providers: [ + { + provide: VaultNudgesService, + useValue: { + showNudge$: new BehaviorSubject({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + } as NudgeStatus), + }, + }, { provide: CipherFormService, useClass: TestAddEditFormService, diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.html b/libs/vault/src/cipher-form/components/cipher-form.component.html index 6b327486c47..614c7f3dc7a 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.html +++ b/libs/vault/src/cipher-form/components/cipher-form.component.html @@ -1,3 +1,4 @@ + diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index 080af489253..96e1328338b 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -45,6 +45,7 @@ import { CardDetailsSectionComponent } from "./card-details-section/card-details import { IdentitySectionComponent } from "./identity/identity.component"; import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component"; import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component"; +import { NewItemNudgeComponent } from "./new-item-nudge/new-item-nudge.component"; import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.component"; @Component({ @@ -76,6 +77,7 @@ import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.componen NgIf, AdditionalOptionsSectionComponent, LoginDetailsSectionComponent, + NewItemNudgeComponent, ], }) export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer { diff --git a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.html b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.html new file mode 100644 index 00000000000..5cd1246fd36 --- /dev/null +++ b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.html @@ -0,0 +1,8 @@ + + + + diff --git a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.spec.ts b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.spec.ts new file mode 100644 index 00000000000..073c588690d --- /dev/null +++ b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.spec.ts @@ -0,0 +1,101 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/sdk-internal"; + +import { VaultNudgesService, VaultNudgeType } from "../../../services/vault-nudges.service"; + +import { NewItemNudgeComponent } from "./new-item-nudge.component"; + +describe("NewItemNudgeComponent", () => { + let component: NewItemNudgeComponent; + let fixture: ComponentFixture; + + let i18nService: MockProxy; + let accountService: MockProxy; + let vaultNudgesService: MockProxy; + + beforeEach(async () => { + i18nService = mock({ t: (key: string) => key }); + accountService = mock(); + vaultNudgesService = mock(); + + await TestBed.configureTestingModule({ + imports: [NewItemNudgeComponent, CommonModule], + providers: [ + { provide: I18nService, useValue: i18nService }, + { provide: AccountService, useValue: accountService }, + { provide: VaultNudgesService, useValue: vaultNudgesService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NewItemNudgeComponent); + component = fixture.componentInstance; + component.configType = null; // Set to null for initial state + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should set nudge title and body for CipherType.Login type", async () => { + component.configType = CipherType.Login; + accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account); + jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(true); + + await component.ngOnInit(); + + expect(component.showNewItemSpotlight).toBe(true); + expect(component.nudgeTitle).toBe("newLoginNudgeTitle"); + expect(component.nudgeBody).toBe("newLoginNudgeBody"); + expect(component.dismissalNudgeType).toBe(VaultNudgeType.newLoginItemStatus); + }); + + it("should set nudge title and body for CipherType.Card type", async () => { + component.configType = CipherType.Card; + accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account); + jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(true); + + await component.ngOnInit(); + + expect(component.showNewItemSpotlight).toBe(true); + expect(component.nudgeTitle).toBe("newCardNudgeTitle"); + expect(component.nudgeBody).toBe("newCardNudgeBody"); + expect(component.dismissalNudgeType).toBe(VaultNudgeType.newCardItemStatus); + }); + + it("should not show anything if spotlight has been dismissed", async () => { + component.configType = CipherType.Identity; + accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account); + jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(false); + + await component.ngOnInit(); + + expect(component.showNewItemSpotlight).toBe(false); + expect(component.dismissalNudgeType).toBe(VaultNudgeType.newIdentityItemStatus); + }); + + it("should set showNewItemSpotlight to false when user dismisses spotlight", async () => { + component.showNewItemSpotlight = true; + component.dismissalNudgeType = VaultNudgeType.newLoginItemStatus; + component.activeUserId = "test-user-id" as UserId; + + const dismissSpy = jest.spyOn(vaultNudgesService, "dismissNudge").mockResolvedValue(); + + await component.dismissNewItemSpotlight(); + + expect(component.showNewItemSpotlight).toBe(false); + expect(dismissSpy).toHaveBeenCalledWith( + VaultNudgeType.newLoginItemStatus, + component.activeUserId, + ); + }); +}); diff --git a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts new file mode 100644 index 00000000000..b497585d4fb --- /dev/null +++ b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts @@ -0,0 +1,90 @@ +import { NgIf } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/sdk-internal"; + +import { SpotlightComponent } from "../../../components/spotlight/spotlight.component"; +import { VaultNudgesService, VaultNudgeType } from "../../../services/vault-nudges.service"; + +@Component({ + selector: "vault-new-item-nudge", + templateUrl: "./new-item-nudge.component.html", + standalone: true, + imports: [NgIf, SpotlightComponent], +}) +export class NewItemNudgeComponent implements OnInit { + @Input({ required: true }) configType: CipherType | null = null; + activeUserId: UserId | null = null; + showNewItemSpotlight: boolean = false; + nudgeTitle: string = ""; + nudgeBody: string = ""; + dismissalNudgeType: VaultNudgeType | null = null; + + constructor( + private i18nService: I18nService, + private accountService: AccountService, + private vaultNudgesService: VaultNudgesService, + ) {} + + async ngOnInit() { + this.activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + + switch (this.configType) { + case CipherType.Login: + this.dismissalNudgeType = VaultNudgeType.newLoginItemStatus; + this.nudgeTitle = this.i18nService.t("newLoginNudgeTitle"); + this.nudgeBody = this.i18nService.t("newLoginNudgeBody"); + break; + + case CipherType.Card: + this.dismissalNudgeType = VaultNudgeType.newCardItemStatus; + this.nudgeTitle = this.i18nService.t("newCardNudgeTitle"); + this.nudgeBody = this.i18nService.t("newCardNudgeBody"); + break; + + case CipherType.Identity: + this.dismissalNudgeType = VaultNudgeType.newIdentityItemStatus; + this.nudgeTitle = this.i18nService.t("newIdentityNudgeTitle"); + this.nudgeBody = this.i18nService.t("newIdentityNudgeBody"); + break; + + case CipherType.SecureNote: + this.dismissalNudgeType = VaultNudgeType.newNoteItemStatus; + this.nudgeTitle = this.i18nService.t("newNoteNudgeTitle"); + this.nudgeBody = this.i18nService.t("newNoteNudgeBody"); + break; + + case CipherType.SshKey: + this.dismissalNudgeType = VaultNudgeType.newSshItemStatus; + this.nudgeTitle = this.i18nService.t("newSshNudgeTitle"); + this.nudgeBody = this.i18nService.t("newSshNudgeBody"); + break; + default: + throw new Error("Unsupported cipher type"); + } + this.showNewItemSpotlight = await this.checkHasSpotlightDismissed( + this.dismissalNudgeType as VaultNudgeType, + this.activeUserId, + ); + } + + async dismissNewItemSpotlight() { + if (this.dismissalNudgeType && this.activeUserId) { + await this.vaultNudgesService.dismissNudge( + this.dismissalNudgeType, + this.activeUserId as UserId, + ); + this.showNewItemSpotlight = false; + } + } + + async checkHasSpotlightDismissed(nudgeType: VaultNudgeType, userId: UserId): Promise { + return !(await firstValueFrom(this.vaultNudgesService.showNudge$(nudgeType, userId))) + .hasSpotlightDismissed; + } +} diff --git a/libs/vault/src/components/copy-cipher-field.directive.spec.ts b/libs/vault/src/components/copy-cipher-field.directive.spec.ts new file mode 100644 index 00000000000..0847e7147a9 --- /dev/null +++ b/libs/vault/src/components/copy-cipher-field.directive.spec.ts @@ -0,0 +1,197 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { BitIconButtonComponent, MenuItemDirective } from "@bitwarden/components"; +import { CopyCipherFieldService } from "@bitwarden/vault"; + +import { CopyCipherFieldDirective } from "./copy-cipher-field.directive"; + +describe("CopyCipherFieldDirective", () => { + const copyFieldService = { + copy: jest.fn().mockResolvedValue(null), + totpAllowed: jest.fn().mockResolvedValue(true), + }; + + let copyCipherFieldDirective: CopyCipherFieldDirective; + + beforeEach(() => { + copyFieldService.copy.mockClear(); + copyFieldService.totpAllowed.mockClear(); + + copyCipherFieldDirective = new CopyCipherFieldDirective( + copyFieldService as unknown as CopyCipherFieldService, + ); + copyCipherFieldDirective.cipher = new CipherView(); + }); + + describe("disabled state", () => { + it("should be enabled when the field is available", async () => { + copyCipherFieldDirective.action = "username"; + copyCipherFieldDirective.cipher.login.username = "test-username"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(copyCipherFieldDirective["disabled"]).toBe(null); + }); + + it("should be disabled when the field is not available", async () => { + // create empty cipher + copyCipherFieldDirective.cipher = new CipherView(); + + copyCipherFieldDirective.action = "username"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(copyCipherFieldDirective["disabled"]).toBe(true); + }); + + it("updates icon button disabled state", async () => { + const iconButton = { + disabled: { + set: jest.fn(), + }, + }; + + copyCipherFieldDirective = new CopyCipherFieldDirective( + copyFieldService as unknown as CopyCipherFieldService, + undefined, + iconButton as unknown as BitIconButtonComponent, + ); + + copyCipherFieldDirective.action = "password"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(iconButton.disabled.set).toHaveBeenCalledWith(true); + }); + + it("updates menuItemDirective disabled state", async () => { + const menuItemDirective = { + disabled: false, + }; + + copyCipherFieldDirective = new CopyCipherFieldDirective( + copyFieldService as unknown as CopyCipherFieldService, + menuItemDirective as unknown as MenuItemDirective, + ); + + copyCipherFieldDirective.action = "totp"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(menuItemDirective.disabled).toBe(true); + }); + }); + + describe("login", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.login.username = "test-username"; + copyCipherFieldDirective.cipher.login.password = "test-password"; + copyCipherFieldDirective.cipher.login.totp = "test-totp"; + }); + + it.each([ + ["username", "test-username"], + ["password", "test-password"], + ["totp", "test-totp"], + ])("copies %s field from login to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("identity", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.identity.username = "test-username"; + copyCipherFieldDirective.cipher.identity.email = "test-email"; + copyCipherFieldDirective.cipher.identity.phone = "test-phone"; + copyCipherFieldDirective.cipher.identity.address1 = "test-address-1"; + }); + + it.each([ + ["username", "test-username"], + ["email", "test-email"], + ["phone", "test-phone"], + ["address", "test-address-1"], + ])("copies %s field from identity to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("card", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.card.number = "test-card-number"; + copyCipherFieldDirective.cipher.card.code = "test-card-code"; + }); + + it.each([ + ["cardNumber", "test-card-number"], + ["securityCode", "test-card-code"], + ])("copies %s field from card to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("secure note", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.notes = "test-secure-note"; + }); + + it("copies secure note field to clipboard", async () => { + copyCipherFieldDirective.action = "secureNote"; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + "test-secure-note", + "secureNote", + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("ssh key", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.sshKey.privateKey = "test-private-key"; + copyCipherFieldDirective.cipher.sshKey.publicKey = "test-public-key"; + copyCipherFieldDirective.cipher.sshKey.keyFingerprint = "test-key-fingerprint"; + }); + + it.each([ + ["privateKey", "test-private-key"], + ["publicKey", "test-public-key"], + ["keyFingerprint", "test-key-fingerprint"], + ])("copies %s field from ssh key to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); +}); diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts index 1eb96a30449..324b43f12d4 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -1,7 +1,7 @@ import { Directive, HostBinding, HostListener, Input, OnChanges, Optional } from "@angular/core"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { MenuItemDirective } from "@bitwarden/components"; +import { MenuItemDirective, BitIconButtonComponent } from "@bitwarden/components"; import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault"; /** @@ -33,6 +33,7 @@ export class CopyCipherFieldDirective implements OnChanges { constructor( private copyCipherFieldService: CopyCipherFieldService, @Optional() private menuItemDirective?: MenuItemDirective, + @Optional() private iconButtonComponent?: BitIconButtonComponent, ) {} @HostBinding("attr.disabled") @@ -65,6 +66,11 @@ export class CopyCipherFieldDirective implements OnChanges { ? true : null; + // When used on an icon button, update the disabled state of the button component + if (this.iconButtonComponent) { + this.iconButtonComponent.disabled.set(this.disabled ?? false); + } + // If the directive is used on a menu item, update the menu item to prevent keyboard navigation if (this.menuItemDirective) { this.menuItemDirective.disabled = this.disabled ?? false; 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..c9077a7283b 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,28 +17,32 @@ export class HasNudgeService extends DefaultSingleNudgeService { private accountService = inject(AccountService); private nudgeTypes: VaultNudgeType[] = [ - VaultNudgeType.HasVaultItems, - VaultNudgeType.IntroCarouselDismissal, + VaultNudgeType.EmptyVaultNudge, // add additional nudge types here as needed ]; /** * 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..131db023175 100644 --- a/libs/vault/src/services/custom-nudges-services/index.ts +++ b/libs/vault/src/services/custom-nudges-services/index.ts @@ -1,2 +1,4 @@ export * from "./has-items-nudge.service"; +export * from "./empty-vault-nudge.service"; export * from "./has-nudge.service"; +export * from "./new-item-nudge.service"; diff --git a/libs/vault/src/services/custom-nudges-services/new-item-nudge.service.ts b/libs/vault/src/services/custom-nudges-services/new-item-nudge.service.ts new file mode 100644 index 00000000000..93ef5d81dc4 --- /dev/null +++ b/libs/vault/src/services/custom-nudges-services/new-item-nudge.service.ts @@ -0,0 +1,65 @@ +import { inject, Injectable } from "@angular/core"; +import { combineLatest, Observable, switchMap } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; + +import { DefaultSingleNudgeService } from "../default-single-nudge.service"; +import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service"; + +/** + * Custom Nudge Service Checking Nudge Status For Vault New Item Types + */ +@Injectable({ + providedIn: "root", +}) +export class NewItemNudgeService extends DefaultSingleNudgeService { + cipherService = inject(CipherService); + + nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable { + return combineLatest([ + this.getNudgeStatus$(nudgeType, userId), + this.cipherService.cipherViews$(userId), + ]).pipe( + switchMap(async ([nudgeStatus, ciphers]) => { + if (nudgeStatus.hasSpotlightDismissed) { + return nudgeStatus; + } + + let currentType: CipherType; + + switch (nudgeType) { + case VaultNudgeType.newLoginItemStatus: + currentType = CipherType.Login; + break; + case VaultNudgeType.newCardItemStatus: + currentType = CipherType.Card; + break; + case VaultNudgeType.newIdentityItemStatus: + currentType = CipherType.Identity; + break; + case VaultNudgeType.newNoteItemStatus: + currentType = CipherType.SecureNote; + break; + case VaultNudgeType.newSshItemStatus: + currentType = CipherType.SshKey; + break; + } + + const ciphersBoolean = ciphers.some((cipher) => cipher.type === currentType); + + if (ciphersBoolean) { + const dismissedStatus = { + hasSpotlightDismissed: true, + hasBadgeDismissed: true, + }; + await this.setNudgeStatus(nudgeType, dismissedStatus, userId); + return dismissedStatus; + } + + return nudgeStatus; + }), + ); + } +} 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..69ddf1cdaa0 100644 --- a/libs/vault/src/services/vault-nudges.service.spec.ts +++ b/libs/vault/src/services/vault-nudges.service.spec.ts @@ -2,12 +2,14 @@ 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; 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 +17,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 +38,56 @@ describe("Vault Nudges Service", () => { provide: StateProvider, useValue: fakeStateProvider, }, + { provide: ConfigService, useValue: mockConfigService }, { provide: HasItemsNudgeService, useValue: mock(), }, + { + provide: EmptyVaultNudgeService, + useValue: mock(), + }, + { provide: CipherService, 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 +98,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..98f28af9954 100644 --- a/libs/vault/src/services/vault-nudges.service.ts +++ b/libs/vault/src/services/vault-nudges.service.ts @@ -1,11 +1,23 @@ 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, + NewItemNudgeService, +} 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,23 +25,28 @@ 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", + newLoginItemStatus = "new-login-item-status", + newCardItemStatus = "new-card-item-status", + newIdentityItemStatus = "new-identity-item-status", + newNoteItemStatus = "new-note-item-status", + newSshItemStatus = "new-ssh-item-status", } -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", }) export class VaultNudgesService { + private newItemNudgeService = inject(NewItemNudgeService); + /** * Custom nudge services to use for specific nudge types * Each nudge type can have its own service to determine when to show the nudge @@ -37,6 +54,12 @@ export class VaultNudgesService { */ private customNudgeServices: any = { [VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService), + [VaultNudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService), + [VaultNudgeType.newLoginItemStatus]: this.newItemNudgeService, + [VaultNudgeType.newCardItemStatus]: this.newItemNudgeService, + [VaultNudgeType.newIdentityItemStatus]: this.newItemNudgeService, + [VaultNudgeType.newNoteItemStatus]: this.newItemNudgeService, + [VaultNudgeType.newSshItemStatus]: this.newItemNudgeService, }; /** @@ -45,6 +68,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 +80,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 +95,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); } } diff --git a/package-lock.json b/package-lock.json index dcfc0c475bc..25322b844b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -159,7 +159,7 @@ "nx": "20.8.0", "postcss": "8.5.1", "postcss-loader": "8.1.1", - "prettier": "3.4.2", + "prettier": "3.5.3", "prettier-plugin-tailwindcss": "0.6.11", "process": "0.11.10", "remark-gfm": "4.0.0", @@ -231,7 +231,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.4.2", + "version": "2025.5.0", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -30918,9 +30918,9 @@ } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index ad30bae428f..bc00ac57a59 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "nx": "20.8.0", "postcss": "8.5.1", "postcss-loader": "8.1.1", - "prettier": "3.4.2", + "prettier": "3.5.3", "prettier-plugin-tailwindcss": "0.6.11", "process": "0.11.10", "remark-gfm": "4.0.0",