From 7418f678744fbd15d3ee93084319b7bfe5034742 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 22 Oct 2025 08:36:31 -0500 Subject: [PATCH 01/18] [PM-24929] Clear route history cache for add/edit cipher views on tab change (#16755) * clear route history cache for add/edit cipher views on tab change * remove duplicate words --- .../browser/src/background/main.background.ts | 6 ++ .../view-cache/popup-router-cache.service.ts | 50 +++++++++++------ .../view-cache/popup-router-cache.spec.ts | 33 ++++++++++- .../popup-router-cache-background.service.ts | 55 +++++++++++++++++++ .../popup-view-cache-background.service.ts | 20 ++++++- apps/browser/src/popup/app-routing.module.ts | 6 +- 6 files changed, 147 insertions(+), 23 deletions(-) create mode 100644 apps/browser/src/platform/services/popup-router-cache-background.service.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index bcceac6fb84..7a4ee64070f 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -319,6 +319,7 @@ import I18nService from "../platform/services/i18n.service"; import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service"; import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; +import { PopupRouterCacheBackgroundService } from "../platform/services/popup-router-cache-background.service"; import { PopupViewCacheBackgroundService } from "../platform/services/popup-view-cache-background.service"; import { BrowserSdkLoadService } from "../platform/services/sdk/browser-sdk-load.service"; import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service"; @@ -488,6 +489,7 @@ export default class MainBackground { private nativeMessagingBackground: NativeMessagingBackground; private popupViewCacheBackgroundService: PopupViewCacheBackgroundService; + private popupRouterCacheBackgroundService: PopupRouterCacheBackgroundService; constructor() { // Services @@ -684,6 +686,9 @@ export default class MainBackground { this.globalStateProvider, this.taskSchedulerService, ); + this.popupRouterCacheBackgroundService = new PopupRouterCacheBackgroundService( + this.globalStateProvider, + ); this.migrationRunner = new MigrationRunner( this.storageService, @@ -1514,6 +1519,7 @@ export default class MainBackground { (this.eventUploadService as EventUploadService).init(true); this.popupViewCacheBackgroundService.startObservingMessages(); + this.popupRouterCacheBackgroundService.init(); await this.vaultTimeoutService.init(true); this.fido2Background.init(); diff --git a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts index 2e9746642f4..abb7c6405c2 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts @@ -5,6 +5,7 @@ import { Injectable, inject } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivateFn, + Data, NavigationEnd, Router, UrlSerializer, @@ -14,7 +15,10 @@ import { filter, first, firstValueFrom, map, Observable, of, switchMap, tap } fr import { GlobalStateProvider } from "@bitwarden/common/platform/state"; -import { POPUP_ROUTE_HISTORY_KEY } from "../../../platform/services/popup-view-cache-background.service"; +import { + POPUP_ROUTE_HISTORY_KEY, + RouteHistoryCacheState, +} from "../../../platform/services/popup-view-cache-background.service"; import BrowserPopupUtils from "../../browser/browser-popup-utils"; /** @@ -42,8 +46,7 @@ export class PopupRouterCacheService { this.history$() .pipe(first()) .subscribe( - (history) => - Array.isArray(history) && history.forEach((location) => this.location.go(location)), + (history) => Array.isArray(history) && history.forEach(({ url }) => this.location.go(url)), ); // update state when route change occurs @@ -54,31 +57,33 @@ export class PopupRouterCacheService { // `Location.back()` can now be called successfully this.hasNavigated = true; }), - filter((_event: NavigationEnd) => { + map((event) => { const state: ActivatedRouteSnapshot = this.router.routerState.snapshot.root; let child = state.firstChild; while (child.firstChild) { child = child.firstChild; } - - return !child?.data?.doNotSaveUrl; + return { event, data: child.data }; }), - switchMap((event) => this.push(event.url)), + filter(({ data }) => { + return !data?.doNotSaveUrl; + }), + switchMap(({ event, data }) => this.push(event.url, data)), ) .subscribe(); } - history$(): Observable { + history$(): Observable { return this.state.state$; } - async setHistory(state: string[]): Promise { + async setHistory(state: RouteHistoryCacheState[]): Promise { return this.state.update(() => state); } /** Get the last item from the history stack, or `null` if empty */ - last$(): Observable { + last$(): Observable { return this.history$().pipe( map((history) => { if (!history || history.length === 0) { @@ -92,11 +97,24 @@ export class PopupRouterCacheService { /** * If in browser popup, push new route onto history stack */ - private async push(url: string) { - if (!BrowserPopupUtils.inPopup(window) || url === (await firstValueFrom(this.last$()))) { + private async push(url: string, data: Data) { + if ( + !BrowserPopupUtils.inPopup(window) || + url === (await firstValueFrom(this.last$().pipe(map((h) => h?.url)))) + ) { return; } - await this.state.update((prevState) => (prevState == null ? [url] : prevState.concat(url))); + + const routeEntry: RouteHistoryCacheState = { + url, + options: { + resetRouterCacheOnTabChange: data?.resetRouterCacheOnTabChange ?? false, + }, + }; + + await this.state.update((prevState) => + prevState == null ? [routeEntry] : prevState.concat(routeEntry), + ); } /** @@ -142,13 +160,13 @@ export const popupRouterCacheGuard = ((): Observable => { } return popupHistoryService.last$().pipe( - map((url: string) => { - if (!url) { + map((entry) => { + if (!entry) { return true; } popupHistoryService.markCacheRestored(); - return urlSerializer.parse(url); + return urlSerializer.parse(entry.url); }), ); }) satisfies CanActivateFn; diff --git a/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts b/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts index 22fb7bf99b9..3304a99023e 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts @@ -96,11 +96,31 @@ describe("Popup router cache guard", () => { // wait for router events subscription await flushPromises(); - expect(await firstValueFrom(service.history$())).toEqual(["/a", "/b"]); + expect(await firstValueFrom(service.history$())).toEqual([ + { + options: { + resetRouterCacheOnTabChange: false, + }, + url: "/a", + }, + { + options: { + resetRouterCacheOnTabChange: false, + }, + url: "/b", + }, + ]); await service.back(); - expect(await firstValueFrom(service.history$())).toEqual(["/a"]); + expect(await firstValueFrom(service.history$())).toEqual([ + { + options: { + resetRouterCacheOnTabChange: false, + }, + url: "/a", + }, + ]); }); it("does not save ignored routes", async () => { @@ -121,6 +141,13 @@ describe("Popup router cache guard", () => { await flushPromises(); - expect(await firstValueFrom(service.history$())).toEqual(["/a"]); + expect(await firstValueFrom(service.history$())).toEqual([ + { + options: { + resetRouterCacheOnTabChange: false, + }, + url: "/a", + }, + ]); }); }); diff --git a/apps/browser/src/platform/services/popup-router-cache-background.service.ts b/apps/browser/src/platform/services/popup-router-cache-background.service.ts new file mode 100644 index 00000000000..37e9f7cd4a0 --- /dev/null +++ b/apps/browser/src/platform/services/popup-router-cache-background.service.ts @@ -0,0 +1,55 @@ +import { switchMap, filter, map, first, of } from "rxjs"; + +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; + +import { BrowserApi } from "../browser/browser-api"; +import { fromChromeEvent } from "../browser/from-chrome-event"; + +import { POPUP_ROUTE_HISTORY_KEY } from "./popup-view-cache-background.service"; + +export class PopupRouterCacheBackgroundService { + private popupRouteHistoryState = this.globalStateProvider.get(POPUP_ROUTE_HISTORY_KEY); + + constructor(private globalStateProvider: GlobalStateProvider) {} + + init() { + fromChromeEvent(chrome.tabs.onActivated) + .pipe( + switchMap((tabs) => BrowserApi.getTab(tabs[0].tabId)!), + switchMap((tab) => { + // FireFox sets the `url` to "about:blank" and won't populate the `url` until the `onUpdated` event + if (tab.url !== "about:blank") { + return of(tab); + } + + return fromChromeEvent(chrome.tabs.onUpdated).pipe( + first(), + switchMap(([tabId]) => BrowserApi.getTab(tabId)!), + ); + }), + map((tab) => tab.url || tab.pendingUrl), + filter((url) => !url?.startsWith(chrome.runtime.getURL(""))), + switchMap(() => + this.popupRouteHistoryState.update((state) => { + if (!state || state.length === 0) { + return state; + } + + const lastRoute = state.at(-1); + if (!lastRoute) { + return state; + } + + // When the last route has resetRouterCacheOnTabChange set + // Reset the route history to empty to force the user to the default route + if (lastRoute.options?.resetRouterCacheOnTabChange) { + return []; + } + + return state; + }), + ), + ) + .subscribe(); + } +} diff --git a/apps/browser/src/platform/services/popup-view-cache-background.service.ts b/apps/browser/src/platform/services/popup-view-cache-background.service.ts index 49eae15fbbd..6a0a72ceccd 100644 --- a/apps/browser/src/platform/services/popup-view-cache-background.service.ts +++ b/apps/browser/src/platform/services/popup-view-cache-background.service.ts @@ -42,6 +42,22 @@ export type ViewCacheState = { options?: ViewCacheOptions; }; +export type RouteCacheOptions = { + /** + * When true, the route history will be reset on tab change and respective route was the last visited route. + * i.e. Upon the user re-opening the extension the route history will be empty and the user will be taken to the default route. + */ + resetRouterCacheOnTabChange?: boolean; +}; + +export type RouteHistoryCacheState = { + /** Route URL */ + url: string; + + /** Options for managing the route history cache */ + options?: RouteCacheOptions; +}; + /** We cannot use `UserKeyDefinition` because we must be able to store state when there is no active user. */ export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record( POPUP_VIEW_MEMORY, @@ -51,9 +67,9 @@ export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record( }, ); -export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition( +export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition( POPUP_VIEW_MEMORY, - "popup-route-history", + "popup-route-history-details", { deserializer: (jsonValue) => jsonValue, }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index e3d63d20c17..e57625d382a 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -59,6 +59,7 @@ import { ProtectedByComponent } from "../dirt/phishing-detection/pages/protected import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import BrowserPopupUtils from "../platform/browser/browser-popup-utils"; import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service"; +import { RouteCacheOptions } from "../platform/services/popup-view-cache-background.service"; import { CredentialGeneratorHistoryComponent } from "../tools/popup/generator/credential-generator-history.component"; import { CredentialGeneratorComponent } from "../tools/popup/generator/credential-generator.component"; import { SendAddEditComponent as SendAddEditV2Component } from "../tools/popup/send-v2/add-edit/send-add-edit.component"; @@ -98,7 +99,7 @@ import { TabsV2Component } from "./tabs-v2.component"; /** * Data properties acceptable for use in extension route objects */ -export interface RouteDataProperties { +export interface RouteDataProperties extends RouteCacheOptions { elevation: RouteElevation; /** @@ -204,7 +205,7 @@ const routes: Routes = [ path: "add-cipher", component: AddEditV2Component, canActivate: [authGuard, debounceNavigationGuard()], - data: { elevation: 1 } satisfies RouteDataProperties, + data: { elevation: 1, resetRouterCacheOnTabChange: true } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }, { @@ -214,6 +215,7 @@ const routes: Routes = [ data: { // Above "trash" elevation: 3, + resetRouterCacheOnTabChange: true, } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }, From 2179cb684817d7cfa6277e7255e5fa941708e985 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 22 Oct 2025 08:43:03 -0500 Subject: [PATCH 02/18] prompt for master password first before confirming archive (#16892) --- .../vault/individual-vault/vault.component.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) 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 4fbfd540fd9..32f35375542 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -692,6 +692,12 @@ export class VaultComponent implements OnInit, OnDestr } async archive(cipher: C) { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); + + if (!repromptPassed) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveItem" }, content: { key: "archiveItemConfirmDesc" }, @@ -702,10 +708,6 @@ export class VaultComponent implements OnInit, OnDestr return; } - const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); - if (!repromptPassed) { - return; - } const activeUserId = await firstValueFrom(this.userId$); try { await this.cipherArchiveService.archiveWithServer(cipher.id as CipherId, activeUserId); @@ -724,6 +726,10 @@ export class VaultComponent implements OnInit, OnDestr } async bulkArchive(ciphers: C[]) { + if (!(await this.repromptCipher(ciphers))) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveBulkItems" }, content: { key: "archiveBulkItemsConfirmDesc" }, @@ -734,10 +740,6 @@ export class VaultComponent implements OnInit, OnDestr return; } - if (!(await this.repromptCipher(ciphers))) { - return; - } - const activeUserId = await firstValueFrom(this.userId$); const cipherIds = ciphers.map((c) => c.id as CipherId); try { From cc954ed123567676603ecd987c7849937b615dc2 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Wed, 22 Oct 2025 10:10:56 -0500 Subject: [PATCH 03/18] [PM-27204] New Feature Flag for datadog and crowdstrike (#16968) --- .../hec-organization-integration-service.ts | 5 +++- .../integrations.component.ts | 26 +++---------------- libs/common/src/enums/feature-flag.enum.ts | 4 +-- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts index ad9854c4b25..b83ea26e166 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts @@ -171,7 +171,10 @@ export class HecOrganizationIntegrationService { ); if (updatedIntegration !== null) { - this._integrations$.next([...this._integrations$.getValue(), updatedIntegration]); + const unchangedIntegrations = this._integrations$ + .getValue() + .filter((i) => i.id !== OrganizationIntegrationId); + this._integrations$.next([...unchangedIntegrations, updatedIntegration]); } return { mustBeOwner: false, success: true }; } catch (error) { diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index 1a684d4094b..f0292ef90e7 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -29,7 +29,7 @@ import { FilterIntegrationsPipe } from "./integrations.pipe"; export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { tabIndex: number = 0; organization$: Observable = new Observable(); - isEventBasedIntegrationsEnabled: boolean = false; + isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false; private destroy$ = new Subject(); // initialize the integrations list with default integrations @@ -230,24 +230,6 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id); this.datadogOrganizationIntegrationService.setOrganizationIntegrations(org.id); }); - - // For all existing event based configurations loop through and assign the - // organizationIntegration for the correct services. - this.hecOrganizationIntegrationService.integrations$ - .pipe(takeUntil(this.destroy$)) - .subscribe((integrations) => { - // reset all integrations to null first - in case one was deleted - this.integrationsList.forEach((i) => { - i.organizationIntegration = null; - }); - - integrations.map((integration) => { - const item = this.integrationsList.find((i) => i.name === integration.serviceType); - if (item) { - item.organizationIntegration = integration; - } - }); - }); } constructor( @@ -259,14 +241,14 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService, ) { this.configService - .getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations) + .getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike) .pipe(takeUntil(this.destroy$)) .subscribe((isEnabled) => { - this.isEventBasedIntegrationsEnabled = isEnabled; + this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled; }); // Add the new event based items to the list - if (this.isEventBasedIntegrationsEnabled) { + if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) { const crowdstrikeIntegration: Integration = { name: OrganizationIntegrationServiceType.CrowdStrike, linkURL: "https://bitwarden.com/help/crowdstrike-siem/", diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 6a561d29a0f..abe8a6bba68 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -45,7 +45,7 @@ export enum FeatureFlag { ChromiumImporterWithABE = "pm-25855-chromium-importer-abe", /* DIRT */ - EventBasedOrganizationIntegrations = "event-based-organization-integrations", + EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", PhishingDetection = "phishing-detection", PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab", @@ -91,7 +91,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.ChromiumImporterWithABE]: FALSE, /* DIRT */ - [FeatureFlag.EventBasedOrganizationIntegrations]: FALSE, + [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, [FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE, From 03d636108d1d5a2fa23a6b11920a2a5b5fc76cf7 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:36:51 -0500 Subject: [PATCH 04/18] [PM-23680] Report Applications data (#16819) * Move files to folders. Delete unused component. Move model to file * Move risk insights services to folder structure capturing domains, api, and view organization. Move mock data * Remove legacy risk insight report code * Move api model to file * Separate data service and orchestration of data to make the data service a facade * Add orchestration updates for fetching applications as well as migrating data. * Updated migration of critical applications and merged old saved data to new critical applications on report object * Update test cases * Fixed test case after merge. Cleaned up per comments on review * Fixed decryption and encryption issue when not using existing content key * Fix type errors * Fix test update * Fixe remove critical applications * Fix report generating flag not being reset * Removed extra logs --- .../helpers/risk-insights-data-mappers.ts | 176 ++--- .../risk-insights/models/api-models.types.ts | 36 +- .../models/drawer-models.types.ts | 44 ++ .../reports/risk-insights/models/index.ts | 1 + .../mocks}/ciphers.mock.ts | 0 .../member-cipher-details-response.mock.ts} | 86 +- .../models/{ => mocks}/mock-data.ts | 49 +- .../risk-insights/models/password-health.ts | 37 - .../risk-insights/models/report-models.ts | 92 +-- .../member-cipher-details.response.ts | 18 - .../critical-apps-api.service.spec.ts | 4 +- .../{ => api}/critical-apps-api.service.ts | 2 +- .../member-cipher-details-api.service.spec.ts | 38 + .../member-cipher-details-api.service.ts | 2 +- .../risk-insights-api.service.spec.ts | 20 +- .../{ => api}/risk-insights-api.service.ts | 20 +- .../security-tasks-api.service.spec.ts | 0 .../{ => api}/security-tasks-api.service.ts | 0 .../critical-apps.service.spec.ts | 10 +- .../{ => domain}/critical-apps.service.ts | 45 +- .../password-health.service.spec.ts | 0 .../{ => domain}/password-health.service.ts | 2 +- .../risk-insights-encryption.service.spec.ts | 7 +- .../risk-insights-encryption.service.ts | 131 +++- ...risk-insights-orchestrator.service.spec.ts | 224 ++++++ .../risk-insights-orchestrator.service.ts | 742 ++++++++++++++++++ .../risk-insights-report.service.spec.ts | 157 +--- .../domain/risk-insights-report.service.ts | 385 +++++++++ .../reports/risk-insights/services/index.ts | 20 +- .../member-cipher-details-response.mock.ts | 79 -- .../services/risk-insights-data.service.ts | 469 ----------- .../services/risk-insights-report.service.ts | 698 ---------------- .../{ => view}/all-activities.service.ts | 6 +- .../view/risk-insights-data.service.ts | 186 +++++ .../access-intelligence.module.ts | 27 +- .../activity-card.component.html | 0 .../{ => activity}/activity-card.component.ts | 0 .../password-change-metric.component.html | 0 .../password-change-metric.component.ts | 34 +- .../all-activity.component.html | 0 .../{ => activity}/all-activity.component.ts | 28 +- .../new-applications-dialog.component.html | 0 .../new-applications-dialog.component.ts | 0 .../all-applications.component.html | 0 .../all-applications.component.ts | 6 +- .../critical-applications.component.html | 0 .../critical-applications.component.ts | 9 +- .../models/activity.models.ts | 7 + .../models/risk-insights.models.ts | 8 + .../notified-members-table.component.html | 11 - .../notified-members-table.component.ts | 18 - .../risk-insights.component.html | 4 +- .../risk-insights.component.ts | 45 +- .../app-table-row-scrollable.component.html | 0 .../app-table-row-scrollable.component.ts | 0 .../risk-insights-loading.component.html | 0 .../risk-insights-loading.component.ts | 0 .../shared/security-tasks.service.spec.ts | 14 +- .../shared/security-tasks.service.ts | 9 +- 59 files changed, 2142 insertions(+), 1864 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/{services => models/mocks}/ciphers.mock.ts (100%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/{services/member-cipher-details-api.service.spec.ts => models/mocks/member-cipher-details-response.mock.ts} (56%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/{ => mocks}/mock-data.ts (75%) delete mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/critical-apps-api.service.spec.ts (96%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/critical-apps-api.service.ts (97%) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.spec.ts rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/member-cipher-details-api.service.ts (88%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/risk-insights-api.service.spec.ts (93%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/risk-insights-api.service.ts (87%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/security-tasks-api.service.spec.ts (100%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/security-tasks-api.service.ts (100%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/critical-apps.service.spec.ts (96%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/critical-apps.service.ts (87%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/password-health.service.spec.ts (100%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/password-health.service.ts (99%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/risk-insights-encryption.service.spec.ts (97%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/risk-insights-encryption.service.ts (50%) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/risk-insights-report.service.spec.ts (57%) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-response.mock.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => view}/all-activities.service.ts (94%) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/activity-card.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/activity-card.component.ts (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/activity-cards/password-change-metric.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/activity-cards/password-change-metric.component.ts (88%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/all-activity.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/all-activity.component.ts (91%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/new-applications-dialog.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/new-applications-dialog.component.ts (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => all-applications}/all-applications.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => all-applications}/all-applications.component.ts (94%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => critical-applications}/critical-applications.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => critical-applications}/critical-applications.component.ts (93%) create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/risk-insights.models.ts delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.html delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.ts rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => shared}/app-table-row-scrollable.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => shared}/app-table-row-scrollable.component.ts (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => shared}/risk-insights-loading.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => shared}/risk-insights-loading.component.ts (100%) diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts index 6afb0ee6815..624b695e6be 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts @@ -2,20 +2,19 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { - LEGACY_MemberDetailsFlat, - LEGACY_CipherHealthReportDetail, - LEGACY_CipherHealthReportUriDetail, -} from "../models/password-health"; + AtRiskApplicationDetail, + AtRiskMemberDetail, + MemberCipherDetailsResponse, +} from "../models"; import { ApplicationHealthReportDetail, + MemberDetails, OrganizationReportSummary, - RiskInsightsData, } from "../models/report-models"; -import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; export function flattenMemberDetails( memberCiphers: MemberCipherDetailsResponse[], -): LEGACY_MemberDetailsFlat[] { +): MemberDetails[] { return memberCiphers.flatMap((member) => member.cipherIds.map((cipherId) => ({ userGuid: member.userGuid, @@ -48,9 +47,7 @@ export function getTrimmedCipherUris(cipher: CipherView): string[] { } // Returns a deduplicated array of members by email -export function getUniqueMembers( - orgMembers: LEGACY_MemberDetailsFlat[], -): LEGACY_MemberDetailsFlat[] { +export function getUniqueMembers(orgMembers: MemberDetails[]): MemberDetails[] { const existingEmails = new Set(); return orgMembers.filter((member) => { if (existingEmails.has(member.email)) { @@ -61,108 +58,6 @@ export function getUniqueMembers( }); } -/** - * Creates a flattened member details object - * @param userGuid User GUID - * @param userName User name - * @param email User email - * @param cipherId Cipher ID - * @returns Flattened member details - */ -export function getMemberDetailsFlat( - userGuid: string, - userName: string, - email: string, - cipherId: string, -): LEGACY_MemberDetailsFlat { - return { - userGuid: userGuid, - userName: userName, - email: email, - cipherId: cipherId, - }; -} - -/** - * Creates a flattened cipher details object for URI reporting - * @param detail Cipher health report detail - * @param uri Trimmed URI - * @returns Flattened cipher health details to URI - */ -export function getFlattenedCipherDetails( - detail: LEGACY_CipherHealthReportDetail, - uri: string, -): LEGACY_CipherHealthReportUriDetail { - return { - cipherId: detail.id, - reusedPasswordCount: detail.reusedPasswordCount, - weakPasswordDetail: detail.weakPasswordDetail, - exposedPasswordDetail: detail.exposedPasswordDetail, - cipherMembers: detail.cipherMembers, - trimmedUri: uri, - cipher: detail as CipherView, - }; -} - -/** - * Create the new application health report detail object with the details from the cipher health report uri detail object - * update or create the at risk values if the item is at risk. - * @param newUriDetail New cipher uri detail - * @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk - * @param existingUriDetail The previously processed Uri item - * @returns The new or updated application health report detail - */ -export function getApplicationReportDetail( - newUriDetail: LEGACY_CipherHealthReportUriDetail, - isAtRisk: boolean, - existingUriDetail?: ApplicationHealthReportDetail, -): ApplicationHealthReportDetail { - const reportDetail = { - applicationName: existingUriDetail - ? existingUriDetail.applicationName - : newUriDetail.trimmedUri, - passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1, - memberDetails: existingUriDetail - ? getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers)) - : newUriDetail.cipherMembers, - atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [], - atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0, - atRiskCipherIds: existingUriDetail ? existingUriDetail.atRiskCipherIds : [], - atRiskMemberCount: existingUriDetail ? existingUriDetail.atRiskMemberDetails.length : 0, - cipherIds: existingUriDetail - ? existingUriDetail.cipherIds.concat(newUriDetail.cipherId) - : [newUriDetail.cipherId], - } as ApplicationHealthReportDetail; - - if (isAtRisk) { - reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1; - reportDetail.atRiskCipherIds.push(newUriDetail.cipherId); - - reportDetail.atRiskMemberDetails = getUniqueMembers( - reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers), - ); - reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length; - } - - reportDetail.memberCount = reportDetail.memberDetails.length; - - return reportDetail; -} - -/** - * Create a new Risk Insights Report - * - * @returns An empty report - */ -export function createNewReportData(): RiskInsightsData { - return { - creationDate: new Date(), - reportData: [], - summaryData: createNewSummaryData(), - applicationData: [], - }; -} - /** * Create a new Risk Insights Report Summary * @@ -181,3 +76,60 @@ export function createNewSummaryData(): OrganizationReportSummary { newApplications: [], }; } +export function getAtRiskApplicationList( + cipherHealthReportDetails: ApplicationHealthReportDetail[], +): AtRiskApplicationDetail[] { + const applicationPasswordRiskMap = new Map(); + + cipherHealthReportDetails + .filter((app) => app.atRiskPasswordCount > 0) + .forEach((app) => { + const atRiskPasswordCount = applicationPasswordRiskMap.get(app.applicationName) ?? 0; + applicationPasswordRiskMap.set( + app.applicationName, + atRiskPasswordCount + app.atRiskPasswordCount, + ); + }); + + return Array.from(applicationPasswordRiskMap.entries()).map( + ([applicationName, atRiskPasswordCount]) => ({ + applicationName, + atRiskPasswordCount, + }), + ); +} +/** + * Generates a list of members with at-risk passwords along with the number of at-risk passwords. + */ +export function getAtRiskMemberList( + cipherHealthReportDetails: ApplicationHealthReportDetail[], +): AtRiskMemberDetail[] { + const memberRiskMap = new Map(); + + cipherHealthReportDetails.forEach((app) => { + app.atRiskMemberDetails.forEach((member) => { + const currentCount = memberRiskMap.get(member.email) ?? 0; + memberRiskMap.set(member.email, currentCount + 1); + }); + }); + + return Array.from(memberRiskMap.entries()).map(([email, atRiskPasswordCount]) => ({ + email, + atRiskPasswordCount, + })); +} + +/** + * Builds a map of passwords to the number of times they are used across ciphers + * + * @param ciphers List of ciphers to check for password reuse + * @returns A map where the key is the password and the value is the number of times it is used + */ +export function buildPasswordUseMap(ciphers: CipherView[]): Map { + const passwordUseMap = new Map(); + ciphers.forEach((cipher) => { + const password = cipher.login.password!; + passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1); + }); + return passwordUseMap; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts index 871db2b68ac..529789ebb8d 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts @@ -1,6 +1,6 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { BaseResponse } from "@bitwarden/common/models/response/base.response"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { createNewSummaryData } from "../helpers"; @@ -46,11 +46,11 @@ export interface SaveRiskInsightsReportRequest { } export class SaveRiskInsightsReportResponse extends BaseResponse { - id: string; + id: OrganizationReportId; constructor(response: any) { super(response); - this.id = this.getResponseProperty("organizationId"); + this.id = this.getResponseProperty("id"); } } export function isSaveRiskInsightsReportResponse(obj: any): obj is SaveRiskInsightsReportResponse { @@ -69,7 +69,7 @@ export class GetRiskInsightsReportResponse extends BaseResponse { constructor(response: any) { super(response); - this.id = this.getResponseProperty("organizationId"); + this.id = this.getResponseProperty("id"); this.organizationId = this.getResponseProperty("organizationId"); this.creationDate = new Date(this.getResponseProperty("creationDate")); this.reportData = new EncString(this.getResponseProperty("reportData")); @@ -113,3 +113,31 @@ export class GetRiskInsightsApplicationDataResponse extends BaseResponse { this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); } } + +export class MemberCipherDetailsResponse extends BaseResponse { + userGuid: string; + userName: string; + email: string; + useKeyConnector: boolean; + cipherIds: string[] = []; + + constructor(response: any) { + super(response); + this.userGuid = this.getResponseProperty("UserGuid"); + this.userName = this.getResponseProperty("UserName"); + this.email = this.getResponseProperty("Email"); + this.useKeyConnector = this.getResponseProperty("UseKeyConnector"); + this.cipherIds = this.getResponseProperty("CipherIds"); + } +} + +export interface UpdateRiskInsightsApplicationDataRequest { + data: { + applicationData: string; + }; +} +export class UpdateRiskInsightsApplicationDataResponse extends BaseResponse { + constructor(response: any) { + super(response); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts new file mode 100644 index 00000000000..dffb22af3ee --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts @@ -0,0 +1,44 @@ +import { MemberDetails } from "./report-models"; + +// -------------------- Drawer and UI Models -------------------- +// FIXME: update to use a const object instead of a typescript enum +// eslint-disable-next-line @bitwarden/platform/no-enums +export enum DrawerType { + None = 0, + AppAtRiskMembers = 1, + OrgAtRiskMembers = 2, + OrgAtRiskApps = 3, +} + +export type DrawerDetails = { + open: boolean; + invokerId: string; + activeDrawerType: DrawerType; + atRiskMemberDetails?: AtRiskMemberDetail[]; + appAtRiskMembers?: AppAtRiskMembersDialogParams | null; + atRiskAppDetails?: AtRiskApplicationDetail[] | null; +}; + +export type AppAtRiskMembersDialogParams = { + members: MemberDetails[]; + applicationName: string; +}; + +/** + * Member email with the number of at risk passwords + * At risk member detail that contains the email + * and the count of at risk ciphers + */ +export type AtRiskMemberDetail = { + email: string; + atRiskPasswordCount: number; +}; + +/* + * A list of applications and the count of + * at risk passwords for each application + */ +export type AtRiskApplicationDetail = { + applicationName: string; + atRiskPasswordCount: number; +}; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts index abe1f7200dc..c0364b12f87 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts @@ -3,3 +3,4 @@ export * from "./password-health"; export * from "./report-data-service.types"; export * from "./report-encryption.types"; export * from "./report-models"; +export * from "./drawer-models.types"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/ciphers.mock.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/ciphers.mock.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/ciphers.mock.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/ciphers.mock.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/member-cipher-details-response.mock.ts similarity index 56% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/member-cipher-details-response.mock.ts index d6474c2c9c4..0f14642dc6b 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/member-cipher-details-response.mock.ts @@ -1,109 +1,83 @@ import { mock } from "jest-mock-extended"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { MemberCipherDetailsResponse } from ".."; -import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; - -export const mockMemberCipherDetails: any = [ - { +export const mockMemberCipherDetailsResponse: MemberCipherDetailsResponse[] = [ + mock({ + userGuid: "user-1", userName: "David Brent", email: "david.brent@wernhamhogg.uk", - usesKeyConnector: true, + useKeyConnector: true, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228ab1", "cbea34a8-bde4-46ad-9d19-b05001228ab2", "cbea34a8-bde4-46ad-9d19-b05001228xy4", "cbea34a8-bde4-46ad-9d19-b05001227nm5", ], - }, - { + }), + mock({ + userGuid: "user-2", userName: "Tim Canterbury", email: "tim.canterbury@wernhamhogg.uk", - usesKeyConnector: false, + useKeyConnector: false, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228ab2", "cbea34a8-bde4-46ad-9d19-b05001228cd3", "cbea34a8-bde4-46ad-9d19-b05001228xy4", "cbea34a8-bde4-46ad-9d19-b05001227nm5", ], - }, - { + }), + mock({ + userGuid: "user-3", userName: "Gareth Keenan", email: "gareth.keenan@wernhamhogg.uk", - usesKeyConnector: true, + useKeyConnector: true, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228cd3", "cbea34a8-bde4-46ad-9d19-b05001228xy4", "cbea34a8-bde4-46ad-9d19-b05001227nm5", "cbea34a8-bde4-46ad-9d19-b05001227nm7", ], - }, - { + }), + mock({ + userGuid: "user-4", userName: "Dawn Tinsley", email: "dawn.tinsley@wernhamhogg.uk", - usesKeyConnector: true, + useKeyConnector: true, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228ab2", "cbea34a8-bde4-46ad-9d19-b05001228cd3", "cbea34a8-bde4-46ad-9d19-b05001228xy4", ], - }, - { + }), + mock({ + userGuid: "user-5", userName: "Keith Bishop", email: "keith.bishop@wernhamhogg.uk", - usesKeyConnector: false, + useKeyConnector: false, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228ab1", "cbea34a8-bde4-46ad-9d19-b05001228cd3", "cbea34a8-bde4-46ad-9d19-b05001228xy4", "cbea34a8-bde4-46ad-9d19-b05001227nm5", ], - }, - { + }), + mock({ + userGuid: "user-1", userName: "Chris Finch", email: "chris.finch@wernhamhogg.uk", - usesKeyConnector: true, + useKeyConnector: true, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228ab2", "cbea34a8-bde4-46ad-9d19-b05001228cd3", "cbea34a8-bde4-46ad-9d19-b05001228xy4", ], - }, - { + }), + mock({ + userGuid: "user-1", userName: "Mister Secure", email: "mister.secure@secureco.com", - usesKeyConnector: true, + useKeyConnector: true, cipherIds: ["cbea34a8-bde4-46ad-9d19-b05001227tt1"], - }, + }), ]; - -describe("Member Cipher Details API Service", () => { - let memberCipherDetailsApiService: MemberCipherDetailsApiService; - - const apiService = mock(); - - beforeEach(() => { - memberCipherDetailsApiService = new MemberCipherDetailsApiService(apiService); - jest.resetAllMocks(); - }); - - it("instantiates", () => { - expect(memberCipherDetailsApiService).not.toBeFalsy(); - }); - - it("getMemberCipherDetails retrieves data", async () => { - apiService.send.mockResolvedValue(mockMemberCipherDetails); - - const orgId = "1234"; - const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId); - expect(result).not.toBeNull(); - expect(result).toHaveLength(7); - expect(apiService.send).toHaveBeenCalledWith( - "GET", - "/reports/member-cipher-details/" + orgId, - null, - true, - true, - ); - }); -}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts similarity index 75% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts index c790fc327a9..1a30ad754c3 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts @@ -3,14 +3,15 @@ import { mock } from "jest-mock-extended"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; - -import { ApplicationHealthReportDetailEnriched } from "./report-data-service.types"; +import { MemberCipherDetailsResponse } from ".."; +import { ApplicationHealthReportDetailEnriched } from "../report-data-service.types"; import { ApplicationHealthReportDetail, + CipherHealthReport, OrganizationReportApplication, OrganizationReportSummary, -} from "./report-models"; + PasswordHealthData, +} from "../report-models"; const mockApplication1: ApplicationHealthReportDetail = { applicationName: "application1.com", @@ -82,10 +83,12 @@ export const mockApplicationData: OrganizationReportApplication[] = [ { applicationName: "application1.com", isCritical: true, + reviewedDate: new Date(), }, { applicationName: "application2.com", isCritical: false, + reviewedDate: null, }, ]; @@ -138,3 +141,41 @@ export const mockMemberDetails = [ email: "user3@other.com", }), ]; + +export const mockCipherHealthReports: CipherHealthReport[] = [ + { + applications: ["app.com"], + cipherMembers: [], + healthData: createPasswordHealthData(0), + cipher: mockCipherViews[0], + }, + { + applications: ["app.com"], + cipherMembers: [], + healthData: createPasswordHealthData(1), + cipher: mockCipherViews[1], + }, + { + applications: ["other.com"], + cipherMembers: [], + healthData: createPasswordHealthData(2), + cipher: mockCipherViews[2], + }, +]; + +function createPasswordHealthData(reusedPasswordCount: number | null): PasswordHealthData { + return { + reusedPasswordCount: reusedPasswordCount ?? 0, + weakPasswordDetail: { + score: 0, + detailValue: { + label: "", + badgeVariant: "info", + }, + }, + exposedPasswordDetail: { + cipherId: "", + exposedXTimes: 0, + }, + }; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts index 8127ea41085..8406a9107b8 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts @@ -1,10 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant } from "@bitwarden/components"; -import { ApplicationHealthReportDetail } from "./report-models"; - /** * Weak password details containing the score * and the score type for the label and badge @@ -30,37 +27,3 @@ export type ExposedPasswordDetail = { cipherId: string; exposedXTimes: number; } | null; - -export type LEGACY_MemberDetailsFlat = { - userGuid: string; - userName: string; - email: string; - cipherId: string; -}; - -export type LEGACY_ApplicationHealthReportDetailWithCriticalFlag = ApplicationHealthReportDetail & { - isMarkedAsCritical: boolean; -}; - -export type LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher = - LEGACY_ApplicationHealthReportDetailWithCriticalFlag & { - ciphers: CipherView[]; - }; - -export type LEGACY_CipherHealthReportDetail = CipherView & { - reusedPasswordCount: number; - weakPasswordDetail: WeakPasswordDetail; - exposedPasswordDetail: ExposedPasswordDetail; - cipherMembers: LEGACY_MemberDetailsFlat[]; - trimmedUris: string[]; -}; - -export type LEGACY_CipherHealthReportUriDetail = { - cipherId: string; - reusedPasswordCount: number; - weakPasswordDetail: WeakPasswordDetail; - exposedPasswordDetail: ExposedPasswordDetail; - cipherMembers: LEGACY_MemberDetailsFlat[]; - trimmedUri: string; - cipher: CipherView; -}; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts index 564f483813a..93955c7dbfb 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts @@ -1,44 +1,13 @@ import { Opaque } from "type-fest"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { OrganizationReportId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant } from "@bitwarden/components"; import { ExposedPasswordDetail, WeakPasswordDetail } from "./password-health"; -// -------------------- Drawer and UI Models -------------------- -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum DrawerType { - None = 0, - AppAtRiskMembers = 1, - OrgAtRiskMembers = 2, - OrgAtRiskApps = 3, -} - -export type DrawerDetails = { - open: boolean; - invokerId: string; - activeDrawerType: DrawerType; - atRiskMemberDetails?: AtRiskMemberDetail[]; - appAtRiskMembers?: AppAtRiskMembersDialogParams | null; - atRiskAppDetails?: AtRiskApplicationDetail[] | null; -}; - -export type AppAtRiskMembersDialogParams = { - members: MemberDetails[]; - applicationName: string; -}; - // -------------------- Member Models -------------------- -/** - * Member email with the number of at risk passwords - * At risk member detail that contains the email - * and the count of at risk ciphers - */ -export type AtRiskMemberDetail = { - email: string; - atRiskPasswordCount: number; -}; /** * Flattened member details that associates an @@ -71,18 +40,6 @@ export type CipherHealthReport = { cipher: CipherView; }; -/** - * Breaks the cipher health info out by uri and passes - * along the password health and member info - */ -export type CipherApplicationView = { - cipherId: string; - cipher: CipherView; - cipherMembers: MemberDetails[]; - application: string; - healthData: PasswordHealthData; -}; - // -------------------- Application Health Report Models -------------------- /** * All applications report summary. The total members, @@ -91,21 +48,16 @@ export type CipherApplicationView = { */ export type OrganizationReportSummary = { totalMemberCount: number; - totalCriticalMemberCount: number; - totalAtRiskMemberCount: number; - totalCriticalAtRiskMemberCount: number; totalApplicationCount: number; - totalCriticalApplicationCount: number; + totalAtRiskMemberCount: number; totalAtRiskApplicationCount: number; + totalCriticalApplicationCount: number; + totalCriticalMemberCount: number; + totalCriticalAtRiskMemberCount: number; totalCriticalAtRiskApplicationCount: number; newApplications: string[]; }; -export type CriticalSummaryDetails = { - totalCriticalMembersCount: number; - totalCriticalApplicationsCount: number; -}; - /** * An entry for an organization application and if it is * marked as critical @@ -113,6 +65,11 @@ export type CriticalSummaryDetails = { export type OrganizationReportApplication = { applicationName: string; isCritical: boolean; + /** + * Captures when a report has been reviewed by a user and + * can be filtered on to check for new applications + * */ + reviewedDate: Date | null; }; /** @@ -131,15 +88,6 @@ export type ApplicationHealthReportDetail = { cipherIds: string[]; }; -/* - * A list of applications and the count of - * at risk passwords for each application - */ -export type AtRiskApplicationDetail = { - applicationName: string; - atRiskPasswordCount: number; -}; - // -------------------- Password Health Report Models -------------------- export type PasswordHealthReportApplicationId = Opaque; @@ -152,8 +100,26 @@ export type ReportResult = CipherView & { }; export interface RiskInsightsData { + id: OrganizationReportId; creationDate: Date; + contentEncryptionKey: EncString; reportData: ApplicationHealthReportDetail[]; summaryData: OrganizationReportSummary; applicationData: OrganizationReportApplication[]; } + +export interface ReportState { + loading: boolean; + error: string | null; + data: RiskInsightsData | null; +} + +// TODO Make Versioned models for structure changes +// export type VersionedRiskInsightsData = RiskInsightsDataV1 | RiskInsightsDataV2; +// export interface RiskInsightsDataV1 { +// version: 1; +// creationDate: Date; +// reportData: ApplicationHealthReportDetail[]; +// summaryData: OrganizationReportSummary; +// applicationData: OrganizationReportApplication[]; +// } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts deleted file mode 100644 index 7aa52330663..00000000000 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BaseResponse } from "@bitwarden/common/models/response/base.response"; - -export class MemberCipherDetailsResponse extends BaseResponse { - userGuid: string; - userName: string; - email: string; - useKeyConnector: boolean; - cipherIds: string[] = []; - - constructor(response: any) { - super(response); - this.userGuid = this.getResponseProperty("UserGuid"); - this.userName = this.getResponseProperty("UserName"); - this.email = this.getResponseProperty("Email"); - this.useKeyConnector = this.getResponseProperty("UseKeyConnector"); - this.cipherIds = this.getResponseProperty("CipherIds"); - } -} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.spec.ts similarity index 96% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.spec.ts index f53bf92c47f..5880d81fed2 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.spec.ts @@ -7,8 +7,8 @@ import { PasswordHealthReportApplicationDropRequest, PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/api-models.types"; -import { PasswordHealthReportApplicationId } from "../models/report-models"; +} from "../../models/api-models.types"; +import { PasswordHealthReportApplicationId } from "../../models/report-models"; import { CriticalAppsApiService } from "./critical-apps-api.service"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.ts similarity index 97% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.ts index 29d2364f302..b3e378389d6 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.ts @@ -7,7 +7,7 @@ import { PasswordHealthReportApplicationDropRequest, PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/api-models.types"; +} from "../../models/api-models.types"; export class CriticalAppsApiService { constructor(private apiService: ApiService) {} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.spec.ts new file mode 100644 index 00000000000..beb19c91c13 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.spec.ts @@ -0,0 +1,38 @@ +import { mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock"; + +import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; + +describe("Member Cipher Details API Service", () => { + let memberCipherDetailsApiService: MemberCipherDetailsApiService; + + const apiService = mock(); + + beforeEach(() => { + memberCipherDetailsApiService = new MemberCipherDetailsApiService(apiService); + jest.resetAllMocks(); + }); + + it("instantiates", () => { + expect(memberCipherDetailsApiService).not.toBeFalsy(); + }); + + it("getMemberCipherDetails retrieves data", async () => { + apiService.send.mockResolvedValue(mockMemberCipherDetailsResponse); + + const orgId = "1234"; + const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId); + expect(result).not.toBeNull(); + expect(result).toHaveLength(7); + expect(apiService.send).toHaveBeenCalledWith( + "GET", + "/reports/member-cipher-details/" + orgId, + null, + true, + true, + ); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.ts similarity index 88% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.ts index b38f8712add..66f40ffc5a4 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; +import { MemberCipherDetailsResponse } from "../../models"; @Injectable() export class MemberCipherDetailsApiService { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.spec.ts similarity index 93% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.spec.ts index 56246f3c3b6..879563af526 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.spec.ts @@ -7,17 +7,16 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response" import { makeEncString } from "@bitwarden/common/spec"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; -import { EncryptedDataWithKey } from "../models"; +import { EncryptedDataWithKey } from "../../models"; import { GetRiskInsightsApplicationDataResponse, GetRiskInsightsReportResponse, GetRiskInsightsSummaryResponse, SaveRiskInsightsReportRequest, SaveRiskInsightsReportResponse, -} from "../models/api-models.types"; -import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data"; - -import { RiskInsightsApiService } from "./risk-insights-api.service"; +} from "../../models/api-models.types"; +import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data"; +import { RiskInsightsApiService } from "../api/risk-insights-api.service"; describe("RiskInsightsApiService", () => { let service: RiskInsightsApiService; @@ -229,19 +228,22 @@ describe("RiskInsightsApiService", () => { it("updateRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => { const reportId = "report123" as OrganizationReportId; - const mockApplication = mockApplicationData[0]; + // TODO Update to be encrypted test + const mockApplication = makeEncString("application-data"); mockApiService.send.mockResolvedValueOnce(undefined); const result = await firstValueFrom( - service.updateRiskInsightsApplicationData$(mockApplication, orgId, reportId), + service.updateRiskInsightsApplicationData$(reportId, orgId, { + data: { applicationData: mockApplication.encryptedString! }, + }), ); expect(mockApiService.send).toHaveBeenCalledWith( "PATCH", `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, - mockApplication, + { applicationData: mockApplication.encryptedString!, id: reportId, organizationId: orgId }, true, true, ); - expect(result).toBeUndefined(); + expect(result).toBeTruthy(); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.ts similarity index 87% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.ts index 99bf27506be..d1896f487b2 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.ts @@ -4,14 +4,18 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; -import { EncryptedDataWithKey, OrganizationReportApplication } from "../models"; +import { + EncryptedDataWithKey, + UpdateRiskInsightsApplicationDataRequest, + UpdateRiskInsightsApplicationDataResponse, +} from "../../models"; import { GetRiskInsightsApplicationDataResponse, GetRiskInsightsReportResponse, GetRiskInsightsSummaryResponse, SaveRiskInsightsReportRequest, SaveRiskInsightsReportResponse, -} from "../models/api-models.types"; +} from "../../models/api-models.types"; export class RiskInsightsApiService { constructor(private apiService: ApiService) {} @@ -102,18 +106,20 @@ export class RiskInsightsApiService { } updateRiskInsightsApplicationData$( - applicationData: OrganizationReportApplication, - orgId: OrganizationId, reportId: OrganizationReportId, - ): Observable { + orgId: OrganizationId, + request: UpdateRiskInsightsApplicationDataRequest, + ): Observable { const dbResponse = this.apiService.send( "PATCH", `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, - applicationData, + { ...request.data, id: reportId, organizationId: orgId }, true, true, ); - return from(dbResponse as Promise); + return from(dbResponse).pipe( + map((response) => new UpdateRiskInsightsApplicationDataResponse(response)), + ); } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.spec.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.spec.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.spec.ts similarity index 96% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.spec.ts index 28d670f226d..d69814572c7 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.spec.ts @@ -14,10 +14,10 @@ import { KeyService } from "@bitwarden/key-management"; import { PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/api-models.types"; -import { PasswordHealthReportApplicationId } from "../models/report-models"; +} from "../../models/api-models.types"; +import { PasswordHealthReportApplicationId } from "../../models/report-models"; +import { CriticalAppsApiService } from "../api/critical-apps-api.service"; -import { CriticalAppsApiService } from "./critical-apps-api.service"; import { CriticalAppsService } from "./critical-apps.service"; const SomeCsprngArray = new Uint8Array(64) as CsprngArray; @@ -181,7 +181,7 @@ describe("CriticalAppsService", () => { privateCriticalAppsSubject.next(initialList); // act - await service.dropCriticalApp(SomeOrganization, selectedUrl); + await service.dropCriticalAppByUrl(SomeOrganization, selectedUrl); // expectations expect(criticalAppsApiService.dropCriticalApp).toHaveBeenCalledWith({ @@ -213,7 +213,7 @@ describe("CriticalAppsService", () => { privateCriticalAppsSubject.next(initialList); // act - await service.dropCriticalApp(SomeOrganization, selectedUrl); + await service.dropCriticalAppByUrl(SomeOrganization, selectedUrl); // expectations expect(criticalAppsApiService.dropCriticalApp).not.toHaveBeenCalled(); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.ts similarity index 87% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.ts index b3b2f7c44e8..d310b3aeaac 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.ts @@ -1,13 +1,17 @@ import { BehaviorSubject, + catchError, filter, first, firstValueFrom, forkJoin, + from, map, Observable, of, switchMap, + tap, + throwError, zip, } from "rxjs"; @@ -20,9 +24,8 @@ import { KeyService } from "@bitwarden/key-management"; import { PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/api-models.types"; - -import { CriticalAppsApiService } from "./critical-apps-api.service"; +} from "../../models/api-models.types"; +import { CriticalAppsApiService } from "../api/critical-apps-api.service"; /* Retrieves and decrypts critical apps for a given organization * Encrypts and saves data for a given organization @@ -125,9 +128,14 @@ export class CriticalAppsService { this.criticalAppsListSubject$.next(updatedList); } - // Drop a critical app for a given organization - // Only one app may be dropped at a time - async dropCriticalApp(orgId: OrganizationId, selectedUrl: string) { + /** + * Drop a critical application by url + * + * @param orgId + * @param selectedUrl + * @returns + */ + async dropCriticalAppByUrl(orgId: OrganizationId, selectedUrl: string) { if (orgId != this.organizationId.value) { throw new Error("Organization ID mismatch"); } @@ -150,6 +158,31 @@ export class CriticalAppsService { ); } + /** + * Drop multiple critical applications by id + * + * @param orgId + * @param ids + * @returns + */ + dropCriticalAppsById(orgId: OrganizationId, ids: string[]) { + return from( + this.criticalAppsApiService.dropCriticalApp({ + organizationId: orgId, + passwordHealthReportApplicationIds: ids, + }), + ).pipe( + tap((response) => { + this.criticalAppsListSubject$.next( + this.criticalAppsListSubject$.value.filter((f) => ids.some((id) => id === f.id)), + ); + }), + catchError((error: unknown) => { + return throwError(() => error); + }), + ); + } + private retrieveCriticalApps( orgId: OrganizationId | null, ): Observable { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.spec.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.spec.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts similarity index 99% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts index 2ad9f1c7cfd..267c1dc9563 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts @@ -10,7 +10,7 @@ import { ExposedPasswordDetail, WeakPasswordDetail, WeakPasswordScore, -} from "../models/password-health"; +} from "../../models/password-health"; export class PasswordHealthService { constructor( diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts similarity index 97% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts index e2c92ad4b9b..2efd97b3c30 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts @@ -9,9 +9,10 @@ import { makeSymmetricCryptoKey } from "@bitwarden/common/spec"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; -import { EncryptedReportData, DecryptedReportData } from "../models"; -import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data"; +import { EncryptedReportData, DecryptedReportData } from "../../models"; +import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; @@ -20,6 +21,7 @@ describe("RiskInsightsEncryptionService", () => { const mockKeyService = mock(); const mockEncryptService = mock(); const mockKeyGenerationService = mock(); + const mockLogService = mock(); const ENCRYPTED_TEXT = "This data has been encrypted"; const ENCRYPTED_KEY = "Re-encrypted Cipher Key"; @@ -43,6 +45,7 @@ describe("RiskInsightsEncryptionService", () => { mockKeyService, mockEncryptService, mockKeyGenerationService, + mockLogService, ); jest.clearAllMocks(); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts similarity index 50% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts index 04811f9cfcd..5206cd1ecff 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts @@ -6,14 +6,24 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; -import { DecryptedReportData, EncryptedReportData, EncryptedDataWithKey } from "../models"; +import { createNewSummaryData } from "../../helpers"; +import { + DecryptedReportData, + EncryptedReportData, + EncryptedDataWithKey, + ApplicationHealthReportDetail, + OrganizationReportSummary, + OrganizationReportApplication, +} from "../../models"; export class RiskInsightsEncryptionService { constructor( private keyService: KeyService, private encryptService: EncryptService, private keyGeneratorService: KeyGenerationService, + private logService: LogService, ) {} async encryptRiskInsightsReport( @@ -24,6 +34,7 @@ export class RiskInsightsEncryptionService { data: DecryptedReportData, wrappedKey?: EncString, ): Promise { + this.logService.info("[RiskInsightsEncryptionService] Encrypting risk insights report"); const { userId, organizationId } = context; const orgKey = await firstValueFrom( this.keyService @@ -36,16 +47,24 @@ export class RiskInsightsEncryptionService { ); if (!orgKey) { + this.logService.warning( + "[RiskInsightsEncryptionService] Attempted to encrypt report data without org id", + ); throw new Error("Organization key not found"); } let contentEncryptionKey: SymmetricCryptoKey; - if (!wrappedKey) { - // Generate a new key - contentEncryptionKey = await this.keyGeneratorService.createKey(512); - } else { - // Unwrap the existing key - contentEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey); + try { + if (!wrappedKey) { + // Generate a new key + contentEncryptionKey = await this.keyGeneratorService.createKey(512); + } else { + // Unwrap the existing key + contentEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey); + } + } catch (error: unknown) { + this.logService.error("[RiskInsightsEncryptionService] Failed to get encryption key", error); + throw new Error("Failed to get encryption key"); } const { reportData, summaryData, applicationData } = data; @@ -75,6 +94,9 @@ export class RiskInsightsEncryptionService { !encryptedApplicationData.encryptedString || !wrappedEncryptionKey.encryptedString ) { + this.logService.error( + "[RiskInsightsEncryptionService] Encryption failed, encrypted strings are null", + ); throw new Error("Encryption failed, encrypted strings are null"); } @@ -97,6 +119,8 @@ export class RiskInsightsEncryptionService { encryptedData: EncryptedReportData, wrappedKey: EncString, ): Promise { + this.logService.info("[RiskInsightsEncryptionService] Decrypting risk insights report"); + const { userId, organizationId } = context; const orgKey = await firstValueFrom( this.keyService @@ -109,47 +133,106 @@ export class RiskInsightsEncryptionService { ); if (!orgKey) { + this.logService.warning( + "[RiskInsightsEncryptionService] Attempted to decrypt report data without org id", + ); throw new Error("Organization key not found"); } const unwrappedEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey); if (!unwrappedEncryptionKey) { + this.logService.error("[RiskInsightsEncryptionService] Encryption key not found"); throw Error("Encryption key not found"); } const { encryptedReportData, encryptedSummaryData, encryptedApplicationData } = encryptedData; - if (!encryptedReportData || !encryptedSummaryData || !encryptedApplicationData) { - throw new Error("Missing data"); - } // Decrypt the data - const decryptedReportData = await this.encryptService.decryptString( + const decryptedReportData = await this._handleDecryptReport( encryptedReportData, unwrappedEncryptionKey, ); - const decryptedSummaryData = await this.encryptService.decryptString( + const decryptedSummaryData = await this._handleDecryptSummary( encryptedSummaryData, unwrappedEncryptionKey, ); - const decryptedApplicationData = await this.encryptService.decryptString( + const decryptedApplicationData = await this._handleDecryptApplication( encryptedApplicationData, unwrappedEncryptionKey, ); - if (!decryptedReportData || !decryptedSummaryData || !decryptedApplicationData) { - throw new Error("Decryption failed, decrypted strings are null"); - } - - const decryptedReportDataJson = JSON.parse(decryptedReportData); - const decryptedSummaryDataJson = JSON.parse(decryptedSummaryData); - const decryptedApplicationDataJson = JSON.parse(decryptedApplicationData); - const decryptedFullReport = { - reportData: decryptedReportDataJson, - summaryData: decryptedSummaryDataJson, - applicationData: decryptedApplicationDataJson, + reportData: decryptedReportData, + summaryData: decryptedSummaryData, + applicationData: decryptedApplicationData, }; return decryptedFullReport; } + + private async _handleDecryptReport( + encryptedData: EncString | null, + key: SymmetricCryptoKey, + ): Promise { + if (encryptedData == null) { + return []; + } + + try { + const decryptedData = await this.encryptService.decryptString(encryptedData, key); + const parsedData = JSON.parse(decryptedData); + + // TODO Add type guard to check that parsed data is actual type + return parsedData as ApplicationHealthReportDetail[]; + } catch (error: unknown) { + this.logService.error("[RiskInsightsEncryptionService] Failed to decrypt report", error); + return []; + } + } + + private async _handleDecryptSummary( + encryptedData: EncString | null, + key: SymmetricCryptoKey, + ): Promise { + if (encryptedData == null) { + return createNewSummaryData(); + } + + try { + const decryptedData = await this.encryptService.decryptString(encryptedData, key); + const parsedData = JSON.parse(decryptedData); + + // TODO Add type guard to check that parsed data is actual type + return parsedData as OrganizationReportSummary; + } catch (error: unknown) { + this.logService.error( + "[RiskInsightsEncryptionService] Failed to decrypt report summary", + error, + ); + return createNewSummaryData(); + } + } + + private async _handleDecryptApplication( + encryptedData: EncString | null, + key: SymmetricCryptoKey, + ): Promise { + if (encryptedData == null) { + return []; + } + + try { + const decryptedData = await this.encryptService.decryptString(encryptedData, key); + const parsedData = JSON.parse(decryptedData); + + // TODO Add type guard to check that parsed data is actual type + return parsedData as OrganizationReportApplication[]; + } catch (error: unknown) { + this.logService.error( + "[RiskInsightsEncryptionService] Failed to decrypt report applications", + error, + ); + return []; + } + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts new file mode 100644 index 00000000000..7606e3af7f3 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts @@ -0,0 +1,224 @@ +import { mock } from "jest-mock-extended"; +import { of, throwError } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { makeEncString } from "@bitwarden/common/spec"; +import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { LogService } from "@bitwarden/logging"; + +import { createNewSummaryData } from "../../helpers"; +import { RiskInsightsData, SaveRiskInsightsReportResponse } from "../../models"; +import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock"; +import { + mockApplicationData, + mockEnrichedReportData, + mockSummaryData, +} from "../../models/mocks/mock-data"; +import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service"; +import { RiskInsightsApiService } from "../api/risk-insights-api.service"; + +import { CriticalAppsService } from "./critical-apps.service"; +import { PasswordHealthService } from "./password-health.service"; +import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; +import { RiskInsightsOrchestratorService } from "./risk-insights-orchestrator.service"; +import { RiskInsightsReportService } from "./risk-insights-report.service"; + +describe("RiskInsightsOrchestratorService", () => { + let service: RiskInsightsOrchestratorService; + + // Non changing mock data + const mockOrgId = "org-789" as OrganizationId; + const mockOrgName = "Test Org"; + const mockUserId = "user-101" as UserId; + const mockReportId = "report-1" as OrganizationReportId; + const mockKey: EncString = makeEncString("wrappedKey"); + + const reportState: RiskInsightsData = { + id: mockReportId, + reportData: [], + summaryData: createNewSummaryData(), + applicationData: [], + creationDate: new Date(), + contentEncryptionKey: mockKey, + }; + const mockCiphers = [{ id: "cipher-1" }] as any; + + // Mock services + const mockAccountService = mock({ + activeAccount$: of(mock({ id: mockUserId })), + }); + const mockCriticalAppsService = mock({ + criticalAppsList$: of([]), + }); + const mockOrganizationService = mock(); + const mockCipherService = mock(); + const mockMemberCipherDetailsApiService = mock(); + let mockPasswordHealthService: PasswordHealthService; + const mockReportApiService = mock(); + let mockReportService: RiskInsightsReportService; + const mockRiskInsightsEncryptionService = mock(); + const mockLogService = mock(); + + beforeEach(() => { + // Mock pipes from constructor + mockReportService = mock({ + generateApplicationsReport: jest.fn().mockReturnValue(mockEnrichedReportData), + getApplicationsSummary: jest.fn().mockReturnValue(mockSummaryData), + getOrganizationApplications: jest.fn().mockReturnValue(mockApplicationData), + getRiskInsightsReport$: jest.fn().mockReturnValue(of(reportState)), + saveRiskInsightsReport$: jest.fn().mockReturnValue( + of({ + response: { id: mockReportId } as SaveRiskInsightsReportResponse, + contentEncryptionKey: mockKey, + }), + ), + }); + // Arrange mocks for new flow + mockMemberCipherDetailsApiService.getMemberCipherDetails.mockResolvedValue( + mockMemberCipherDetailsResponse, + ); + + mockPasswordHealthService = mock({ + auditPasswordLeaks$: jest.fn(() => of([])), + isValidCipher: jest.fn().mockReturnValue(true), + findWeakPasswordDetails: jest.fn().mockReturnValue(null), + }); + + mockCipherService.getAllFromApiForOrganization.mockReturnValue(mockCiphers); + + service = new RiskInsightsOrchestratorService( + mockAccountService, + mockCipherService, + mockCriticalAppsService, + mockLogService, + mockMemberCipherDetailsApiService, + mockOrganizationService, + mockPasswordHealthService, + mockReportApiService, + mockReportService, + mockRiskInsightsEncryptionService, + ); + }); + + describe("fetchReport", () => { + it("should call with correct org and user IDs and emit ReportState", (done) => { + // Arrange + const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"]; + const privateUserIdSubject = service["_userIdSubject"]; + + // Set up organization and user context + privateOrganizationDetailsSubject.next({ + organizationId: mockOrgId, + organizationName: mockOrgName, + }); + privateUserIdSubject.next(mockUserId); + + // Act + service.fetchReport(); + + // Assert + service.rawReportData$.subscribe((state) => { + if (!state.loading) { + expect(mockReportService.getRiskInsightsReport$).toHaveBeenCalledWith( + mockOrgId, + mockUserId, + ); + expect(state.data).toEqual(reportState); + done(); + } + }); + }); + + it("should emit error ReportState when getRiskInsightsReport$ throws", (done) => { + // Setup error passed via constructor for this test case + mockReportService.getRiskInsightsReport$ = jest + .fn() + .mockReturnValue(throwError(() => new Error("API error"))); + const testService = new RiskInsightsOrchestratorService( + mockAccountService, + mockCipherService, + mockCriticalAppsService, + mockLogService, + mockMemberCipherDetailsApiService, + mockOrganizationService, + mockPasswordHealthService, + mockReportApiService, + mockReportService, + mockRiskInsightsEncryptionService, + ); + + const { _organizationDetailsSubject, _userIdSubject } = testService as any; + _organizationDetailsSubject.next({ + organizationId: mockOrgId, + organizationName: mockOrgName, + }); + _userIdSubject.next(mockUserId); + testService.fetchReport(); + testService.rawReportData$.subscribe((state) => { + if (!state.loading) { + expect(state.error).toBe("Failed to fetch report"); + expect(state.data).toBeNull(); + done(); + } + }); + }); + }); + + describe("generateReport", () => { + it("should generate report using member ciphers and password health, then save and emit ReportState", (done) => { + const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"]; + const privateUserIdSubject = service["_userIdSubject"]; + + // Set up ciphers in orchestrator + privateOrganizationDetailsSubject.next({ + organizationId: mockOrgId, + organizationName: mockOrgName, + }); + privateUserIdSubject.next(mockUserId); + + // Act + service.generateReport(); + + // Assert + service.rawReportData$.subscribe((state) => { + if (!state.loading && state.data) { + expect(mockMemberCipherDetailsApiService.getMemberCipherDetails).toHaveBeenCalledWith( + mockOrgId, + ); + expect(mockReportService.generateApplicationsReport).toHaveBeenCalled(); + expect(mockReportService.saveRiskInsightsReport$).toHaveBeenCalledWith( + mockEnrichedReportData, + mockSummaryData, + mockApplicationData, + { organizationId: mockOrgId, userId: mockUserId }, + ); + expect(state.data.reportData).toEqual(mockEnrichedReportData); + expect(state.data.summaryData).toEqual(mockSummaryData); + expect(state.data.applicationData).toEqual(mockApplicationData); + done(); + } + }); + }); + + describe("destroy", () => { + it("should complete destroy$ subject and unsubscribe reportStateSubscription", () => { + const privateDestroy = (service as any)._destroy$; + const privateReportStateSubscription = (service as any)._reportStateSubscription; + + // Spy on the methods you expect to be called. + const destroyCompleteSpy = jest.spyOn(privateDestroy, "complete"); + const unsubscribeSpy = jest.spyOn(privateReportStateSubscription, "unsubscribe"); + + // Execute the destroy method. + service.destroy(); + + // Assert that the methods were called as expected. + expect(destroyCompleteSpy).toHaveBeenCalled(); + expect(unsubscribeSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts new file mode 100644 index 00000000000..b9df2748e85 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts @@ -0,0 +1,742 @@ +import { + BehaviorSubject, + combineLatest, + forkJoin, + from, + merge, + Observable, + of, + Subject, + Subscription, + throwError, +} from "rxjs"; +import { + catchError, + distinctUntilChanged, + exhaustMap, + filter, + map, + scan, + shareReplay, + startWith, + switchMap, + take, + takeUntil, + tap, + withLatestFrom, +} from "rxjs/operators"; + +import { + getOrganizationById, + 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 { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LogService } from "@bitwarden/logging"; + +import { + buildPasswordUseMap, + createNewSummaryData, + flattenMemberDetails, + getTrimmedCipherUris, +} from "../../helpers"; +import { + ApplicationHealthReportDetailEnriched, + PasswordHealthReportApplicationsResponse, +} from "../../models"; +import { RiskInsightsEnrichedData } from "../../models/report-data-service.types"; +import { + CipherHealthReport, + MemberDetails, + OrganizationReportApplication, + ReportState, +} from "../../models/report-models"; +import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service"; +import { RiskInsightsApiService } from "../api/risk-insights-api.service"; + +import { CriticalAppsService } from "./critical-apps.service"; +import { PasswordHealthService } from "./password-health.service"; +import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; +import { RiskInsightsReportService } from "./risk-insights-report.service"; + +export class RiskInsightsOrchestratorService { + private _destroy$ = new Subject(); + + // -------------------------- Context state -------------------------- + // Current user viewing risk insights + private _userIdSubject = new BehaviorSubject(null); + private _userId$ = this._userIdSubject.asObservable(); + + // Organization the user is currently viewing + private _organizationDetailsSubject = new BehaviorSubject<{ + organizationId: OrganizationId; + organizationName: string; + } | null>(null); + organizationDetails$ = this._organizationDetailsSubject.asObservable(); + + // ------------------------- Raw data ------------------------- + private _ciphersSubject = new BehaviorSubject(null); + private _ciphers$ = this._ciphersSubject.asObservable(); + + // ------------------------- Report Variables ---------------- + private _rawReportDataSubject = new BehaviorSubject({ + loading: true, + error: null, + data: null, + }); + rawReportData$ = this._rawReportDataSubject.asObservable(); + private _enrichedReportDataSubject = new BehaviorSubject(null); + enrichedReportData$ = this._enrichedReportDataSubject.asObservable(); + + // Generate report trigger and state + private _generateReportTriggerSubject = new BehaviorSubject(false); + generatingReport$ = this._generateReportTriggerSubject.asObservable(); + + // --------------------------- Critical Application data --------------------- + criticalReportResults$: Observable = of(null); + + // --------------------------- Trigger subjects --------------------- + private _initializeOrganizationTriggerSubject = new Subject(); + private _fetchReportTriggerSubject = new Subject(); + + private _reportStateSubscription: Subscription | null = null; + private _migrationSubscription: Subscription | null = null; + + constructor( + private accountService: AccountService, + private cipherService: CipherService, + private criticalAppsService: CriticalAppsService, + private logService: LogService, + private memberCipherDetailsApiService: MemberCipherDetailsApiService, + private organizationService: OrganizationService, + private passwordHealthService: PasswordHealthService, + private reportApiService: RiskInsightsApiService, + private reportService: RiskInsightsReportService, + private riskInsightsEncryptionService: RiskInsightsEncryptionService, + ) { + this.logService.debug("[RiskInsightsOrchestratorService] Setting up"); + this._setupCriticalApplicationContext(); + this._setupCriticalApplicationReport(); + this._setupEnrichedReportData(); + this._setupInitializationPipeline(); + this._setupMigrationAndCleanup(); + this._setupReportState(); + this._setupUserId(); + } + + destroy(): void { + this.logService.debug("[RiskInsightsOrchestratorService] Destroying"); + if (this._reportStateSubscription) { + this._reportStateSubscription.unsubscribe(); + } + if (this._migrationSubscription) { + this._migrationSubscription.unsubscribe(); + } + this._destroy$.next(); + this._destroy$.complete(); + } + + /** + * Fetches the latest report for the current organization and user + */ + fetchReport(): void { + this.logService.debug("[RiskInsightsOrchestratorService] Fetch report triggered"); + this._fetchReportTriggerSubject.next(); + } + + /** + * Generates a new report for the current organization and user + */ + generateReport(): void { + this.logService.debug("[RiskInsightsOrchestratorService] Create new report triggered"); + this._generateReportTriggerSubject.next(true); + } + + /** + * Initializes the service context for a specific organization + * + * @param organizationId The ID of the organization to initialize context for + */ + initializeForOrganization(organizationId: OrganizationId) { + this.logService.debug("[RiskInsightsOrchestratorService] Initializing for org", organizationId); + this._initializeOrganizationTriggerSubject.next(organizationId); + } + + removeCriticalApplication$(criticalApplication: string): Observable { + this.logService.info( + "[RiskInsightsOrchestratorService] Removing critical applications from report", + ); + return this.rawReportData$.pipe( + take(1), + filter((data) => !data.loading && data.data != null), + withLatestFrom( + this.organizationDetails$.pipe(filter((org) => !!org && !!org.organizationId)), + this._userId$.pipe(filter((userId) => !!userId)), + ), + map(([reportState, organizationDetails, userId]) => { + // Create a set for quick lookup of the new critical apps + const existingApplicationData = reportState?.data?.applicationData || []; + const updatedApplicationData = this._removeCriticalApplication( + existingApplicationData, + criticalApplication, + ); + + const updatedState = { + ...reportState, + data: { + ...reportState.data, + applicationData: updatedApplicationData, + }, + } as ReportState; + + return { reportState, organizationDetails, updatedState, userId }; + }), + switchMap(({ reportState, organizationDetails, updatedState, userId }) => { + return from( + this.riskInsightsEncryptionService.encryptRiskInsightsReport( + { + organizationId: organizationDetails!.organizationId, + userId: userId!, + }, + { + reportData: reportState?.data?.reportData ?? [], + summaryData: reportState?.data?.summaryData ?? createNewSummaryData(), + applicationData: updatedState?.data?.applicationData ?? [], + }, + reportState?.data?.contentEncryptionKey, + ), + ).pipe( + map((encryptedData) => ({ + reportState, + organizationDetails, + updatedState, + encryptedData, + })), + ); + }), + switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => { + this.logService.debug( + `[RiskInsightsOrchestratorService] Saving applicationData with toggled critical flag for report with id: ${reportState?.data?.id} and org id: ${organizationDetails?.organizationId}`, + ); + if (!reportState?.data?.id || !organizationDetails?.organizationId) { + return of({ ...reportState }); + } + return this.reportApiService + .updateRiskInsightsApplicationData$( + reportState.data.id, + organizationDetails.organizationId, + { + data: { + applicationData: encryptedData.encryptedApplicationData.toSdk(), + }, + }, + ) + .pipe( + map(() => updatedState), + tap((finalState) => this._rawReportDataSubject.next(finalState)), + catchError((error: unknown) => { + this.logService.error("Failed to save updated applicationData", error); + return of({ ...reportState, error: "Failed to remove a critical application" }); + }), + ); + }), + ); + } + + saveCriticalApplications$(criticalApplications: string[]): Observable { + this.logService.info( + "[RiskInsightsOrchestratorService] Saving critical applications to report", + ); + return this.rawReportData$.pipe( + take(1), + filter((data) => !data.loading && data.data != null), + withLatestFrom( + this.organizationDetails$.pipe(filter((org) => !!org && !!org.organizationId)), + this._userId$.pipe(filter((userId) => !!userId)), + ), + map(([reportState, organizationDetails, userId]) => { + // Create a set for quick lookup of the new critical apps + const newCriticalAppNamesSet = new Set(criticalApplications); + const existingApplicationData = reportState?.data?.applicationData || []; + const updatedApplicationData = this._mergeApplicationData( + existingApplicationData, + newCriticalAppNamesSet, + ); + + const updatedState = { + ...reportState, + data: { + ...reportState.data, + applicationData: updatedApplicationData, + }, + } as ReportState; + + return { reportState, organizationDetails, updatedState, userId }; + }), + switchMap(({ reportState, organizationDetails, updatedState, userId }) => { + return from( + this.riskInsightsEncryptionService.encryptRiskInsightsReport( + { + organizationId: organizationDetails!.organizationId, + userId: userId!, + }, + { + reportData: reportState?.data?.reportData ?? [], + summaryData: reportState?.data?.summaryData ?? createNewSummaryData(), + applicationData: updatedState?.data?.applicationData ?? [], + }, + reportState?.data?.contentEncryptionKey, + ), + ).pipe( + map((encryptedData) => ({ + reportState, + organizationDetails, + updatedState, + encryptedData, + })), + ); + }), + switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => { + this.logService.debug( + `[RiskInsightsOrchestratorService] Saving critical applications on applicationData with report id: ${reportState?.data?.id} and org id: ${organizationDetails?.organizationId}`, + ); + if (!reportState?.data?.id || !organizationDetails?.organizationId) { + return of({ ...reportState }); + } + return this.reportApiService + .updateRiskInsightsApplicationData$( + reportState.data.id, + organizationDetails.organizationId, + { + data: { + applicationData: encryptedData.encryptedApplicationData.toSdk(), + }, + }, + ) + .pipe( + map(() => updatedState), + tap((finalState) => this._rawReportDataSubject.next(finalState)), + catchError((error: unknown) => { + this.logService.error("Failed to save updated applicationData", error); + return of({ ...reportState, error: "Failed to save critical applications" }); + }), + ); + }), + ); + } + + private _fetchReport$(organizationId: OrganizationId, userId: UserId): Observable { + return this.reportService.getRiskInsightsReport$(organizationId, userId).pipe( + tap(() => this.logService.debug("[RiskInsightsOrchestratorService] Fetching report")), + map((result): ReportState => { + return { + loading: false, + error: null, + data: result ?? null, + }; + }), + catchError(() => of({ loading: false, error: "Failed to fetch report", data: null })), + startWith({ loading: true, error: null, data: null }), + ); + } + + private _generateNewApplicationsReport$( + organizationId: OrganizationId, + userId: UserId, + ): Observable { + // Generate the report + const memberCiphers$ = from( + this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), + ).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers))); + + return forkJoin([this._ciphers$.pipe(take(1)), memberCiphers$]).pipe( + tap(() => { + this.logService.debug("[RiskInsightsOrchestratorService] Generating new report"); + }), + switchMap(([ciphers, memberCiphers]) => this._getCipherHealth(ciphers ?? [], memberCiphers)), + map((cipherHealthReports) => + this.reportService.generateApplicationsReport(cipherHealthReports), + ), + withLatestFrom(this.rawReportData$), + map(([report, previousReport]) => ({ + report: report, + summary: this.reportService.getApplicationsSummary(report), + applications: this.reportService.getOrganizationApplications( + report, + previousReport?.data?.applicationData ?? [], + ), + })), + switchMap(({ report, summary, applications }) => { + // Save the report after enrichment + return this.reportService + .saveRiskInsightsReport$(report, summary, applications, { + organizationId, + userId, + }) + .pipe( + map((result) => ({ + report, + summary, + applications, + id: result.response.id, + contentEncryptionKey: result.contentEncryptionKey, + })), + ); + }), + // Update the running state + map((mappedResult): ReportState => { + const { id, report, summary, applications, contentEncryptionKey } = mappedResult; + return { + loading: false, + error: null, + data: { + id, + reportData: report, + summaryData: summary, + applicationData: applications, + creationDate: new Date(), + contentEncryptionKey, + }, + }; + }), + catchError(() => { + return of({ loading: false, error: "Failed to generate or save report", data: null }); + }), + startWith({ loading: true, error: null, data: null }), + ); + } + + /** + * Associates the members with the ciphers they have access to. Calculates the password health. + * Finds the trimmed uris. + * @param ciphers Org ciphers + * @param memberDetails Org members + * @returns Cipher password health data with trimmed uris and associated members + */ + private _getCipherHealth( + ciphers: CipherView[], + memberDetails: MemberDetails[], + ): Observable { + const validCiphers = ciphers.filter((cipher) => + this.passwordHealthService.isValidCipher(cipher), + ); + const passwordUseMap = buildPasswordUseMap(validCiphers); + + // Check for exposed passwords and map to cipher health report + return this.passwordHealthService.auditPasswordLeaks$(validCiphers).pipe( + map((exposedDetails) => { + return validCiphers.map((cipher) => { + const exposedPasswordDetail = exposedDetails.find((x) => x?.cipherId === cipher.id); + const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id); + const applications = getTrimmedCipherUris(cipher); + const weakPasswordDetail = this.passwordHealthService.findWeakPasswordDetails(cipher); + const reusedPasswordCount = passwordUseMap.get(cipher.login.password!) ?? 0; + return { + cipher, + cipherMembers, + applications, + healthData: { + weakPasswordDetail, + reusedPasswordCount, + exposedPasswordDetail, + }, + } as CipherHealthReport; + }); + }), + ); + } + + private _mergeApplicationData( + existingApplications: OrganizationReportApplication[], + criticalApplications: Set, + ): OrganizationReportApplication[] { + const setToMerge = new Set(criticalApplications); + // First, iterate through the existing apps and update their isCritical flag + const updatedApps = existingApplications.map((app) => { + const foundCritical = setToMerge.has(app.applicationName); + + if (foundCritical) { + setToMerge.delete(app.applicationName); + } + + return { + ...app, + isCritical: foundCritical || app.isCritical, + }; + }); + + setToMerge.forEach((applicationName) => { + updatedApps.push({ + applicationName, + isCritical: true, + reviewedDate: null, + }); + }); + + return updatedApps; + } + + // Toggles the isCritical flag on applications via criticalApplicationName + private _removeCriticalApplication( + applicationData: OrganizationReportApplication[], + criticalApplication: string, + ): OrganizationReportApplication[] { + const updatedApplicationData = applicationData.map((application) => { + if (application.applicationName == criticalApplication) { + return { ...application, isCritical: false } as OrganizationReportApplication; + } + return application; + }); + return updatedApplicationData; + } + + private _runMigrationAndCleanup$(criticalApps: PasswordHealthReportApplicationsResponse[]) { + return of(criticalApps).pipe( + withLatestFrom(this.organizationDetails$), + switchMap(([savedCriticalApps, organizationDetails]) => { + // No saved critical apps for migration + if (!savedCriticalApps || savedCriticalApps.length === 0) { + this.logService.debug("[RiskInsightsOrchestratorService] No critical apps to migrate."); + return of([]); + } + + const criticalAppsNames = savedCriticalApps.map((app) => app.uri); + const criticalAppsIds = savedCriticalApps.map((app) => app.id); + + // Use the setCriticalApplications$ function to update and save the report + return this.saveCriticalApplications$(criticalAppsNames).pipe( + // After setCriticalApplications$ completes, trigger the deletion. + switchMap(() => { + return this.criticalAppsService + .dropCriticalAppsById(organizationDetails!.organizationId, criticalAppsIds) + .pipe( + // After all deletes complete, map to the migrated apps. + tap(() => { + this.logService.debug( + "[RiskInsightsOrchestratorService] Migrated and deleted critical applications.", + ); + }), + ); + }), + catchError((error: unknown) => { + this.logService.error( + "[RiskInsightsOrchestratorService] Failed to save migrated critical applications", + error, + ); + return throwError(() => error); + }), + ); + }), + ); + } + + // Setup the pipeline to load critical applications when organization or user changes + private _setupCriticalApplicationContext() { + this.organizationDetails$ + .pipe( + filter((orgDetails) => !!orgDetails), + withLatestFrom(this._userId$), + filter(([_, userId]) => !!userId), + tap(([orgDetails, userId]) => { + this.logService.debug( + "[RiskInsightsOrchestratorService] Loading critical applications for org", + orgDetails!.organizationId, + ); + this.criticalAppsService.loadOrganizationContext(orgDetails!.organizationId, userId!); + }), + takeUntil(this._destroy$), + ) + .subscribe(); + } + + // Setup the pipeline to create a report view filtered to only critical applications + private _setupCriticalApplicationReport() { + const criticalReportResultsPipeline$ = this.enrichedReportData$.pipe( + filter((state) => !!state), + map((enrichedReports) => { + const criticalApplications = enrichedReports!.reportData.filter( + (app) => app.isMarkedAsCritical, + ); + // Generate a new summary based on just the critical applications + const summary = this.reportService.getApplicationsSummary(criticalApplications); + return { + ...enrichedReports, + summaryData: summary, + reportData: criticalApplications, + }; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.criticalReportResults$ = criticalReportResultsPipeline$; + } + + /** + * Takes the basic application health report details and enriches them to include + * critical app status and associated ciphers. + */ + private _setupEnrichedReportData() { + // Setup the enriched report data pipeline + const enrichmentSubscription = combineLatest([ + this.rawReportData$.pipe(filter((data) => !!data && !!data?.data)), + this._ciphers$.pipe(filter((data) => !!data)), + ]).pipe( + switchMap(([rawReportData, ciphers]) => { + this.logService.debug( + "[RiskInsightsOrchestratorService] Enriching report data with ciphers and critical app status", + ); + const criticalApps = + rawReportData?.data?.applicationData.filter((app) => app.isCritical) ?? []; + const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.applicationName)); + const rawReports = rawReportData.data?.reportData || []; + const cipherMap = this.reportService.getApplicationCipherMap(ciphers, rawReports); + + const enrichedReports: ApplicationHealthReportDetailEnriched[] = rawReports.map((app) => ({ + ...app, + ciphers: cipherMap.get(app.applicationName) || [], + isMarkedAsCritical: criticalApplicationNames.has(app.applicationName), + })); + + const enrichedData = { + ...rawReportData.data, + reportData: enrichedReports, + } as RiskInsightsEnrichedData; + + return of(enrichedData); + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + enrichmentSubscription.pipe(takeUntil(this._destroy$)).subscribe((enrichedData) => { + this._enrichedReportDataSubject.next(enrichedData); + }); + } + + // Setup the pipeline to initialize organization context + private _setupInitializationPipeline() { + this._initializeOrganizationTriggerSubject + .pipe( + withLatestFrom(this._userId$), + filter(([orgId, userId]) => !!orgId && !!userId), + exhaustMap(([orgId, userId]) => + this.organizationService.organizations$(userId!).pipe( + getOrganizationById(orgId), + map((org) => ({ organizationId: orgId!, organizationName: org?.name ?? "" })), + ), + ), + tap(async (orgDetails) => { + this.logService.debug("[RiskInsightsOrchestratorService] Fetching organization ciphers"); + const ciphers = await this.cipherService.getAllFromApiForOrganization( + orgDetails.organizationId, + ); + this._ciphersSubject.next(ciphers); + }), + takeUntil(this._destroy$), + ) + .subscribe((orgDetails) => this._organizationDetailsSubject.next(orgDetails)); + } + + private _setupMigrationAndCleanup() { + const criticalApps$ = this.criticalAppsService.criticalAppsList$.pipe( + filter((criticalApps) => criticalApps.length > 0), + take(1), + ); + + const rawReportData$ = this.rawReportData$.pipe( + filter((reportState) => !!reportState.data), + take(1), + ); + + this._migrationSubscription = forkJoin([criticalApps$, rawReportData$]) + .pipe( + tap(([criticalApps]) => { + this.logService.debug( + `[RiskInsightsOrchestratorService] Detected ${criticalApps.length} legacy critical apps, running migration and cleanup`, + criticalApps, + ); + }), + switchMap(([criticalApps, _reportState]) => + this._runMigrationAndCleanup$(criticalApps).pipe( + catchError((error: unknown) => { + this.logService.error( + "[RiskInsightsOrchestratorService] Migration and cleanup failed.", + error, + ); + return of([]); + }), + ), + ), + take(1), + ) + .subscribe(); + } + + // Setup the report state management pipeline + private _setupReportState() { + // Dependencies needed for report state + const reportDependencies$ = combineLatest([ + this.organizationDetails$.pipe(filter((org) => !!org)), + this._userId$.pipe(filter((user) => !!user)), + ]).pipe(shareReplay({ bufferSize: 1, refCount: true })); + + // A stream for the initial report fetch + const initialReportLoad$ = reportDependencies$.pipe( + take(1), + exhaustMap(([orgDetails, userId]) => this._fetchReport$(orgDetails!.organizationId, userId!)), + ); + + // A stream for manually triggered fetches + const manualReportFetch$ = this._fetchReportTriggerSubject.pipe( + withLatestFrom(reportDependencies$), + exhaustMap(([_, [orgDetails, userId]]) => + this._fetchReport$(orgDetails!.organizationId, userId!), + ), + ); + + // A stream for generating a new report + const newReportGeneration$ = this.generatingReport$.pipe( + distinctUntilChanged(), + filter((isRunning) => isRunning), + withLatestFrom(reportDependencies$), + exhaustMap(([_, [orgDetails, userId]]) => + this._generateNewApplicationsReport$(orgDetails!.organizationId, userId!), + ), + tap(() => { + this._generateReportTriggerSubject.next(false); + }), + ); + + // Combine all triggers and update the single report state + const mergedReportState$ = merge( + initialReportLoad$, + manualReportFetch$, + newReportGeneration$, + ).pipe( + scan((prevState: ReportState, currState: ReportState) => ({ + ...prevState, + ...currState, + data: currState.data !== null ? currState.data : prevState.data, + })), + startWith({ loading: false, error: null, data: null }), + shareReplay({ bufferSize: 1, refCount: true }), + takeUntil(this._destroy$), + ); + + this._reportStateSubscription = mergedReportState$ + .pipe(takeUntil(this._destroy$)) + .subscribe((state) => { + this._rawReportDataSubject.next(state); + }); + } + + // Setup the user ID observable to track the current user + private _setupUserId() { + // Watch userId changes + this.accountService.activeAccount$.pipe(getUserId).subscribe((userId) => { + this._userIdSubject.next(userId); + }); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts similarity index 57% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts index 5f8fdaa244a..3211b44322a 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts @@ -6,24 +6,25 @@ import { makeEncString } from "@bitwarden/common/spec"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { DecryptedReportData, EncryptedDataWithKey } from "../models"; +import { DecryptedReportData, EncryptedDataWithKey } from "../../models"; import { GetRiskInsightsReportResponse, SaveRiskInsightsReportResponse, -} from "../models/api-models.types"; +} from "../../models/api-models.types"; +import { mockCiphers } from "../../models/mocks/ciphers.mock"; +import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock"; import { mockApplicationData, + mockCipherHealthReports, mockCipherViews, mockMemberDetails, mockReportData, mockSummaryData, -} from "../models/mock-data"; +} from "../../models/mocks/mock-data"; +import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service"; +import { RiskInsightsApiService } from "../api/risk-insights-api.service"; -import { mockCiphers } from "./ciphers.mock"; -import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; -import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec"; import { PasswordHealthService } from "./password-health.service"; -import { RiskInsightsApiService } from "./risk-insights-api.service"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; import { RiskInsightsReportService } from "./risk-insights-report.service"; @@ -54,7 +55,9 @@ describe("RiskInsightsReportService", () => { beforeEach(() => { cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers); - memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberCipherDetails); + memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue( + mockMemberCipherDetailsResponse, + ); // Mock PasswordHealthService methods mockPasswordHealthService.isValidCipher.mockImplementation((cipher: any) => { @@ -79,9 +82,6 @@ describe("RiskInsightsReportService", () => { }); service = new RiskInsightsReportService( - cipherService, - memberCipherDetailsService, - mockPasswordHealthService, mockRiskInsightsApiService, mockRiskInsightsEncryptionService, ); @@ -93,127 +93,21 @@ describe("RiskInsightsReportService", () => { }; }); - it("should group and aggregate application health reports correctly", (done) => { + it("should group and aggregate application health reports correctly", () => { // Mock the service methods cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCipherViews); memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberDetails); - service.generateApplicationsReport$("orgId" as any).subscribe((result) => { - expect(Array.isArray(result)).toBe(true); + const result = service.generateApplicationsReport(mockCipherHealthReports); + expect(Array.isArray(result)).toBe(true); - // Should group by application name (trimmedUris) - const appCom = result.find((r) => r.applicationName === "app.com"); - const otherCom = result.find((r) => r.applicationName === "other.com"); - expect(appCom).toBeTruthy(); - expect(appCom?.passwordCount).toBe(2); - expect(otherCom).toBeTruthy(); - expect(otherCom?.passwordCount).toBe(1); - done(); - }); - }); - - it("should generate the raw data report correctly", async () => { - const result = await firstValueFrom(service.LEGACY_generateRawDataReport$(mockOrganizationId)); - - expect(result).toHaveLength(6); - - let testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001228ab1"); - expect(testCaseResults).toHaveLength(1); - let testCase = testCaseResults[0]; - expect(testCase).toBeTruthy(); - expect(testCase.cipherMembers).toHaveLength(2); - expect(testCase.trimmedUris).toHaveLength(5); - expect(testCase.weakPasswordDetail).toBeTruthy(); - expect(testCase.exposedPasswordDetail).toBeTruthy(); - expect(testCase.reusedPasswordCount).toEqual(2); - - testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001227tt1"); - expect(testCaseResults).toHaveLength(1); - testCase = testCaseResults[0]; - expect(testCase).toBeTruthy(); - expect(testCase.cipherMembers).toHaveLength(1); - expect(testCase.trimmedUris).toHaveLength(1); - expect(testCase.weakPasswordDetail).toBeFalsy(); - expect(testCase.exposedPasswordDetail).toBeFalsy(); - expect(testCase.reusedPasswordCount).toEqual(1); - }); - - it("should generate the raw data + uri report correctly", async () => { - const result = await firstValueFrom(service.generateRawDataUriReport$(mockOrganizationId)); - - expect(result).toHaveLength(11); - - // Two ciphers that have google.com as their uri. There should be 2 results - const googleResults = result.filter((x) => x.trimmedUri === "google.com"); - expect(googleResults).toHaveLength(2); - - // There is an invalid uri and it should not be trimmed - const invalidUriResults = result.filter((x) => x.trimmedUri === "this_is-not|a-valid-uri123@+"); - expect(invalidUriResults).toHaveLength(1); - - // Verify the details for one of the googles matches the password health info - // expected - const firstGoogle = googleResults.filter( - (x) => x.cipherId === "cbea34a8-bde4-46ad-9d19-b05001228ab1" && x.trimmedUri === "google.com", - )[0]; - expect(firstGoogle.weakPasswordDetail).toBeTruthy(); - expect(firstGoogle.exposedPasswordDetail).toBeTruthy(); - expect(firstGoogle.reusedPasswordCount).toEqual(2); - }); - - it("should generate applications health report data correctly", async () => { - const result = await firstValueFrom( - service.LEGACY_generateApplicationsReport$(mockOrganizationId), - ); - - expect(result).toHaveLength(8); - - // Two ciphers have google.com associated with them. The first cipher - // has 2 members and the second has 4. However, the 2 members in the first - // cipher are also associated with the second. The total amount of members - // should be 4 not 6 - const googleTestResults = result.filter((x) => x.applicationName === "google.com"); - expect(googleTestResults).toHaveLength(1); - const googleTest = googleTestResults[0]; - expect(googleTest.memberCount).toEqual(4); - - // Both ciphers have at risk passwords - expect(googleTest.passwordCount).toEqual(2); - - // All members are at risk since both ciphers are at risk - expect(googleTest.atRiskMemberDetails).toHaveLength(4); - expect(googleTest.atRiskPasswordCount).toEqual(2); - - // There are 2 ciphers associated with 101domain.com - const domain101TestResults = result.filter((x) => x.applicationName === "101domain.com"); - expect(domain101TestResults).toHaveLength(1); - const domain101Test = domain101TestResults[0]; - expect(domain101Test.passwordCount).toEqual(2); - - // The first cipher is at risk. The second cipher is not at risk - expect(domain101Test.atRiskPasswordCount).toEqual(1); - - // The first cipher has 2 members. The second cipher the second - // cipher has 4. One of the members in the first cipher is associated - // with the second. So there should be 5 members total. - expect(domain101Test.memberCount).toEqual(5); - - // The first cipher is at risk. The total at risk members is 2 and - // at risk password count is 1. - expect(domain101Test.atRiskMemberDetails).toHaveLength(2); - expect(domain101Test.atRiskPasswordCount).toEqual(1); - }); - - it("should generate applications summary data correctly", async () => { - const reportResult = await firstValueFrom( - service.LEGACY_generateApplicationsReport$(mockOrganizationId), - ); - const reportSummary = service.generateApplicationsSummary(reportResult); - - expect(reportSummary.totalMemberCount).toEqual(7); - expect(reportSummary.totalAtRiskMemberCount).toEqual(6); - expect(reportSummary.totalApplicationCount).toEqual(8); - expect(reportSummary.totalAtRiskApplicationCount).toEqual(7); + // Should group by application name (trimmedUris) + const appCom = result.find((r) => r.applicationName === "app.com"); + const otherCom = result.find((r) => r.applicationName === "other.com"); + expect(appCom).toBeTruthy(); + expect(appCom?.passwordCount).toBe(2); + expect(otherCom).toBeTruthy(); + expect(otherCom?.passwordCount).toBe(1); }); describe("saveRiskInsightsReport$", () => { @@ -249,8 +143,6 @@ describe("RiskInsightsReportService", () => { }, }); }); - - it("should encrypt and save report, then update subjects", async () => {}); }); describe("getRiskInsightsReport$", () => { @@ -338,7 +230,12 @@ describe("RiskInsightsReportService", () => { expect.anything(), expect.anything(), ); - expect(result).toEqual({ ...mockDecryptedData, creationDate: mockResponse.creationDate }); + expect(result).toEqual({ + ...mockDecryptedData, + id: mockResponse.id, + creationDate: mockResponse.creationDate, + contentEncryptionKey: mockEncryptedKey, + }); }); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts new file mode 100644 index 00000000000..470442a811b --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts @@ -0,0 +1,385 @@ +import { catchError, EMPTY, from, map, Observable, switchMap, throwError } from "rxjs"; + +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { getUniqueMembers } from "../../helpers/risk-insights-data-mappers"; +import { + isSaveRiskInsightsReportResponse, + SaveRiskInsightsReportResponse, +} from "../../models/api-models.types"; +import { + ApplicationHealthReportDetail, + OrganizationReportSummary, + CipherHealthReport, + PasswordHealthData, + OrganizationReportApplication, + RiskInsightsData, +} from "../../models/report-models"; +import { RiskInsightsApiService } from "../api/risk-insights-api.service"; + +import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; + +export class RiskInsightsReportService { + constructor( + private riskInsightsApiService: RiskInsightsApiService, + private riskInsightsEncryptionService: RiskInsightsEncryptionService, + ) {} + + /** + * Report data for the aggregation of uris to like uris and getting password/member counts, + * members, and at risk statuses. + * + * @param ciphers The list of ciphers to analyze + * @param memberCiphers The list of member cipher details to associate members to ciphers + * @returns The all applications health report data + */ + generateApplicationsReport(ciphers: CipherHealthReport[]): ApplicationHealthReportDetail[] { + const groupedByApplication = this._groupCiphersByApplication(ciphers); + + return Array.from(groupedByApplication.entries()).map(([application, ciphers]) => + this._getApplicationHealthReport(application, ciphers), + ); + } + + /** + * Gets the summary from the application health report. Returns total members and applications as well + * as the total at risk members and at risk applications + * @param reports The previously calculated application health report data + * @returns A summary object containing report totals + */ + getApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary { + const totalMembers = reports.flatMap((x) => x.memberDetails); + const uniqueMembers = getUniqueMembers(totalMembers); + + const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails); + const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers); + + // TODO: Replace with actual new applications detection logic (PM-26185) + const dummyNewApplications = [ + "github.com", + "google.com", + "stackoverflow.com", + "gitlab.com", + "bitbucket.org", + "npmjs.com", + "docker.com", + "aws.amazon.com", + "azure.microsoft.com", + "jenkins.io", + "terraform.io", + "kubernetes.io", + "atlassian.net", + ]; + + return { + totalMemberCount: uniqueMembers.length, + totalAtRiskMemberCount: uniqueAtRiskMembers.length, + totalApplicationCount: reports.length, + totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length, + totalCriticalMemberCount: 0, + totalCriticalAtRiskMemberCount: 0, + totalCriticalApplicationCount: 0, + totalCriticalAtRiskApplicationCount: 0, + newApplications: dummyNewApplications, + }; + } + + /** + * Generate a snapshot of applications and related data associated to this report + * + * @param reports + * @returns A list of applications with a critical marking flag + */ + getOrganizationApplications( + reports: ApplicationHealthReportDetail[], + previousApplications: OrganizationReportApplication[] = [], + ): OrganizationReportApplication[] { + if (previousApplications.length > 0) { + // Preserve existing critical application markings and dates + return reports.map((report) => { + const existingApp = previousApplications.find( + (app) => app.applicationName === report.applicationName, + ); + return { + applicationName: report.applicationName, + isCritical: existingApp ? existingApp.isCritical : false, + reviewedDate: existingApp ? existingApp.reviewedDate : null, + }; + }); + } + + // No previous applications, return all as non-critical with current date + return reports.map( + (report): OrganizationReportApplication => ({ + applicationName: report.applicationName, + isCritical: false, + reviewedDate: null, + }), + ); + } + + /** + * Gets the risk insights report for a specific organization and user. + * + * @param organizationId + * @param userId + * @returns An observable that emits the decrypted risk insights report data. + */ + getRiskInsightsReport$( + organizationId: OrganizationId, + userId: UserId, + ): Observable { + return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe( + switchMap((response) => { + if (!response) { + // Return an empty report and summary if response is falsy + return EMPTY; + } + if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") { + return throwError(() => new Error("Report key not found")); + } + if (!response.reportData) { + return throwError(() => new Error("Report data not found")); + } + if (!response.summaryData) { + return throwError(() => new Error("Summary data not found")); + } + if (!response.applicationData) { + return throwError(() => new Error("Application data not found")); + } + + return from( + this.riskInsightsEncryptionService.decryptRiskInsightsReport( + { + organizationId, + userId, + }, + { + encryptedReportData: response.reportData, + encryptedSummaryData: response.summaryData, + encryptedApplicationData: response.applicationData, + }, + response.contentEncryptionKey, + ), + ).pipe( + map((decryptedData) => { + const newReport: RiskInsightsData = { + id: response.id as OrganizationReportId, + reportData: decryptedData.reportData, + summaryData: decryptedData.summaryData, + applicationData: decryptedData.applicationData, + creationDate: response.creationDate, + contentEncryptionKey: response.contentEncryptionKey, + }; + return newReport; + }), + catchError((error: unknown) => { + return throwError(() => error); + }), + ); + }), + catchError((error: unknown) => { + return throwError(() => error); + }), + ); + } + + /** + * Encrypts the risk insights report data for a specific organization. + * @param organizationId The ID of the organization. + * @param userId The ID of the user. + * @param report The report data to encrypt. + * @returns A promise that resolves to an object containing the encrypted data and encryption key. + */ + saveRiskInsightsReport$( + report: ApplicationHealthReportDetail[], + summary: OrganizationReportSummary, + applications: OrganizationReportApplication[], + encryptionParameters: { + organizationId: OrganizationId; + userId: UserId; + }, + ): Observable<{ response: SaveRiskInsightsReportResponse; contentEncryptionKey: EncString }> { + return from( + this.riskInsightsEncryptionService.encryptRiskInsightsReport( + { + organizationId: encryptionParameters.organizationId, + userId: encryptionParameters.userId, + }, + { + reportData: report, + summaryData: summary, + applicationData: applications, + }, + ), + ).pipe( + map( + ({ + encryptedReportData, + encryptedSummaryData, + encryptedApplicationData, + contentEncryptionKey, + }) => ({ + requestPayload: { + data: { + organizationId: encryptionParameters.organizationId, + creationDate: new Date().toISOString(), + reportData: encryptedReportData.toSdk(), + summaryData: encryptedSummaryData.toSdk(), + applicationData: encryptedApplicationData.toSdk(), + contentEncryptionKey: contentEncryptionKey.toSdk(), + }, + }, + // Keep the original EncString alongside the SDK payload so downstream can return the EncString type. + contentEncryptionKey, + }), + ), + switchMap(({ requestPayload, contentEncryptionKey }) => + this.riskInsightsApiService + .saveRiskInsightsReport$(requestPayload, encryptionParameters.organizationId) + .pipe( + map((response) => ({ + response, + contentEncryptionKey, + })), + ), + ), + catchError((error: unknown) => { + return EMPTY; + }), + map((result) => { + if (!isSaveRiskInsightsReportResponse(result.response)) { + throw new Error("Invalid response from API"); + } + return result; + }), + ); + } + + private _groupCiphersByApplication( + cipherHealthData: CipherHealthReport[], + ): Map { + const applicationMap = new Map(); + + cipherHealthData.forEach((cipher: CipherHealthReport) => { + cipher.applications.forEach((application) => { + const existingApplication = applicationMap.get(application) || []; + existingApplication.push(cipher); + applicationMap.set(application, existingApplication); + }); + }); + + return applicationMap; + } + + /** + * + * @param applications The list of application health report details to map ciphers to + * @param organizationId + * @returns + */ + getApplicationCipherMap( + ciphers: CipherView[], + applications: ApplicationHealthReportDetail[], + ): Map { + const cipherMap = new Map(); + applications.forEach((app) => { + const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id)); + cipherMap.set(app.applicationName, filteredCiphers); + }); + return cipherMap; + } + + // --------------------------- Aggregation methods --------------------------- + /** + * Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item. + * If the item is new, create and add the object with the flattened details + * @param cipherHealthReport Cipher and password health info broken out into their uris + * @returns Application health reports + */ + private _getApplicationHealthReport( + application: string, + ciphers: CipherHealthReport[], + ): ApplicationHealthReportDetail { + let aggregatedReport: ApplicationHealthReportDetail | undefined; + + ciphers.forEach((cipher) => { + const isAtRisk = this._isPasswordAtRisk(cipher.healthData); + aggregatedReport = this._aggregateReport(application, cipher, isAtRisk, aggregatedReport); + }); + + return aggregatedReport!; + } + + private _aggregateReport( + application: string, + newCipherReport: CipherHealthReport, + isAtRisk: boolean, + existingReport?: ApplicationHealthReportDetail, + ): ApplicationHealthReportDetail { + let baseReport = existingReport + ? this._updateExistingReport(existingReport, newCipherReport) + : this._createNewReport(application, newCipherReport); + if (isAtRisk) { + baseReport = { ...baseReport, ...this._getAtRiskData(baseReport, newCipherReport) }; + } + + baseReport.memberCount = baseReport.memberDetails.length; + baseReport.atRiskMemberCount = baseReport.atRiskMemberDetails.length; + + return baseReport; + } + private _createNewReport( + application: string, + cipherReport: CipherHealthReport, + ): ApplicationHealthReportDetail { + return { + applicationName: application, + cipherIds: [cipherReport.cipher.id], + passwordCount: 1, + memberDetails: [...cipherReport.cipherMembers], + memberCount: cipherReport.cipherMembers.length, + atRiskCipherIds: [], + atRiskMemberCount: 0, + atRiskMemberDetails: [], + atRiskPasswordCount: 0, + }; + } + + private _updateExistingReport( + existingReport: ApplicationHealthReportDetail, + newCipherReport: CipherHealthReport, + ): ApplicationHealthReportDetail { + return { + ...existingReport, + passwordCount: existingReport.passwordCount + 1, + memberDetails: getUniqueMembers( + existingReport.memberDetails.concat(newCipherReport.cipherMembers), + ), + cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id), + }; + } + + private _getAtRiskData(report: ApplicationHealthReportDetail, cipherReport: CipherHealthReport) { + const atRiskMemberDetails = getUniqueMembers( + report.atRiskMemberDetails.concat(cipherReport.cipherMembers), + ); + return { + atRiskPasswordCount: report.atRiskPasswordCount + 1, + atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id), + atRiskMemberDetails, + atRiskMemberCount: atRiskMemberDetails.length, + }; + } + + // TODO Move to health service + private _isPasswordAtRisk(healthData: PasswordHealthData): boolean { + return !!( + healthData.exposedPasswordDetail || + healthData.weakPasswordDetail || + healthData.reusedPasswordCount > 1 + ); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts index 53ee3ffa892..1e14c09d089 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts @@ -1,9 +1,11 @@ -export * from "./member-cipher-details-api.service"; -export * from "./password-health.service"; -export * from "./critical-apps.service"; -export * from "./critical-apps-api.service"; -export * from "./risk-insights-api.service"; -export * from "./risk-insights-report.service"; -export * from "./risk-insights-data.service"; -export * from "./all-activities.service"; -export * from "./security-tasks-api.service"; +export * from "./api/critical-apps-api.service"; +export * from "./api/member-cipher-details-api.service"; +export * from "./api/risk-insights-api.service"; +export * from "./api/security-tasks-api.service"; +export * from "./domain/critical-apps.service"; +export * from "./domain/password-health.service"; +export * from "./domain/risk-insights-encryption.service"; +export * from "./domain/risk-insights-orchestrator.service"; +export * from "./domain/risk-insights-report.service"; +export * from "./view/all-activities.service"; +export * from "./view/risk-insights-data.service"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-response.mock.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-response.mock.ts deleted file mode 100644 index 92d87175974..00000000000 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-response.mock.ts +++ /dev/null @@ -1,79 +0,0 @@ -export const mockMemberCipherDetailsResponse: { data: any[] } = { - data: [ - { - UserName: "David Brent", - Email: "david.brent@wernhamhogg.uk", - UsesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - UserName: "Tim Canterbury", - Email: "tim.canterbury@wernhamhogg.uk", - UsesKeyConnector: false, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - UserName: "Gareth Keenan", - Email: "gareth.keenan@wernhamhogg.uk", - UsesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - "cbea34a8-bde4-46ad-9d19-b05001227nm7", - ], - }, - { - UserName: "Dawn Tinsley", - Email: "dawn.tinsley@wernhamhogg.uk", - UsesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - ], - }, - { - UserName: "Keith Bishop", - Email: "keith.bishop@wernhamhogg.uk", - UsesKeyConnector: false, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - UserName: "Chris Finch", - Email: "chris.finch@wernhamhogg.uk", - UsesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - ], - }, - { - UserName: "Chris Finch Tester", - Email: "chris.finch@wernhamhogg.uk", - UsesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - ], - }, - ], -}; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts deleted file mode 100644 index 6b775f8432e..00000000000 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts +++ /dev/null @@ -1,469 +0,0 @@ -import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of, throwError } from "rxjs"; -import { - catchError, - distinctUntilChanged, - exhaustMap, - filter, - finalize, - map, - shareReplay, - switchMap, - tap, - withLatestFrom, -} from "rxjs/operators"; - -import { - getOrganizationById, - 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 { OrganizationId, UserId } from "@bitwarden/common/types/guid"; - -import { ApplicationHealthReportDetailEnriched } from "../models"; -import { RiskInsightsEnrichedData } from "../models/report-data-service.types"; -import { DrawerType, DrawerDetails, ApplicationHealthReportDetail } from "../models/report-models"; - -import { CriticalAppsService } from "./critical-apps.service"; -import { RiskInsightsReportService } from "./risk-insights-report.service"; - -export class RiskInsightsDataService { - // -------------------------- Context state -------------------------- - // Current user viewing risk insights - private userIdSubject = new BehaviorSubject(null); - userId$ = this.userIdSubject.asObservable(); - - // Organization the user is currently viewing - private organizationDetailsSubject = new BehaviorSubject<{ - organizationId: OrganizationId; - organizationName: string; - } | null>(null); - organizationDetails$ = this.organizationDetailsSubject.asObservable(); - - // -------------------------- Data ------------------------------------ - // TODO: Remove. Will use report results - private LEGACY_applicationsSubject = new BehaviorSubject( - null, - ); - LEGACY_applications$ = this.LEGACY_applicationsSubject.asObservable(); - - // TODO: Remove. Will use date from report results - private LEGACY_dataLastUpdatedSubject = new BehaviorSubject(null); - dataLastUpdated$ = this.LEGACY_dataLastUpdatedSubject.asObservable(); - - // --------------------------- UI State ------------------------------------ - private isLoadingSubject = new BehaviorSubject(false); - isLoading$ = this.isLoadingSubject.asObservable(); - - private isRefreshingSubject = new BehaviorSubject(false); - isRefreshing$ = this.isRefreshingSubject.asObservable(); - - private errorSubject = new BehaviorSubject(null); - error$ = this.errorSubject.asObservable(); - - // ------------------------- Drawer Variables ---------------- - // Drawer variables unified into a single BehaviorSubject - private drawerDetailsSubject = new BehaviorSubject({ - open: false, - invokerId: "", - activeDrawerType: DrawerType.None, - atRiskMemberDetails: [], - appAtRiskMembers: null, - atRiskAppDetails: null, - }); - drawerDetails$ = this.drawerDetailsSubject.asObservable(); - - // ------------------------- Report Variables ---------------- - // The last run report details - private reportResultsSubject = new BehaviorSubject(null); - reportResults$ = this.reportResultsSubject.asObservable(); - // Is a report being generated - private isRunningReportSubject = new BehaviorSubject(false); - isRunningReport$ = this.isRunningReportSubject.asObservable(); - - // --------------------------- Critical Application data --------------------- - criticalReportResults$: Observable = of(null); - - constructor( - private accountService: AccountService, - private criticalAppsService: CriticalAppsService, - private organizationService: OrganizationService, - private reportService: RiskInsightsReportService, - ) { - // Reload report if critical applications change - // This also handles the original report load - this.criticalAppsService.criticalAppsList$ - .pipe(withLatestFrom(this.organizationDetails$, this.userId$)) - .subscribe({ - next: ([_criticalApps, organizationDetails, userId]) => { - if (organizationDetails?.organizationId && userId) { - this.fetchLastReport(organizationDetails?.organizationId, userId); - } - }, - }); - - // Setup critical application data and summary generation for live critical application usage - this.criticalReportResults$ = this.reportResults$.pipe( - filter((report) => !!report), - map((r) => { - const criticalApplications = r.reportData.filter( - (application) => application.isMarkedAsCritical, - ); - const summary = this.reportService.generateApplicationsSummary(criticalApplications); - - return { - ...r, - summaryData: summary, - reportData: criticalApplications, - }; - }), - shareReplay({ bufferSize: 1, refCount: true }), - ); - } - - async initializeForOrganization(organizationId: OrganizationId) { - // Fetch current user - const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - if (userId) { - this.userIdSubject.next(userId); - } - - // [FIXME] getOrganizationById is now deprecated - replace with appropriate method - // Fetch organization details - const org = await firstValueFrom( - this.organizationService.organizations$(userId).pipe(getOrganizationById(organizationId)), - ); - if (org) { - this.organizationDetailsSubject.next({ - organizationId: organizationId, - organizationName: org.name, - }); - } - - // Load critical applications for organization - await this.criticalAppsService.loadOrganizationContext(organizationId, userId); - - // Setup new report generation - this._runApplicationsReport().subscribe({ - next: (result) => { - this.isRunningReportSubject.next(false); - }, - error: () => { - this.errorSubject.next("Failed to save report"); - }, - }); - } - - /** - * Fetches the applications report and updates the applicationsSubject. - * @param organizationId The ID of the organization. - */ - LEGACY_fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void { - if (isRefresh) { - this.isRefreshingSubject.next(true); - } else { - this.isLoadingSubject.next(true); - } - this.reportService - .LEGACY_generateApplicationsReport$(organizationId) - .pipe( - finalize(() => { - this.isLoadingSubject.next(false); - this.isRefreshingSubject.next(false); - this.LEGACY_dataLastUpdatedSubject.next(new Date()); - }), - ) - .subscribe({ - next: (reports: ApplicationHealthReportDetail[]) => { - this.LEGACY_applicationsSubject.next(reports); - this.errorSubject.next(null); - }, - error: () => { - this.LEGACY_applicationsSubject.next([]); - }, - }); - } - - // ------------------------------- Enrichment methods ------------------------------- - /** - * Takes the basic application health report details and enriches them to include - * critical app status and associated ciphers. - * - * @param applications The list of application health report details to enrich - * @returns The enriched application health report details with critical app status and ciphers - */ - enrichReportData$( - applications: ApplicationHealthReportDetail[], - ): Observable { - // TODO Compare applications on report to updated critical applications - // TODO Compare applications on report to any new applications - return of(applications).pipe( - withLatestFrom(this.organizationDetails$, this.criticalAppsService.criticalAppsList$), - switchMap(async ([apps, orgDetails, criticalApps]) => { - if (!orgDetails) { - return []; - } - - // Get ciphers for application - const cipherMap = await this.reportService.getApplicationCipherMap( - apps, - orgDetails.organizationId, - ); - - // Find critical apps - const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.uri)); - - // Return enriched application data - return apps.map((app) => ({ - ...app, - ciphers: cipherMap.get(app.applicationName) || [], - isMarkedAsCritical: criticalApplicationNames.has(app.applicationName), - })) as ApplicationHealthReportDetailEnriched[]; - }), - ); - } - - // ------------------------- Drawer functions ----------------------------- - isActiveDrawerType = (drawerType: DrawerType): boolean => { - return this.drawerDetailsSubject.value.activeDrawerType === drawerType; - }; - - isDrawerOpenForInvoker = (applicationName: string): boolean => { - return this.drawerDetailsSubject.value.invokerId === applicationName; - }; - - closeDrawer = (): void => { - this.drawerDetailsSubject.next({ - open: false, - invokerId: "", - activeDrawerType: DrawerType.None, - atRiskMemberDetails: [], - appAtRiskMembers: null, - atRiskAppDetails: null, - }); - }; - - setDrawerForOrgAtRiskMembers = async (invokerId: string = ""): Promise => { - const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; - const shouldClose = - open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId; - - if (shouldClose) { - this.closeDrawer(); - } else { - const reportResults = await firstValueFrom(this.reportResults$); - if (!reportResults) { - return; - } - - const atRiskMemberDetails = this.reportService.generateAtRiskMemberList( - reportResults.reportData, - ); - - this.drawerDetailsSubject.next({ - open: true, - invokerId, - activeDrawerType: DrawerType.OrgAtRiskMembers, - atRiskMemberDetails, - appAtRiskMembers: null, - atRiskAppDetails: null, - }); - } - }; - - setDrawerForAppAtRiskMembers = async (invokerId: string = ""): Promise => { - const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; - const shouldClose = - open && activeDrawerType === DrawerType.AppAtRiskMembers && currentInvokerId === invokerId; - - if (shouldClose) { - this.closeDrawer(); - } else { - const reportResults = await firstValueFrom(this.reportResults$); - if (!reportResults) { - return; - } - - const atRiskMembers = { - members: - reportResults.reportData.find((app) => app.applicationName === invokerId) - ?.atRiskMemberDetails ?? [], - applicationName: invokerId, - }; - this.drawerDetailsSubject.next({ - open: true, - invokerId, - activeDrawerType: DrawerType.AppAtRiskMembers, - atRiskMemberDetails: [], - appAtRiskMembers: atRiskMembers, - atRiskAppDetails: null, - }); - } - }; - - setDrawerForOrgAtRiskApps = async (invokerId: string = ""): Promise => { - const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; - const shouldClose = - open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId; - - if (shouldClose) { - this.closeDrawer(); - } else { - const reportResults = await firstValueFrom(this.reportResults$); - if (!reportResults) { - return; - } - const atRiskAppDetails = this.reportService.generateAtRiskApplicationList( - reportResults.reportData, - ); - - this.drawerDetailsSubject.next({ - open: true, - invokerId, - activeDrawerType: DrawerType.OrgAtRiskApps, - atRiskMemberDetails: [], - appAtRiskMembers: null, - atRiskAppDetails, - }); - } - }; - - // ------------------- Trigger Report Generation ------------------- - /** Trigger generating a report based on the current applications */ - triggerReport(): void { - this.isRunningReportSubject.next(true); - } - - /** - * Fetches the applications report and updates the applicationsSubject. - * @param organizationId The ID of the organization. - */ - fetchLastReport(organizationId: OrganizationId, userId: UserId): void { - this.isLoadingSubject.next(true); - - this.reportService - .getRiskInsightsReport$(organizationId, userId) - .pipe( - switchMap((report) => { - // Take fetched report data and merge with critical applications - return this.enrichReportData$(report.reportData).pipe( - map((enrichedReport) => ({ - report: enrichedReport, - summary: report.summaryData, - applications: report.applicationData, - creationDate: report.creationDate, - })), - ); - }), - catchError((error: unknown) => { - // console.error("An error occurred when fetching the last report", error); - return EMPTY; - }), - finalize(() => { - this.isLoadingSubject.next(false); - }), - ) - .subscribe({ - next: ({ report, summary, applications, creationDate }) => { - this.reportResultsSubject.next({ - reportData: report, - summaryData: summary, - applicationData: applications, - creationDate: creationDate, - }); - this.errorSubject.next(null); - this.isLoadingSubject.next(false); - }, - error: () => { - this.errorSubject.next("Failed to fetch report"); - this.reportResultsSubject.next(null); - this.isLoadingSubject.next(false); - }, - }); - } - - private _runApplicationsReport() { - return this.isRunningReport$.pipe( - distinctUntilChanged(), - // Only run this report if the flag for running is true - filter((isRunning) => isRunning), - withLatestFrom(this.organizationDetails$, this.userId$), - exhaustMap(([_, organizationDetails, userId]) => { - const organizationId = organizationDetails?.organizationId; - if (!organizationId || !userId) { - return EMPTY; - } - - // Generate the report - return this.reportService.generateApplicationsReport$(organizationId).pipe( - map((report) => ({ - report, - summary: this.reportService.generateApplicationsSummary(report), - applications: this.reportService.generateOrganizationApplications(report), - })), - // Enrich report with critical markings - switchMap(({ report, summary, applications }) => - this.enrichReportData$(report).pipe( - map((enrichedReport) => ({ report: enrichedReport, summary, applications })), - ), - ), - // Load the updated data into the UI - tap(({ report, summary, applications }) => { - this.reportResultsSubject.next({ - reportData: report, - summaryData: summary, - applicationData: applications, - creationDate: new Date(), - }); - this.errorSubject.next(null); - }), - switchMap(({ report, summary, applications }) => { - // Save the generated data - return this.reportService.saveRiskInsightsReport$(report, summary, applications, { - organizationId, - userId, - }); - }), - ); - }), - ); - } - - // ------------------------------ Critical application methods -------------- - - saveCriticalApplications(selectedUrls: string[]) { - return this.organizationDetails$.pipe( - exhaustMap((organizationDetails) => { - if (!organizationDetails?.organizationId) { - return EMPTY; - } - return this.criticalAppsService.setCriticalApps( - organizationDetails?.organizationId, - selectedUrls, - ); - }), - catchError((error: unknown) => { - this.errorSubject.next("Failed to save critical applications"); - return throwError(() => error); - }), - ); - } - - removeCriticalApplication(hostname: string) { - return this.organizationDetails$.pipe( - exhaustMap((organizationDetails) => { - if (!organizationDetails?.organizationId) { - return EMPTY; - } - return this.criticalAppsService.dropCriticalApp( - organizationDetails?.organizationId, - hostname, - ); - }), - catchError((error: unknown) => { - this.errorSubject.next("Failed to remove critical application"); - return throwError(() => error); - }), - ); - } -} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts deleted file mode 100644 index fcfc7a255df..00000000000 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts +++ /dev/null @@ -1,698 +0,0 @@ -import { - catchError, - concatMap, - EMPTY, - first, - firstValueFrom, - forkJoin, - from, - map, - Observable, - of, - switchMap, - throwError, - zip, -} from "rxjs"; - -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; - -import { - createNewReportData, - flattenMemberDetails, - getApplicationReportDetail, - getFlattenedCipherDetails, - getMemberDetailsFlat, - getTrimmedCipherUris, - getUniqueMembers, -} from "../helpers/risk-insights-data-mappers"; -import { - isSaveRiskInsightsReportResponse, - SaveRiskInsightsReportResponse, -} from "../models/api-models.types"; -import { - LEGACY_CipherHealthReportDetail, - LEGACY_CipherHealthReportUriDetail, - LEGACY_MemberDetailsFlat, - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, -} from "../models/password-health"; -import { - ApplicationHealthReportDetail, - OrganizationReportSummary, - AtRiskApplicationDetail, - AtRiskMemberDetail, - CipherHealthReport, - MemberDetails, - PasswordHealthData, - OrganizationReportApplication, - RiskInsightsData, -} from "../models/report-models"; - -import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; -import { PasswordHealthService } from "./password-health.service"; -import { RiskInsightsApiService } from "./risk-insights-api.service"; -import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; - -export class RiskInsightsReportService { - // [FIXME] CipherData - // Cipher data - // private _ciphersSubject = new BehaviorSubject(null); - // _ciphers$ = this._ciphersSubject.asObservable(); - - constructor( - private cipherService: CipherService, - private memberCipherDetailsApiService: MemberCipherDetailsApiService, - private passwordHealthService: PasswordHealthService, - private riskInsightsApiService: RiskInsightsApiService, - private riskInsightsEncryptionService: RiskInsightsEncryptionService, - ) {} - - // [FIXME] CipherData - // async loadCiphersForOrganization(organizationId: OrganizationId): Promise { - // await this.cipherService.getAllFromApiForOrganization(organizationId).then((ciphers) => { - // this._ciphersSubject.next(ciphers); - // }); - // } - - /** - * Report data from raw cipher health data. - * Can be used in the Raw Data diagnostic tab (just exclude the members in the view) - * and can be used in the raw data + members tab when including the members in the view - * @param organizationId - * @returns Cipher health report data with members and trimmed uris - */ - LEGACY_generateRawDataReport$( - organizationId: OrganizationId, - ): Observable { - const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId)); - const memberCiphers$ = from( - this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), - ); - - const results$ = zip(allCiphers$, memberCiphers$).pipe( - map(([allCiphers, memberCiphers]) => { - const details: LEGACY_MemberDetailsFlat[] = memberCiphers.flatMap((dtl) => - dtl.cipherIds.map((c) => getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c)), - ); - return [allCiphers, details] as const; - }), - concatMap(([ciphers, flattenedDetails]) => - this.LEGACY_getCipherDetails(ciphers, flattenedDetails), - ), - first(), - ); - - return results$; - } - - /** - * Report data for raw cipher health broken out into the uris - * Can be used in the raw data + members + uri diagnostic report - * @param organizationId Id of the organization - * @returns Cipher health report data flattened to the uris - */ - generateRawDataUriReport$( - organizationId: OrganizationId, - ): Observable { - const cipherHealthDetails$ = this.LEGACY_generateRawDataReport$(organizationId); - const results$ = cipherHealthDetails$.pipe( - map((healthDetails) => this.getCipherUriDetails(healthDetails)), - first(), - ); - - return results$; - } - - /** - * Report data for the aggregation of uris to like uris and getting password/member counts, - * members, and at risk statuses. - * @param organizationId Id of the organization - * @returns The all applications health report data - */ - LEGACY_generateApplicationsReport$( - organizationId: OrganizationId, - ): Observable { - const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId); - const results$ = cipherHealthUriReport$.pipe( - map((uriDetails) => this.LEGACY_getApplicationHealthReport(uriDetails)), - first(), - ); - - return results$; - } - - /** - * Report data for the aggregation of uris to like uris and getting password/member counts, - * members, and at risk statuses. - * - * @param organizationId Id of the organization - * @returns The all applications health report data - */ - generateApplicationsReport$( - organizationId: OrganizationId, - ): Observable { - const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId)); - const memberCiphers$ = from( - this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), - ).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers))); - - return forkJoin([allCiphers$, memberCiphers$]).pipe( - switchMap(([ciphers, memberCiphers]) => this._getCipherDetails(ciphers, memberCiphers)), - map((cipherApplications) => { - const groupedByApplication = this._groupCiphersByApplication(cipherApplications); - - return Array.from(groupedByApplication.entries()).map(([application, ciphers]) => - this._getApplicationHealthReport(application, ciphers), - ); - }), - ); - } - - /** - * Generates a list of members with at-risk passwords along with the number of at-risk passwords. - */ - generateAtRiskMemberList( - cipherHealthReportDetails: ApplicationHealthReportDetail[], - ): AtRiskMemberDetail[] { - const memberRiskMap = new Map(); - - cipherHealthReportDetails.forEach((app) => { - app.atRiskMemberDetails.forEach((member) => { - const currentCount = memberRiskMap.get(member.email) ?? 0; - memberRiskMap.set(member.email, currentCount + 1); - }); - }); - - return Array.from(memberRiskMap.entries()).map(([email, atRiskPasswordCount]) => ({ - email, - atRiskPasswordCount, - })); - } - - generateAtRiskApplicationList( - cipherHealthReportDetails: ApplicationHealthReportDetail[], - ): AtRiskApplicationDetail[] { - const applicationPasswordRiskMap = new Map(); - - cipherHealthReportDetails - .filter((app) => app.atRiskPasswordCount > 0) - .forEach((app) => { - const atRiskPasswordCount = applicationPasswordRiskMap.get(app.applicationName) ?? 0; - applicationPasswordRiskMap.set( - app.applicationName, - atRiskPasswordCount + app.atRiskPasswordCount, - ); - }); - - return Array.from(applicationPasswordRiskMap.entries()).map( - ([applicationName, atRiskPasswordCount]) => ({ - applicationName, - atRiskPasswordCount, - }), - ); - } - - /** - * Gets the summary from the application health report. Returns total members and applications as well - * as the total at risk members and at risk applications - * @param reports The previously calculated application health report data - * @returns A summary object containing report totals - */ - generateApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary { - const totalMembers = reports.flatMap((x) => x.memberDetails); - const uniqueMembers = getUniqueMembers(totalMembers); - - const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails); - const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers); - - // TODO: Replace with actual new applications detection logic (PM-26185) - const dummyNewApplications = [ - "github.com", - "google.com", - "stackoverflow.com", - "gitlab.com", - "bitbucket.org", - "npmjs.com", - "docker.com", - "aws.amazon.com", - "azure.microsoft.com", - "jenkins.io", - "terraform.io", - "kubernetes.io", - "atlassian.net", - ]; - - return { - totalMemberCount: uniqueMembers.length, - totalAtRiskMemberCount: uniqueAtRiskMembers.length, - totalApplicationCount: reports.length, - totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length, - totalCriticalMemberCount: 0, - totalCriticalAtRiskMemberCount: 0, - totalCriticalApplicationCount: 0, - totalCriticalAtRiskApplicationCount: 0, - newApplications: dummyNewApplications, - }; - } - - /** - * Generate a snapshot of applications and related data associated to this report - * - * @param reports - * @returns A list of applications with a critical marking flag - */ - generateOrganizationApplications( - reports: ApplicationHealthReportDetail[], - ): OrganizationReportApplication[] { - return reports.map((report) => ({ - applicationName: report.applicationName, - isCritical: false, - })); - } - - async identifyCiphers( - data: ApplicationHealthReportDetail[], - organizationId: OrganizationId, - ): Promise { - const cipherViews = await this.cipherService.getAllFromApiForOrganization(organizationId); - - const dataWithCiphers = data.map( - (app, index) => - ({ - ...app, - ciphers: cipherViews.filter((c) => app.cipherIds.some((a) => a === c.id)), - }) as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, - ); - return dataWithCiphers; - } - - /** - * Gets the risk insights report for a specific organization and user. - * - * @param organizationId - * @param userId - * @returns An observable that emits the decrypted risk insights report data. - */ - getRiskInsightsReport$( - organizationId: OrganizationId, - userId: UserId, - ): Observable { - return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe( - switchMap((response) => { - if (!response) { - // Return an empty report and summary if response is falsy - return of(createNewReportData()); - } - if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") { - return throwError(() => new Error("Report key not found")); - } - if (!response.reportData) { - return throwError(() => new Error("Report data not found")); - } - if (!response.summaryData) { - return throwError(() => new Error("Summary data not found")); - } - if (!response.applicationData) { - return throwError(() => new Error("Application data not found")); - } - - return from( - this.riskInsightsEncryptionService.decryptRiskInsightsReport( - { - organizationId, - userId, - }, - { - encryptedReportData: response.reportData, - encryptedSummaryData: response.summaryData, - encryptedApplicationData: response.applicationData, - }, - response.contentEncryptionKey, - ), - ).pipe( - map((decryptedData) => ({ - reportData: decryptedData.reportData, - summaryData: decryptedData.summaryData, - applicationData: decryptedData.applicationData, - creationDate: response.creationDate, - })), - catchError((error: unknown) => { - // TODO Handle errors appropriately - // console.error("An error occurred when decrypting report", error); - return EMPTY; - }), - ); - }), - catchError((error: unknown) => { - // console.error("An error occurred when fetching the last report", error); - return EMPTY; - }), - ); - } - - /** - * Encrypts the risk insights report data for a specific organization. - * @param organizationId The ID of the organization. - * @param userId The ID of the user. - * @param report The report data to encrypt. - * @returns A promise that resolves to an object containing the encrypted data and encryption key. - */ - saveRiskInsightsReport$( - report: ApplicationHealthReportDetail[], - summary: OrganizationReportSummary, - applications: OrganizationReportApplication[], - encryptionParameters: { - organizationId: OrganizationId; - userId: UserId; - }, - ): Observable { - return from( - this.riskInsightsEncryptionService.encryptRiskInsightsReport( - { - organizationId: encryptionParameters.organizationId, - userId: encryptionParameters.userId, - }, - { - reportData: report, - summaryData: summary, - applicationData: applications, - }, - ), - ).pipe( - map( - ({ - encryptedReportData, - encryptedSummaryData, - encryptedApplicationData, - contentEncryptionKey, - }) => ({ - data: { - organizationId: encryptionParameters.organizationId, - creationDate: new Date().toISOString(), - reportData: encryptedReportData.toSdk(), - summaryData: encryptedSummaryData.toSdk(), - applicationData: encryptedApplicationData.toSdk(), - contentEncryptionKey: contentEncryptionKey.toSdk(), - }, - }), - ), - switchMap((encryptedReport) => - this.riskInsightsApiService.saveRiskInsightsReport$( - encryptedReport, - encryptionParameters.organizationId, - ), - ), - catchError((error: unknown) => { - return EMPTY; - }), - map((response) => { - if (!isSaveRiskInsightsReportResponse(response)) { - throw new Error("Invalid response from API"); - } - return response; - }), - ); - } - - /** - * Associates the members with the ciphers they have access to. Calculates the password health. - * Finds the trimmed uris. - * @param ciphers Org ciphers - * @param memberDetails Org members - * @returns Cipher password health data with trimmed uris and associated members - */ - private async LEGACY_getCipherDetails( - ciphers: CipherView[], - memberDetails: LEGACY_MemberDetailsFlat[], - ): Promise { - const cipherHealthReports: LEGACY_CipherHealthReportDetail[] = []; - const passwordUseMap = new Map(); - const exposedDetails = await firstValueFrom( - this.passwordHealthService.auditPasswordLeaks$(ciphers), - ); - for (const cipher of ciphers) { - if (this.passwordHealthService.isValidCipher(cipher)) { - const weakPassword = this.passwordHealthService.findWeakPasswordDetails(cipher); - // Looping over all ciphers needs to happen first to determine reused passwords over all ciphers. - // Store in the set and evaluate later - if (passwordUseMap.has(cipher.login.password!)) { - passwordUseMap.set( - cipher.login.password!, - (passwordUseMap.get(cipher.login.password!) || 0) + 1, - ); - } else { - passwordUseMap.set(cipher.login.password!, 1); - } - - const exposedPassword = exposedDetails.find((x) => x?.cipherId === cipher.id); - - // Get the cipher members - const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id); - - // Trim uris to host name and create the cipher health report - const cipherTrimmedUris = getTrimmedCipherUris(cipher); - const cipherHealth = { - ...cipher, - weakPasswordDetail: weakPassword, - exposedPasswordDetail: exposedPassword, - cipherMembers: cipherMembers, - trimmedUris: cipherTrimmedUris, - } as LEGACY_CipherHealthReportDetail; - - cipherHealthReports.push(cipherHealth); - } - } - - // loop for reused passwords - cipherHealthReports.forEach((detail) => { - detail.reusedPasswordCount = passwordUseMap.get(detail.login.password!) ?? 0; - }); - return cipherHealthReports; - } - - /** - * Flattens the cipher to trimmed uris. Used for the raw data + uri - * @param cipherHealthReport Cipher health report with uris and members - * @returns Flattened cipher health details to uri - */ - private getCipherUriDetails( - cipherHealthReport: LEGACY_CipherHealthReportDetail[], - ): LEGACY_CipherHealthReportUriDetail[] { - return cipherHealthReport.flatMap((rpt) => - rpt.trimmedUris.map((u) => getFlattenedCipherDetails(rpt, u)), - ); - } - - /** - * Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item. - * If the item is new, create and add the object with the flattened details - * @param cipherHealthUriReport Cipher and password health info broken out into their uris - * @returns Application health reports - */ - private LEGACY_getApplicationHealthReport( - cipherHealthUriReport: LEGACY_CipherHealthReportUriDetail[], - ): ApplicationHealthReportDetail[] { - const appReports: ApplicationHealthReportDetail[] = []; - cipherHealthUriReport.forEach((uri) => { - const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri); - - let atRisk: boolean = false; - if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) { - atRisk = true; - } - - if (index === -1) { - appReports.push(getApplicationReportDetail(uri, atRisk)); - } else { - appReports[index] = getApplicationReportDetail(uri, atRisk, appReports[index]); - } - }); - return appReports; - } - - private _buildPasswordUseMap(ciphers: CipherView[]): Map { - const passwordUseMap = new Map(); - ciphers.forEach((cipher) => { - const password = cipher.login.password!; - passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1); - }); - return passwordUseMap; - } - - private _groupCiphersByApplication( - cipherHealthData: CipherHealthReport[], - ): Map { - const applicationMap = new Map(); - - cipherHealthData.forEach((cipher: CipherHealthReport) => { - // Warning: Currently does not show ciphers with NO Application - // if (cipher.applications.length === 0) { - // const existingApplication = applicationMap.get("None") || []; - // existingApplication.push(cipher); - // applicationMap.set("None", existingApplication); - // } - - cipher.applications.forEach((application) => { - const existingApplication = applicationMap.get(application) || []; - existingApplication.push(cipher); - applicationMap.set(application, existingApplication); - }); - }); - - return applicationMap; - } - - /** - * - * @param applications The list of application health report details to map ciphers to - * @param organizationId - * @returns - */ - async getApplicationCipherMap( - applications: ApplicationHealthReportDetail[], - organizationId: OrganizationId, - ): Promise> { - // [FIXME] CipherData - // This call is made multiple times. We can optimize this - // by loading the ciphers once via a load method to avoid multiple API calls - // for the same organization - const allCiphers = await this.cipherService.getAllFromApiForOrganization(organizationId); - const cipherMap = new Map(); - - applications.forEach((app) => { - const filteredCiphers = allCiphers.filter((c) => app.cipherIds.includes(c.id)); - cipherMap.set(app.applicationName, filteredCiphers); - }); - return cipherMap; - } - - // --------------------------- Aggregation methods --------------------------- - /** - * Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item. - * If the item is new, create and add the object with the flattened details - * @param cipherHealthReport Cipher and password health info broken out into their uris - * @returns Application health reports - */ - private _getApplicationHealthReport( - application: string, - ciphers: CipherHealthReport[], - ): ApplicationHealthReportDetail { - let aggregatedReport: ApplicationHealthReportDetail | undefined; - - ciphers.forEach((cipher) => { - const isAtRisk = this._isPasswordAtRisk(cipher.healthData); - aggregatedReport = this._aggregateReport(application, cipher, isAtRisk, aggregatedReport); - }); - - return aggregatedReport!; - } - - private _aggregateReport( - application: string, - newCipherReport: CipherHealthReport, - isAtRisk: boolean, - existingReport?: ApplicationHealthReportDetail, - ): ApplicationHealthReportDetail { - let baseReport = existingReport - ? this._updateExistingReport(existingReport, newCipherReport) - : this._createNewReport(application, newCipherReport); - if (isAtRisk) { - baseReport = { ...baseReport, ...this._getAtRiskData(baseReport, newCipherReport) }; - } - - baseReport.memberCount = baseReport.memberDetails.length; - baseReport.atRiskMemberCount = baseReport.atRiskMemberDetails.length; - - return baseReport; - } - private _createNewReport( - application: string, - cipherReport: CipherHealthReport, - ): ApplicationHealthReportDetail { - return { - applicationName: application, - cipherIds: [cipherReport.cipher.id], - passwordCount: 1, - memberDetails: [...cipherReport.cipherMembers], - memberCount: cipherReport.cipherMembers.length, - atRiskCipherIds: [], - atRiskMemberCount: 0, - atRiskMemberDetails: [], - atRiskPasswordCount: 0, - }; - } - - private _updateExistingReport( - existingReport: ApplicationHealthReportDetail, - newCipherReport: CipherHealthReport, - ): ApplicationHealthReportDetail { - return { - ...existingReport, - passwordCount: existingReport.passwordCount + 1, - memberDetails: getUniqueMembers( - existingReport.memberDetails.concat(newCipherReport.cipherMembers), - ), - cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id), - }; - } - - private _getAtRiskData(report: ApplicationHealthReportDetail, cipherReport: CipherHealthReport) { - const atRiskMemberDetails = getUniqueMembers( - report.atRiskMemberDetails.concat(cipherReport.cipherMembers), - ); - return { - atRiskPasswordCount: report.atRiskPasswordCount + 1, - atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id), - atRiskMemberDetails, - atRiskMemberCount: atRiskMemberDetails.length, - }; - } - - // TODO Move to health service - private _isPasswordAtRisk(healthData: PasswordHealthData): boolean { - return !!( - healthData.exposedPasswordDetail || - healthData.weakPasswordDetail || - healthData.reusedPasswordCount > 1 - ); - } - /** - * Associates the members with the ciphers they have access to. Calculates the password health. - * Finds the trimmed uris. - * @param ciphers Org ciphers - * @param memberDetails Org members - * @returns Cipher password health data with trimmed uris and associated members - */ - private _getCipherDetails( - ciphers: CipherView[], - memberDetails: MemberDetails[], - ): Observable { - const validCiphers = ciphers.filter((cipher) => - this.passwordHealthService.isValidCipher(cipher), - ); - // Build password use map - const passwordUseMap = this._buildPasswordUseMap(validCiphers); - - return this.passwordHealthService.auditPasswordLeaks$(validCiphers).pipe( - map((exposedDetails) => { - return validCiphers.map((cipher) => { - const exposedPassword = exposedDetails.find((x) => x?.cipherId === cipher.id); - const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id); - - const result = { - cipher: cipher, - cipherMembers, - healthData: { - weakPasswordDetail: this.passwordHealthService.findWeakPasswordDetails(cipher), - exposedPasswordDetail: exposedPassword, - reusedPasswordCount: passwordUseMap.get(cipher.login.password!) ?? 0, - }, - applications: getTrimmedCipherUris(cipher), - } as CipherHealthReport; - return result; - }); - }), - ); - } -} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts similarity index 94% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts index 4044a01926c..97db491823c 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts @@ -1,7 +1,7 @@ import { BehaviorSubject } from "rxjs"; -import { ApplicationHealthReportDetailEnriched } from "../models"; -import { OrganizationReportSummary } from "../models/report-models"; +import { ApplicationHealthReportDetailEnriched } from "../../models"; +import { OrganizationReportSummary } from "../../models/report-models"; import { RiskInsightsDataService } from "./risk-insights-data.service"; @@ -40,7 +40,7 @@ export class AllActivitiesService { constructor(private dataService: RiskInsightsDataService) { // All application summary changes - this.dataService.reportResults$.subscribe((report) => { + this.dataService.enrichedReportData$.subscribe((report) => { if (report) { this.setAllAppsReportSummary(report.summaryData); this.setAllAppsReportDetails(report.reportData); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts new file mode 100644 index 00000000000..89f120cbded --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts @@ -0,0 +1,186 @@ +import { BehaviorSubject, firstValueFrom, Observable, of, Subject } from "rxjs"; +import { distinctUntilChanged, map } from "rxjs/operators"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers"; +import { ReportState, DrawerDetails, DrawerType, RiskInsightsEnrichedData } from "../../models"; +import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service"; + +export class RiskInsightsDataService { + private _destroy$ = new Subject(); + + // -------------------------- Context state -------------------------- + // Organization the user is currently viewing + readonly organizationDetails$: Observable<{ + organizationId: OrganizationId; + organizationName: string; + } | null> = of(null); + + // --------------------------- UI State ------------------------------------ + private errorSubject = new BehaviorSubject(null); + error$ = this.errorSubject.asObservable(); + + // -------------------------- Orchestrator-driven state ------------- + // The full report state (for internal facade use or complex components) + private readonly reportState$: Observable; + readonly isLoading$: Observable = of(false); + readonly enrichedReportData$: Observable = of(null); + readonly isGeneratingReport$: Observable = of(false); + readonly criticalReportResults$: Observable = of(null); + + // ------------------------- Drawer Variables --------------------- + // Drawer variables unified into a single BehaviorSubject + private drawerDetailsSubject = new BehaviorSubject({ + open: false, + invokerId: "", + activeDrawerType: DrawerType.None, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }); + drawerDetails$ = this.drawerDetailsSubject.asObservable(); + + // --------------------------- Critical Application data --------------------- + constructor(private orchestrator: RiskInsightsOrchestratorService) { + this.reportState$ = this.orchestrator.rawReportData$; + this.isGeneratingReport$ = this.orchestrator.generatingReport$; + this.organizationDetails$ = this.orchestrator.organizationDetails$; + this.enrichedReportData$ = this.orchestrator.enrichedReportData$; + this.criticalReportResults$ = this.orchestrator.criticalReportResults$; + + // Expose the loading state + this.isLoading$ = this.reportState$.pipe( + map((state) => state.loading), + distinctUntilChanged(), // Prevent unnecessary component re-renders + ); + } + + destroy(): void { + this._destroy$.next(); + this._destroy$.complete(); + } + + // ----- UI-triggered methods (delegate to orchestrator) ----- + initializeForOrganization(organizationId: OrganizationId) { + this.orchestrator.initializeForOrganization(organizationId); + } + + triggerReport(): void { + this.orchestrator.generateReport(); + } + + fetchReport(): void { + this.orchestrator.fetchReport(); + } + + // ------------------------- Drawer functions ----------------------------- + isActiveDrawerType = (drawerType: DrawerType): boolean => { + return this.drawerDetailsSubject.value.activeDrawerType === drawerType; + }; + + isDrawerOpenForInvoker = (applicationName: string): boolean => { + return this.drawerDetailsSubject.value.invokerId === applicationName; + }; + + closeDrawer = (): void => { + this.drawerDetailsSubject.next({ + open: false, + invokerId: "", + activeDrawerType: DrawerType.None, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }); + }; + + setDrawerForOrgAtRiskMembers = async (invokerId: string = ""): Promise => { + const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; + const shouldClose = + open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId; + + if (shouldClose) { + this.closeDrawer(); + } else { + const reportResults = await firstValueFrom(this.enrichedReportData$); + if (!reportResults) { + return; + } + + const atRiskMemberDetails = getAtRiskMemberList(reportResults.reportData); + + this.drawerDetailsSubject.next({ + open: true, + invokerId, + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails, + appAtRiskMembers: null, + atRiskAppDetails: null, + }); + } + }; + + setDrawerForAppAtRiskMembers = async (invokerId: string = ""): Promise => { + const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; + const shouldClose = + open && activeDrawerType === DrawerType.AppAtRiskMembers && currentInvokerId === invokerId; + + if (shouldClose) { + this.closeDrawer(); + } else { + const reportResults = await firstValueFrom(this.enrichedReportData$); + if (!reportResults) { + return; + } + + const atRiskMembers = { + members: + reportResults.reportData.find((app) => app.applicationName === invokerId) + ?.atRiskMemberDetails ?? [], + applicationName: invokerId, + }; + this.drawerDetailsSubject.next({ + open: true, + invokerId, + activeDrawerType: DrawerType.AppAtRiskMembers, + atRiskMemberDetails: [], + appAtRiskMembers: atRiskMembers, + atRiskAppDetails: null, + }); + } + }; + + setDrawerForOrgAtRiskApps = async (invokerId: string = ""): Promise => { + const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; + const shouldClose = + open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId; + + if (shouldClose) { + this.closeDrawer(); + } else { + const reportResults = await firstValueFrom(this.enrichedReportData$); + if (!reportResults) { + return; + } + const atRiskAppDetails = getAtRiskApplicationList(reportResults.reportData); + + this.drawerDetailsSubject.next({ + open: true, + invokerId, + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails, + }); + } + }; + + // ------------------------------ Critical application methods -------------- + saveCriticalApplications(selectedUrls: string[]) { + return this.orchestrator.saveCriticalApplications$(selectedUrls); + } + + removeCriticalApplication(hostname: string) { + return this.orchestrator.removeCriticalApplication$(hostname); + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts index 9e9a695cd1e..2cb9140f174 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts @@ -12,7 +12,8 @@ import { RiskInsightsReportService, SecurityTasksApiService, } from "@bitwarden/bit-common/dirt/reports/risk-insights/services"; -import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service"; +import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service"; +import { RiskInsightsOrchestratorService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -24,6 +25,7 @@ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/pass import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service"; @@ -52,28 +54,31 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks. safeProvider({ provide: RiskInsightsReportService, useClass: RiskInsightsReportService, + deps: [RiskInsightsApiService, RiskInsightsEncryptionService], + }), + safeProvider({ + provide: RiskInsightsOrchestratorService, deps: [ + AccountServiceAbstraction, CipherService, + CriticalAppsService, + LogService, MemberCipherDetailsApiService, + OrganizationService, PasswordHealthService, RiskInsightsApiService, + RiskInsightsReportService, RiskInsightsEncryptionService, ], }), safeProvider({ provide: RiskInsightsDataService, - deps: [ - AccountServiceAbstraction, - CriticalAppsService, - OrganizationService, - RiskInsightsReportService, - ], + deps: [RiskInsightsOrchestratorService], }), - { + safeProvider({ provide: RiskInsightsEncryptionService, - useClass: RiskInsightsEncryptionService, - deps: [KeyService, EncryptService, KeyGenerationService], - }, + deps: [KeyService, EncryptService, KeyGenerationService, LogService], + }), safeProvider({ provide: CriticalAppsService, useClass: CriticalAppsService, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts similarity index 88% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts index 4d2085d7c3a..910b326c662 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts @@ -1,12 +1,12 @@ import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, ChangeDetectionStrategy } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { Subject, switchMap, takeUntil, of, BehaviorSubject, combineLatest } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AllActivitiesService, - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ApplicationHealthReportDetailEnriched, SecurityTasksApiService, TaskMetrics, OrganizationReportSummary, @@ -14,17 +14,12 @@ import { import { OrganizationId } from "@bitwarden/common/types/guid"; import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components"; -import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; -import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; - -export const RenderMode = { - noCriticalApps: "noCriticalApps", - criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks", - criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks", -} as const; -export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode]; +import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service"; +import { RenderMode } from "../../models/activity.models"; +import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "dirt-password-change-metric", imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule], templateUrl: "./password-change-metric.component.html", @@ -34,8 +29,7 @@ export class PasswordChangeMetricComponent implements OnInit { protected taskMetrics$ = new BehaviorSubject({ totalTasks: 0, completedTasks: 0 }); private completedTasks: number = 0; private totalTasks: number = 0; - private allApplicationsDetails: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[] = - []; + private allApplicationsDetails: ApplicationHealthReportDetailEnriched[] = []; atRiskAppsCount: number = 0; atRiskPasswordsCount: number = 0; @@ -43,6 +37,13 @@ export class PasswordChangeMetricComponent implements OnInit { private destroyRef = new Subject(); renderMode: RenderMode = "noCriticalApps"; + constructor( + private activatedRoute: ActivatedRoute, + private securityTasksApiService: SecurityTasksApiService, + private allActivitiesService: AllActivitiesService, + protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, + ) {} + async ngOnInit(): Promise { combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$]) .pipe( @@ -83,13 +84,6 @@ export class PasswordChangeMetricComponent implements OnInit { }); } - constructor( - private activatedRoute: ActivatedRoute, - private securityTasksApiService: SecurityTasksApiService, - private allActivitiesService: AllActivitiesService, - protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, - ) {} - private determineRenderMode( summary: OrganizationReportSummary, taskMetrics: TaskMetrics, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts similarity index 91% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts index e4942344b0e..9e3dff3144c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts @@ -11,16 +11,16 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; 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 { getById } from "@bitwarden/common/platform/misc"; -import { ToastService, DialogService } from "@bitwarden/components"; +import { DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { RiskInsightsTabType } from "../models/risk-insights.models"; +import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component"; + import { ActivityCardComponent } from "./activity-card.component"; import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component"; import { NewApplicationsDialogComponent } from "./new-applications-dialog.component"; -import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; -import { RiskInsightsTabType } from "./risk-insights.component"; @Component({ selector: "dirt-all-activity", @@ -43,6 +43,15 @@ export class AllActivityComponent implements OnInit { destroyRef = inject(DestroyRef); + constructor( + private accountService: AccountService, + protected activatedRoute: ActivatedRoute, + protected allActivitiesService: AllActivitiesService, + protected dataService: RiskInsightsDataService, + private dialogService: DialogService, + protected organizationService: OrganizationService, + ) {} + async ngOnInit(): Promise { const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); @@ -71,17 +80,6 @@ export class AllActivityComponent implements OnInit { } } - constructor( - protected activatedRoute: ActivatedRoute, - private accountService: AccountService, - protected organizationService: OrganizationService, - protected dataService: RiskInsightsDataService, - protected allActivitiesService: AllActivitiesService, - private toastService: ToastService, - private i18nService: I18nService, - private dialogService: DialogService, - ) {} - get RiskInsightsTabType() { return RiskInsightsTabType; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts similarity index 94% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index bc04884c799..57ee0b20360 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -25,8 +25,8 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; -import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.component"; -import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; +import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component"; +import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component"; @Component({ selector: "dirt-all-applications", @@ -67,7 +67,7 @@ export class AllApplicationsComponent implements OnInit { } async ngOnInit() { - this.dataService.reportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (report) => { this.applicationSummary = report?.summaryData ?? createNewSummaryData(); this.dataSource.data = report?.reportData ?? []; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts similarity index 93% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts index 0ea273546b5..dffc493e51d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts @@ -23,11 +23,10 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; -import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service"; - -import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.component"; -import { RiskInsightsTabType } from "./risk-insights.component"; -import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service"; +import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; +import { RiskInsightsTabType } from "../models/risk-insights.models"; +import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component"; +import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; @Component({ selector: "dirt-critical-applications", diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts new file mode 100644 index 00000000000..6f108a46029 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts @@ -0,0 +1,7 @@ +export const RenderMode = { + noCriticalApps: "noCriticalApps", + criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks", + criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks", +} as const; + +export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode]; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/risk-insights.models.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/risk-insights.models.ts new file mode 100644 index 00000000000..18493a386dd --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/risk-insights.models.ts @@ -0,0 +1,8 @@ +// FIXME: update to use a const object instead of a typescript enum +// eslint-disable-next-line @bitwarden/platform/no-enums +export enum RiskInsightsTabType { + AllActivity = 0, + AllApps = 1, + CriticalApps = 2, + NotifiedMembers = 3, +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.html deleted file mode 100644 index dc94f28f944..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - {{ "member" | i18n }} - {{ "atRiskPasswords" | i18n }} - {{ "totalPasswords" | i18n }} - {{ "atRiskApplications" | i18n }} - {{ "totalApplications" | i18n }} - - - diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.ts deleted file mode 100644 index 15dc80a1b00..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { TableDataSource, TableModule } from "@bitwarden/components"; - -@Component({ - selector: "tools-notified-members-table", - templateUrl: "./notified-members-table.component.html", - imports: [CommonModule, JslibModule, TableModule], -}) -export class NotifiedMembersTableComponent { - dataSource = new TableDataSource(); - - constructor() { - this.dataSource.data = []; - } -} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 49ccfb73c5d..18df046b82c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -17,7 +17,7 @@ } @else { {{ "noReportRan" | i18n }} } - @let isRunningReport = dataService.isRunningReport$ | async; + @let isRunningReport = dataService.isGeneratingReport$ | async; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 308cc351dc3..e1264b009b8 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -1,13 +1,15 @@ import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, OnInit, inject } from "@angular/core"; +import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { EMPTY } from "rxjs"; -import { map, switchMap } from "rxjs/operators"; +import { map, tap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; +import { + DrawerType, + RiskInsightsDataService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -21,18 +23,10 @@ import { } from "@bitwarden/components"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; -import { AllActivityComponent } from "./all-activity.component"; -import { AllApplicationsComponent } from "./all-applications.component"; -import { CriticalApplicationsComponent } from "./critical-applications.component"; - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum RiskInsightsTabType { - AllActivity = 0, - AllApps = 1, - CriticalApps = 2, - NotifiedMembers = 3, -} +import { AllActivityComponent } from "./activity/all-activity.component"; +import { AllApplicationsComponent } from "./all-applications/all-applications.component"; +import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; +import { RiskInsightsTabType } from "./models/risk-insights.models"; @Component({ templateUrl: "./risk-insights.component.html", @@ -51,7 +45,7 @@ export enum RiskInsightsTabType { AllActivityComponent, ], }) -export class RiskInsightsComponent implements OnInit { +export class RiskInsightsComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); private _isDrawerOpen: boolean = false; @@ -65,7 +59,6 @@ export class RiskInsightsComponent implements OnInit { private organizationId: OrganizationId = "" as OrganizationId; dataLastUpdated: Date | null = null; - refetching: boolean = false; constructor( private route: ActivatedRoute, @@ -91,11 +84,10 @@ export class RiskInsightsComponent implements OnInit { .pipe( takeUntilDestroyed(this.destroyRef), map((params) => params.get("organizationId")), - switchMap(async (orgId) => { + tap((orgId) => { if (orgId) { // Initialize Data Service - await this.dataService.initializeForOrganization(orgId as OrganizationId); - + this.dataService.initializeForOrganization(orgId as OrganizationId); this.organizationId = orgId as OrganizationId; } else { return EMPTY; @@ -105,7 +97,7 @@ export class RiskInsightsComponent implements OnInit { .subscribe(); // Subscribe to report result details - this.dataService.reportResults$ + this.dataService.enrichedReportData$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((report) => { this.appsCount = report?.reportData.length ?? 0; @@ -119,15 +111,16 @@ export class RiskInsightsComponent implements OnInit { this._isDrawerOpen = details.open; }); } - runReport = () => { - this.dataService.triggerReport(); - }; + + ngOnDestroy(): void { + this.dataService.destroy(); + } /** * Refreshes the data by re-fetching the applications report. * This will automatically notify child components subscribed to the RiskInsightsDataService observables. */ - refreshData(): void { + generateReport(): void { if (this.organizationId) { this.dataService.triggerReport(); } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.ts diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts index a57bdfc279c..22f8ea55f51 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts @@ -3,7 +3,7 @@ import { mock } from "jest-mock-extended"; import { AllActivitiesService, - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ApplicationHealthReportDetailEnriched, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -43,7 +43,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid1"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2); await service.assignTasks(organizationId, apps); @@ -60,12 +60,12 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 2, atRiskCipherIds: ["cid1", "cid2"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, { isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid2"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined); i18nServiceSpy.t.mockImplementation((key) => key); @@ -91,7 +91,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid3"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail")); i18nServiceSpy.t.mockImplementation((key) => key); @@ -113,7 +113,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 0, atRiskCipherIds: ["cid4"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; const result = await service.requestPasswordChange(organizationId, apps); @@ -128,7 +128,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: false, atRiskPasswordCount: 2, atRiskCipherIds: ["cid5", "cid6"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; const result = await service.requestPasswordChange(organizationId, apps); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts index 34fd6daa2b0..4d7a41007eb 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { AllActivitiesService, - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ApplicationHealthReportDetailEnriched, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; @@ -20,10 +20,7 @@ export class AccessIntelligenceSecurityTasksService { private toastService: ToastService, private i18nService: I18nService, ) {} - async assignTasks( - organizationId: OrganizationId, - apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], - ) { + async assignTasks(organizationId: OrganizationId, apps: ApplicationHealthReportDetailEnriched[]) { const taskCount = await this.requestPasswordChange(organizationId, apps); this.allActivitiesService.setTaskCreatedCount(taskCount); } @@ -31,7 +28,7 @@ export class AccessIntelligenceSecurityTasksService { // TODO: this method is shared between here and critical-applications.component.ts async requestPasswordChange( organizationId: OrganizationId, - apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], + apps: ApplicationHealthReportDetailEnriched[], ): Promise { // Only create tasks for CRITICAL applications with at-risk passwords const cipherIds = apps From 3812e5d81bcd5d42c446d077851bbe1aa39f0016 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Wed, 22 Oct 2025 12:14:55 -0400 Subject: [PATCH 05/18] [PM-26371] Add state definition for auto confirm (#16953) * add state definition for auto confirm * typo --- .../auto-confirm/auto-confirm.state.ts | 21 +++++++++++++++++++ libs/state/src/core/state-definitions.ts | 1 + 2 files changed, 22 insertions(+) create mode 100644 libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts diff --git a/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts b/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts new file mode 100644 index 00000000000..b97f980b644 --- /dev/null +++ b/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts @@ -0,0 +1,21 @@ +import { AUTO_CONFIRM, UserKeyDefinition } from "../../../platform/state"; + +export class AutoConfirmState { + enabled: boolean; + showSetupDialog: boolean; + showBrowserNotification: boolean | undefined; + + constructor() { + this.enabled = false; + this.showSetupDialog = true; + } +} + +export const AUTO_CONFIRM_STATE = UserKeyDefinition.record( + AUTO_CONFIRM, + "autoConfirm", + { + deserializer: (autoConfirmState) => autoConfirmState, + clearOn: ["logout"], + }, +); diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 1c09b071e99..b558c1c83b6 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -36,6 +36,7 @@ export const DELETE_MANAGED_USER_WARNING = new StateDefinition( web: "disk-local", }, ); +export const AUTO_CONFIRM = new StateDefinition("autoConfirm", "disk"); // Billing export const BILLING_DISK = new StateDefinition("billing", "disk"); From 0340a881aeb6aba33e10b9df860b9c8fa5dc2f74 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 22 Oct 2025 12:37:58 -0400 Subject: [PATCH 06/18] [PM-20040] all tasks complete banner (#16033) * saved WIP * created at risk password callout service to hold state for callout data. wip * update at-risk-password-callout to use states for tracking showing and dismissing success banner * adding spec file for new serive * update styles to match figma * minor wording changes * fix undefined lint error in at risk password callout * moved service to libs * added another route guard so when user clears all at risk items they are directed back to the vault page * small cleanup in at risk callout component and at risk pw guard * clean up code in at risk password callout component * update state to memory * refactor for readability at risk password callout component * move state update logic from component to at risk password callout service * fix: bypass router cache on back() in popout * Revert "fix: bypass router cache on back() in popout" This reverts commit 23f9312434d2369f724539d205a6331c172f1374. * refactor updatePendingTasksState call * refactor at risk password callout component and service. remove signals, implement logic through observables. Completed value for tasks utilized. * clean up completedTasks in at risk password callout service * add updated state value to prevent banner among diff clients * move hasInteracted call to page component to avoid looping * remove excess call in service * update icon null logic in banner component * update the callout to use a new banner * fix classes * updating banners in at risk password callout component * anchor tag * move at-risk callout to above nudges * update `showCompletedTasksBanner$` variable naming --------- Co-authored-by: Andreas Coroiu Co-authored-by: Nick Krantz --- apps/browser/src/_locales/en/messages.json | 3 + apps/browser/src/popup/app-routing.module.ts | 7 +- .../at-risk-password-callout.component.html | 36 +++- .../at-risk-password-callout.component.ts | 55 +++--- .../at-risk-passwords.component.spec.ts | 16 +- .../at-risk-passwords.component.ts | 8 + .../vault-v2/vault-v2.component.html | 4 +- .../popup/guards/at-risk-passwords.guard.ts | 40 ++++- .../vault/tasks/abstractions/task.service.ts | 6 + .../tasks/services/default-task.service.ts | 6 + .../components/src/banner/banner.component.ts | 5 +- libs/state/src/core/state-definitions.ts | 1 + libs/vault/src/index.ts | 4 + .../at-risk-password-callout.service.spec.ts | 162 ++++++++++++++++++ .../at-risk-password-callout.service.ts | 93 ++++++++++ 15 files changed, 405 insertions(+), 41 deletions(-) create mode 100644 libs/vault/src/services/at-risk-password-callout.service.spec.ts create mode 100644 libs/vault/src/services/at-risk-password-callout.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ce4c5c76b81..6a0e8c01c4d 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index e57625d382a..02adaff9b83 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -77,7 +77,10 @@ import { IntroCarouselComponent } from "../vault/popup/components/vault-v2/intro import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component"; import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component"; import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component"; -import { canAccessAtRiskPasswords } from "../vault/popup/guards/at-risk-passwords.guard"; +import { + canAccessAtRiskPasswords, + hasAtRiskPasswords, +} from "../vault/popup/guards/at-risk-passwords.guard"; import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard"; import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard"; import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component"; @@ -692,7 +695,7 @@ const routes: Routes = [ { path: "at-risk-passwords", component: AtRiskPasswordsComponent, - canActivate: [authGuard, canAccessAtRiskPasswords], + canActivate: [authGuard, canAccessAtRiskPasswords, hasAtRiskPasswords], }, { path: "account-switcher", diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html index 6c2bc3f77a0..0efe2bd14e2 100644 --- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html @@ -1,8 +1,28 @@ - - - {{ - (taskCount === 1 ? "reviewAndChangeAtRiskPassword" : "reviewAndChangeAtRiskPasswordsPlural") - | i18n: taskCount.toString() - }} - - +@if ((currentPendingTasks$ | async)?.length > 0) { + + + {{ + ((currentPendingTasks$ | async)?.length === 1 + ? "reviewAndChangeAtRiskPassword" + : "reviewAndChangeAtRiskPasswordsPlural" + ) | i18n: (currentPendingTasks$ | async)?.length.toString() + }} + + +} + +@if (showCompletedTasksBanner$ | async) { + + {{ "atRiskLoginsSecured" | i18n }} + +} diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts index 3c3270e557c..c3d4f461d70 100644 --- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts @@ -1,42 +1,47 @@ import { CommonModule } from "@angular/common"; import { Component, inject } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { combineLatest, map, switchMap } from "rxjs"; +import { firstValueFrom, switchMap } 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; -import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components"; +import { AnchorLinkDirective, CalloutModule, BannerModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwarden/vault"; @Component({ selector: "vault-at-risk-password-callout", - imports: [CommonModule, AnchorLinkDirective, RouterModule, CalloutModule, I18nPipe], + imports: [ + AnchorLinkDirective, + CommonModule, + RouterModule, + CalloutModule, + I18nPipe, + BannerModule, + JslibModule, + ], + providers: [AtRiskPasswordCalloutService], templateUrl: "./at-risk-password-callout.component.html", }) export class AtRiskPasswordCalloutComponent { - private taskService = inject(TaskService); - private cipherService = inject(CipherService); private activeAccount$ = inject(AccountService).activeAccount$.pipe(getUserId); + private atRiskPasswordCalloutService = inject(AtRiskPasswordCalloutService); - protected pendingTasks$ = this.activeAccount$.pipe( - switchMap((userId) => - combineLatest([ - this.taskService.pendingTasks$(userId), - this.cipherService.cipherViews$(userId), - ]), - ), - map(([tasks, ciphers]) => - tasks.filter((t) => { - const associatedCipher = ciphers.find((c) => c.id === t.cipherId); - - return ( - t.type === SecurityTaskType.UpdateAtRiskCredential && - associatedCipher && - !associatedCipher.isDeleted - ); - }), - ), + showCompletedTasksBanner$ = this.activeAccount$.pipe( + switchMap((userId) => this.atRiskPasswordCalloutService.showCompletedTasksBanner$(userId)), ); + + currentPendingTasks$ = this.activeAccount$.pipe( + switchMap((userId) => this.atRiskPasswordCalloutService.pendingTasks$(userId)), + ); + + async successBannerDismissed() { + const updateObject: AtRiskPasswordCalloutData = { + hasInteractedWithTasks: true, + tasksBannerDismissed: true, + }; + const userId = await firstValueFrom(this.activeAccount$); + this.atRiskPasswordCalloutService.updateAtRiskPasswordState(userId, updateObject); + } } diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts index e40ffcf31c9..0cbfa037e35 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts @@ -14,6 +14,9 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { FakeAccountService, FakeStateProvider } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { EndUserNotificationService } from "@bitwarden/common/vault/notifications"; @@ -24,6 +27,7 @@ import { ChangeLoginPasswordService, DefaultChangeLoginPasswordService, PasswordRepromptService, + AtRiskPasswordCalloutService, } from "@bitwarden/vault"; import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; @@ -68,6 +72,9 @@ describe("AtRiskPasswordsComponent", () => { let mockNotifications$: BehaviorSubject; let mockInlineMenuVisibility$: BehaviorSubject; let calloutDismissed$: BehaviorSubject; + let mockAtRiskPasswordCalloutService: any; + let stateProvider: FakeStateProvider; + let mockAccountService: FakeAccountService; const setInlineMenuVisibility = jest.fn(); const mockToastService = mock(); const mockAtRiskPasswordPageService = mock(); @@ -112,6 +119,11 @@ describe("AtRiskPasswordsComponent", () => { mockToastService.showToast.mockClear(); mockDialogService.open.mockClear(); mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$); + mockAccountService = { + activeAccount$: of({ id: "user" as UserId }), + activeUserId: "user" as UserId, + } as unknown as FakeAccountService; + stateProvider = new FakeStateProvider(mockAccountService); await TestBed.configureTestingModule({ imports: [AtRiskPasswordsComponent], @@ -141,7 +153,7 @@ describe("AtRiskPasswordsComponent", () => { }, }, { provide: I18nService, useValue: { t: (key: string) => key } }, - { provide: AccountService, useValue: { activeAccount$: of({ id: "user" }) } }, + { provide: AccountService, useValue: mockAccountService }, { provide: PlatformUtilsService, useValue: mock() }, { provide: PasswordRepromptService, useValue: mock() }, { @@ -152,6 +164,8 @@ describe("AtRiskPasswordsComponent", () => { }, }, { provide: ToastService, useValue: mockToastService }, + { provide: StateProvider, useValue: stateProvider }, + { provide: AtRiskPasswordCalloutService, useValue: mockAtRiskPasswordCalloutService }, ], }) .overrideModule(JslibModule, { diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts index e4a8293c52e..6551c84a4e2 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts @@ -41,6 +41,7 @@ import { TypographyModule, } from "@bitwarden/components"; import { + AtRiskPasswordCalloutService, ChangeLoginPasswordService, DefaultChangeLoginPasswordService, PasswordRepromptService, @@ -75,6 +76,7 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service"; providers: [ AtRiskPasswordPageService, { provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService }, + AtRiskPasswordCalloutService, ], selector: "vault-at-risk-passwords", templateUrl: "./at-risk-passwords.component.html", @@ -95,6 +97,7 @@ export class AtRiskPasswordsComponent implements OnInit { private dialogService = inject(DialogService); private endUserNotificationService = inject(EndUserNotificationService); private destroyRef = inject(DestroyRef); + private atRiskPasswordCalloutService = inject(AtRiskPasswordCalloutService); /** * The cipher that is currently being launched. Used to show a loading spinner on the badge button. @@ -199,6 +202,11 @@ export class AtRiskPasswordsComponent implements OnInit { } this.markTaskNotificationsAsRead(); + + this.atRiskPasswordCalloutService.updateAtRiskPasswordState(userId, { + hasInteractedWithTasks: true, + tasksBannerDismissed: false, + }); } private markTaskNotificationsAsRead() { 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 a56eef4dfc1..07d3f042e60 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 @@ -41,6 +41,9 @@ + + +
-
diff --git a/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts b/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts index fc302dd6c36..03111859165 100644 --- a/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts +++ b/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts @@ -1,10 +1,11 @@ import { inject } from "@angular/core"; import { CanActivateFn, Router } from "@angular/router"; -import { map, switchMap } from "rxjs"; +import { combineLatest, map, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { TaskService } from "@bitwarden/common/vault/tasks"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { ToastService } from "@bitwarden/components"; @@ -32,3 +33,38 @@ export const canAccessAtRiskPasswords: CanActivateFn = () => { }), ); }; + +export const hasAtRiskPasswords: CanActivateFn = () => { + const accountService = inject(AccountService); + const taskService = inject(TaskService); + const cipherService = inject(CipherService); + const router = inject(Router); + + return accountService.activeAccount$.pipe( + filterOutNullish(), + switchMap((user) => + combineLatest([ + taskService.pendingTasks$(user.id), + cipherService.cipherViews$(user.id).pipe( + filterOutNullish(), + map((ciphers) => Object.fromEntries(ciphers.map((c) => [c.id, c]))), + ), + ]).pipe( + map(([tasks, ciphers]) => { + const hasAtRiskCiphers = tasks.some( + (t) => + t.type === SecurityTaskType.UpdateAtRiskCredential && + t.cipherId != null && + ciphers[t.cipherId] != null && + !ciphers[t.cipherId].isDeleted, + ); + + if (!hasAtRiskCiphers) { + return router.createUrlTree(["/tabs/vault"]); + } + return true; + }), + ), + ), + ); +}; diff --git a/libs/common/src/vault/tasks/abstractions/task.service.ts b/libs/common/src/vault/tasks/abstractions/task.service.ts index 79cefff0b71..816c676413a 100644 --- a/libs/common/src/vault/tasks/abstractions/task.service.ts +++ b/libs/common/src/vault/tasks/abstractions/task.service.ts @@ -25,6 +25,12 @@ export abstract class TaskService { */ abstract pendingTasks$(userId: UserId): Observable; + /** + * Observable of completed tasks for a given user. + * @param userId + */ + abstract completedTasks$(userId: UserId): Observable; + /** * Retrieves tasks from the API for a given user and updates the local state. * @param userId diff --git a/libs/common/src/vault/tasks/services/default-task.service.ts b/libs/common/src/vault/tasks/services/default-task.service.ts index 6238076ccf5..bbf58aec5e9 100644 --- a/libs/common/src/vault/tasks/services/default-task.service.ts +++ b/libs/common/src/vault/tasks/services/default-task.service.ts @@ -80,6 +80,12 @@ export class DefaultTaskService implements TaskService { ); }); + completedTasks$ = perUserCache$((userId) => { + return this.tasks$(userId).pipe( + map((tasks) => tasks.filter((t) => t.status === SecurityTaskStatus.Completed)), + ); + }); + async refreshTasks(userId: UserId): Promise { await this.fetchTasksFromApi(userId); } diff --git a/libs/components/src/banner/banner.component.ts b/libs/components/src/banner/banner.component.ts index f258ed0c48c..1f9bf960d4b 100644 --- a/libs/components/src/banner/banner.component.ts +++ b/libs/components/src/banner/banner.component.ts @@ -38,7 +38,8 @@ const defaultIcon: Record = { export class BannerComponent implements OnInit { readonly bannerType = input("info"); - readonly icon = model(); + // passing `null` will remove the icon from element from the banner + readonly icon = model(); readonly useAlertRole = input(true); readonly showClose = input(true); @@ -47,7 +48,7 @@ export class BannerComponent implements OnInit { @Output() onClose = new EventEmitter(); ngOnInit(): void { - if (!this.icon()) { + if (!this.icon() && this.icon() !== null) { this.icon.set(defaultIcon[this.bannerType()]); } } diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index b558c1c83b6..1c72f5f3230 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -218,3 +218,4 @@ export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition( "vaultBrowserIntroCarousel", "disk", ); +export const VAULT_AT_RISK_PASSWORDS_MEMORY = new StateDefinition("vaultAtRiskPasswords", "memory"); diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 3f05c753da4..ccd830cd34e 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -1,3 +1,7 @@ +export { + AtRiskPasswordCalloutService, + AtRiskPasswordCalloutData, +} from "./services/at-risk-password-callout.service"; export { PasswordRepromptService } from "./services/password-reprompt.service"; export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service"; export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive"; diff --git a/libs/vault/src/services/at-risk-password-callout.service.spec.ts b/libs/vault/src/services/at-risk-password-callout.service.spec.ts new file mode 100644 index 00000000000..47b83f4a903 --- /dev/null +++ b/libs/vault/src/services/at-risk-password-callout.service.spec.ts @@ -0,0 +1,162 @@ +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom, of } from "rxjs"; + +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { + SecurityTask, + SecurityTaskStatus, + SecurityTaskType, + TaskService, +} from "@bitwarden/common/vault/tasks"; +import { StateProvider } from "@bitwarden/state"; +import { UserId } from "@bitwarden/user-core"; + +import { FakeSingleUserState } from "../../../common/spec/fake-state"; + +import { + AtRiskPasswordCalloutData, + AtRiskPasswordCalloutService, +} from "./at-risk-password-callout.service"; + +const fakeUserState = () => + ({ + update: jest.fn().mockResolvedValue(undefined), + state$: of(null), + }) as unknown as FakeSingleUserState; + +class MockCipherView { + constructor( + public id: string, + private deleted: boolean, + ) {} + get isDeleted() { + return this.deleted; + } +} + +describe("AtRiskPasswordCalloutService", () => { + let service: AtRiskPasswordCalloutService; + const mockTaskService = { + pendingTasks$: jest.fn(), + completedTasks$: jest.fn(), + }; + const mockCipherService = { cipherViews$: jest.fn() }; + const mockStateProvider = { getUser: jest.fn().mockReturnValue(fakeUserState()) }; + const userId: UserId = "user1" as UserId; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + AtRiskPasswordCalloutService, + { + provide: TaskService, + useValue: mockTaskService, + }, + { + provide: CipherService, + useValue: mockCipherService, + }, + { + provide: StateProvider, + useValue: mockStateProvider, + }, + ], + }); + + service = TestBed.inject(AtRiskPasswordCalloutService); + }); + + describe("completedTasks$", () => { + it(" should return true if completed tasks exist", async () => { + const tasks: SecurityTask[] = [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Completed, + } as any, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as any, + { + id: "t3", + cipherId: "nope", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Completed, + } as any, + { + id: "t4", + cipherId: "c3", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Completed, + } as any, + ]; + + jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of(tasks)); + + const result = await firstValueFrom(service.completedTasks$(userId)); + + expect(result).toEqual(tasks[0]); + expect(result?.id).toBe("t1"); + }); + }); + + describe("showCompletedTasksBanner$", () => { + beforeEach(() => { + jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of([])); + jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of([])); + jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of([])); + }); + + it("should return false if banner has been dismissed", async () => { + const state: AtRiskPasswordCalloutData = { + hasInteractedWithTasks: true, + tasksBannerDismissed: true, + }; + const mockState = { ...fakeUserState(), state$: of(state) }; + mockStateProvider.getUser.mockReturnValue(mockState); + + const result = await firstValueFrom(service.showCompletedTasksBanner$(userId)); + + expect(result).toBe(false); + }); + + it("should return true when has completed tasks, no pending tasks, and banner not dismissed", async () => { + const completedTasks = [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Completed, + }, + ]; + const ciphers = [new MockCipherView("c1", false)]; + const state: AtRiskPasswordCalloutData = { + hasInteractedWithTasks: true, + tasksBannerDismissed: false, + }; + + jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of(completedTasks)); + jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers)); + mockStateProvider.getUser.mockReturnValue({ state$: of(state) }); + + const result = await firstValueFrom(service.showCompletedTasksBanner$(userId)); + + expect(result).toBe(true); + }); + + it("returns false when no completed tasks", async () => { + const state: AtRiskPasswordCalloutData = { + hasInteractedWithTasks: true, + tasksBannerDismissed: false, + }; + mockStateProvider.getUser.mockReturnValue({ state$: of(state) }); + + const result = await firstValueFrom(service.showCompletedTasksBanner$(userId)); + + expect(result).toBe(false); + }); + }); +}); diff --git a/libs/vault/src/services/at-risk-password-callout.service.ts b/libs/vault/src/services/at-risk-password-callout.service.ts new file mode 100644 index 00000000000..d3af4f8421e --- /dev/null +++ b/libs/vault/src/services/at-risk-password-callout.service.ts @@ -0,0 +1,93 @@ +import { Injectable } from "@angular/core"; +import { combineLatest, map, Observable } from "rxjs"; + +import { + SingleUserState, + StateProvider, + UserKeyDefinition, + VAULT_AT_RISK_PASSWORDS_MEMORY, +} from "@bitwarden/common/platform/state"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; +import { UserId } from "@bitwarden/user-core"; + +export type AtRiskPasswordCalloutData = { + hasInteractedWithTasks: boolean; + tasksBannerDismissed: boolean; +}; + +export const AT_RISK_PASSWORD_CALLOUT_KEY = new UserKeyDefinition( + VAULT_AT_RISK_PASSWORDS_MEMORY, + "atRiskPasswords", + { + deserializer: (jsonData) => jsonData, + clearOn: ["lock", "logout"], + }, +); + +@Injectable() +export class AtRiskPasswordCalloutService { + constructor( + private taskService: TaskService, + private cipherService: CipherService, + private stateProvider: StateProvider, + ) {} + + pendingTasks$(userId: UserId): Observable { + return combineLatest([ + this.taskService.pendingTasks$(userId), + this.cipherService.cipherViews$(userId), + ]).pipe( + map(([tasks, ciphers]) => { + return tasks.filter((t: SecurityTask) => { + const associatedCipher = ciphers.find((c) => c.id === t.cipherId); + + return ( + t.type === SecurityTaskType.UpdateAtRiskCredential && + associatedCipher && + !associatedCipher.isDeleted + ); + }); + }), + ); + } + + completedTasks$(userId: UserId): Observable { + return this.taskService.completedTasks$(userId).pipe( + map((tasks) => { + return tasks.find((t: SecurityTask) => t.type === SecurityTaskType.UpdateAtRiskCredential); + }), + ); + } + + showCompletedTasksBanner$(userId: UserId): Observable { + return combineLatest([ + this.pendingTasks$(userId), + this.completedTasks$(userId), + this.atRiskPasswordState(userId).state$, + ]).pipe( + map(([pendingTasks, completedTasks, state]) => { + const hasPendingTasks = pendingTasks.length > 0; + const bannerDismissed = state?.tasksBannerDismissed ?? false; + const hasInteracted = state?.hasInteractedWithTasks ?? false; + + // This will ensure the banner remains visible only in the client the user resolved their tasks in + // e.g. if the user did not see tasks in the browser, and resolves them in the web, the browser will not show the banner + if (!hasPendingTasks && (!hasInteracted || bannerDismissed)) { + return false; + } + + // Show banner if there are completed tasks and no pending tasks, and banner hasn't been dismissed + return !!completedTasks && !hasPendingTasks && !(state?.tasksBannerDismissed ?? false); + }), + ); + } + + atRiskPasswordState(userId: UserId): SingleUserState { + return this.stateProvider.getUser(userId, AT_RISK_PASSWORD_CALLOUT_KEY); + } + + updateAtRiskPasswordState(userId: UserId, updatedState: AtRiskPasswordCalloutData): void { + void this.atRiskPasswordState(userId).update(() => updatedState); + } +} From 91be36bfcf4cdf0d20835ed59cc0fb570700118f Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 22 Oct 2025 12:27:48 -0500 Subject: [PATCH 07/18] force a `null` value for angular forms as `undefined` gets forced to `null` anyway (#16985) --- .../item-details-section.component.spec.ts | 40 +++++++++++++++++++ .../item-details-section.component.ts | 8 +++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index 67b5509c8ac..e40231ce801 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -640,6 +640,46 @@ describe("ItemDetailsSectionComponent", () => { }); }); + describe("initFromExistingCipher", () => { + it("should set organizationId to null when prefillCipher.organizationId is undefined", async () => { + component.config.organizationDataOwnershipDisabled = true; + component.config.organizations = [{ id: "org1" } as Organization]; + + const prefillCipher = { + name: "Test Cipher", + organizationId: undefined, + folderId: null, + collectionIds: [], + favorite: false, + } as unknown as CipherView; + + getInitialCipherView.mockReturnValueOnce(prefillCipher); + + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.value).toBeNull(); + }); + + it("should preserve organizationId when prefillCipher.organizationId has a value", async () => { + component.config.organizationDataOwnershipDisabled = true; + component.config.organizations = [{ id: "org1", name: "Organization 1" } as Organization]; + + const prefillCipher = { + name: "Test Cipher", + organizationId: "org1", + folderId: null, + collectionIds: [], + favorite: false, + } as unknown as CipherView; + + getInitialCipherView.mockReturnValueOnce(prefillCipher); + + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.value).toBe("org1"); + }); + }); + describe("form status when editing a cipher", () => { beforeEach(() => { component.config.mode = "edit"; diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index ce0244bc759..892fc5804ec 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -4,7 +4,7 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; -import { concatMap, firstValueFrom, map } from "rxjs"; +import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -236,6 +236,7 @@ export class ItemDetailsSectionComponent implements OnInit { this.itemDetailsForm.controls.organizationId.valueChanges .pipe( takeUntilDestroyed(this.destroyRef), + distinctUntilChanged(), concatMap(async () => { await this.updateCollectionOptions(); this.setFormState(); @@ -314,7 +315,10 @@ export class ItemDetailsSectionComponent implements OnInit { this.itemDetailsForm.patchValue({ name: name ? name : (this.initialValues?.name ?? ""), - organizationId: prefillCipher.organizationId, // We do not allow changing ownership of an existing cipher. + // We do not allow changing ownership of an existing cipher. + // Angular forms do not support `undefined` as a value for a form control, + // force `null` if `organizationId` is undefined. + organizationId: prefillCipher.organizationId ?? null, folderId: folderId ? folderId : (this.initialValues?.folderId ?? null), collectionIds: [], favorite: prefillCipher.favorite, From 8154613462a9bd02be701f1b84b431b510a9f916 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Wed, 22 Oct 2025 20:29:36 +0200 Subject: [PATCH 08/18] [PM-23995] Updated change kdf component for Forced update KDF settings (#16516) * move change-kdf into KM ownership * Change kdf component update for Forced KDF update * correct validators load on init * incorrect feature flag observable check * unit test coverage * unit test coverage * remove Close button, wrong icon * change to `pm-23995-no-logout-on-kdf-change` feature flag * updated unit tests * revert bad merge Signed-off-by: Maciej Zieniuk * updated wording, TS strict enabled, use form controls, updated tests * use localisation for button label * small margin in confirmation dialog * simpler I18nService mock --------- Signed-off-by: Maciej Zieniuk --- .../change-kdf-confirmation.component.html | 14 +- .../change-kdf-confirmation.component.spec.ts | 243 ++++++++++++ .../change-kdf-confirmation.component.ts | 49 ++- .../change-kdf/change-kdf.component.html | 153 ++++---- .../change-kdf/change-kdf.component.spec.ts | 365 ++++++++++++++++++ .../change-kdf/change-kdf.component.ts | 125 +++--- .../change-kdf/change-kdf.module.ts | 4 +- apps/web/src/locales/en/messages.json | 94 ++--- 8 files changed, 848 insertions(+), 199 deletions(-) create mode 100644 apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.spec.ts create mode 100644 apps/web/src/app/key-management/change-kdf/change-kdf.component.spec.ts diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.html b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.html index 9f21b28f190..88c6c8b9aca 100644 --- a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.html +++ b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.html @@ -1,12 +1,14 @@
- {{ "changeKdf" | i18n }} + {{ "updateYourEncryptionSettings" | i18n }} - {{ "kdfSettingsChangeLogoutWarning" | i18n }} - + @if (!(noLogoutOnKdfChangeFeatureFlag$ | async)) { + {{ "kdfSettingsChangeLogoutWarning" | i18n }} + } + {{ "masterPass" | i18n }} {{ "confirmIdentity" | i18n }} - + + - - {{ "kdfMemory" | i18n }} - -
-
- + @if (isPBKDF2(kdfConfig)) { + {{ "kdfIterations" | i18n }} - - - {{ "kdfIterationRecommends" | i18n }} - - - - {{ "kdfIterations" | i18n }} - - - - - - {{ "kdfParallelism" | i18n }} - - - - -
+ } @else if (isArgon2(kdfConfig)) { + + {{ "kdfMemory" | i18n }} + + + }
+ @if (isArgon2(kdfConfig)) { +
+ + + {{ "kdfIterations" | i18n }} + + + +
+
+ + + {{ "kdfParallelism" | i18n }} + + + +
+ } + + +
    +
  • {{ "encryptionKeySettingsAlgorithmPopoverPBKDF2" | i18n }}
  • +
  • {{ "encryptionKeySettingsAlgorithmPopoverArgon2Id" | i18n }}
  • +
+ +
diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf.component.spec.ts b/apps/web/src/app/key-management/change-kdf/change-kdf.component.spec.ts new file mode 100644 index 00000000000..c5144223ba0 --- /dev/null +++ b/apps/web/src/app/key-management/change-kdf/change-kdf.component.spec.ts @@ -0,0 +1,365 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, FormControl } from "@angular/forms"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService, PopoverModule, CalloutModule } from "@bitwarden/components"; +import { + KdfConfigService, + Argon2KdfConfig, + PBKDF2KdfConfig, + KdfType, +} from "@bitwarden/key-management"; + +import { SharedModule } from "../../shared"; + +import { ChangeKdfComponent } from "./change-kdf.component"; + +describe("ChangeKdfComponent", () => { + let component: ChangeKdfComponent; + let fixture: ComponentFixture; + + // Mock Services + let mockDialogService: MockProxy; + let mockKdfConfigService: MockProxy; + let mockConfigService: MockProxy; + let mockI18nService: MockProxy; + let accountService: FakeAccountService; + let formBuilder: FormBuilder; + + const mockUserId = "user-id" as UserId; + + // Helper functions for validation testing + function expectPBKDF2Validation( + iterationsControl: FormControl, + memoryControl: FormControl, + parallelismControl: FormControl, + ) { + // Assert current validators state + expect(iterationsControl.hasError("required")).toBe(false); + expect(iterationsControl.hasError("min")).toBe(false); + expect(iterationsControl.hasError("max")).toBe(false); + expect(memoryControl.validator).toBeNull(); + expect(parallelismControl.validator).toBeNull(); + + // Test validation boundaries + iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.min - 1); + expect(iterationsControl.hasError("min")).toBe(true); + + iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.max + 1); + expect(iterationsControl.hasError("max")).toBe(true); + } + + function expectArgon2Validation( + iterationsControl: FormControl, + memoryControl: FormControl, + parallelismControl: FormControl, + ) { + // Assert current validators state + expect(iterationsControl.hasError("required")).toBe(false); + expect(memoryControl.hasError("required")).toBe(false); + expect(parallelismControl.hasError("required")).toBe(false); + + // Test validation boundaries - min values + iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.min - 1); + expect(iterationsControl.hasError("min")).toBe(true); + + memoryControl.setValue(Argon2KdfConfig.MEMORY.min - 1); + expect(memoryControl.hasError("min")).toBe(true); + + parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.min - 1); + expect(parallelismControl.hasError("min")).toBe(true); + + // Test validation boundaries - max values + iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.max + 1); + expect(iterationsControl.hasError("max")).toBe(true); + + memoryControl.setValue(Argon2KdfConfig.MEMORY.max + 1); + expect(memoryControl.hasError("max")).toBe(true); + + parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.max + 1); + expect(parallelismControl.hasError("max")).toBe(true); + } + + beforeEach(() => { + mockDialogService = mock(); + mockKdfConfigService = mock(); + mockConfigService = mock(); + mockI18nService = mock(); + accountService = mockAccountServiceWith(mockUserId); + formBuilder = new FormBuilder(); + + mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + + mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`); + + TestBed.configureTestingModule({ + declarations: [ChangeKdfComponent], + imports: [SharedModule, PopoverModule, CalloutModule], + providers: [ + { provide: DialogService, useValue: mockDialogService }, + { provide: KdfConfigService, useValue: mockKdfConfigService }, + { provide: AccountService, useValue: accountService }, + { provide: FormBuilder, useValue: formBuilder }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: I18nService, useValue: mockI18nService }, + ], + }); + }); + + describe("Component Initialization", () => { + describe("given PBKDF2 configuration", () => { + it("should initialize form with PBKDF2 values and validators when component loads", async () => { + // Arrange + const mockPBKDF2Config = new PBKDF2KdfConfig(600_000); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config); + + // Act + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + + // Extract form controls + const formGroup = component["formGroup"]; + + // Assert form values + expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256); + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000); + expect(kdfConfigFormGroup.controls.memory.value).toBeNull(); + expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull(); + expect(component.kdfConfig).toEqual(mockPBKDF2Config); + + // Assert validators + expectPBKDF2Validation( + kdfConfigFormGroup.controls.iterations, + kdfConfigFormGroup.controls.memory, + kdfConfigFormGroup.controls.parallelism, + ); + }); + }); + + describe("given Argon2id configuration", () => { + it("should initialize form with Argon2id values and validators when component loads", async () => { + // Arrange + const mockArgon2Config = new Argon2KdfConfig(3, 64, 4); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config); + + // Act + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + + // Extract form controls + const formGroup = component["formGroup"]; + + // Assert form values + expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id); + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expect(kdfConfigFormGroup.controls.iterations.value).toBe(3); + expect(kdfConfigFormGroup.controls.memory.value).toBe(64); + expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4); + expect(component.kdfConfig).toEqual(mockArgon2Config); + + // Assert validators + expectArgon2Validation( + kdfConfigFormGroup.controls.iterations, + kdfConfigFormGroup.controls.memory, + kdfConfigFormGroup.controls.parallelism, + ); + }); + }); + + it.each([ + [true, false], + [false, true], + ])( + "should show log out banner = %s when feature flag observable is %s", + async (showLogOutBanner, forceUpgradeKdfFeatureFlag) => { + // Arrange + const mockPBKDF2Config = new PBKDF2KdfConfig(600_000); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config); + mockConfigService.getFeatureFlag$.mockReturnValue(of(forceUpgradeKdfFeatureFlag)); + + // Act + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + fixture.detectChanges(); + + // Assert + const calloutElement = fixture.debugElement.query((el) => + el.nativeElement.textContent?.includes("kdfSettingsChangeLogoutWarning"), + ); + + if (showLogOutBanner) { + expect(calloutElement).not.toBeNull(); + expect(calloutElement.nativeElement.textContent).toContain( + "kdfSettingsChangeLogoutWarning-used-i18n", + ); + } else { + expect(calloutElement).toBeNull(); + } + }, + ); + }); + + describe("KDF Type Switching", () => { + describe("switching from PBKDF2 to Argon2id", () => { + beforeEach(async () => { + // Setup component with initial PBKDF2 configuration + const mockPBKDF2Config = new PBKDF2KdfConfig(600_001); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config); + + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + }); + + it("should update form structure and default values when KDF type changes to Argon2id", () => { + // Arrange + const formGroup = component["formGroup"]; + + // Act - change KDF type to Argon2id + formGroup.controls.kdf.setValue(KdfType.Argon2id); + + // Assert form values update to Argon2id defaults + expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id); + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expect(kdfConfigFormGroup.controls.iterations.value).toBe(3); // Argon2id default + expect(kdfConfigFormGroup.controls.memory.value).toBe(64); // Argon2id default + expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4); // Argon2id default + }); + + it("should update validators when KDF type changes to Argon2id", () => { + // Arrange + const formGroup = component["formGroup"]; + + // Act - change KDF type to Argon2id + formGroup.controls.kdf.setValue(KdfType.Argon2id); + + // Assert validators update to Argon2id validation rules + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expectArgon2Validation( + kdfConfigFormGroup.controls.iterations, + kdfConfigFormGroup.controls.memory, + kdfConfigFormGroup.controls.parallelism, + ); + }); + }); + + describe("switching from Argon2id to PBKDF2", () => { + beforeEach(async () => { + // Setup component with initial Argon2id configuration + const mockArgon2IdConfig = new Argon2KdfConfig(4, 65, 5); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2IdConfig); + + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + }); + + it("should update form structure and default values when KDF type changes to PBKDF2", () => { + // Arrange + const formGroup = component["formGroup"]; + + // Act - change KDF type back to PBKDF2 + formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256); + + // Assert form values update to PBKDF2 defaults + expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256); + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000); // PBKDF2 default + expect(kdfConfigFormGroup.controls.memory.value).toBeNull(); // PBKDF2 doesn't use memory + expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull(); // PBKDF2 doesn't use parallelism + }); + + it("should update validators when KDF type changes to PBKDF2", () => { + // Arrange + const formGroup = component["formGroup"]; + + // Act - change KDF type back to PBKDF2 + formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256); + + // Assert validators update to PBKDF2 validation rules + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expectPBKDF2Validation( + kdfConfigFormGroup.controls.iterations, + kdfConfigFormGroup.controls.memory, + kdfConfigFormGroup.controls.parallelism, + ); + }); + }); + }); + + describe("openConfirmationModal", () => { + describe("when form is valid", () => { + it("should open confirmation modal with PBKDF2 config when form is submitted", async () => { + // Arrange + const mockPBKDF2Config = new PBKDF2KdfConfig(600_001); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config); + + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + + // Act + await component.openConfirmationModal(); + + // Assert + expect(mockDialogService.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + data: expect.objectContaining({ + kdfConfig: mockPBKDF2Config, + }), + }), + ); + }); + + it("should open confirmation modal with Argon2id config when form is submitted", async () => { + // Arrange + const mockArgon2Config = new Argon2KdfConfig(4, 65, 5); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config); + + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + + // Act + await component.openConfirmationModal(); + + // Assert + expect(mockDialogService.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + data: expect.objectContaining({ + kdfConfig: mockArgon2Config, + }), + }), + ); + }); + + it("should not open modal when form is invalid", async () => { + // Arrange + const mockPBKDF2Config = new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.min - 1); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config); + + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + + // Act + await component.openConfirmationModal(); + + // Assert + expect(mockDialogService.open).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf.component.ts b/apps/web/src/app/key-management/change-kdf/change-kdf.component.ts index 0463c6d4afc..f128aefdd9b 100644 --- a/apps/web/src/app/key-management/change-kdf/change-kdf.component.ts +++ b/apps/web/src/app/key-management/change-kdf/change-kdf.component.ts @@ -1,11 +1,11 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; -import { FormBuilder, FormControl, ValidatorFn, Validators } from "@angular/forms"; -import { Subject, firstValueFrom, takeUntil } from "rxjs"; +import { FormBuilder, FormControl, Validators } from "@angular/forms"; +import { Subject, firstValueFrom, takeUntil, Observable } from "rxjs"; 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 { DialogService } from "@bitwarden/components"; import { KdfConfigService, @@ -31,11 +31,11 @@ export class ChangeKdfComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); protected formGroup = this.formBuilder.group({ - kdf: new FormControl(KdfType.PBKDF2_SHA256, [Validators.required]), + kdf: new FormControl(KdfType.PBKDF2_SHA256, [Validators.required]), kdfConfig: this.formBuilder.group({ - iterations: [this.kdfConfig.iterations], - memory: [null as number], - parallelism: [null as number], + iterations: new FormControl(null), + memory: new FormControl(null), + parallelism: new FormControl(null), }), }); @@ -45,95 +45,102 @@ export class ChangeKdfComponent implements OnInit, OnDestroy { protected ARGON2_MEMORY = Argon2KdfConfig.MEMORY; protected ARGON2_PARALLELISM = Argon2KdfConfig.PARALLELISM; + noLogoutOnKdfChangeFeatureFlag$: Observable; + constructor( private dialogService: DialogService, private kdfConfigService: KdfConfigService, private accountService: AccountService, private formBuilder: FormBuilder, + configService: ConfigService, ) { this.kdfOptions = [ { name: "PBKDF2 SHA-256", value: KdfType.PBKDF2_SHA256 }, { name: "Argon2id", value: KdfType.Argon2id }, ]; + this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$( + FeatureFlag.NoLogoutOnKdfChange, + ); } async ngOnInit() { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId); - this.formGroup.get("kdf").setValue(this.kdfConfig.kdfType); + this.formGroup.controls.kdf.setValue(this.kdfConfig.kdfType); this.setFormControlValues(this.kdfConfig); + this.setFormValidators(this.kdfConfig.kdfType); - this.formGroup - .get("kdf") - .valueChanges.pipe(takeUntil(this.destroy$)) + this.formGroup.controls.kdf.valueChanges + .pipe(takeUntil(this.destroy$)) .subscribe((newValue) => { - this.updateKdfConfig(newValue); + this.updateKdfConfig(newValue!); }); } private updateKdfConfig(newValue: KdfType) { let config: KdfConfig; - const validators: { [key: string]: ValidatorFn[] } = { - iterations: [], - memory: [], - parallelism: [], - }; switch (newValue) { case KdfType.PBKDF2_SHA256: config = new PBKDF2KdfConfig(); - validators.iterations = [ - Validators.required, - Validators.min(PBKDF2KdfConfig.ITERATIONS.min), - Validators.max(PBKDF2KdfConfig.ITERATIONS.max), - ]; break; case KdfType.Argon2id: config = new Argon2KdfConfig(); - validators.iterations = [ - Validators.required, - Validators.min(Argon2KdfConfig.ITERATIONS.min), - Validators.max(Argon2KdfConfig.ITERATIONS.max), - ]; - validators.memory = [ - Validators.required, - Validators.min(Argon2KdfConfig.MEMORY.min), - Validators.max(Argon2KdfConfig.MEMORY.max), - ]; - validators.parallelism = [ - Validators.required, - Validators.min(Argon2KdfConfig.PARALLELISM.min), - Validators.max(Argon2KdfConfig.PARALLELISM.max), - ]; break; default: throw new Error("Unknown KDF type."); } this.kdfConfig = config; - this.setFormValidators(validators); + this.setFormValidators(newValue); this.setFormControlValues(this.kdfConfig); } - private setFormValidators(validators: { [key: string]: ValidatorFn[] }) { - this.setValidators("kdfConfig.iterations", validators.iterations); - this.setValidators("kdfConfig.memory", validators.memory); - this.setValidators("kdfConfig.parallelism", validators.parallelism); - } - private setValidators(controlName: string, validators: ValidatorFn[]) { - const control = this.formGroup.get(controlName); - if (control) { - control.setValidators(validators); - control.updateValueAndValidity(); + private setFormValidators(kdfType: KdfType) { + const kdfConfigFormGroup = this.formGroup.controls.kdfConfig; + switch (kdfType) { + case KdfType.PBKDF2_SHA256: + kdfConfigFormGroup.controls.iterations.setValidators([ + Validators.required, + Validators.min(PBKDF2KdfConfig.ITERATIONS.min), + Validators.max(PBKDF2KdfConfig.ITERATIONS.max), + ]); + kdfConfigFormGroup.controls.memory.setValidators([]); + kdfConfigFormGroup.controls.parallelism.setValidators([]); + break; + case KdfType.Argon2id: + kdfConfigFormGroup.controls.iterations.setValidators([ + Validators.required, + Validators.min(Argon2KdfConfig.ITERATIONS.min), + Validators.max(Argon2KdfConfig.ITERATIONS.max), + ]); + kdfConfigFormGroup.controls.memory.setValidators([ + Validators.required, + Validators.min(Argon2KdfConfig.MEMORY.min), + Validators.max(Argon2KdfConfig.MEMORY.max), + ]); + kdfConfigFormGroup.controls.parallelism.setValidators([ + Validators.required, + Validators.min(Argon2KdfConfig.PARALLELISM.min), + Validators.max(Argon2KdfConfig.PARALLELISM.max), + ]); + break; + default: + throw new Error("Unknown KDF type."); } + kdfConfigFormGroup.controls.iterations.updateValueAndValidity(); + kdfConfigFormGroup.controls.memory.updateValueAndValidity(); + kdfConfigFormGroup.controls.parallelism.updateValueAndValidity(); } + private setFormControlValues(kdfConfig: KdfConfig) { - this.formGroup.get("kdfConfig").reset(); + const kdfConfigFormGroup = this.formGroup.controls.kdfConfig; + kdfConfigFormGroup.reset(); if (kdfConfig.kdfType === KdfType.PBKDF2_SHA256) { - this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations); + kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations); } else if (kdfConfig.kdfType === KdfType.Argon2id) { - this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations); - this.formGroup.get("kdfConfig.memory").setValue(kdfConfig.memory); - this.formGroup.get("kdfConfig.parallelism").setValue(kdfConfig.parallelism); + kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations); + kdfConfigFormGroup.controls.memory.setValue(kdfConfig.memory); + kdfConfigFormGroup.controls.parallelism.setValue(kdfConfig.parallelism); } } @@ -155,12 +162,14 @@ export class ChangeKdfComponent implements OnInit, OnDestroy { if (this.formGroup.invalid) { return; } + + const kdfConfigFormGroup = this.formGroup.controls.kdfConfig; if (this.kdfConfig.kdfType === KdfType.PBKDF2_SHA256) { - this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value; + this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!; } else if (this.kdfConfig.kdfType === KdfType.Argon2id) { - this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value; - this.kdfConfig.memory = this.formGroup.get("kdfConfig.memory").value; - this.kdfConfig.parallelism = this.formGroup.get("kdfConfig.parallelism").value; + this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!; + this.kdfConfig.memory = kdfConfigFormGroup.controls.memory.value!; + this.kdfConfig.parallelism = kdfConfigFormGroup.controls.parallelism.value!; } this.dialogService.open(ChangeKdfConfirmationComponent, { data: { diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf.module.ts b/apps/web/src/app/key-management/change-kdf/change-kdf.module.ts index 342ad43e368..4c9cd00e79d 100644 --- a/apps/web/src/app/key-management/change-kdf/change-kdf.module.ts +++ b/apps/web/src/app/key-management/change-kdf/change-kdf.module.ts @@ -1,13 +1,15 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { PopoverModule } from "@bitwarden/components"; + import { SharedModule } from "../../shared"; import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component"; import { ChangeKdfComponent } from "./change-kdf.component"; @NgModule({ - imports: [CommonModule, SharedModule], + imports: [CommonModule, SharedModule, PopoverModule], declarations: [ChangeKdfComponent, ChangeKdfConfirmationComponent], exports: [ChangeKdfComponent, ChangeKdfConfirmationComponent], }) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1f8c4ec55b2..f328e3e0bea 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11,7 +11,7 @@ "criticalApplications": { "message": "Critical applications" }, - "noCriticalAppsAtRisk":{ + "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, "accessIntelligence": { @@ -1719,7 +1719,6 @@ } } }, - "dontAskAgainOnThisDeviceFor30Days": { "message": "Don't ask again on this device for 30 days" }, @@ -2090,9 +2089,6 @@ "encKeySettings": { "message": "Encryption key settings" }, - "kdfAlgorithm": { - "message": "KDF algorithm" - }, "kdfIterations": { "message": "KDF iterations" }, @@ -2127,9 +2123,6 @@ "argon2Desc": { "message": "Higher KDF iterations, memory, and parallelism can help protect your master password from being brute forced by an attacker." }, - "changeKdf": { - "message": "Change KDF" - }, "encKeySettingsChanged": { "message": "Encryption key settings saved" }, @@ -2146,22 +2139,22 @@ "message": "Proceeding will also log you out of your current session, requiring you to log back in. You will also be prompted for two-step login again, if set up. Active sessions on other devices may continue to remain active for up to one hour." }, "newDeviceLoginProtection": { - "message":"New device login" + "message": "New device login" }, "turnOffNewDeviceLoginProtection": { - "message":"Turn off new device login protection" + "message": "Turn off new device login protection" }, "turnOnNewDeviceLoginProtection": { - "message":"Turn on new device login protection" + "message": "Turn on new device login protection" }, "turnOffNewDeviceLoginProtectionModalDesc": { - "message":"Proceed below to turn off the verification emails bitwarden sends when you login from a new device." + "message": "Proceed below to turn off the verification emails bitwarden sends when you login from a new device." }, "turnOnNewDeviceLoginProtectionModalDesc": { - "message":"Proceed below to have bitwarden send you verification emails when you login from a new device." + "message": "Proceed below to have bitwarden send you verification emails when you login from a new device." }, "turnOffNewDeviceLoginProtectionWarning": { - "message":"With new device login protection turned off, anyone with your master password can access your account from any device. To protect your account without verification emails, set up two-step login." + "message": "With new device login protection turned off, anyone with your master password can access your account from any device. To protect your account without verification emails, set up two-step login." }, "accountNewDeviceLoginProtectionSaved": { "message": "New device login protection changes saved" @@ -2297,7 +2290,7 @@ "selectImportCollection": { "message": "Select a collection" }, - "importTargetHintCollection": { + "importTargetHintCollection": { "message": "Select this option if you want the imported file contents moved to a collection" }, "importTargetHintFolder": { @@ -5700,7 +5693,7 @@ "message": "All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the ", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the credential lifecycle.'" }, - "organizationDataOwnershipContentAnchor":{ + "organizationDataOwnershipContentAnchor": { "message": "credential lifecycle", "description": "This will be used as a hyperlink" }, @@ -10374,27 +10367,9 @@ "memberAccessReportAuthenticationEnabledFalse": { "message": "Off" }, - "higherKDFIterations": { - "message": "Higher KDF iterations can help protect your master password from being brute forced by an attacker." - }, - "incrementsOf100,000": { - "message": "increments of 100,000" - }, - "smallIncrements": { - "message": "small increments" - }, "kdfIterationRecommends": { "message": "We recommend 600,000 or more" }, - "kdfToHighWarningIncreaseInIncrements": { - "message": "For older devices, setting your KDF too high may lead to performance issues. Increase the value in $VALUE$ and test your devices.", - "placeholders": { - "value": { - "content": "$1", - "example": "increments of 100,000" - } - } - }, "providerReinstate": { "message": " Contact Customer Support to reinstate your subscription." }, @@ -11079,7 +11054,7 @@ "orgTrustWarning1": { "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." }, - "trustUser":{ + "trustUser": { "message": "Trust user" }, "sshKeyWrongPassword": { @@ -11115,7 +11090,7 @@ "openingExtension": { "message": "Opening the Bitwarden browser extension" }, - "somethingWentWrong":{ + "somethingWentWrong": { "message": "Something went wrong..." }, "openingExtensionError": { @@ -11202,7 +11177,7 @@ } } }, - "accountDeprovisioningNotification" : { + "accountDeprovisioningNotification": { "message": "Administrators now have the ability to delete member accounts that belong to a claimed domain." }, "deleteManagedUserWarningDesc": { @@ -11293,14 +11268,14 @@ "upgradeForFullEventsMessage": { "message": "Event logs are not stored for your organization. Upgrade to a Teams or Enterprise plan to get full access to organization event logs." }, - "upgradeEventLogTitleMessage" : { - "message" : "Upgrade to see event logs from your organization." + "upgradeEventLogTitleMessage": { + "message": "Upgrade to see event logs from your organization." }, - "upgradeEventLogMessage":{ - "message" : "These events are examples only and do not reflect real events within your Bitwarden organization." + "upgradeEventLogMessage": { + "message": "These events are examples only and do not reflect real events within your Bitwarden organization." }, - "viewEvents":{ - "message" : "View Events" + "viewEvents": { + "message": "View Events" }, "cannotCreateCollection": { "message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections." @@ -11619,14 +11594,14 @@ } } }, - "unlimitedSecretsAndProjects": { + "unlimitedSecretsAndProjects": { "message": "Unlimited secrets and projects" }, - "providersubscriptionCanceled": { + "providersubscriptionCanceled": { "message": "Subscription canceled" }, "providersubCanceledmessage": { - "message" : "To resubscribe, contact Bitwarden Customer Support." + "message": "To resubscribe, contact Bitwarden Customer Support." }, "showMore": { "message": "Show more" @@ -11878,5 +11853,32 @@ }, "viewbusinessplans": { "message": "View business plans" + }, + "updateEncryptionSettings": { + "message": "Update encryption settings" + }, + "updateYourEncryptionSettings": { + "message": "Update your encryption settings" + }, + "updateSettings": { + "message": "Update settings" + }, + "algorithm": { + "message": "Algorithm" + }, + "encryptionKeySettingsHowShouldWeEncryptYourData": { + "message": "Choose how Bitwarden should encrypt your vault data. All options are secure, but stronger methods offer better protection - especially against brute-force attacks. Bitwarden recommends the default setting for most users." + }, + "encryptionKeySettingsIncreaseImproveSecurity": { + "message": "Increasing the values above the default will improve security, but your vault may take longer to unlock as a result." + }, + "encryptionKeySettingsAlgorithmPopoverTitle": { + "message": "About encryption algorithms" + }, + "encryptionKeySettingsAlgorithmPopoverPBKDF2": { + "message": "PBKDF2-SHA256 is a well-tested encryption method that balances security and performance. Good for all users." + }, + "encryptionKeySettingsAlgorithmPopoverArgon2Id": { + "message": "Argon2id offers stronger protection against modern attacks. Best for advanced users with powerful devices." } } From 2147f74ae834b912fd68869bffc018bcae83e70e Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Wed, 22 Oct 2025 22:06:05 +0200 Subject: [PATCH 09/18] Autofill - Prefer signal & change detection (#16943) --- .../autofill/popup/fido2/fido2-cipher-row.component.ts | 8 ++++++++ .../popup/fido2/fido2-use-browser-link.component.ts | 2 ++ apps/browser/src/autofill/popup/fido2/fido2.component.ts | 2 ++ .../src/autofill/popup/settings/autofill.component.ts | 2 ++ .../autofill/popup/settings/blocked-domains.component.ts | 4 ++++ .../autofill/popup/settings/excluded-domains.component.ts | 4 ++++ .../autofill/popup/settings/notifications.component.ts | 2 ++ 7 files changed, 24 insertions(+) diff --git a/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts index 074e23d642d..adabae2c31d 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts @@ -28,9 +28,17 @@ import { ], }) export class Fido2CipherRowComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSelected = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() cipher: CipherView; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() last: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() title: string; protected selectCipher(c: CipherView) { diff --git a/apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.ts b/apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.ts index b8b49f993e3..f4c4c871478 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.ts @@ -15,6 +15,8 @@ import { MenuModule } from "@bitwarden/components"; import { fido2PopoutSessionData$ } from "../../../vault/popup/utils/fido2-popout-session-data"; import { BrowserFido2UserInterfaceSession } from "../../fido2/services/browser-fido2-user-interface.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-fido2-use-browser-link", templateUrl: "fido2-use-browser-link.component.html", diff --git a/apps/browser/src/autofill/popup/fido2/fido2.component.ts b/apps/browser/src/autofill/popup/fido2/fido2.component.ts index 11e00749bdf..c6799f93a5e 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2.component.ts @@ -71,6 +71,8 @@ interface ViewData { fallbackSupported: boolean; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-fido2", templateUrl: "fido2.component.html", diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index c3b5915a10a..62e5ba3a151 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -77,6 +77,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "autofill.component.html", imports: [ diff --git a/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts b/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts index 15379eff436..30a64e03c56 100644 --- a/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts @@ -41,6 +41,8 @@ import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-heade import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-blocked-domains", templateUrl: "blocked-domains.component.html", @@ -66,6 +68,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu ], }) export class BlockedDomainsComponent implements AfterViewInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChildren("uriInput") uriInputElements: QueryList> = new QueryList(); diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts index a5bfad726f5..e67c826cac6 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -42,6 +42,8 @@ import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-heade import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-excluded-domains", templateUrl: "excluded-domains.component.html", @@ -67,6 +69,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu ], }) export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChildren("uriInput") uriInputElements: QueryList> = new QueryList(); diff --git a/apps/browser/src/autofill/popup/settings/notifications.component.ts b/apps/browser/src/autofill/popup/settings/notifications.component.ts index cb10dec620b..3c77d746e9c 100644 --- a/apps/browser/src/autofill/popup/settings/notifications.component.ts +++ b/apps/browser/src/autofill/popup/settings/notifications.component.ts @@ -21,6 +21,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "notifications.component.html", imports: [ From 67ba1b83eac94d9e6298f85b33422a8fd98668ac Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Wed, 22 Oct 2025 16:11:33 -0400 Subject: [PATCH 10/18] [PM-26369] [PM-26362] Implement Auto Confirm Policy and Multi Step Dialog Workflow (#16831) * implement multi step dialog for auto confirm * wip * implement extension messgae for auto confirm * expand layout logic for header and footer, implement function to open extension * add back missing test * refactor test * clean up * clean up * clean up * fix policy step increment * clean up * Ac/pm 26369 add auto confirm policy to client domain models (#16830) * refactor BasePoliicyEditDefinition * fix circular dep * wip * wip * fix policy submission and refreshing * add svg, copy, and finish layout * clean up * cleanup * cleanup, fix SVG * design review changes * fix copy * fix padding * address organization plan feature FIXME * fix test * remove placeholder URL * prevent duplicate messages --- ...-confirm-edit-policy-dialog.component.html | 91 +++++++ ...to-confirm-edit-policy-dialog.component.ts | 249 ++++++++++++++++++ .../policies/base-policy-edit.component.ts | 17 +- .../policies/policies.component.ts | 29 +- .../auto-confirm-policy.component.html | 59 +++++ .../auto-confirm-policy.component.ts | 50 ++++ .../policies/policy-edit-definitions/index.ts | 1 + .../policies/policy-edit-dialog.component.ts | 12 +- .../policies/policy-edit-register.ts | 2 + .../browser-extension-prompt.component.html | 5 +- ...browser-extension-prompt.component.spec.ts | 17 +- .../browser-extension-prompt.component.ts | 33 ++- .../browser-extension-prompt.service.spec.ts | 55 ++-- .../browser-extension-prompt.service.ts | 25 +- apps/web/src/locales/en/messages.json | 59 +++++ libs/assets/src/svg/svgs/auto-confirmation.ts | 5 + libs/assets/src/svg/svgs/index.ts | 1 + .../admin-console/enums/policy-type.enum.ts | 1 + .../models/data/organization.data.spec.ts | 1 + .../models/data/organization.data.ts | 2 + .../models/domain/organization.ts | 2 + .../response/profile-organization.response.ts | 2 + libs/common/src/enums/feature-flag.enum.ts | 2 + 23 files changed, 659 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html create mode 100644 apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts create mode 100644 apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html create mode 100644 apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts create mode 100644 libs/assets/src/svg/svgs/auto-confirmation.ts diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html new file mode 100644 index 00000000000..2388bb06bd8 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html @@ -0,0 +1,91 @@ +
+ + + @let title = (multiStepSubmit | async)[currentStep()]?.titleContent(); + @if (title) { + + } + + + + @if (loading) { +
+ + {{ "loading" | i18n }} +
+ } +
+ @if (policy.showDescription) { +

{{ policy.description | i18n }}

+ } +
+ +
+ + @let footer = (multiStepSubmit | async)[currentStep()]?.footerContent(); + @if (footer) { + + } + +
+
+ + +
+ @let showBadge = firstTimeDialog(); + @if (showBadge) { + {{ "availableNow" | i18n }} + } + + {{ (firstTimeDialog ? "autoConfirm" : "editPolicy") | i18n }} + @if (!firstTimeDialog) { + + {{ policy.name | i18n }} + + } + +
+
+ + + {{ "howToTurnOnAutoConfirm" | i18n }} + + + + + + + + + + + diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts new file mode 100644 index 00000000000..18a9306b7d1 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts @@ -0,0 +1,249 @@ +import { + AfterViewInit, + ChangeDetectorRef, + Component, + Inject, + signal, + Signal, + TemplateRef, + viewChild, +} from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { Router } from "@angular/router"; +import { + combineLatest, + firstValueFrom, + map, + Observable, + of, + shareReplay, + startWith, + switchMap, + tap, +} from "rxjs"; + +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { SharedModule } from "../../../shared"; + +import { AutoConfirmPolicyEditComponent } from "./policy-edit-definitions/auto-confirm-policy.component"; +import { + PolicyEditDialogComponent, + PolicyEditDialogData, + PolicyEditDialogResult, +} from "./policy-edit-dialog.component"; + +export type MultiStepSubmit = { + sideEffect: () => Promise; + footerContent: Signal | undefined>; + titleContent: Signal | undefined>; +}; + +export type AutoConfirmPolicyDialogData = PolicyEditDialogData & { + firstTimeDialog?: boolean; +}; + +/** + * Custom policy dialog component for Auto-Confirm policy. + * Satisfies the PolicyDialogComponent interface structurally + * via its static open() function. + */ +@Component({ + templateUrl: "auto-confirm-edit-policy-dialog.component.html", + imports: [SharedModule], +}) +export class AutoConfirmPolicyDialogComponent + extends PolicyEditDialogComponent + implements AfterViewInit +{ + policyType = PolicyType; + + protected firstTimeDialog = signal(false); + protected currentStep = signal(0); + protected multiStepSubmit: Observable = of([]); + protected autoConfirmEnabled$: Observable = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.policies$(userId)), + map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false), + ); + + private submitPolicy: Signal | undefined> = viewChild("step0"); + private openExtension: Signal | undefined> = viewChild("step1"); + + private submitPolicyTitle: Signal | undefined> = viewChild("step0Title"); + private openExtensionTitle: Signal | undefined> = viewChild("step1Title"); + + override policyComponent: AutoConfirmPolicyEditComponent | undefined; + + constructor( + @Inject(DIALOG_DATA) protected data: AutoConfirmPolicyDialogData, + accountService: AccountService, + policyApiService: PolicyApiServiceAbstraction, + i18nService: I18nService, + cdr: ChangeDetectorRef, + formBuilder: FormBuilder, + dialogRef: DialogRef, + toastService: ToastService, + configService: ConfigService, + keyService: KeyService, + private policyService: PolicyService, + private router: Router, + ) { + super( + data, + accountService, + policyApiService, + i18nService, + cdr, + formBuilder, + dialogRef, + toastService, + configService, + keyService, + ); + + this.firstTimeDialog.set(data.firstTimeDialog ?? false); + } + + /** + * Instantiates the child policy component and inserts it into the view. + */ + async ngAfterViewInit() { + await super.ngAfterViewInit(); + + if (this.policyComponent) { + this.saveDisabled$ = combineLatest([ + this.autoConfirmEnabled$, + this.policyComponent.enabled.valueChanges.pipe( + startWith(this.policyComponent.enabled.value), + ), + ]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value)); + } + + this.multiStepSubmit = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.policies$(userId)), + map((policies) => policies.find((p) => p.type === PolicyType.SingleOrg)?.enabled ?? false), + tap((singleOrgPolicyEnabled) => + this.policyComponent?.setSingleOrgEnabled(singleOrgPolicyEnabled), + ), + map((singleOrgPolicyEnabled) => [ + { + sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false), + footerContent: this.submitPolicy, + titleContent: this.submitPolicyTitle, + }, + { + sideEffect: () => this.openBrowserExtension(), + footerContent: this.openExtension, + titleContent: this.openExtensionTitle, + }, + ]), + shareReplay({ bufferSize: 1, refCount: true }), + ); + } + + private async handleSubmit(singleOrgEnabled: boolean) { + if (!singleOrgEnabled) { + await this.submitSingleOrg(); + } + await this.submitAutoConfirm(); + } + + /** + * Triggers policy submission for auto confirm. + * @returns boolean: true if multi-submit workflow should continue, false otherwise. + */ + private async submitAutoConfirm() { + if (!this.policyComponent) { + throw new Error("PolicyComponent not initialized."); + } + + const autoConfirmRequest = await this.policyComponent.buildRequest(); + await this.policyApiService.putPolicy( + this.data.organizationId, + this.data.policy.type, + autoConfirmRequest, + ); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)), + }); + + if (!this.policyComponent.enabled.value) { + this.dialogRef.close("saved"); + } + } + + private async submitSingleOrg(): Promise { + const singleOrgRequest: PolicyRequest = { + type: PolicyType.SingleOrg, + enabled: true, + data: null, + }; + + await this.policyApiService.putPolicy( + this.data.organizationId, + PolicyType.SingleOrg, + singleOrgRequest, + ); + } + + private async openBrowserExtension() { + await this.router.navigate(["/browser-extension-prompt"], { + queryParams: { url: "AutoConfirm" }, + }); + } + + submit = async () => { + if (!this.policyComponent) { + throw new Error("PolicyComponent not initialized."); + } + + if ((await this.policyComponent.confirm()) == false) { + this.dialogRef.close(); + return; + } + + try { + const multiStepSubmit = await firstValueFrom(this.multiStepSubmit); + await multiStepSubmit[this.currentStep()].sideEffect(); + + if (this.currentStep() === multiStepSubmit.length - 1) { + this.dialogRef.close("saved"); + return; + } + + this.currentStep.update((value) => value + 1); + this.policyComponent.setStep(this.currentStep()); + } catch (error: any) { + this.toastService.showToast({ + variant: "error", + message: error.message, + }); + } + }; + + static open = ( + dialogService: DialogService, + config: DialogConfig, + ) => { + return dialogService.open(AutoConfirmPolicyDialogComponent, config); + }; +} diff --git a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts index 9293c686b7f..9bf0ad24b1b 100644 --- a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts @@ -8,8 +8,20 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; -import type { PolicyEditDialogComponent } from "./policy-edit-dialog.component"; +import type { PolicyEditDialogData, PolicyEditDialogResult } from "./policy-edit-dialog.component"; + +/** + * Interface for policy dialog components. + * Any component that implements this interface can be used as a custom policy edit dialog. + */ +export interface PolicyDialogComponent { + open: ( + dialogService: DialogService, + config: DialogConfig, + ) => DialogRef; +} /** * A metadata class that defines how a policy is displayed in the Admin Console Policies page for editing. @@ -37,9 +49,8 @@ export abstract class BasePolicyEditDefinition { /** * The dialog component that will be opened when editing this policy. * This allows customizing the look and feel of each policy's dialog contents. - * If not specified, defaults to {@link PolicyEditDialogComponent}. */ - editDialogComponent?: typeof PolicyEditDialogComponent; + editDialogComponent?: PolicyDialogComponent; /** * If true, the {@link description} will be reused in the policy edit modal. Set this to false if you diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index 95c00f74f1c..7bab6f262a6 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -1,17 +1,18 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; import { combineLatest, firstValueFrom, - lastValueFrom, Observable, of, switchMap, first, map, withLatestFrom, + tap, } from "rxjs"; import { @@ -19,9 +20,11 @@ import { OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogService } from "@bitwarden/components"; import { safeProvider } from "@bitwarden/ui-common"; @@ -29,7 +32,7 @@ import { safeProvider } from "@bitwarden/ui-common"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; -import { BasePolicyEditDefinition } from "./base-policy-edit.component"; +import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component"; import { PolicyEditDialogComponent } from "./policy-edit-dialog.component"; import { PolicyListService } from "./policy-list.service"; import { POLICY_EDIT_REGISTER } from "./policy-register-token"; @@ -59,8 +62,18 @@ export class PoliciesComponent implements OnInit { private policyApiService: PolicyApiServiceAbstraction, private policyListService: PolicyListService, private dialogService: DialogService, + private policyService: PolicyService, protected configService: ConfigService, - ) {} + ) { + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => this.policyService.policies$(userId)), + tap(async () => await this.load()), + takeUntilDestroyed(), + ) + .subscribe(); + } async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe @@ -127,17 +140,13 @@ export class PoliciesComponent implements OnInit { } async edit(policy: BasePolicyEditDefinition) { - const dialogComponent = policy.editDialogComponent ?? PolicyEditDialogComponent; - const dialogRef = dialogComponent.open(this.dialogService, { + const dialogComponent: PolicyDialogComponent = + policy.editDialogComponent ?? PolicyEditDialogComponent; + dialogComponent.open(this.dialogService, { data: { policy: policy, organizationId: this.organizationId, }, }); - - const result = await lastValueFrom(dialogRef.closed); - if (result == "saved") { - await this.load(); - } } } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html new file mode 100644 index 00000000000..8334b451d22 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html @@ -0,0 +1,59 @@ + + + +

+ {{ "autoConfirmPolicyEditDescription" | i18n }} +

+ +
    +
  • + + {{ "autoConfirmAcceptSecurityRiskTitle" | i18n }} + + {{ "autoConfirmAcceptSecurityRiskDescription" | i18n }} + + {{ "autoConfirmAcceptSecurityRiskLearnMore" | i18n }} + + +
  • + +
  • + @if (singleOrgEnabled$ | async) { + + {{ "autoConfirmSingleOrgExemption" | i18n }} + + } @else { + + {{ "autoConfirmSingleOrgRequired" | i18n }} + + } + {{ "autoConfirmSingleOrgRequiredDescription" | i18n }} +
  • + +
  • + + {{ "autoConfirmNoEmergencyAccess" | i18n }} + + {{ "autoConfirmNoEmergencyAccessDescription" | i18n }} +
  • +
+ + + {{ "autoConfirmCheckBoxLabel" | i18n }} +
+ + +
+ +
+
    +
  1. 1. {{ "autoConfirmStep1" | i18n }}
  2. + +
  3. + 2. {{ "autoConfirmStep2a" | i18n }} + + {{ "autoConfirmStep2b" | i18n }} + +
  4. +
+
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts new file mode 100644 index 00000000000..a5ea2ef8790 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts @@ -0,0 +1,50 @@ +import { Component, OnInit, Signal, TemplateRef, viewChild } from "@angular/core"; +import { BehaviorSubject, map, Observable } from "rxjs"; + +import { AutoConfirmSvg } from "@bitwarden/assets/svg"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { SharedModule } from "../../../../shared"; +import { AutoConfirmPolicyDialogComponent } from "../auto-confirm-edit-policy-dialog.component"; +import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component"; + +export class AutoConfirmPolicy extends BasePolicyEditDefinition { + name = "autoConfirm"; + description = "autoConfirmDescription"; + type = PolicyType.AutoConfirm; + component = AutoConfirmPolicyEditComponent; + showDescription = false; + editDialogComponent = AutoConfirmPolicyDialogComponent; + + override display$(organization: Organization, configService: ConfigService): Observable { + return configService + .getFeatureFlag$(FeatureFlag.AutoConfirm) + .pipe(map((enabled) => enabled && organization.useAutomaticUserConfirmation)); + } +} + +@Component({ + templateUrl: "auto-confirm-policy.component.html", + imports: [SharedModule], +}) +export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent implements OnInit { + protected readonly autoConfirmSvg = AutoConfirmSvg; + private policyForm: Signal | undefined> = viewChild("step0"); + private extensionButton: Signal | undefined> = viewChild("step1"); + + protected step: number = 0; + protected steps = [this.policyForm, this.extensionButton]; + + protected singleOrgEnabled$: BehaviorSubject = new BehaviorSubject(false); + + setSingleOrgEnabled(enabled: boolean) { + this.singleOrgEnabled$.next(enabled); + } + + setStep(step: number) { + this.step = step; + } +} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts index bb2c40b7a76..7373e1ff888 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts @@ -14,3 +14,4 @@ export { vNextOrganizationDataOwnershipPolicy, vNextOrganizationDataOwnershipPolicyComponent, } from "./vnext-organization-data-ownership.component"; +export { AutoConfirmPolicy } from "./auto-confirm-policy.component"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts index f0672d0f861..d98b5d4809b 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts @@ -30,7 +30,7 @@ import { KeyService } from "@bitwarden/key-management"; import { SharedModule } from "../../../shared"; import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component"; -import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions"; +import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions/vnext-organization-data-ownership.component"; export type PolicyEditDialogData = { /** @@ -64,13 +64,13 @@ export class PolicyEditDialogComponent implements AfterViewInit { }); constructor( @Inject(DIALOG_DATA) protected data: PolicyEditDialogData, - private accountService: AccountService, - private policyApiService: PolicyApiServiceAbstraction, - private i18nService: I18nService, + protected accountService: AccountService, + protected policyApiService: PolicyApiServiceAbstraction, + protected i18nService: I18nService, private cdr: ChangeDetectorRef, private formBuilder: FormBuilder, - private dialogRef: DialogRef, - private toastService: ToastService, + protected dialogRef: DialogRef, + protected toastService: ToastService, private configService: ConfigService, private keyService: KeyService, ) {} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts index 5e63ba1358a..ca44818764c 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts @@ -1,5 +1,6 @@ import { BasePolicyEditDefinition } from "./base-policy-edit.component"; import { + AutoConfirmPolicy, DesktopAutotypeDefaultSettingPolicy, DisableSendPolicy, MasterPasswordPolicy, @@ -33,4 +34,5 @@ export const ossPolicyEditRegister: BasePolicyEditDefinition[] = [ new SendOptionsPolicy(), new RestrictedItemTypesPolicy(), new DesktopAutotypeDefaultSettingPolicy(), + new AutoConfirmPolicy(), ]; diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html index 56332cc424b..aff549a84f4 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html @@ -4,13 +4,14 @@

{{ "openingExtension" | i18n }}

+ @let page = extensionPage$ | async;

{{ "openingExtensionError" | i18n }}

- - - + + +

{{ "summary" | i18n }}

+ {{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }}
+ {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB × + {{ storagePrice$ | async | currency: "$" }} = + {{ storageCost$ | async | currency: "$" }} +
+
+ +

{{ "paymentInformation" | i18n }}

+
+ + + + +
+
+
+ {{ "planPrice" | i18n }}: {{ subtotal$ | async | currency: "USD $" }} + {{ "estimatedTax" | i18n }}: {{ tax$ | async | currency: "USD $" }} +
+
+
+

+ {{ "total" | i18n }}: {{ total$ | async | currency: "USD $" }}/{{ + "year" | i18n + }} +

+ +
+ + +} diff --git a/apps/web/src/app/billing/individual/premium/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts index d541ab95b95..526b020a9e3 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -4,7 +4,19 @@ import { Component, ViewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, concatMap, from, map, Observable, of, startWith, switchMap } from "rxjs"; +import { + combineLatest, + concatMap, + filter, + from, + map, + Observable, + of, + startWith, + switchMap, + catchError, + shareReplay, +} from "rxjs"; import { debounceTime } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -26,7 +38,9 @@ import { tokenizablePaymentMethodToLegacyEnum, NonTokenizablePaymentMethods, } from "@bitwarden/web-vault/app/billing/payment/types"; +import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/services/subscription-pricing.service"; import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier"; @Component({ templateUrl: "./premium.component.html", @@ -37,7 +51,6 @@ export class PremiumComponent { @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected hasPremiumFromAnyOrganization$: Observable; - protected accountCredit$: Observable; protected hasEnoughAccountCredit$: Observable; protected formGroup = new FormGroup({ @@ -46,13 +59,66 @@ export class PremiumComponent { billingAddress: EnterBillingAddressComponent.getFormGroup(), }); + premiumPrices$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe( + map((tiers) => { + const premiumPlan = tiers.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium, + ); + + if (!premiumPlan) { + throw new Error("Could not find Premium plan"); + } + + return { + seat: premiumPlan.passwordManager.annualPrice, + storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB, + }; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + premiumPrice$ = this.premiumPrices$.pipe(map((prices) => prices.seat)); + + storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage)); + + protected isLoadingPrices$ = this.premiumPrices$.pipe( + map(() => false), + startWith(true), + catchError(() => of(false)), + ); + + storageCost$ = combineLatest([ + this.storagePrice$, + this.formGroup.controls.additionalStorage.valueChanges.pipe( + startWith(this.formGroup.value.additionalStorage), + ), + ]).pipe(map(([storagePrice, additionalStorage]) => storagePrice * additionalStorage)); + + subtotal$ = combineLatest([this.premiumPrice$, this.storageCost$]).pipe( + map(([premiumPrice, storageCost]) => premiumPrice + storageCost), + ); + + tax$ = this.formGroup.valueChanges.pipe( + filter(() => this.formGroup.valid), + debounceTime(1000), + switchMap(async () => { + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase( + this.formGroup.value.additionalStorage, + billingAddress, + ); + return taxAmounts.tax; + }), + startWith(0), + ); + + total$ = combineLatest([this.subtotal$, this.tax$]).pipe( + map(([subtotal, tax]) => subtotal + tax), + ); + protected cloudWebVaultURL: string; protected isSelfHost = false; - - protected estimatedTax: number = 0; protected readonly familyPlanMaxUserCount = 6; - protected readonly premiumPrice = 10; - protected readonly storageGBPrice = 4; constructor( private activatedRoute: ActivatedRoute, @@ -67,6 +133,7 @@ export class PremiumComponent { private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, + private subscriptionPricingService: SubscriptionPricingService, ) { this.isSelfHost = this.platformUtilsService.isSelfHost(); @@ -76,23 +143,23 @@ export class PremiumComponent { ), ); - // Fetch account credit - this.accountCredit$ = this.accountService.activeAccount$.pipe( + const accountCredit$ = this.accountService.activeAccount$.pipe( mapAccountToSubscriber, switchMap((account) => this.subscriberBillingClient.getCredit(account)), ); - // Check if user has enough account credit for the purchase this.hasEnoughAccountCredit$ = combineLatest([ - this.accountCredit$, - this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)), + accountCredit$, + this.total$, + this.formGroup.controls.paymentMethod.controls.type.valueChanges.pipe( + startWith(this.formGroup.value.paymentMethod.type), + ), ]).pipe( - map(([credit, formValue]) => { - const selectedPaymentType = formValue.paymentMethod?.type; - if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { - return true; // Not using account credit, so this check doesn't apply + map(([credit, total, paymentMethod]) => { + if (paymentMethod !== NonTokenizablePaymentMethods.accountCredit) { + return true; } - return credit >= this.total; + return credit >= total; }), ); @@ -116,14 +183,6 @@ export class PremiumComponent { }), ) .subscribe(); - - this.formGroup.valueChanges - .pipe( - debounceTime(1000), - switchMap(async () => await this.refreshSalesTax()), - takeUntilDestroyed(), - ) - .subscribe(); } finalizeUpgrade = async () => { @@ -177,38 +236,11 @@ export class PremiumComponent { await this.postFinalizeUpgrade(); }; - protected get additionalStorageCost(): number { - return this.storageGBPrice * this.formGroup.value.additionalStorage; - } - protected get premiumURL(): string { return `${this.cloudWebVaultURL}/#/settings/subscription/premium`; } - protected get subtotal(): number { - return this.premiumPrice + this.additionalStorageCost; - } - - protected get total(): number { - return this.subtotal + this.estimatedTax; - } - protected async onLicenseFileSelectedChanged(): Promise { await this.postFinalizeUpgrade(); } - - private async refreshSalesTax(): Promise { - if (this.formGroup.invalid) { - return; - } - - const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); - - const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase( - this.formGroup.value.additionalStorage, - billingAddress, - ); - - this.estimatedTax = taxAmounts.tax; - } } diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts b/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts index 0fb33020bc3..de80cdcbdbf 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts +++ b/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts @@ -1,9 +1,12 @@ import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -18,7 +21,8 @@ import { SubscriptionPricingService } from "./subscription-pricing.service"; describe("SubscriptionPricingService", () => { let service: SubscriptionPricingService; - let apiService: MockProxy; + let billingApiService: MockProxy; + let configService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; let toastService: MockProxy; @@ -217,6 +221,15 @@ describe("SubscriptionPricingService", () => { continuationToken: null, }; + const mockPremiumPlanResponse: PremiumPlanResponse = { + seat: { + price: 10, + }, + storage: { + price: 4, + }, + } as PremiumPlanResponse; + beforeAll(() => { i18nService = mock(); logService = mock(); @@ -320,14 +333,18 @@ describe("SubscriptionPricingService", () => { }); beforeEach(() => { - apiService = mock(); + billingApiService = mock(); + configService = mock(); - apiService.getPlans.mockResolvedValue(mockPlansResponse); + billingApiService.getPlans.mockResolvedValue(mockPlansResponse); + billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); + configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value) TestBed.configureTestingModule({ providers: [ SubscriptionPricingService, - { provide: ApiService, useValue: apiService }, + { provide: BillingApiServiceAbstraction, useValue: billingApiService }, + { provide: ConfigService, useValue: configService }, { provide: I18nService, useValue: i18nService }, { provide: LogService, useValue: logService }, { provide: ToastService, useValue: toastService }, @@ -406,13 +423,16 @@ describe("SubscriptionPricingService", () => { }); it("should handle API errors by logging and showing toast", (done) => { - const errorApiService = mock(); + const errorBillingApiService = mock(); + const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); const errorToastService = mock(); const testError = new Error("API error"); - errorApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); + errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorI18nService.t.mockImplementation((key: string) => { if (key === "unexpectedError") { @@ -422,7 +442,8 @@ describe("SubscriptionPricingService", () => { }); const errorService = new SubscriptionPricingService( - errorApiService, + errorBillingApiService, + errorConfigService, errorI18nService, errorLogService, errorToastService, @@ -591,13 +612,16 @@ describe("SubscriptionPricingService", () => { }); it("should handle API errors by logging and showing toast", (done) => { - const errorApiService = mock(); + const errorBillingApiService = mock(); + const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); const errorToastService = mock(); const testError = new Error("API error"); - errorApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); + errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorI18nService.t.mockImplementation((key: string) => { if (key === "unexpectedError") { @@ -607,7 +631,8 @@ describe("SubscriptionPricingService", () => { }); const errorService = new SubscriptionPricingService( - errorApiService, + errorBillingApiService, + errorConfigService, errorI18nService, errorLogService, errorToastService, @@ -831,13 +856,16 @@ describe("SubscriptionPricingService", () => { }); it("should handle API errors by logging and showing toast", (done) => { - const errorApiService = mock(); + const errorBillingApiService = mock(); + const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); const errorToastService = mock(); const testError = new Error("API error"); - errorApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); + errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorI18nService.t.mockImplementation((key: string) => { if (key === "unexpectedError") { @@ -847,7 +875,8 @@ describe("SubscriptionPricingService", () => { }); const errorService = new SubscriptionPricingService( - errorApiService, + errorBillingApiService, + errorConfigService, errorI18nService, errorLogService, errorToastService, @@ -871,9 +900,137 @@ describe("SubscriptionPricingService", () => { }); }); + describe("Edge case handling", () => { + it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => { + const errorBillingApiService = mock(); + const errorConfigService = mock(); + + const testError = new Error("Premium plan API error"); + errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); + errorBillingApiService.getPremiumPlan.mockRejectedValue(testError); + errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API + + const errorService = new SubscriptionPricingService( + errorBillingApiService, + errorConfigService, + i18nService, + logService, + toastService, + ); + + errorService.getPersonalSubscriptionPricingTiers$().subscribe({ + next: (tiers) => { + // Should return empty array due to error in premium plan fetch + expect(tiers).toEqual([]); + expect(logService.error).toHaveBeenCalledWith( + "Failed to fetch premium plan from API", + testError, + ); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: "An unexpected error has occurred.", + }); + done(); + }, + error: () => { + fail("Observable should not error, it should return empty array"); + }, + }); + }); + + it("should handle malformed premium plan API response", (done) => { + const errorBillingApiService = mock(); + const errorConfigService = mock(); + + // Malformed response missing the Seat property + const malformedResponse = { + Storage: { + StripePriceId: "price_storage", + Price: 4, + }, + }; + + errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); + errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any); + errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag + + const errorService = new SubscriptionPricingService( + errorBillingApiService, + errorConfigService, + i18nService, + logService, + toastService, + ); + + errorService.getPersonalSubscriptionPricingTiers$().subscribe({ + next: (tiers) => { + // Should return empty array due to validation error + expect(tiers).toEqual([]); + expect(logService.error).toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: "An unexpected error has occurred.", + }); + done(); + }, + error: () => { + fail("Observable should not error, it should return empty array"); + }, + }); + }); + + it("should handle malformed premium plan with invalid price types", (done) => { + const errorBillingApiService = mock(); + const errorConfigService = mock(); + + // Malformed response with price as string instead of number + const malformedResponse = { + Seat: { + StripePriceId: "price_seat", + Price: "10", // Should be a number + }, + Storage: { + StripePriceId: "price_storage", + Price: 4, + }, + }; + + errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); + errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any); + errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag + + const errorService = new SubscriptionPricingService( + errorBillingApiService, + errorConfigService, + i18nService, + logService, + toastService, + ); + + errorService.getPersonalSubscriptionPricingTiers$().subscribe({ + next: (tiers) => { + // Should return empty array due to validation error + expect(tiers).toEqual([]); + expect(logService.error).toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: "An unexpected error has occurred.", + }); + done(); + }, + error: () => { + fail("Observable should not error, it should return empty array"); + }, + }); + }); + }); + describe("Observable behavior and caching", () => { it("should share API response between multiple subscriptions", () => { - const getPlansResponse = jest.spyOn(apiService, "getPlans"); + const getPlansResponse = jest.spyOn(billingApiService, "getPlans"); // Subscribe to multiple observables service.getPersonalSubscriptionPricingTiers$().subscribe(); @@ -883,5 +1040,67 @@ describe("SubscriptionPricingService", () => { // API should only be called once due to shareReplay expect(getPlansResponse).toHaveBeenCalledTimes(1); }); + + it("should share premium plan API response between multiple subscriptions when feature flag is enabled", () => { + // Create a new mock to avoid conflicts with beforeEach setup + const newBillingApiService = mock(); + const newConfigService = mock(); + + newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); + newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); + newConfigService.getFeatureFlag$.mockReturnValue(of(true)); + + const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan"); + + // Create a new service instance with the feature flag enabled + const newService = new SubscriptionPricingService( + newBillingApiService, + newConfigService, + i18nService, + logService, + toastService, + ); + + // Subscribe to the premium pricing tier multiple times + newService.getPersonalSubscriptionPricingTiers$().subscribe(); + newService.getPersonalSubscriptionPricingTiers$().subscribe(); + + // API should only be called once due to shareReplay on premiumPlanResponse$ + expect(getPremiumPlanSpy).toHaveBeenCalledTimes(1); + }); + + it("should use hardcoded premium price when feature flag is disabled", (done) => { + // Create a new mock to test from scratch + const newBillingApiService = mock(); + const newConfigService = mock(); + + newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); + newBillingApiService.getPremiumPlan.mockResolvedValue({ + seat: { price: 999 }, // Different price to verify hardcoded value is used + storage: { price: 999 }, + } as PremiumPlanResponse); + newConfigService.getFeatureFlag$.mockReturnValue(of(false)); + + // Create a new service instance with the feature flag disabled + const newService = new SubscriptionPricingService( + newBillingApiService, + newConfigService, + i18nService, + logService, + toastService, + ); + + // Subscribe with feature flag disabled + newService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => { + const premiumTier = tiers.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium, + ); + + // Should use hardcoded value of 10, not the API response value of 999 + expect(premiumTier!.passwordManager.annualPrice).toBe(10); + expect(premiumTier!.passwordManager.annualPricePerAdditionalStorageGB).toBe(4); + done(); + }); + }); }); }); diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.ts b/apps/web/src/app/billing/services/subscription-pricing.service.ts index 82ec9f180b9..71729a42d23 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.ts +++ b/apps/web/src/app/billing/services/subscription-pricing.service.ts @@ -1,11 +1,14 @@ import { Injectable } from "@angular/core"; -import { combineLatest, from, map, Observable, of, shareReplay } from "rxjs"; +import { combineLatest, from, map, Observable, of, shareReplay, switchMap, take } from "rxjs"; import { catchError } from "rxjs/operators"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -20,8 +23,18 @@ import { @Injectable({ providedIn: BillingServicesModule }) export class SubscriptionPricingService { + /** + * Fallback premium pricing used when the feature flag is disabled. + * These values represent the legacy pricing model and will not reflect + * server-side price changes. They are retained for backward compatibility + * during the feature flag rollout period. + */ + private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10; + private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4; + constructor( - private apiService: ApiService, + private billingApiService: BillingApiServiceAbstraction, + private configService: ConfigService, private i18nService: I18nService, private logService: LogService, private toastService: ToastService, @@ -55,34 +68,56 @@ export class SubscriptionPricingService { ); private plansResponse$: Observable> = from( - this.apiService.getPlans(), + this.billingApiService.getPlans(), ).pipe(shareReplay({ bufferSize: 1, refCount: false })); - private premium$: Observable = of({ - // premium plan is not configured server-side so for now, hardcode it - basePrice: 10, - additionalStoragePricePerGb: 4, - }).pipe( - map((details) => ({ - id: PersonalSubscriptionPricingTierIds.Premium, - name: this.i18nService.t("premium"), - description: this.i18nService.t("planDescPremium"), - availableCadences: [SubscriptionCadenceIds.Annually], - passwordManager: { - type: "standalone", - annualPrice: details.basePrice, - annualPricePerAdditionalStorageGB: details.additionalStoragePricePerGb, - features: [ - this.featureTranslations.builtInAuthenticator(), - this.featureTranslations.secureFileStorage(), - this.featureTranslations.emergencyAccess(), - this.featureTranslations.breachMonitoring(), - this.featureTranslations.andMoreFeatures(), - ], - }, - })), + private premiumPlanResponse$: Observable = from( + this.billingApiService.getPremiumPlan(), + ).pipe( + catchError((error: unknown) => { + this.logService.error("Failed to fetch premium plan from API", error); + throw error; // Re-throw to propagate to higher-level error handler + }), + shareReplay({ bufferSize: 1, refCount: false }), ); + private premium$: Observable = this.configService + .getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService) + .pipe( + take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream + switchMap((fetchPremiumFromPricingService) => + fetchPremiumFromPricingService + ? this.premiumPlanResponse$.pipe( + map((premiumPlan) => ({ + seat: premiumPlan.seat.price, + storage: premiumPlan.storage.price, + })), + ) + : of({ + seat: SubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE, + storage: SubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE, + }), + ), + map((premiumPrices) => ({ + id: PersonalSubscriptionPricingTierIds.Premium, + name: this.i18nService.t("premium"), + description: this.i18nService.t("planDescPremium"), + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "standalone", + annualPrice: premiumPrices.seat, + annualPricePerAdditionalStorageGB: premiumPrices.storage, + features: [ + this.featureTranslations.builtInAuthenticator(), + this.featureTranslations.secureFileStorage(), + this.featureTranslations.emergencyAccess(), + this.featureTranslations.breachMonitoring(), + this.featureTranslations.andMoreFeatures(), + ], + }, + })), + ); + private families$: Observable = this.plansResponse$.pipe( map((plans) => { const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!; diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index d581fdaa95c..ef01c98ecb5 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -1,3 +1,5 @@ +import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; + import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; @@ -25,6 +27,8 @@ export abstract class BillingApiServiceAbstraction { abstract getPlans(): Promise>; + abstract getPremiumPlan(): Promise; + abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise; abstract getProviderInvoices(providerId: string): Promise; diff --git a/libs/common/src/billing/models/response/premium-plan.response.ts b/libs/common/src/billing/models/response/premium-plan.response.ts new file mode 100644 index 00000000000..f5df560a601 --- /dev/null +++ b/libs/common/src/billing/models/response/premium-plan.response.ts @@ -0,0 +1,47 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class PremiumPlanResponse extends BaseResponse { + seat: { + stripePriceId: string; + price: number; + }; + storage: { + stripePriceId: string; + price: number; + }; + + constructor(response: any) { + super(response); + + const seat = this.getResponseProperty("Seat"); + if (!seat || typeof seat !== "object") { + throw new Error("PremiumPlanResponse: Missing or invalid 'Seat' property"); + } + this.seat = new PurchasableResponse(seat); + + const storage = this.getResponseProperty("Storage"); + if (!storage || typeof storage !== "object") { + throw new Error("PremiumPlanResponse: Missing or invalid 'Storage' property"); + } + this.storage = new PurchasableResponse(storage); + } +} + +class PurchasableResponse extends BaseResponse { + stripePriceId: string; + price: number; + + constructor(response: any) { + super(response); + + this.stripePriceId = this.getResponseProperty("StripePriceId"); + if (!this.stripePriceId || typeof this.stripePriceId !== "string") { + throw new Error("PurchasableResponse: Missing or invalid 'StripePriceId' property"); + } + + this.price = this.getResponseProperty("Price"); + if (typeof this.price !== "number" || isNaN(this.price)) { + throw new Error("PurchasableResponse: Missing or invalid 'Price' property"); + } + } +} diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index 165ebf5c3b4..673d4a9784e 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -1,6 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; + import { ApiService } from "../../abstractions/api.service"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { ListResponse } from "../../models/response/list.response"; @@ -61,10 +63,15 @@ export class BillingApiService implements BillingApiServiceAbstraction { } async getPlans(): Promise> { - const r = await this.apiService.send("GET", "/plans", null, false, true); + const r = await this.apiService.send("GET", "/plans", null, true, true); return new ListResponse(r, PlanResponse); } + async getPremiumPlan(): Promise { + const response = await this.apiService.send("GET", "/plans/premium", null, true, true); + return new PremiumPlanResponse(response); + } + async getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise { const response = await this.apiService.send( "GET", diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index e26fa69fa91..d9cd1dbfab3 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -30,6 +30,7 @@ export enum FeatureFlag { PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page", + PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -115,6 +116,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, [FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE, + [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE,